Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d33863faeb | |||
| 5d7aa85132 | |||
| 243b13abb3 | |||
| d20c465457 | |||
| f443086bd6 | |||
| 203eb3e589 | |||
| 981c4fff3e | |||
| 8103829ef8 | |||
| 03b6ee51f5 | |||
| a8b030b786 | |||
| 0a4a8114ad | |||
| f7861ec494 | |||
| eeec1cedf0 | |||
| 3d537bba59 | |||
| 67f9db8bc8 | |||
| 2c2afac192 | |||
| 733234c303 | |||
| 039661185d | |||
| 4b6779febb | |||
| f914095c1d | |||
| 269dffacb6 | |||
| 55459d5df9 | |||
| 2fbd660f55 | |||
| 275465d862 | |||
| 93ce9a957e | |||
| a101840bbc | |||
| 5689ef33ee | |||
| 9a0e24213b | |||
| 1732faf3f3 | |||
| bb5de03e50 | |||
| fd9a7d94ff | |||
| 8e02d27bc3 | |||
| 11bd5fbb5e | |||
| 7560752a23 | |||
| 45424e0739 | |||
| 1bc828088c | |||
| 9d54826ea5 | |||
| 403095cbf8 | |||
| 5baab2fa88 | |||
| 2e0098ed1f | |||
| 5e02fd192a | |||
| 3da0e9f4e0 | |||
| 9a3d4dea54 | |||
| 32f0d78ebd | |||
| fe421c98a7 | |||
| 0e940538c4 | |||
| 9fc0c382be | |||
| 636fb6a774 | |||
| b86ae8e121 | |||
| 0b0fc3f2f9 | |||
| ccaa42e926 | |||
| adffcbbc4b | |||
| ef60c1f874 | |||
| d00c5bd9b1 | |||
| b08eb50f2e | |||
| b8fd98ef7b | |||
| 1ed6b49671 | |||
| 33ec51372b | |||
| b2de85a996 | |||
| 044ca76f36 | |||
| 5520a5e00d | |||
| d68aabe03c | |||
| 179774eb63 | |||
| 9bc3425913 | |||
| 752d3eb3d6 | |||
| 4ac3dfc5be | |||
| 741f2d29e4 |
@@ -21,6 +21,7 @@ REVIEW_LABEL = "waiting-for-review"
|
||||
WAITING_DAYS = 7
|
||||
CANONICAL_REPO = "omnigent-ai/omnigent"
|
||||
MAX_CLOSURES_PER_RUN = 30
|
||||
REVIEW_EVENTS = {"pull_request_review", "pull_request_review_comment"}
|
||||
|
||||
|
||||
def label_names(item: dict[str, Any]) -> list[str]:
|
||||
@@ -114,6 +115,16 @@ class GitHubAPI:
|
||||
pull, _ = self.request("GET", f"/repos/{self.repo}/pulls/{pull_number}")
|
||||
return pull
|
||||
|
||||
def get_review(self, pull_number: int, review_id: int) -> dict[str, Any]:
|
||||
review, _ = self.request(
|
||||
"GET", f"/repos/{self.repo}/pulls/{pull_number}/reviews/{review_id}"
|
||||
)
|
||||
return review
|
||||
|
||||
def get_review_comment(self, comment_id: int) -> dict[str, Any]:
|
||||
comment, _ = self.request("GET", f"/repos/{self.repo}/pulls/comments/{comment_id}")
|
||||
return comment
|
||||
|
||||
def remove_label(self, issue_number: int, label: str) -> bool:
|
||||
quoted = urllib.parse.quote(label, safe="")
|
||||
try:
|
||||
@@ -355,10 +366,14 @@ def apply_waiting_on_maintainer_activity(
|
||||
pull_number = payload["pull_request"]["number"]
|
||||
review = payload.get("review") or {}
|
||||
actor = (review.get("user") or {}).get("login")
|
||||
review_state = (review.get("state") or "").lower()
|
||||
# An approval asks nothing of the author; it means the PR is ready.
|
||||
if (review.get("state") or "").lower() == "approved":
|
||||
if review_state == "approved":
|
||||
print(f"#{pull_number}: approving review, leaving the label alone.")
|
||||
return False
|
||||
if review_state not in {"commented", "changes_requested"}:
|
||||
print(f"#{pull_number}: {review_state or 'unknown'} review, leaving the label alone.")
|
||||
return False
|
||||
if is_slash_command(review.get("body")):
|
||||
return False
|
||||
reason = "a maintainer reviewed"
|
||||
@@ -473,6 +488,42 @@ def close_stale_waiting_prs(api: GitHubAPI, now: datetime | None = None) -> int:
|
||||
return closed
|
||||
|
||||
|
||||
def relay_integer(record: dict[str, Any], field: str) -> int:
|
||||
value = record.get(field)
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
||||
raise ValueError(f"Relay field {field!r} must be a positive integer")
|
||||
return value
|
||||
|
||||
|
||||
def hydrate_relay_event(
|
||||
record: dict[str, Any], api: GitHubAPI, repo: str, expected_event: str
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
event_name = record.get("event_name")
|
||||
if event_name not in REVIEW_EVENTS:
|
||||
raise ValueError(f"Unsupported relayed event: {event_name!r}")
|
||||
if event_name != expected_event:
|
||||
raise ValueError(
|
||||
f"Relayed event {event_name!r} does not match workflow event {expected_event!r}"
|
||||
)
|
||||
|
||||
pull_number = relay_integer(record, "pull_number")
|
||||
activity_id = relay_integer(record, "activity_id")
|
||||
pull = api.get_pull(pull_number)
|
||||
base_repo = ((pull.get("base") or {}).get("repo") or {}).get("full_name") or ""
|
||||
if base_repo.lower() != repo.lower():
|
||||
raise ValueError(f"Relayed PR #{pull_number} targets {base_repo!r}, not {repo!r}")
|
||||
|
||||
if event_name == "pull_request_review":
|
||||
review = api.get_review(pull_number, activity_id)
|
||||
return event_name, {"pull_request": pull, "review": review}
|
||||
|
||||
comment = api.get_review_comment(activity_id)
|
||||
expected_url = f"https://api.github.com/repos/{repo}/pulls/{pull_number}"
|
||||
if comment.get("pull_request_url") != expected_url:
|
||||
raise ValueError(f"Review comment {activity_id} does not belong to PR #{pull_number}")
|
||||
return event_name, {"pull_request": pull, "comment": comment}
|
||||
|
||||
|
||||
def run(
|
||||
event_name: str,
|
||||
payload: dict[str, Any],
|
||||
@@ -495,8 +546,7 @@ def run(
|
||||
apply_waiting_on_maintainer_activity(event_name, payload, api)
|
||||
|
||||
|
||||
def load_event_payload() -> dict[str, Any]:
|
||||
path = os.environ.get("GITHUB_EVENT_PATH")
|
||||
def load_json(path: str | None) -> dict[str, Any]:
|
||||
if not path:
|
||||
return {}
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
@@ -509,8 +559,15 @@ def main() -> int:
|
||||
if not token:
|
||||
print("GITHUB_TOKEN is required", file=sys.stderr)
|
||||
return 1
|
||||
event_name = os.environ.get("GITHUB_EVENT_NAME", "")
|
||||
run(event_name, load_event_payload(), GitHubAPI(token, repo), repo)
|
||||
api = GitHubAPI(token, repo)
|
||||
relay_path = os.environ.get("WAITING_ON_AUTHOR_RELAY_PATH")
|
||||
if relay_path:
|
||||
expected_event = os.environ.get("WAITING_ON_AUTHOR_RELAY_EVENT", "")
|
||||
event_name, payload = hydrate_relay_event(load_json(relay_path), api, repo, expected_event)
|
||||
else:
|
||||
event_name = os.environ.get("GITHUB_EVENT_NAME", "")
|
||||
payload = load_json(os.environ.get("GITHUB_EVENT_PATH"))
|
||||
run(event_name, payload, api, repo)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -63,6 +63,8 @@ class FakeAPI:
|
||||
issue_comments: dict[int, list[dict[str, Any]]] | None = None,
|
||||
review_comments: dict[int, list[dict[str, Any]]] | None = None,
|
||||
reviews: dict[int, list[dict[str, Any]]] | None = None,
|
||||
review_by_id: dict[tuple[int, int], dict[str, Any]] | None = None,
|
||||
review_comment_by_id: dict[int, dict[str, Any]] | None = None,
|
||||
commits: dict[int, list[dict[str, Any]]] | None = None,
|
||||
writers: list[str] | None = None,
|
||||
):
|
||||
@@ -73,6 +75,8 @@ class FakeAPI:
|
||||
self.issue_comments = issue_comments or {}
|
||||
self.review_comments = review_comments or {}
|
||||
self.reviews = reviews or {}
|
||||
self.review_by_id = review_by_id or {}
|
||||
self.review_comment_by_id = review_comment_by_id or {}
|
||||
self.commits = commits or {}
|
||||
self.removed: list[tuple[int, str]] = []
|
||||
self.closed: list[int] = []
|
||||
@@ -83,6 +87,12 @@ class FakeAPI:
|
||||
def get_pull(self, pull_number: int) -> dict[str, Any]:
|
||||
return self.pull | {"number": pull_number}
|
||||
|
||||
def get_review(self, pull_number: int, review_id: int) -> dict[str, Any]:
|
||||
return self.review_by_id[(pull_number, review_id)]
|
||||
|
||||
def get_review_comment(self, comment_id: int) -> dict[str, Any]:
|
||||
return self.review_comment_by_id[comment_id]
|
||||
|
||||
def remove_label(self, issue_number: int, label: str) -> bool:
|
||||
self.removed.append((issue_number, label))
|
||||
return True
|
||||
@@ -442,6 +452,21 @@ class AutoWaitingOnAuthorTest(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(api.added, [])
|
||||
|
||||
def test_dismissed_review_leaves_the_label_alone(self) -> None:
|
||||
api = self.dispatch(
|
||||
"pull_request_review",
|
||||
{
|
||||
"pull_request": {"number": 12},
|
||||
"review": {
|
||||
"user": {"login": "maintainer1"},
|
||||
"state": "dismissed",
|
||||
"body": "stale feedback",
|
||||
},
|
||||
},
|
||||
pull=pr(labels=[]),
|
||||
)
|
||||
self.assertEqual(api.added, [])
|
||||
|
||||
def test_commenting_review_applies_the_label(self) -> None:
|
||||
api = self.dispatch(
|
||||
"pull_request_review",
|
||||
@@ -483,6 +508,99 @@ class AutoWaitingOnAuthorTest(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(api.added, [(12, waiting_on_author.LABEL)])
|
||||
|
||||
def test_relayed_review_rehydrates_trusted_api_data(self) -> None:
|
||||
pull = pr(labels=[]) | {"base": {"repo": {"full_name": waiting_on_author.CANONICAL_REPO}}}
|
||||
api = FakeAPI(
|
||||
pull=pull,
|
||||
review_by_id={
|
||||
(12, 41): {
|
||||
"id": 41,
|
||||
"user": {"login": "maintainer1"},
|
||||
"state": "changes_requested",
|
||||
"body": "please fix",
|
||||
}
|
||||
},
|
||||
)
|
||||
event, payload = waiting_on_author.hydrate_relay_event(
|
||||
{"event_name": "pull_request_review", "pull_number": 12, "activity_id": 41},
|
||||
api,
|
||||
waiting_on_author.CANONICAL_REPO,
|
||||
"pull_request_review",
|
||||
)
|
||||
|
||||
waiting_on_author.run(event, payload, api, waiting_on_author.CANONICAL_REPO)
|
||||
|
||||
self.assertEqual(api.added, [(12, waiting_on_author.LABEL)])
|
||||
|
||||
def test_relayed_review_comment_rehydrates_author_reply(self) -> None:
|
||||
pull = pr(author="alice") | {
|
||||
"base": {"repo": {"full_name": waiting_on_author.CANONICAL_REPO}}
|
||||
}
|
||||
api = FakeAPI(
|
||||
pull=pull,
|
||||
review_comment_by_id={
|
||||
73: {
|
||||
"id": 73,
|
||||
"user": {"login": "alice"},
|
||||
"body": "fixed",
|
||||
"pull_request_url": (
|
||||
"https://api.github.com/repos/omnigent-ai/omnigent/pulls/12"
|
||||
),
|
||||
}
|
||||
},
|
||||
)
|
||||
event, payload = waiting_on_author.hydrate_relay_event(
|
||||
{
|
||||
"event_name": "pull_request_review_comment",
|
||||
"pull_number": 12,
|
||||
"activity_id": 73,
|
||||
},
|
||||
api,
|
||||
waiting_on_author.CANONICAL_REPO,
|
||||
"pull_request_review_comment",
|
||||
)
|
||||
|
||||
waiting_on_author.run(event, payload, api, waiting_on_author.CANONICAL_REPO)
|
||||
|
||||
self.assertEqual(api.removed, [(12, waiting_on_author.LABEL)])
|
||||
|
||||
def test_relayed_review_comment_must_match_pull(self) -> None:
|
||||
pull = pr() | {"base": {"repo": {"full_name": waiting_on_author.CANONICAL_REPO}}}
|
||||
api = FakeAPI(
|
||||
pull=pull,
|
||||
review_comment_by_id={
|
||||
73: {
|
||||
"id": 73,
|
||||
"pull_request_url": (
|
||||
"https://api.github.com/repos/omnigent-ai/omnigent/pulls/99"
|
||||
),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "does not belong"):
|
||||
waiting_on_author.hydrate_relay_event(
|
||||
{
|
||||
"event_name": "pull_request_review_comment",
|
||||
"pull_number": 12,
|
||||
"activity_id": 73,
|
||||
},
|
||||
api,
|
||||
waiting_on_author.CANONICAL_REPO,
|
||||
"pull_request_review_comment",
|
||||
)
|
||||
|
||||
def test_relayed_event_must_match_workflow_event(self) -> None:
|
||||
api = FakeAPI()
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "does not match workflow event"):
|
||||
waiting_on_author.hydrate_relay_event(
|
||||
{"event_name": "pull_request_review", "pull_number": 12, "activity_id": 41},
|
||||
api,
|
||||
waiting_on_author.CANONICAL_REPO,
|
||||
"pull_request_review_comment",
|
||||
)
|
||||
|
||||
def test_applying_clears_waiting_for_review(self) -> None:
|
||||
api = self.dispatch(
|
||||
"issue_comment",
|
||||
|
||||
@@ -25,8 +25,10 @@ name: Issue Triage
|
||||
# 5. Flags incomplete issues — `needs-info` (replaces priority label)
|
||||
# 6. Optionally comments when a duplicate or related issue is found
|
||||
# (disabled by default; never comments when nothing matches)
|
||||
# 7. Optionally closes validated high-confidence duplicates (disabled by default)
|
||||
# 8. Assigns P0/P1 issues to a maintainer via round-robin
|
||||
# 7. Assigns an owner to every triaged open issue via round-robin — duplicates
|
||||
# included, so the owner persists if the issue is later reopened
|
||||
# 8. Optionally closes validated high-confidence duplicates (disabled by
|
||||
# default), after the owner above is assigned
|
||||
|
||||
on:
|
||||
issues:
|
||||
@@ -714,40 +716,25 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
duplicate_decision=$(jq -r '.duplicate_decision' /tmp/triage_result.json)
|
||||
if [ "$duplicate_decision" = "duplicate" ]; then
|
||||
close_duplicate_issue=$(jq -r '.close_duplicate_issue' /tmp/triage_result.json)
|
||||
if [ "$close_duplicate_issue" = "true" ]; then
|
||||
duplicate_of=$(jq -r '.duplicate_of' /tmp/triage_result.json)
|
||||
issue_state=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" \
|
||||
--json state --jq '.state')
|
||||
if [ "$issue_state" = "OPEN" ]; then
|
||||
gh issue close "$ISSUE_NUMBER" --repo "$REPO" \
|
||||
--duplicate-of "$duplicate_of"
|
||||
fi
|
||||
else
|
||||
echo "Duplicate closure disabled; leaving issue open."
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
# Assign an owner to every triaged open issue, BEFORE the duplicate
|
||||
# closure below. Duplicates get an owner too, and it persists if the
|
||||
# issue is later reopened — triage only fires on `opened`, so a
|
||||
# reopened issue would otherwise come back unassigned.
|
||||
|
||||
# If the issue was filed by a maintainer, assign it to them directly.
|
||||
author=$(jq -r '.author.login // empty' /tmp/issue.json)
|
||||
maintainer_assigned=false
|
||||
if [ -n "$author" ] && grep -qxF "$author" .github/MAINTAINER; then
|
||||
echo "Issue filed by maintainer $author — assigning to author"
|
||||
issue_state=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" \
|
||||
--json state --jq '.state')
|
||||
if [ "$issue_state" = "OPEN" ]; then
|
||||
gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --add-assignee "$author"
|
||||
maintainer_assigned=true
|
||||
fi
|
||||
gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --add-assignee "$author"
|
||||
maintainer_assigned=true
|
||||
fi
|
||||
|
||||
# Otherwise, assign an owner: the least-loaded area owner, with LLM
|
||||
# rank as a tiebreaker (load primary, rank secondary). Symmetric with
|
||||
# the PR reviewer path. Skipped if the maintainer-author was already
|
||||
# assigned above. Every triaged issue gets an owner — the only issues
|
||||
# assigned above. Every triaged issue gets an owner — duplicates
|
||||
# included, even ones about to be closed below — so the only issues
|
||||
# left unassigned are needs_info ones (too vague to route until the
|
||||
# reporter adds detail).
|
||||
needs_info=$(jq -r '.needs_info // false' /tmp/triage_result.json)
|
||||
@@ -794,11 +781,26 @@ jobs:
|
||||
|
||||
assignee=$(cat /tmp/assignee.txt)
|
||||
if [ -n "$assignee" ]; then
|
||||
gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --add-assignee "$assignee"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Finally, close the issue if it is a high-confidence duplicate and
|
||||
# closure is enabled. The owner assigned above stays on the issue,
|
||||
# ready if it is reopened.
|
||||
duplicate_decision=$(jq -r '.duplicate_decision' /tmp/triage_result.json)
|
||||
if [ "$duplicate_decision" = "duplicate" ]; then
|
||||
close_duplicate_issue=$(jq -r '.close_duplicate_issue' /tmp/triage_result.json)
|
||||
if [ "$close_duplicate_issue" = "true" ]; then
|
||||
duplicate_of=$(jq -r '.duplicate_of' /tmp/triage_result.json)
|
||||
issue_state=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" \
|
||||
--json state --jq '.state')
|
||||
if [ "$issue_state" = "OPEN" ]; then
|
||||
gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --add-assignee "$assignee"
|
||||
gh issue close "$ISSUE_NUMBER" --repo "$REPO" \
|
||||
--duplicate-of "$duplicate_of"
|
||||
fi
|
||||
else
|
||||
echo "Duplicate closure disabled; leaving issue open."
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
name: Waiting on Author Review Run
|
||||
|
||||
# Privileged half of the fork-review relay. The artifact contains numeric IDs
|
||||
# only; the script re-fetches the PR and review/comment from GitHub before using
|
||||
# actor identity, review state, or comment content. No PR code is checked out.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [Waiting on Author Review]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: waiting-on-author-review-run-${{ github.event.workflow_run.head_sha }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
hygiene:
|
||||
if: >-
|
||||
github.repository == 'omnigent-ai/omnigent'
|
||||
&& github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Download recorded review event IDs
|
||||
id: download
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const { owner, repo } = context.repo;
|
||||
const arts = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner, repo, run_id: context.payload.workflow_run.id,
|
||||
});
|
||||
const art = arts.data.artifacts.find(
|
||||
a => a.name === 'waiting-on-author-review-event'
|
||||
);
|
||||
if (!art) {
|
||||
core.info('No fork-review artifact; the direct review job handled this event.');
|
||||
core.setOutput('found', 'false');
|
||||
return;
|
||||
}
|
||||
const dl = await github.rest.actions.downloadArtifact({
|
||||
owner, repo, artifact_id: art.id, archive_format: 'zip',
|
||||
});
|
||||
fs.writeFileSync(
|
||||
`${process.env.RUNNER_TEMP}/waiting-on-author-review.zip`,
|
||||
Buffer.from(dl.data)
|
||||
);
|
||||
core.setOutput('found', 'true');
|
||||
|
||||
- name: Check out trusted .github
|
||||
if: steps.download.outputs.found == 'true'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
sparse-checkout: .github
|
||||
persist-credentials: false
|
||||
|
||||
- name: Apply relayed waiting-on-author state
|
||||
if: steps.download.outputs.found == 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
RELAY_ZIP: ${{ runner.temp }}/waiting-on-author-review.zip
|
||||
RELAY_DIR: ${{ runner.temp }}/waiting-on-author-review
|
||||
WAITING_ON_AUTHOR_RELAY_EVENT: ${{ github.event.workflow_run.event }}
|
||||
WAITING_ON_AUTHOR_RELAY_PATH: ${{ runner.temp }}/waiting-on-author-review/event.json
|
||||
run: |
|
||||
mkdir -p "$RELAY_DIR"
|
||||
unzip -q "$RELAY_ZIP" -d "$RELAY_DIR"
|
||||
python3 .github/scripts/waiting_on_author.py
|
||||
@@ -0,0 +1,71 @@
|
||||
name: Waiting on Author Review
|
||||
|
||||
# Review events on fork PRs receive a read-only token. Same-repo reviews run the
|
||||
# hygiene script directly; fork reviews record only GitHub-provided numeric IDs
|
||||
# for the privileged workflow_run consumer. No PR code is checked out.
|
||||
|
||||
on:
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: waiting-on-author-review-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
hygiene:
|
||||
if: >-
|
||||
github.repository == 'omnigent-ai/omnigent'
|
||||
&& github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check out .github
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
sparse-checkout: .github
|
||||
persist-credentials: false
|
||||
- name: Update waiting-on-author state
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: python3 .github/scripts/waiting_on_author.py
|
||||
|
||||
record:
|
||||
if: >-
|
||||
github.repository == 'omnigent-ai/omnigent'
|
||||
&& github.event.pull_request.head.repo.full_name != github.repository
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Record review event IDs
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
PULL_NUMBER: ${{ github.event.pull_request.number }}
|
||||
ACTIVITY_ID: ${{ github.event.review.id || github.event.comment.id }}
|
||||
run: |
|
||||
mkdir -p relay
|
||||
jq -n \
|
||||
--arg event_name "$EVENT_NAME" \
|
||||
--argjson pull_number "$PULL_NUMBER" \
|
||||
--argjson activity_id "$ACTIVITY_ID" \
|
||||
'{event_name: $event_name, pull_number: $pull_number, activity_id: $activity_id}' \
|
||||
> relay/event.json
|
||||
- name: Upload review event IDs
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: waiting-on-author-review-event
|
||||
path: relay/event.json
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
@@ -9,6 +9,8 @@ on:
|
||||
- .github/scripts/waiting_on_author.py
|
||||
- .github/scripts/waiting_on_author_test.py
|
||||
- .github/workflows/waiting-on-author.yml
|
||||
- .github/workflows/waiting-on-author-review.yml
|
||||
- .github/workflows/waiting-on-author-review-run.yml
|
||||
- .github/workflows/waiting-on-author-test.yml
|
||||
workflow_dispatch:
|
||||
|
||||
|
||||
@@ -6,16 +6,14 @@ name: Waiting on Author Hygiene
|
||||
# once a review is submitted). The two labels are mutually exclusive. PRs left
|
||||
# waiting on the author for 7 days are closed. The workflow runs from trusted
|
||||
# default-branch code and never checks out PR-authored files.
|
||||
# Review events use the read-only -> workflow_run relay in
|
||||
# waiting-on-author-review.yml and waiting-on-author-review-run.yml.
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [synchronize, labeled]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
schedule:
|
||||
- cron: "0 */12 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -159,6 +159,13 @@ POSTGRES_PASSWORD=change-me-please
|
||||
|
||||
# ── Optional OIDC tuning ─────────────────────────────────
|
||||
# OMNIGENT_OIDC_SESSION_TTL_HOURS=8
|
||||
#
|
||||
# Absolute lifetime (days) of login-issued refresh grants — how long a
|
||||
# host/CLI that logged in with `omnigent login` can keep renewing its
|
||||
# access before a human must log in again. Default 30. Unattended hosts
|
||||
# renew automatically within this window; each host replica needs its
|
||||
# own login (refresh tokens rotate — shared copies revoke each other).
|
||||
# OMNIGENT_GRANT_MAX_LIFETIME_DAYS=30
|
||||
# OMNIGENT_OIDC_LOGOUT_REDIRECT_URI=https://omnigent.example.com/
|
||||
#
|
||||
# Skip the email_verified claim check on id_tokens. Some IdPs (e.g.
|
||||
|
||||
+88
-12
@@ -17,9 +17,11 @@
|
||||
> enforcement in `omnigent/server/auth.py` (`delegated_path_allowed`,
|
||||
> `set_grant_revocation_check`). Wired in `omnigent/server/app.py`,
|
||||
> **opt-in and default-off** via `OMNIGENT_DEVICE_GRANT_ENABLED` (the
|
||||
> `/oauth/*` routes are unmounted unless it is truthy), and then only in
|
||||
> **accounts** mode (OIDC delegates login to the IdP via the cli-ticket
|
||||
> flow and never mounts these routes).
|
||||
> `/oauth/device/*` consent routes are unmounted unless it is truthy), and
|
||||
> then only in **accounts** mode (OIDC delegates login to the IdP via the
|
||||
> cli-ticket flow and never mounts the consent routes). The token/revoke
|
||||
> half (`/oauth/token`, `/oauth/revoke`) mounts **unconditionally** in both
|
||||
> accounts and OIDC modes: login-issued refresh grants (below) need it.
|
||||
> Slack: `integrations/slack/src/omnigent_slack/oauth.py`,
|
||||
> `tokens.py` (Fernet-encrypted `oauth_tokens`), `auth_manager.py`, plus
|
||||
> the bearer/refresh wiring in `omnigent.py` (`ClientAuth`,
|
||||
@@ -182,15 +184,20 @@ approved it.
|
||||
|
||||
### Router `omnigent/server/routes/device_auth.py`
|
||||
|
||||
Mounted in `app.py` only when **`OMNIGENT_DEVICE_GRANT_ENABLED` is truthy**
|
||||
(opt-in, **default-off** — the `/oauth/*` routes are absent otherwise), and
|
||||
then **only in `accounts` mode** (OIDC delegates login to the IdP via the
|
||||
cli-ticket flow and never mounts these routes; header mode has no
|
||||
server-mintable identity — see `create_device_auth_router`, which raises if
|
||||
constructed for any other source). The `device_grants` table is created
|
||||
unconditionally by the migration regardless of the flag; only the router
|
||||
mount is gated. This router **owns** `mint_delegated_token` and
|
||||
`DELEGATED_SCOPE`.
|
||||
The RFC 8628 consent surface (`/oauth/device/*`) is mounted in `app.py`
|
||||
only when **`OMNIGENT_DEVICE_GRANT_ENABLED` is truthy** (opt-in,
|
||||
**default-off**), and then **only in `accounts` mode** (the in-browser
|
||||
consent needs the accounts login page; header mode has no server-mintable
|
||||
identity — see `create_device_auth_router`, which raises if constructed for
|
||||
any other source). The token/revoke half is factored into
|
||||
`create_oauth_token_router` and mounts **unconditionally** in both accounts
|
||||
and OIDC modes — login-issued refresh grants need `/oauth/token` even where
|
||||
the device flow is off; a standalone mount refuses the `device_code` grant
|
||||
type with `unsupported_grant_type`. The `device_grants` table is created
|
||||
unconditionally by the migration regardless of the flag; only the consent
|
||||
mount is gated. This router also **owns** `mint_delegated_token` and
|
||||
`DELEGATED_SCOPE` (moved here from `oidc.py`, which retains only
|
||||
`mint_session_token` / `mint_session_cookie`).
|
||||
|
||||
- `POST /oauth/device/authorize` — **public** (rate-limited). Generates a
|
||||
high-entropy `device_code` (`secrets.token_urlsafe`, stored **hashed**), a
|
||||
@@ -355,3 +362,72 @@ stateless. This added invariant is the main thing for reviewers to scrutinize.
|
||||
- Applying the same delegated grant to other non-browser clients (the CLI could
|
||||
use it too, superseding the in-memory `_cli_tickets` store).
|
||||
- Per-scope consent granularity beyond the single "session APIs, no admin" scope.
|
||||
|
||||
## Login-issued refresh grants
|
||||
|
||||
The fix for unattended hosts dying at session-JWT expiry (a host
|
||||
authenticated via `omnigent login` previously had **no renewal path** —
|
||||
the stored `{token, user_id, expires_at}` record simply lapsed, default
|
||||
8 h, and the next tunnel reconnect got a misleading 403).
|
||||
|
||||
- **Issuance** — both interactive login flows create a grant born
|
||||
`redeemed` (`DeviceGrantStore.create_redeemed_grant`; the interactive
|
||||
login *is* the consent step, so no device-code dance): the OIDC
|
||||
cli-ticket fulfillment always, and accounts `POST /auth/login` when the
|
||||
body carries `issue_refresh: true` (sent by the CLI, never the web
|
||||
form — a browser must not receive refresh material). The raw refresh
|
||||
token rides back once (`/auth/cli-poll` / the login response) as an
|
||||
optional `refresh_token` key — old CLIs ignore it, new CLIs against old
|
||||
servers see it absent. `client_id` is `"omnigent-cli"`.
|
||||
- **Authority** — a refreshed login-grant token carries `grant_id` (so
|
||||
revocation still kills it) but **no `scope` claim**, so the delegated
|
||||
path allowlist does not apply: it renews the session JWT and keeps that
|
||||
same authority. Scoping it like a third-party device grant instead made
|
||||
every non-allowlisted route (`/v1/usage`, `/v1/scheduled-tasks`,
|
||||
`/v1/policy-registry`) 401 after the first refresh. `LOGIN_GRANT_CLIENT_ID`
|
||||
is the marker and is **reserved** — `/oauth/device/authorize` refuses a
|
||||
request naming it, so a device client cannot self-declare into this class.
|
||||
- **No rotation** — login grants return the SAME refresh token on every
|
||||
renewal. Rotation + reuse detection is right for a browser-adjacent
|
||||
third-party client, but for an unattended host it turns any ambiguous
|
||||
network failure (lost response, crash between the server committing and
|
||||
the client persisting) into a permanently revoked grant. Only the
|
||||
short-lived access token is renewed; revocation and the absolute lifetime
|
||||
cap still bound exposure. Device grants keep rotating.
|
||||
- **Renewal** — `omnigent.cli_auth.refresh_stored_token` POSTs
|
||||
`grant_type=refresh_token`, persists the result, and returns the
|
||||
fresh access token. The runner/host auth-token factory
|
||||
calls it when the stored token lapses, and the host tunnel rebuilds
|
||||
headers through that factory on every reconnect — so an unattended
|
||||
host renews itself for the grant's lifetime. Refreshes on one machine
|
||||
are serialized by an advisory file lock with a re-check after acquire,
|
||||
so a losing racer picks up the winner's rotated pair instead of
|
||||
replaying the stale token (which reuse detection would punish by
|
||||
revoking the grant).
|
||||
- **One grant per host replica (recommended)** — since login grants do
|
||||
not rotate, N replicas sharing one token file no longer revoke each
|
||||
other. A per-replica login is still preferred so a single host can be
|
||||
revoked without cutting off the rest.
|
||||
- **Lifetime** — the absolute grant lifetime stays 30 days by default;
|
||||
`OMNIGENT_GRANT_MAX_LIFETIME_DAYS` lets an operator extend it
|
||||
deliberately for long-lived unattended hosts. Invalid values fall back
|
||||
to the default (never fail open to unbounded).
|
||||
- **Client-secret interaction** — `OMNIGENT_DEVICE_CLIENT_SECRET` gates
|
||||
the endpoints that mint from an ephemeral code (device authorize + the
|
||||
`device_code` exchange). Refresh and revoke are NOT gated: the presented
|
||||
refresh/access token is itself the credential, and a CLI renewing its own
|
||||
login has no way to carry that secret (gating it made every automatic
|
||||
refresh 401 in a Slack-serving deployment).
|
||||
- **Housekeeping** — `/oauth/token` opportunistically purges expired and
|
||||
aged-out grants. The device flow purges on `authorize`, which a
|
||||
standalone token-router mount does not have, so login-grant rows would
|
||||
otherwise accumulate one per login.
|
||||
|
||||
### Known limitation
|
||||
|
||||
General CLI commands (`omnigent usage`, session commands, direct
|
||||
remote-URL chat) still read the stored token without attempting a refresh —
|
||||
only the host/runner auth-token factory renews today. An expired login with
|
||||
valid refresh material therefore leaves those commands unauthenticated
|
||||
until something on the host path renews the shared file. Wiring the
|
||||
remaining CLI entrypoints is follow-up work.
|
||||
|
||||
@@ -72,11 +72,22 @@ they still work with no runner or LLM.
|
||||
|
||||
| Journey | Operation timed |
|
||||
| --- | --- |
|
||||
| `native_hook_spawn` | Spawn the per-chunk `MessageDisplay` hook exactly as Claude Code does — isolated interpreter, module entrypoint, JSON payload on stdin |
|
||||
| `native_hook_spawn` | Spawn one **Python** command hook — isolated interpreter, module entrypoint, JSON payload on stdin — and time its whole lifetime |
|
||||
|
||||
Claude Code **blocks its TUI** on command hooks, so one hook subprocess's
|
||||
lifetime is user-visible streaming latency, and the same interpreter+import
|
||||
cost fronts every statusline refresh and per-tool-call policy hook. The
|
||||
lifetime is user-visible latency. Read this number as *"what a hook costs if it
|
||||
is Python"*.
|
||||
|
||||
It is **not** the per-chunk streaming cost, and treating it as one leads
|
||||
straight to wasted work. The hooks that fire per chunk (`MessageDisplay`) and
|
||||
per tool call (`evaluate-policy`) were deliberately moved off the interpreter —
|
||||
a `/bin/sh` appender and a `curl` to the runner's relay — and
|
||||
`test_message_display_shell_command_round_trips` pins that by asserting
|
||||
`"python"` is absent from the installed command. What still pays this number is
|
||||
the per-turn set (`SessionStart` / `Stop` / `UserPromptSubmit` / `PreCompact` /
|
||||
`Task*`), the `PostToolUse` `TodoWrite`+`TaskUpdate` matchers, and the policy
|
||||
hook's Python fallback before the relay is up. So the journey's real job is to
|
||||
keep the argument for staying off the interpreter measurable. The
|
||||
journey needs no server or runner; registering it here rides hook spawn cost
|
||||
on the same nightly/release regression comparison as everything else
|
||||
(`omnigent/__init__` re-exports lazily so this stays ~interpreter-sized). The
|
||||
|
||||
@@ -881,12 +881,21 @@ async def _measure_cli_startup(env: BenchEnvironment, _ctx: JourneyContext) -> N
|
||||
# ── native hook spawn (no server involved) ───────────────────
|
||||
|
||||
# Claude Code blocks its TUI on command hooks, so one hook subprocess's whole
|
||||
# lifetime is user-visible latency: the MessageDisplay hook runs once per
|
||||
# streamed text chunk, and the same interpreter+import cost fronts every
|
||||
# statusline refresh and per-tool-call policy hook. Spawn the per-chunk hook
|
||||
# exactly as Claude Code does — isolated interpreter, module entrypoint, JSON
|
||||
# payload on stdin — and time the full process lifetime. The import-graph side
|
||||
# of this guarantee is pinned by tests/test_claude_native_message_display_hook.
|
||||
# lifetime is user-visible latency. This journey times that lifetime for a
|
||||
# Python hook — isolated interpreter, module entrypoint, JSON payload on stdin —
|
||||
# which is the cost of ANY hook the bridge installs as a `python -m` command.
|
||||
#
|
||||
# It is NOT the per-chunk streaming path. The hooks that fire per chunk
|
||||
# (MessageDisplay) and per tool call (evaluate-policy) were deliberately moved
|
||||
# off the interpreter — a /bin/sh appender and a curl to the runner's relay
|
||||
# respectively — and tests pin that (`test_message_display_shell_command_round_trips`
|
||||
# asserts "python" is absent from the installed command). What still pays this
|
||||
# is the per-turn set (SessionStart / Stop / UserPromptSubmit / PreCompact /
|
||||
# Task*), the PostToolUse TodoWrite+TaskUpdate matchers, and the policy hook's
|
||||
# Python fallback when the relay is not yet up. So read this number as
|
||||
# "what a hook costs if it is Python", and as the standing argument for keeping
|
||||
# the hot paths off it — not as a per-chunk cost. The import-graph side is
|
||||
# pinned by tests/test_claude_native_message_display_hook.py.
|
||||
_HOOK_SPAWN_PAYLOAD = json.dumps(
|
||||
{
|
||||
"hook_event_name": "MessageDisplay",
|
||||
@@ -905,7 +914,7 @@ async def _setup_hook_spawn(env: BenchEnvironment) -> JourneyContext:
|
||||
|
||||
|
||||
async def _measure_hook_spawn(env: BenchEnvironment, ctx: JourneyContext) -> None:
|
||||
"""Spawn the MessageDisplay hook once, as Claude Code does, and wait."""
|
||||
"""Spawn one Python hook subprocess and wait, as Claude Code would."""
|
||||
del env
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
@@ -1082,7 +1091,12 @@ ALL_JOURNEYS: dict[str, Journey] = {
|
||||
measure=_measure_hook_spawn,
|
||||
setup=_setup_hook_spawn,
|
||||
teardown=_teardown_hook_spawn,
|
||||
description="Spawn the per-chunk MessageDisplay hook exactly as Claude Code does.",
|
||||
description=(
|
||||
"Spawn one Python command hook (isolated interpreter, module "
|
||||
"entrypoint) and time its whole lifetime — what any `python -m` "
|
||||
"hook costs Claude's blocked TUI. Not the per-chunk path: that "
|
||||
"one is a /bin/sh appender."
|
||||
),
|
||||
),
|
||||
Journey(
|
||||
name="cli_startup",
|
||||
|
||||
@@ -180,7 +180,12 @@ def _register_error_handler(app: AsyncApp, logger: logging.Logger) -> None:
|
||||
|
||||
@app.error
|
||||
async def _on_error(error: Exception, body: dict[str, Any]) -> None:
|
||||
logger.exception("Unhandled Slack listener error; body_type=%s", body.get("type"))
|
||||
logger.error(
|
||||
"Unhandled Slack listener error: %s; body_type=%s",
|
||||
error,
|
||||
body.get("type"),
|
||||
exc_info=(type(error), error, error.__traceback__),
|
||||
)
|
||||
|
||||
|
||||
def register_handlers(app: AsyncApp, service: SlackOmnigentService) -> None:
|
||||
|
||||
@@ -20,12 +20,37 @@ async def test_error_handler_logs_with_traceback(caplog: pytest.LogCaptureFixtur
|
||||
error_handler = app._async_middleware_error_handler
|
||||
assert error_handler is not None
|
||||
|
||||
with caplog.at_level(logging.ERROR, logger="test-app-error"):
|
||||
def raise_error() -> RuntimeError:
|
||||
try:
|
||||
raise RuntimeError("boom")
|
||||
except RuntimeError as exc:
|
||||
await error_handler.func(error=exc, body={"type": "event_callback"})
|
||||
return exc
|
||||
|
||||
assert any("Unhandled Slack listener error" in r.message for r in caplog.records)
|
||||
# The exception traceback is attached (logger.exception), not just the message.
|
||||
assert any(r.exc_info for r in caplog.records)
|
||||
error = raise_error()
|
||||
with caplog.at_level(logging.ERROR, logger="test-app-error"):
|
||||
await error_handler.func(error=error, body={"type": "event_callback"})
|
||||
|
||||
record = caplog.records[-1]
|
||||
assert "Unhandled Slack listener error: boom" in record.message
|
||||
assert record.exc_info == (RuntimeError, error, error.__traceback__)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_handler_logs_exception_without_active_traceback(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""An exception passed outside an ``except`` block still logs usefully."""
|
||||
app = AsyncApp(token="xoxb-dummy", signing_secret="x")
|
||||
logger = logging.getLogger("test-app-error-no-traceback")
|
||||
_register_error_handler(app, logger)
|
||||
error_handler = app._async_middleware_error_handler
|
||||
assert error_handler is not None
|
||||
|
||||
error = ValueError("created outside an except block")
|
||||
with caplog.at_level(logging.ERROR, logger="test-app-error-no-traceback"):
|
||||
await error_handler.func(error=error, body={"type": "event_callback"})
|
||||
|
||||
record = caplog.records[-1]
|
||||
assert "created outside an except block" in record.message
|
||||
assert record.exc_info == (ValueError, error, None)
|
||||
assert "NoneType: None" not in caplog.text
|
||||
|
||||
@@ -49,6 +49,19 @@ def record_post_failure(event_type: str, error: BaseException) -> None:
|
||||
_state["last_post_failure"] = (time.monotonic(), f"{event_type}: {error!r}")
|
||||
|
||||
|
||||
def record_transport_failure(detail: str) -> None:
|
||||
"""Record an already-formatted transport failure into the shared slot.
|
||||
|
||||
The SDK codex head has no forwarder POST path, but its subprocess still
|
||||
runs under the same idle-turn watchdog. When the model gateway rejects the
|
||||
CLI (e.g. a 401 read off the CLI's stderr), the turn emits no events and the
|
||||
watchdog would otherwise blame a generic "wedged LLM". Recording the parsed
|
||||
cause here lets the watchdog attribute the real failure, exactly as the
|
||||
native forwarder path does. *detail* is a ready-to-surface human string.
|
||||
"""
|
||||
_state["last_post_failure"] = (time.monotonic(), detail)
|
||||
|
||||
|
||||
def note_post_success() -> None:
|
||||
"""
|
||||
Clear the failure record after a POST that reached the server.
|
||||
|
||||
@@ -146,6 +146,10 @@ def post_may_have_been_delivered(exc: httpx.HTTPError) -> bool:
|
||||
- Connection-establishment / pool-acquire failures
|
||||
(:data:`_DELIVERY_SAFE_RETRY_ERRORS`): no bytes were sent → not
|
||||
delivered → safe to retry, so ``False``.
|
||||
- An unbound ``RequestError``: the failure occurred before httpx
|
||||
associated the exception with the outbound request (for example,
|
||||
an auth flow failed before yielding it). No bytes were sent → safe
|
||||
to retry, so ``False``.
|
||||
- Any other transport error (read/write timeout, read/write error,
|
||||
remote protocol error): the request was sent and we never saw a
|
||||
response, so the server may have processed it → ambiguous →
|
||||
@@ -159,6 +163,10 @@ def post_may_have_been_delivered(exc: httpx.HTTPError) -> bool:
|
||||
return False
|
||||
if isinstance(exc, _DELIVERY_SAFE_RETRY_ERRORS):
|
||||
return False
|
||||
try:
|
||||
_ = exc.request
|
||||
except RuntimeError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -800,10 +800,13 @@ async def _prepare_antigravity_terminal_via_daemon(
|
||||
_update_progress(startup_progress, "Creating Antigravity session...")
|
||||
bridge_id = _mint_agy_conversation_id()
|
||||
conversation_id = bridge_id
|
||||
session_id = await _create_antigravity_session(
|
||||
client,
|
||||
session_bundle,
|
||||
bridge_id=bridge_id,
|
||||
session_id, _ = await asyncio.gather(
|
||||
_create_antigravity_session(
|
||||
client,
|
||||
session_bundle,
|
||||
bridge_id=bridge_id,
|
||||
),
|
||||
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
|
||||
)
|
||||
else:
|
||||
_update_progress(startup_progress, "Loading Antigravity session...")
|
||||
@@ -847,7 +850,8 @@ async def _prepare_antigravity_terminal_via_daemon(
|
||||
conversation_id = external if isinstance(external, str) and external else bridge_id
|
||||
resume = isinstance(external, str) and bool(external)
|
||||
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
if not fresh_session:
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
_update_progress(startup_progress, "Starting runner...")
|
||||
runner_id = await launch_or_reuse_daemon_runner(
|
||||
client,
|
||||
|
||||
+19
-14
@@ -1595,22 +1595,23 @@ async def _prepare_chat_session_via_daemon(
|
||||
)
|
||||
from omnigent.native_terminal import bind_session_runner
|
||||
|
||||
try:
|
||||
async def resolve_session() -> tuple[str, bool]:
|
||||
"""Fork, resume, or create the session to bind, and say if it is fresh.
|
||||
|
||||
:returns: The session id and whether it was created just now.
|
||||
:raises click.ClickException: If the server rejects the create/fork.
|
||||
"""
|
||||
async with OmnigentClient(base_url=base_url, headers=headers, auth=auth) as sdk:
|
||||
try:
|
||||
if fork_session_id is not None:
|
||||
fork_result = await sdk.sessions.fork(fork_session_id)
|
||||
session_id = fork_result["id"]
|
||||
fresh_session = False
|
||||
elif resume_conversation_id is not None:
|
||||
session_id = resume_conversation_id
|
||||
fresh_session = False
|
||||
else:
|
||||
created = await sdk.sessions.create(
|
||||
bundle, filename="agent.tar.gz", workspace=workspace
|
||||
)
|
||||
session_id = created.id
|
||||
fresh_session = True
|
||||
return fork_result["id"], False
|
||||
if resume_conversation_id is not None:
|
||||
return resume_conversation_id, False
|
||||
created = await sdk.sessions.create(
|
||||
bundle, filename="agent.tar.gz", workspace=workspace
|
||||
)
|
||||
return created.id, True
|
||||
except ClientOmnigentError as exc:
|
||||
# Any create/fork/resume rejection here is a server-side answer, not
|
||||
# a client bug worth a traceback: a wrong base URL that answers
|
||||
@@ -1621,6 +1622,7 @@ async def _prepare_chat_session_via_daemon(
|
||||
f"Could not start a session on {base_url}: {exc}"
|
||||
) from exc
|
||||
|
||||
try:
|
||||
# A separate raw httpx client for the host-runner protocol (the daemon
|
||||
# launch helpers operate on httpx, not the SDK), pinned to the host's replica.
|
||||
timeout = httpx.Timeout(30.0, read=120.0)
|
||||
@@ -1629,8 +1631,11 @@ async def _prepare_chat_session_via_daemon(
|
||||
) as client:
|
||||
if progress is not None:
|
||||
progress.update(STARTUP_PHASE_CONNECTING)
|
||||
await wait_for_host_online(
|
||||
client, host_id, timeout_s=_DAEMON_CHAT_HOST_ONLINE_TIMEOUT_S
|
||||
(session_id, fresh_session), _ = await asyncio.gather(
|
||||
resolve_session(),
|
||||
wait_for_host_online(
|
||||
client, host_id, timeout_s=_DAEMON_CHAT_HOST_ONLINE_TIMEOUT_S
|
||||
),
|
||||
)
|
||||
if progress is not None:
|
||||
progress.update(STARTUP_PHASE_LAUNCHING_AGENT)
|
||||
|
||||
@@ -55,6 +55,21 @@ CUSTOM_MODEL_OPTION_ENV_VAR = "ANTHROPIC_CUSTOM_MODEL_OPTION"
|
||||
#: takes — so it is not part of the vocabulary below.
|
||||
CUSTOM_MODEL_OPTION_NAME_ENV_VAR = "ANTHROPIC_CUSTOM_MODEL_OPTION_NAME"
|
||||
|
||||
#: BACK-COMPAT. Omnigent's picker-row id for the custom slot. Named for the
|
||||
#: model the slot first carried (Sonnet 5, which had no family alias of its
|
||||
#: own), but a Smart Routing launch pins ITS model there, so the id does not
|
||||
#: describe the contents — read the slot, never this name. Sessions persist
|
||||
#: it as a model override, so retiring it needs a migration; slated for
|
||||
#: removal in 0.10.0 along with the row, once the ``sonnet`` pin is Sonnet 5.
|
||||
LEGACY_CUSTOM_SLOT_ROW_ID = "sonnet_5"
|
||||
|
||||
#: BACK-COMPAT. Spellings the pre-0.10 substring test read as "this is the
|
||||
#: custom slot's model", kept because :func:`normalized_model_id` does not
|
||||
#: fold a vendor-prefixed ``anthropic/claude-sonnet-5`` onto the catalog id
|
||||
#: the slot holds. Consulted only after an exact match misses; retired with
|
||||
#: :data:`LEGACY_CUSTOM_SLOT_ROW_ID` in 0.10.0.
|
||||
LEGACY_CUSTOM_SLOT_SPELLINGS: tuple[str, ...] = ("sonnet-5", "sonnet_5")
|
||||
|
||||
#: Launch-env keys that define this session's model vocabulary.
|
||||
MODEL_VOCABULARY_ENV_VARS: tuple[str, ...] = (
|
||||
*ALIAS_MODEL_ENV_VARS.values(),
|
||||
@@ -162,6 +177,14 @@ def claude_model_alias(
|
||||
candidate = model.strip().lower()
|
||||
if candidate in CLAUDE_MODEL_ALIASES:
|
||||
return candidate
|
||||
# Bracket variants of the family aliases (``sonnet[1m]``) are settable
|
||||
# aliases in their own right — the harness enumerates them in /model's
|
||||
# usage line and resolves the marker itself (the family pin plus the
|
||||
# marker on a pinned env). Stepping one down to its family would
|
||||
# silently drop the marker; refusing it blocks a switch the pane accepts.
|
||||
base, bracket, marker = candidate.partition("[")
|
||||
if bracket and marker.endswith("]") and base in CLAUDE_MODEL_ALIASES:
|
||||
return candidate
|
||||
pins = alias_pins(env)
|
||||
normalized = normalized_model_id(model)
|
||||
for alias, pinned in pins.items():
|
||||
@@ -202,4 +225,14 @@ def claude_model_command_arg(
|
||||
custom = environ.get(CUSTOM_MODEL_OPTION_ENV_VAR, "").strip()
|
||||
if custom and normalized_model_id(custom) == normalized_model_id(model):
|
||||
return custom
|
||||
candidate = model.strip()
|
||||
if not alias_pins(env) and candidate.lower().startswith("claude-"):
|
||||
# A full Anthropic model id names an EXACT generation, and ``/model``
|
||||
# on an unpinned (canonical-endpoint) session accepts full ids
|
||||
# verbatim — the same spelling the harness's own enumeration
|
||||
# resolves. Stepping down to the family alias here would switch to
|
||||
# claude's CURRENT generation of that family instead (picking
|
||||
# "Opus 4.8 (1M context)" used to type ``/model opus`` and land on
|
||||
# Opus 5).
|
||||
return candidate
|
||||
return claude_model_alias(model, env)
|
||||
|
||||
+570
-53
@@ -81,8 +81,11 @@ from omnigent._wrapper_labels import (
|
||||
)
|
||||
from omnigent.claude_launcher import resolve_claude_launch
|
||||
from omnigent.claude_model_vocabulary import (
|
||||
ALIAS_MODEL_ENV_VARS,
|
||||
CUSTOM_MODEL_OPTION_ENV_VAR,
|
||||
CUSTOM_MODEL_OPTION_NAME_ENV_VAR,
|
||||
LEGACY_CUSTOM_SLOT_ROW_ID,
|
||||
claude_model_alias,
|
||||
)
|
||||
from omnigent.claude_native_bridge import (
|
||||
BRIDGE_ID_LABEL_KEY,
|
||||
@@ -112,7 +115,6 @@ from omnigent.host.daemon_launch import (
|
||||
wait_for_host_online,
|
||||
wait_for_runner_online,
|
||||
)
|
||||
from omnigent.model_fallbacks import static_model_fallback
|
||||
from omnigent.native_coding_agents import native_shell_terminal_spec
|
||||
from omnigent.native_terminal import (
|
||||
DAEMON_HOST_ONLINE_TIMEOUT_S as _DAEMON_HOST_ONLINE_TIMEOUT_S,
|
||||
@@ -132,7 +134,6 @@ from omnigent.native_terminal import (
|
||||
from omnigent.native_terminal import (
|
||||
terminal_attach_url as _attach_url,
|
||||
)
|
||||
from omnigent.onboarding.provider_config import SUBSCRIPTION_KIND
|
||||
from omnigent.terminals.ws_bridge import (
|
||||
WS_CLOSE_TERMINAL_DETACHED,
|
||||
WS_CLOSE_TERMINAL_NOT_FOUND,
|
||||
@@ -193,6 +194,18 @@ _CLAUDE_CODE_NESTED_SESSION_ENV = "CLAUDECODE"
|
||||
_CLAUDE_CODE_API_KEY_HELPER_TTL_ENV = "CLAUDE_CODE_API_KEY_HELPER_TTL_MS"
|
||||
_CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS_ENV = "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS"
|
||||
_CLAUDE_CODE_USE_GATEWAY_ENV = "CLAUDE_CODE_USE_GATEWAY"
|
||||
#: Kill-switch Claude Code treats as covering nonessential startup traffic;
|
||||
#: the probe strips it so speed knobs never mask harness output.
|
||||
_CLAUDE_NONESSENTIAL_TRAFFIC_ENV = "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"
|
||||
_CLAUDE_MODEL_PROBE_TIMEOUT_S = 20.0
|
||||
#: Wall-clock cap for the per-alias resolution fan-out as a whole; aliases
|
||||
#: still unresolved when it expires keep their bare rows (the cache's
|
||||
#: revalidation retries them later). Startup dominates each run and
|
||||
#: stretches with box load (measured 0.7s–17s for the same command), so the
|
||||
#: concurrency covers a whole alias set in one wave and the budget fits one
|
||||
#: slow wave.
|
||||
_CLAUDE_ALIAS_RESOLUTION_BUDGET_S = 30.0
|
||||
_CLAUDE_ALIAS_RESOLUTION_CONCURRENCY = 12
|
||||
_CLAUDE_CODE_ENABLE_TOOL_SEARCH_ENV = "ENABLE_TOOL_SEARCH"
|
||||
_CLAUDE_CODE_CUSTOM_HEADERS_ENV = "ANTHROPIC_CUSTOM_HEADERS"
|
||||
# Claude Code forwards the ANTHROPIC_CUSTOM_HEADERS value verbatim as
|
||||
@@ -233,15 +246,8 @@ _UCODE_CLAUDE_TIER_TO_ENV: dict[str, str] = {
|
||||
# See https://code.claude.com/docs/en/model-config#custom-model-options
|
||||
_ANTHROPIC_CUSTOM_MODEL_OPTION_ENV = CUSTOM_MODEL_OPTION_ENV_VAR
|
||||
_ANTHROPIC_CUSTOM_MODEL_OPTION_NAME_ENV = CUSTOM_MODEL_OPTION_NAME_ENV_VAR
|
||||
_UCODE_CLAUDE_CUSTOM_TIER = "sonnet_5"
|
||||
_UCODE_CLAUDE_CUSTOM_TIER = LEGACY_CUSTOM_SLOT_ROW_ID
|
||||
_UCODE_CLAUDE_CUSTOM_TIER_LABEL = "Sonnet 5"
|
||||
_CLAUDE_NATIVE_STATIC_MODEL_OPTIONS: tuple[tuple[str, str], ...] = (
|
||||
("fable", "Fable"),
|
||||
("opus", "Opus"),
|
||||
("sonnet", "Sonnet 4.6"),
|
||||
(_UCODE_CLAUDE_CUSTOM_TIER, _UCODE_CLAUDE_CUSTOM_TIER_LABEL),
|
||||
("haiku", "Haiku"),
|
||||
)
|
||||
_DEFAULT_UCODE_AUTH_REFRESH_INTERVAL_MS = 900_000
|
||||
_SESSION_LABELS = {
|
||||
"omnigent.ui": "terminal",
|
||||
@@ -409,6 +415,57 @@ def _serves_canonical_anthropic_ids(claude_config: ClaudeNativeUcodeConfig) -> b
|
||||
return host == "anthropic.com" or host.endswith(".anthropic.com")
|
||||
|
||||
|
||||
def _claude_family(token: str) -> str | None:
|
||||
"""
|
||||
The family alias a model id or alias folds onto, bracket markers dropped.
|
||||
|
||||
:param token: A picker id or model id, e.g. ``"opus[1m]"``,
|
||||
``"claude-opus-4-8"``.
|
||||
:returns: The family alias, e.g. ``"opus"``, or ``None`` for none.
|
||||
"""
|
||||
from omnigent.claude_model_vocabulary import claude_model_alias
|
||||
|
||||
alias = claude_model_alias(token, {})
|
||||
return alias.partition("[")[0] if alias else None
|
||||
|
||||
|
||||
def claude_catalog_serves_model(
|
||||
rows: list[dict[str, object]],
|
||||
model: str,
|
||||
claude_config: ClaudeNativeUcodeConfig | None,
|
||||
) -> bool:
|
||||
"""
|
||||
Whether a launch of *model* is backed by this config's catalog.
|
||||
|
||||
An exact row — a picker id or its wire model — always serves. A canonical
|
||||
Anthropic id no row spells exactly still launches when the endpoint takes
|
||||
canonical spellings (``--model`` passes any string through, and a pane's
|
||||
``/model`` persists exactly this id) and the catalog lists the id's
|
||||
family: the same family fold ``/model`` applies to an unpinned canonical
|
||||
id. A gateway that routes only its own ids, and a family the catalog
|
||||
does not list, refuse — a genuinely stale pick still fails fast.
|
||||
|
||||
:param rows: Catalog rows, e.g.
|
||||
``[{"id": "opus", "model": "claude-opus-5"}]``.
|
||||
:param model: A picker id or model id, e.g. ``"claude-opus-4-8"``.
|
||||
:param claude_config: The resolved launch config, or ``None`` (Claude's
|
||||
own login).
|
||||
:returns: ``True`` when the launch can run *model* against this catalog.
|
||||
"""
|
||||
from omnigent.model_catalog_store import catalog_contains
|
||||
|
||||
if catalog_contains(rows, model):
|
||||
return True
|
||||
if claude_config is not None and not _serves_canonical_anthropic_ids(claude_config):
|
||||
return False
|
||||
if not model.lower().startswith("claude-"):
|
||||
return False
|
||||
family = _claude_family(model)
|
||||
return family is not None and any(
|
||||
_claude_family(str(row.get("id") or row.get("model") or "")) == family for row in rows
|
||||
)
|
||||
|
||||
|
||||
def resolve_claude_native_model_selection(
|
||||
model: str | None,
|
||||
claude_config: ClaudeNativeUcodeConfig | None,
|
||||
@@ -419,28 +476,21 @@ def resolve_claude_native_model_selection(
|
||||
extra Sonnet 5 row uses Omnigent's ``sonnet_5`` id because it occupies
|
||||
Claude Code's provider-configured custom model slot. Resolve that id to
|
||||
the exact custom option, preserving provider suffixes such as ``[1m]``.
|
||||
Direct Claude logins have no provider config, so they use the canonical
|
||||
Anthropic model id.
|
||||
Direct Claude logins have no provider config, so the pick degrades to
|
||||
the ``sonnet`` family alias, which Claude resolves itself.
|
||||
|
||||
On a gateway/Bedrock endpoint, a family alias with no tier pin (launch env
|
||||
or managed settings) resolves to the provider's default model — Claude
|
||||
Code would canonicalize it to an Anthropic id the endpoint rejects.
|
||||
Every other pick passes through verbatim: picker rows are pin-backed or
|
||||
vouched for by the harness's own probe, so rewriting one switches the
|
||||
pane to a model nobody chose (an unpinned family alias used to degrade
|
||||
to the provider's default model this way — a Fable pick landed on
|
||||
Opus). An out-of-band unpinned alias on a gateway endpoint now fails
|
||||
visibly at inference instead of silently running the default.
|
||||
|
||||
:param model: Persisted picker id, built-in alias, or concrete model id.
|
||||
:param claude_config: Resolved provider config for the terminal.
|
||||
:returns: A model identifier suitable for ``--model`` or ``/model``.
|
||||
"""
|
||||
if model != _UCODE_CLAUDE_CUSTOM_TIER:
|
||||
tier_env = _UCODE_CLAUDE_TIER_TO_ENV.get(model or "")
|
||||
if (
|
||||
tier_env is not None
|
||||
and claude_config is not None
|
||||
and not claude_config.env.get(tier_env)
|
||||
and not _serves_canonical_anthropic_ids(claude_config)
|
||||
):
|
||||
managed = _managed_claude_model_config()
|
||||
if managed is None or not managed.env.get(tier_env):
|
||||
return claude_config.model or model
|
||||
return model
|
||||
if claude_config is not None:
|
||||
custom_model = claude_config.env.get(_ANTHROPIC_CUSTOM_MODEL_OPTION_ENV)
|
||||
@@ -451,26 +501,9 @@ def resolve_claude_native_model_selection(
|
||||
return provider_fallback
|
||||
if claude_config.model:
|
||||
return claude_config.model
|
||||
fallback = static_model_fallback(SUBSCRIPTION_KIND, "claude")
|
||||
if fallback is None:
|
||||
raise ValueError("Claude subscription fallback has no routable Sonnet model")
|
||||
exact_match = next(
|
||||
(
|
||||
model_id
|
||||
for model_id in fallback.model_ids
|
||||
if _claude_model_display_name("sonnet", model_id) == _UCODE_CLAUDE_CUSTOM_TIER_LABEL
|
||||
),
|
||||
None,
|
||||
)
|
||||
if exact_match is not None:
|
||||
return exact_match
|
||||
family_match = next(
|
||||
(model_id for model_id in fallback.model_ids if "claude-sonnet-" in model_id.lower()),
|
||||
None,
|
||||
)
|
||||
if family_match is None:
|
||||
raise ValueError("Claude subscription fallback has no routable Sonnet model")
|
||||
return family_match
|
||||
# No provider config pins the custom slot: hand Claude Code its own
|
||||
# ``sonnet`` alias and let it resolve the current Sonnet itself.
|
||||
return "sonnet"
|
||||
|
||||
|
||||
def claude_config_with_routed_arms_pinned(
|
||||
@@ -705,15 +738,472 @@ def claude_native_model_options(
|
||||
if not model_id:
|
||||
return []
|
||||
return [{"id": model_id, "model": model_id, "displayName": model_id, "isDefault": True}]
|
||||
return [
|
||||
{
|
||||
"id": model_id,
|
||||
"model": model_id,
|
||||
"displayName": label,
|
||||
"isDefault": False,
|
||||
}
|
||||
for model_id, label in _CLAUDE_NATIVE_STATIC_MODEL_OPTIONS
|
||||
# No curated fallback: an unconfigured shape's rows come from the probe
|
||||
# (the harness's own enumeration) or not at all — a hand-written list
|
||||
# here is exactly how a frozen "Sonnet 4.6" once shipped.
|
||||
return []
|
||||
|
||||
|
||||
def _parse_claude_model_aliases(stdout: str) -> list[str]:
|
||||
"""
|
||||
Extract the alias list from ``claude -p "/model"``'s printed usage line.
|
||||
|
||||
The harness prints e.g. ``Usage: /model <name>. Available: sonnet, opus,
|
||||
haiku, fable, best, sonnet[1m], opusplan, default, or a full model ID.``
|
||||
— its own enumeration of every settable alias. Parsing keeps zero model
|
||||
knowledge here: entries are taken verbatim, and only the trailing prose
|
||||
fragment (anything with whitespace) is dropped.
|
||||
|
||||
:param stdout: The probe run's stdout.
|
||||
:returns: Alias tokens in the harness's order; empty when no line parses.
|
||||
"""
|
||||
for line in stdout.splitlines():
|
||||
_, marker, tail = line.partition("Available:")
|
||||
if not marker:
|
||||
continue
|
||||
aliases: list[str] = []
|
||||
for entry in tail.split(","):
|
||||
token = entry.strip().rstrip(".")
|
||||
if token and " " not in token:
|
||||
aliases.append(token)
|
||||
return aliases
|
||||
return []
|
||||
|
||||
|
||||
def _parse_claude_current_model(stdout: str) -> dict[str, str]:
|
||||
"""
|
||||
Extract the resolved model from a stream-json ``/model`` probe run.
|
||||
|
||||
Two harness-owned facts, taken verbatim: the ``init`` event's exact
|
||||
model id, and the printed ``Current model:`` label with only the
|
||||
trailing ``(effort: …)`` suffix stripped — so labels like
|
||||
``Opus 4.8 (1M context)`` survive untouched.
|
||||
|
||||
:param stdout: The run's ``--output-format stream-json`` stdout.
|
||||
:returns: Whichever of ``{"model": …, "label": …}`` parsed.
|
||||
"""
|
||||
resolved: dict[str, str] = {}
|
||||
for line in stdout.splitlines():
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
if event.get("type") == "system" and event.get("subtype") == "init":
|
||||
model = event.get("model")
|
||||
if isinstance(model, str) and model:
|
||||
resolved["model"] = model
|
||||
if event.get("type") == "result":
|
||||
for text_line in str(event.get("result", "")).splitlines():
|
||||
_, marker, tail = text_line.partition("Current model:")
|
||||
if not marker:
|
||||
continue
|
||||
label = re.sub(r"\s*\(effort:[^)]*\)\s*$", "", tail).strip()
|
||||
if label:
|
||||
resolved["label"] = label
|
||||
break
|
||||
return resolved
|
||||
|
||||
|
||||
def _claude_model_probe_invocation(
|
||||
claude_config: ClaudeNativeUcodeConfig | None,
|
||||
extra_args: Sequence[str] = (),
|
||||
) -> tuple[str, list[str], dict[str, str]]:
|
||||
"""
|
||||
Assemble one headless ``/model`` probe invocation.
|
||||
|
||||
Shared by the alias-enumeration run and the per-alias resolution runs
|
||||
so the two cannot drift: same launch resolution, session env, speed
|
||||
env, and env-unset list.
|
||||
|
||||
:param claude_config: The resolved native launch config, or ``None``.
|
||||
:param extra_args: Appended CLI args (e.g. ``--model <alias>``).
|
||||
:returns: ``(command, launch_args, env)`` ready to exec.
|
||||
"""
|
||||
from omnigent.claude_launcher import resolve_claude_launch
|
||||
|
||||
args = [
|
||||
"-p",
|
||||
"/model",
|
||||
# The probe asks one client-side question; the MCP fleet, session
|
||||
# persistence, and background chatter are irrelevant startup weight.
|
||||
"--strict-mcp-config",
|
||||
"--mcp-config",
|
||||
'{"mcpServers":{}}',
|
||||
"--no-session-persistence",
|
||||
*extra_args,
|
||||
]
|
||||
if claude_config is not None and claude_config.api_key_helper:
|
||||
args.extend(("--settings", json.dumps({"apiKeyHelper": claude_config.api_key_helper})))
|
||||
command, launch_args = resolve_claude_launch("claude", args)
|
||||
env = dict(os.environ)
|
||||
env.update(build_native_claude_terminal_env(claude_config))
|
||||
env.update(
|
||||
{
|
||||
"DISABLE_TELEMETRY": "1",
|
||||
"DISABLE_ERROR_REPORTING": "1",
|
||||
"DISABLE_AUTOUPDATER": "1",
|
||||
}
|
||||
)
|
||||
# Mirrors the native terminal's env-unset list, plus the nonessential-
|
||||
# traffic kill-switch, so speed knobs never mask harness output.
|
||||
env.pop("DATABRICKS_CONFIG_PROFILE", None)
|
||||
env.pop(_CLAUDE_CODE_NESTED_SESSION_ENV, None)
|
||||
env.pop(_CLAUDE_NONESSENTIAL_TRAFFIC_ENV, None)
|
||||
if claude_config is not None and claude_config.api_key_helper:
|
||||
env.pop(_ANTHROPIC_API_KEY_ENV, None)
|
||||
return command, launch_args, env
|
||||
|
||||
|
||||
async def _resolve_claude_model_alias(
|
||||
claude_config: ClaudeNativeUcodeConfig | None,
|
||||
alias: str,
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
Ask the harness what one printed alias resolves to.
|
||||
|
||||
A ``--model <alias>`` run in stream-json mode carries the exact model
|
||||
id in its init event and the human label in its ``Current model:``
|
||||
line; both are the harness's own resolution, never computed here.
|
||||
|
||||
:param claude_config: The resolved native launch config, or ``None``.
|
||||
:param alias: The alias exactly as the harness printed it.
|
||||
:returns: Whichever of ``{"model": …, "label": …}`` resolved; empty on
|
||||
any failure (the alias keeps its bare row).
|
||||
"""
|
||||
command, launch_args, env = _claude_model_probe_invocation(
|
||||
claude_config,
|
||||
("--model", alias, "--output-format", "stream-json", "--verbose"),
|
||||
)
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
command,
|
||||
*launch_args,
|
||||
cwd=str(Path.home()),
|
||||
env=env,
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
except OSError:
|
||||
_logger.debug("Claude alias resolution could not launch for %r", alias, exc_info=True)
|
||||
return {}
|
||||
try:
|
||||
async with asyncio.timeout(_CLAUDE_MODEL_PROBE_TIMEOUT_S):
|
||||
stdout, _stderr = await process.communicate()
|
||||
except (TimeoutError, asyncio.CancelledError) as exc:
|
||||
if process.returncode is None:
|
||||
process.kill()
|
||||
with contextlib.suppress(Exception):
|
||||
await process.wait()
|
||||
if isinstance(exc, asyncio.CancelledError):
|
||||
raise
|
||||
_logger.debug("Claude alias resolution timed out for %r", alias)
|
||||
return {}
|
||||
if process.returncode != 0:
|
||||
_logger.debug("Claude alias resolution exited %s for %r", process.returncode, alias)
|
||||
return {}
|
||||
return _parse_claude_current_model(stdout.decode(errors="replace"))
|
||||
|
||||
|
||||
async def _resolve_claude_model_aliases(
|
||||
claude_config: ClaudeNativeUcodeConfig | None,
|
||||
aliases: Sequence[str],
|
||||
) -> dict[str, dict[str, str]]:
|
||||
"""
|
||||
Resolve every printed alias concurrently, best-effort.
|
||||
|
||||
Bounded fan-out under one overall budget: whatever resolved in time is
|
||||
kept and the rest stay bare, so a hung harness cannot stretch the
|
||||
probe indefinitely.
|
||||
|
||||
:param claude_config: The resolved native launch config, or ``None``.
|
||||
:param aliases: The harness-printed aliases.
|
||||
:returns: Non-empty resolutions keyed by alias.
|
||||
"""
|
||||
if not aliases:
|
||||
return {}
|
||||
semaphore = asyncio.Semaphore(_CLAUDE_ALIAS_RESOLUTION_CONCURRENCY)
|
||||
|
||||
async def _bounded(alias: str) -> dict[str, str]:
|
||||
async with semaphore:
|
||||
return await _resolve_claude_model_alias(claude_config, alias)
|
||||
|
||||
tasks = {alias: asyncio.create_task(_bounded(alias)) for alias in aliases}
|
||||
try:
|
||||
async with asyncio.timeout(_CLAUDE_ALIAS_RESOLUTION_BUDGET_S):
|
||||
await asyncio.gather(*tasks.values())
|
||||
except TimeoutError:
|
||||
for task in tasks.values():
|
||||
task.cancel()
|
||||
await asyncio.gather(*tasks.values(), return_exceptions=True)
|
||||
done = sum(1 for task in tasks.values() if not task.cancelled())
|
||||
_logger.warning(
|
||||
"Claude alias resolution budget expired; keeping %d/%d resolutions",
|
||||
done,
|
||||
len(tasks),
|
||||
)
|
||||
return {
|
||||
alias: task.result()
|
||||
for alias, task in tasks.items()
|
||||
if not task.cancelled() and task.exception() is None and task.result()
|
||||
}
|
||||
|
||||
|
||||
def _claude_alias_row(alias: str, resolution: dict[str, str]) -> dict[str, object]:
|
||||
"""
|
||||
One picker row for a printed alias, shown as its resolution.
|
||||
|
||||
``id`` stays the alias — launches pass it through unchanged — while the
|
||||
display shows only the resolved label. Labels are normalized so every
|
||||
1M-context resolution (a ``[1m]``-suffixed model id) says so; the
|
||||
harness omits the marker for some of them (e.g. ``sonnet[1m]`` prints
|
||||
just "Sonnet 5").
|
||||
|
||||
:param alias: The harness-printed alias.
|
||||
:param resolution: Its resolution, possibly empty.
|
||||
:returns: The picker row.
|
||||
"""
|
||||
label = resolution.get("label")
|
||||
model = resolution.get("model") or alias
|
||||
if label and model.endswith("[1m]") and "1M context" not in label:
|
||||
label = f"{label} (1M context)"
|
||||
return {"id": alias, "model": model, "displayName": label or alias}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClaudeModelProbe:
|
||||
"""One harness enumeration: picker rows plus the bare-launch default.
|
||||
|
||||
:param alias_rows: The printed aliases as deduplicated picker rows.
|
||||
:param default_model: The model the enumeration run itself launched on
|
||||
(its init event's ``model``) — what a no-pick launch of this config
|
||||
actually runs — or ``None`` when unreadable.
|
||||
:param default_label: The harness's own label for *default_model*, or
|
||||
``None``.
|
||||
"""
|
||||
|
||||
alias_rows: list[dict[str, object]]
|
||||
default_model: str | None = None
|
||||
default_label: str | None = None
|
||||
|
||||
|
||||
def _parse_claude_enumeration_aliases(stdout: str) -> list[str]:
|
||||
"""Extract the alias list from a stream-json enumeration run.
|
||||
|
||||
The ``Available:`` line lives inside the ``result`` event's text on a
|
||||
stream-json run; falls back to scanning the raw output so a plain-text
|
||||
run still parses.
|
||||
|
||||
:param stdout: The enumeration run's decoded stdout.
|
||||
:returns: Alias names, e.g. ``["sonnet", "opus", ...]``.
|
||||
"""
|
||||
for line in stdout.splitlines():
|
||||
line = line.strip()
|
||||
if not line.startswith("{"):
|
||||
continue
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if isinstance(event, dict) and event.get("type") == "result":
|
||||
result_text = event.get("result")
|
||||
if isinstance(result_text, str):
|
||||
aliases = _parse_claude_model_aliases(result_text)
|
||||
if aliases:
|
||||
return aliases
|
||||
return _parse_claude_model_aliases(stdout)
|
||||
|
||||
|
||||
async def probe_claude_model_options(
|
||||
claude_config: ClaudeNativeUcodeConfig | None,
|
||||
) -> ClaudeModelProbe | None:
|
||||
"""
|
||||
Ask Claude Code itself which models it would offer, and its default.
|
||||
|
||||
The harness is the source of truth: a short ``claude -p "/model"`` run
|
||||
(stream-json, so its init event also names the model a bare launch of
|
||||
this config actually runs — the truthful "Default") makes Claude Code
|
||||
print its own alias list. Each printed alias is then resolved to its
|
||||
concrete model by a per-alias harness run. All outputs are read
|
||||
verbatim; no selection semantics are replicated here. Runs for every
|
||||
config shape, including the bare subscription launch (``None`` config).
|
||||
|
||||
:param claude_config: The resolved native launch config
|
||||
(:func:`resolve_native_claude_config`), or ``None``.
|
||||
:returns: The probe result, or ``None`` when the probe failed (callers
|
||||
fall back to the configured/static rows).
|
||||
"""
|
||||
command, launch_args, env = _claude_model_probe_invocation(
|
||||
claude_config, ("--output-format", "stream-json", "--verbose")
|
||||
)
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
command,
|
||||
*launch_args,
|
||||
cwd=str(Path.home()),
|
||||
env=env,
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
except OSError:
|
||||
_logger.warning("Claude model probe could not launch the claude CLI", exc_info=True)
|
||||
return None
|
||||
try:
|
||||
async with asyncio.timeout(_CLAUDE_MODEL_PROBE_TIMEOUT_S):
|
||||
stdout, stderr = await process.communicate()
|
||||
except (TimeoutError, asyncio.CancelledError):
|
||||
if process.returncode is None:
|
||||
process.kill()
|
||||
with contextlib.suppress(Exception):
|
||||
await process.wait()
|
||||
_logger.warning("Claude model probe timed out; keeping configured rows only")
|
||||
return None
|
||||
if process.returncode != 0:
|
||||
_logger.warning(
|
||||
"Claude model probe exited %s: %s",
|
||||
process.returncode,
|
||||
stderr.decode(errors="replace").strip()[-500:],
|
||||
)
|
||||
return None
|
||||
text = stdout.decode(errors="replace")
|
||||
aliases = _parse_claude_enumeration_aliases(text)
|
||||
# The enumeration run's own init event names what a bare launch runs —
|
||||
# the harness's truthful Default.
|
||||
default_resolution = _parse_claude_current_model(text)
|
||||
# The picker renders its own top-level Default choice (launch with no
|
||||
# model), which is exactly what the harness's ``default`` alias does —
|
||||
# listing it again would duplicate that row.
|
||||
aliases = [alias for alias in aliases if alias != "default"]
|
||||
resolutions = await _resolve_claude_model_aliases(claude_config, aliases)
|
||||
alias_rows: list[dict[str, object]] = []
|
||||
seen_models: set[object] = set()
|
||||
for alias in aliases:
|
||||
row = _claude_alias_row(alias, resolutions.get(alias, {}))
|
||||
# Distinct aliases can resolve to a model an earlier row already
|
||||
# covers (``best``, ``fable[1m]``, and ``opusplan`` all do today);
|
||||
# repeating the model is picker noise.
|
||||
if row["model"] in seen_models:
|
||||
continue
|
||||
seen_models.add(row["model"])
|
||||
alias_rows.append(row)
|
||||
return ClaudeModelProbe(
|
||||
alias_rows=alias_rows,
|
||||
default_model=default_resolution.get("model"),
|
||||
default_label=default_resolution.get("label"),
|
||||
)
|
||||
|
||||
|
||||
def claude_catalog_fingerprint(claude_config: ClaudeNativeUcodeConfig | None) -> str:
|
||||
"""The launch-config fingerprint keying claude's shared model catalog.
|
||||
|
||||
One formula for every consumer (host boot probe, runner launch, session
|
||||
listing), so they read and write the same catalog file.
|
||||
|
||||
:param claude_config: The resolved launch config, or ``None``.
|
||||
:returns: A stable fingerprint string.
|
||||
"""
|
||||
from omnigent.model_catalog_store import fingerprint_of
|
||||
|
||||
return fingerprint_of(
|
||||
"claude-native",
|
||||
sorted(claude_config.env.items()) if claude_config is not None else None,
|
||||
claude_config.api_key_helper if claude_config is not None else None,
|
||||
claude_config.model if claude_config is not None else None,
|
||||
)
|
||||
|
||||
|
||||
async def claude_model_catalog(
|
||||
claude_config: ClaudeNativeUcodeConfig | None,
|
||||
) -> list[dict[str, object]] | None:
|
||||
"""
|
||||
The harness-truth catalog: probe rows with one truthful ``isDefault``.
|
||||
|
||||
Rows come from the harness's own enumeration alone (no configured/static
|
||||
merge). Servability filtering matches the listing composition: on a
|
||||
non-canonical endpoint, aliases resolving to bare Anthropic ids are
|
||||
dropped. The default marker is what a Default launch of this config
|
||||
actually runs: the config's own launch pin when the provider resolves
|
||||
one (those launches pass ``--model`` explicitly), else the enumeration
|
||||
run's init-event model (a bare subscription launch). It is matched onto
|
||||
its row, or appended as its own row when the catalog lacks it (a
|
||||
``settings.json`` pin, say) and the endpoint can serve it.
|
||||
|
||||
:param claude_config: The resolved launch config, or ``None``.
|
||||
:returns: Catalog rows, or ``None`` when the probe failed.
|
||||
"""
|
||||
probe = await probe_claude_model_options(claude_config)
|
||||
if probe is None:
|
||||
return None
|
||||
rows = list(probe.alias_rows)
|
||||
if claude_config is not None and not _serves_canonical_anthropic_ids(claude_config):
|
||||
rows = [row for row in rows if not str(row.get("model", "")).startswith("claude-")]
|
||||
|
||||
configured_pin = claude_config.model if claude_config is not None else None
|
||||
default_model = configured_pin or probe.default_model
|
||||
marked = False
|
||||
out: list[dict[str, object]] = []
|
||||
for row in rows:
|
||||
is_default = (
|
||||
bool(default_model)
|
||||
and not marked
|
||||
and (row.get("model") == default_model or row.get("id") == default_model)
|
||||
)
|
||||
if is_default:
|
||||
marked = True
|
||||
out.append({**row, "isDefault": True})
|
||||
else:
|
||||
out.append({key: value for key, value in row.items() if key != "isDefault"})
|
||||
if default_model and not marked:
|
||||
# Append the observed default as its own honest row — but never
|
||||
# claim a bare Anthropic id is launchable on an endpoint that
|
||||
# rejects that spelling.
|
||||
servable = (
|
||||
claude_config is None
|
||||
or _serves_canonical_anthropic_ids(claude_config)
|
||||
or not default_model.startswith("claude-")
|
||||
)
|
||||
if servable:
|
||||
# The probe's printed label describes the ENUMERATION run's
|
||||
# model; it only names a config-pinned default when the two are
|
||||
# the same model.
|
||||
label = (
|
||||
probe.default_label
|
||||
if default_model == probe.default_model and probe.default_label
|
||||
else default_model
|
||||
)
|
||||
out.append(
|
||||
{
|
||||
"id": default_model,
|
||||
"model": default_model,
|
||||
"displayName": label,
|
||||
"isDefault": True,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
async def claude_launch_catalog(
|
||||
claude_config: ClaudeNativeUcodeConfig | None,
|
||||
) -> list[dict[str, object]] | None:
|
||||
"""
|
||||
The shared catalog for this launch config: read the store, probe on miss.
|
||||
|
||||
The store read is what keeps launches fast once the host's boot probe
|
||||
(or a previous launch) has run; a cold miss pays one probe and persists
|
||||
the answer for every later consumer.
|
||||
|
||||
:param claude_config: The resolved launch config, or ``None``.
|
||||
:returns: Catalog rows, or ``None`` when no catalog could be obtained.
|
||||
"""
|
||||
from omnigent import model_catalog_store
|
||||
|
||||
fingerprint = claude_catalog_fingerprint(claude_config)
|
||||
return await model_catalog_store.ensure_catalog(
|
||||
"claude-native", fingerprint, lambda: claude_model_catalog(claude_config)
|
||||
)
|
||||
|
||||
|
||||
def build_native_claude_terminal_env(
|
||||
@@ -2234,9 +2724,27 @@ def _provider_config_for_native_claude(entry: ProviderEntry) -> ClaudeNativeUcod
|
||||
family.base_url,
|
||||
family.default_model,
|
||||
)
|
||||
# Pin the alias vocabulary to the entry's declared models, so ``/model``
|
||||
# and the harness probe resolve aliases inside this gateway's routable
|
||||
# set instead of falling back to canonical Anthropic ids the gateway
|
||||
# rejects. The ``models:`` map's flat tier keys (``opus``/``sonnet``/…)
|
||||
# pin their aliases directly; ``models.default`` pins its own family's
|
||||
# alias when nothing else declared that family.
|
||||
pin_env: dict[str, str] = {}
|
||||
for alias, env_var in ALIAS_MODEL_ENV_VARS.items():
|
||||
pinned = family.models.get(alias)
|
||||
if isinstance(pinned, str) and pinned.strip():
|
||||
pin_env[env_var] = pinned.strip()
|
||||
if family.default_model:
|
||||
default_alias = claude_model_alias(family.default_model, env={})
|
||||
base_alias = (default_alias or "").partition("[")[0]
|
||||
default_env_var = ALIAS_MODEL_ENV_VARS.get(base_alias)
|
||||
if default_env_var:
|
||||
pin_env.setdefault(default_env_var, family.default_model)
|
||||
return ClaudeNativeUcodeConfig(
|
||||
env={
|
||||
_UCODE_CLAUDE_BASE_URL_ENV: family.base_url,
|
||||
**pin_env,
|
||||
# Disable beta flags gateways reject (400 "invalid beta flag");
|
||||
# skip when CLAUDE_CODE_USE_GATEWAY=1 to keep tool search enabled.
|
||||
**(
|
||||
@@ -2247,6 +2755,15 @@ def _provider_config_for_native_claude(entry: ProviderEntry) -> ClaudeNativeUcod
|
||||
},
|
||||
api_key_helper=api_key_helper,
|
||||
model=family.default_model,
|
||||
# The declared models are exactly what this entry can route.
|
||||
routable_models=tuple(
|
||||
dict.fromkeys(
|
||||
[
|
||||
*pin_env.values(),
|
||||
*([family.default_model] if family.default_model else []),
|
||||
]
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -179,6 +179,27 @@ _PASTED_PLACEHOLDER_PREFIX = "[Pasted text"
|
||||
# whether the draft is rendered in the input box. Short enough to fit
|
||||
# on the prompt row of a default 80-column detached pane.
|
||||
_DRAFT_NEEDLE_MAX_CHARS = 24
|
||||
# Mode footer Claude Code renders below its input box, keyed by
|
||||
# ``--permission-mode`` value. There is no non-interactive mode command, so
|
||||
# a live switch cycles with shift+tab and reads this footer to know where it
|
||||
# landed. The prompting mode is ``default`` on the CLI, "manual" on screen.
|
||||
_PERMISSION_MODE_FOOTERS: dict[str, str] = {
|
||||
"default": "manual mode on",
|
||||
"acceptEdits": "accept edits on",
|
||||
"plan": "plan mode on",
|
||||
"auto": "auto mode on",
|
||||
}
|
||||
# Modes shift+tab can reach. ``dontAsk`` is never in the cycle and
|
||||
# ``bypassPermissions`` only joins it when launched into, so both are
|
||||
# rejected up front.
|
||||
CYCLEABLE_PERMISSION_MODES = frozenset(_PERMISSION_MODE_FOOTERS)
|
||||
# Cap on shift+tab presses. The cycle is 3-5 modes wide depending on which
|
||||
# optional modes are enabled, so a full lap plus slack proves the target is
|
||||
# unreachable rather than slow.
|
||||
_MODE_CYCLE_MAX_PRESSES = 8
|
||||
# Wait for the footer to repaint after a shift+tab before reading it.
|
||||
_MODE_FOOTER_SETTLE_TIMEOUT_S = 2.0
|
||||
_MODE_FOOTER_POLL_INTERVAL_S = 0.1
|
||||
# Footer Claude Code's interactive ``/model`` picker renders while it is open.
|
||||
# Omnigent never drives that picker — it switches with ``/model <id>`` — but a
|
||||
# picker the person opened by hand covers the input box, so an injection would
|
||||
@@ -253,6 +274,14 @@ _INVOCATION_SETTINGS_FILE = "claude-settings.json"
|
||||
ToolExecutor = Callable[[str, _JsonObject], Awaitable[object]]
|
||||
|
||||
|
||||
class ClaudePromptTimeout(RuntimeError):
|
||||
"""Claude Code's input box did not render before delivery timed out."""
|
||||
|
||||
|
||||
class TmuxSessionNotAdvertised(RuntimeError):
|
||||
"""The bridge's tmux target was not advertised before the deadline."""
|
||||
|
||||
|
||||
def _absolute_syntactic_path(path: Path) -> Path:
|
||||
"""
|
||||
Return an absolute path without following symlinks.
|
||||
@@ -446,6 +475,11 @@ class TranscriptReadResult:
|
||||
entry was scanned.
|
||||
:param latest_model: ``message.model`` from the most recent
|
||||
assistant entry, or ``None``.
|
||||
:param latest_custom_title: ``customTitle`` from the most recent
|
||||
``custom-title`` record — the explicit title a ``/rename`` typed
|
||||
in the Claude Code pane writes. ``None`` when no such record was
|
||||
scanned. Claude's own auto-generated ``aiTitle`` is deliberately
|
||||
not surfaced here; Omnigent titles unnamed sessions itself.
|
||||
"""
|
||||
|
||||
line_cursor: int
|
||||
@@ -454,6 +488,7 @@ class TranscriptReadResult:
|
||||
items: list[ClaudeTranscriptItem]
|
||||
latest_usage: dict[str, int] | None = None
|
||||
latest_model: str | None = None
|
||||
latest_custom_title: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -1213,6 +1248,48 @@ def read_model_env(bridge_dir: Path) -> dict[str, str]:
|
||||
}
|
||||
|
||||
|
||||
def record_model_vocabulary(
|
||||
bridge_dir: Path,
|
||||
*,
|
||||
launch_env: Mapping[str, str] | None,
|
||||
launch_model: str | None,
|
||||
) -> None:
|
||||
"""
|
||||
Persist the launch's model vocabulary after the bridge dir exists.
|
||||
|
||||
The runner prepares the bridge before it resolves the provider config,
|
||||
so the vocabulary (alias pins + custom slot) and the launch model land
|
||||
here in a second write once known — the same keys
|
||||
:func:`prepare_bridge_dir` records on the CLI path, so
|
||||
:func:`read_model_env` / :func:`read_launch_model` serve both paths
|
||||
identically.
|
||||
|
||||
:param bridge_dir: Bridge directory path.
|
||||
:param launch_env: The resolved launch env (pins + custom slot), or
|
||||
``None`` for a bare subscription launch.
|
||||
:param launch_model: The model the launch pins via ``--model``, or
|
||||
``None``.
|
||||
:returns: None.
|
||||
"""
|
||||
config = _read_json_file(bridge_dir / _CONFIG_FILE)
|
||||
if not isinstance(config, dict):
|
||||
return
|
||||
model_env = {
|
||||
key: launch_env[key]
|
||||
for key in MODEL_VOCABULARY_ENV_VARS
|
||||
if launch_env is not None and launch_env.get(key)
|
||||
}
|
||||
changed = False
|
||||
if model_env and config.get("model_env") != model_env:
|
||||
config["model_env"] = model_env
|
||||
changed = True
|
||||
if launch_model and config.get("launch_model") != launch_model:
|
||||
config["launch_model"] = launch_model
|
||||
changed = True
|
||||
if changed:
|
||||
_write_json_file(bridge_dir / _CONFIG_FILE, config)
|
||||
|
||||
|
||||
def read_bridge_id(bridge_dir: Path) -> str | None:
|
||||
"""
|
||||
Read the opaque bridge id from bridge config.
|
||||
@@ -2194,11 +2271,14 @@ def read_transcript_items_since(
|
||||
|
||||
Claude Code writes append-only JSONL records whose ``message``
|
||||
payloads include user prompts, assistant text, native tool calls,
|
||||
and native tool results. This parser intentionally ignores
|
||||
metadata records (title, file-history, permission mode, system
|
||||
bookkeeping) and raw ``thinking`` blocks, while translating the
|
||||
user-visible semantic records into Omnigent item types the web UI
|
||||
already understands.
|
||||
and native tool results. This parser intentionally renders no
|
||||
conversation item for metadata records (title, file-history,
|
||||
permission mode, system bookkeeping) or raw ``thinking`` blocks,
|
||||
while translating the user-visible semantic records into Omnigent
|
||||
item types the web UI already understands. Some metadata is still
|
||||
read for out-of-band mirroring rather than dropped outright — a
|
||||
``custom-title`` record surfaces on
|
||||
:attr:`TranscriptReadResult.latest_custom_title`.
|
||||
|
||||
:param transcript_path: Claude transcript path, e.g.
|
||||
``"/home/user/.claude/projects/x/session.jsonl"``.
|
||||
@@ -2259,6 +2339,7 @@ def read_transcript_items_since_with_position(
|
||||
active_settled_id = settled_response_id
|
||||
latest_usage: dict[str, int] | None = None
|
||||
latest_model: str | None = None
|
||||
latest_custom_title: str | None = None
|
||||
for record in read_result.records:
|
||||
if record.text is None:
|
||||
continue
|
||||
@@ -2288,6 +2369,9 @@ def read_transcript_items_since_with_position(
|
||||
model = _model_from_transcript_entry(entry)
|
||||
if model is not None:
|
||||
latest_model = model
|
||||
custom_title = _custom_title_from_transcript_entry(entry)
|
||||
if custom_title is not None:
|
||||
latest_custom_title = custom_title
|
||||
return TranscriptReadResult(
|
||||
line_cursor=read_result.line_cursor,
|
||||
byte_offset=read_result.byte_offset,
|
||||
@@ -2295,6 +2379,7 @@ def read_transcript_items_since_with_position(
|
||||
items=items,
|
||||
latest_usage=latest_usage,
|
||||
latest_model=latest_model,
|
||||
latest_custom_title=latest_custom_title,
|
||||
)
|
||||
|
||||
|
||||
@@ -2347,6 +2432,7 @@ def read_transcript_items_from_offset(
|
||||
active_settled_id = settled_response_id
|
||||
latest_usage: dict[str, int] | None = None
|
||||
latest_model: str | None = None
|
||||
latest_custom_title: str | None = None
|
||||
for record in read_result.records:
|
||||
if record.text is None:
|
||||
continue
|
||||
@@ -2377,6 +2463,9 @@ def read_transcript_items_from_offset(
|
||||
model = _model_from_transcript_entry(entry)
|
||||
if model is not None:
|
||||
latest_model = model
|
||||
custom_title = _custom_title_from_transcript_entry(entry)
|
||||
if custom_title is not None:
|
||||
latest_custom_title = custom_title
|
||||
return TranscriptReadResult(
|
||||
line_cursor=read_result.line_cursor,
|
||||
byte_offset=read_result.byte_offset,
|
||||
@@ -2384,6 +2473,7 @@ def read_transcript_items_from_offset(
|
||||
items=items,
|
||||
latest_usage=latest_usage,
|
||||
latest_model=latest_model,
|
||||
latest_custom_title=latest_custom_title,
|
||||
)
|
||||
|
||||
|
||||
@@ -3168,10 +3258,16 @@ def kill_session(
|
||||
there is no live session to kill.
|
||||
:returns: None.
|
||||
:raises RuntimeError: If the tmux target is not advertised in
|
||||
time, or if the ``tmux kill-session`` invocation fails.
|
||||
time, or if ``tmux kill-session`` fails for an unexpected reason.
|
||||
"""
|
||||
info = _wait_for_tmux_info(bridge_dir, timeout_s=timeout_s)
|
||||
_run_tmux(info["socket_path"], "kill-session", "-t", info["tmux_target"])
|
||||
try:
|
||||
_run_tmux(info["socket_path"], "kill-session", "-t", info["tmux_target"])
|
||||
except RuntimeError as exc:
|
||||
detail = str(exc).lower()
|
||||
if "can't find session" in detail or "no server running on" in detail:
|
||||
return
|
||||
raise
|
||||
|
||||
|
||||
def inject_slash_command(
|
||||
@@ -3337,6 +3433,171 @@ def _confirm_tui_dialog(
|
||||
return False
|
||||
|
||||
|
||||
def _permission_mode_from_pane(pane: str) -> str | None:
|
||||
"""
|
||||
Read Claude Code's current permission mode off a captured pane.
|
||||
|
||||
The footer (``⏵⏵ auto mode on``, ``⏸ plan mode on``, ...) always sits
|
||||
below the input box's closing rule, so the scan starts there rather than
|
||||
at a fixed offset from the bottom: the footer's height scales with
|
||||
concurrent subagents, which a fixed window cannot bound (the same reason
|
||||
:func:`_claude_prompt_rendered` anchors on :func:`_is_box_rule`). Anchoring
|
||||
also excludes transcript text structurally — a mode name Claude echoed
|
||||
while *discussing* modes sits above the box and can't be misread as live.
|
||||
|
||||
:param pane: Captured pane text from :func:`_capture_pane`.
|
||||
:returns: The ``--permission-mode`` value for the rendered footer,
|
||||
e.g. ``"auto"``, or ``None`` when no footer is visible (the
|
||||
pane is mid-repaint, or the mode is one with no footer).
|
||||
"""
|
||||
lines = [line for line in pane.splitlines() if line.strip()]
|
||||
# Below the last box rule is the footer region. With no rule the input box
|
||||
# isn't mounted; fall back to the tail so a footer still reads during boot.
|
||||
last_rule = max((i for i, line in enumerate(lines) if _is_box_rule(line)), default=None)
|
||||
region = lines[last_rule + 1 :] if last_rule is not None else lines[-_PROMPT_SCAN_TAIL_LINES:]
|
||||
for line in reversed(region):
|
||||
for mode, footer in _PERMISSION_MODE_FOOTERS.items():
|
||||
if footer in line:
|
||||
return mode
|
||||
return None
|
||||
|
||||
|
||||
def _read_settled_permission_mode(
|
||||
socket_path: str,
|
||||
tmux_target: str,
|
||||
*,
|
||||
previous: str | None = None,
|
||||
) -> str | None:
|
||||
"""
|
||||
Poll the pane until its permission-mode footer settles.
|
||||
|
||||
A shift+tab repaints the footer asynchronously, so an immediate
|
||||
capture can read nothing — or, worse, still read the PREVIOUS mode
|
||||
and make the cycler believe the keystroke did nothing. Passing
|
||||
*previous* waits for the footer to actually change.
|
||||
|
||||
:param socket_path: Absolute path to the tmux socket.
|
||||
:param tmux_target: tmux pane target string, e.g. ``"main"``.
|
||||
:param previous: Mode read before the keystroke that prompted this
|
||||
read, e.g. ``"plan"``. The pane keeps rendering it until the TUI
|
||||
repaints, so a read that returns it is treated as not-yet-settled
|
||||
and retried; ``None`` accepts the first mode seen (the initial
|
||||
read, where there is nothing to change from).
|
||||
:returns: The rendered mode, or ``None`` if none appeared — or the
|
||||
footer never moved off *previous* — before
|
||||
:data:`_MODE_FOOTER_SETTLE_TIMEOUT_S`.
|
||||
"""
|
||||
deadline = time.monotonic() + _MODE_FOOTER_SETTLE_TIMEOUT_S
|
||||
while True:
|
||||
mode = _permission_mode_from_pane(_capture_pane(socket_path, tmux_target))
|
||||
if mode is not None and mode != previous:
|
||||
return mode
|
||||
if time.monotonic() >= deadline:
|
||||
# Timed out: report the last mode seen so a pane that legitimately
|
||||
# stayed put is distinguished from one with no footer at all.
|
||||
return mode
|
||||
time.sleep(_MODE_FOOTER_POLL_INTERVAL_S)
|
||||
|
||||
|
||||
def set_permission_mode(
|
||||
bridge_dir: Path,
|
||||
*,
|
||||
mode: str,
|
||||
timeout_s: float = _TMUX_READY_TIMEOUT_S,
|
||||
) -> str:
|
||||
"""
|
||||
Switch the running Claude terminal to *mode* by cycling shift+tab.
|
||||
|
||||
Claude Code has no non-interactive way to set a live session's mode
|
||||
(``--permission-mode`` is launch-only, ``/permissions`` is an interactive
|
||||
dialog, settings load at startup), so this drives the TUI's shift+tab
|
||||
cycle, reading the mode footer after each press. The cycle is walked
|
||||
rather than computed: its width varies with which optional modes are
|
||||
enabled, so a fixed press count could land on the wrong mode.
|
||||
|
||||
:param bridge_dir: Bridge directory path, e.g.
|
||||
``/tmp/omnigent/claude-native/<digest>``.
|
||||
:param mode: Target ``--permission-mode`` value, one of
|
||||
:data:`CYCLEABLE_PERMISSION_MODES`, e.g. ``"auto"``.
|
||||
:param timeout_s: Seconds to wait for ``tmux.json`` to be
|
||||
advertised by the runner, e.g. ``30.0``.
|
||||
:returns: The mode now rendered in the pane (== *mode*).
|
||||
:raises ValueError: If *mode* is not cycle-reachable.
|
||||
:raises RuntimeError: If the tmux target is not advertised in time,
|
||||
a ``tmux`` invocation fails, the pane never renders a mode
|
||||
footer, or the target is not reached within
|
||||
:data:`_MODE_CYCLE_MAX_PRESSES` presses (the mode is not in
|
||||
this session's cycle).
|
||||
"""
|
||||
if mode not in CYCLEABLE_PERMISSION_MODES:
|
||||
raise ValueError(
|
||||
f"permission mode {mode!r} cannot be switched on a running session; "
|
||||
f"expected one of {sorted(CYCLEABLE_PERMISSION_MODES)}"
|
||||
)
|
||||
info = _wait_for_tmux_info(bridge_dir, timeout_s=timeout_s)
|
||||
socket_path, tmux_target = info["socket_path"], info["tmux_target"]
|
||||
# The footer only renders once the input box is mounted; without
|
||||
# this gate a shift+tab sent mid-boot is dropped and the read below
|
||||
# reports a mode the keystroke never reached.
|
||||
_wait_for_claude_prompt_ready(socket_path, tmux_target, timeout_s=timeout_s)
|
||||
current = _read_settled_permission_mode(socket_path, tmux_target)
|
||||
if current is None:
|
||||
pane = _capture_pane(socket_path, tmux_target)
|
||||
raise RuntimeError(
|
||||
"Claude Code did not render a permission-mode footer, so its current "
|
||||
f"mode could not be read.{_format_terminal_failure_tail(pane)}"
|
||||
)
|
||||
seen = [current]
|
||||
for _ in range(_MODE_CYCLE_MAX_PRESSES):
|
||||
if current == mode:
|
||||
return current
|
||||
# No ``-l``: tmux must interpret ``BTab`` as the shift+tab key.
|
||||
_run_tmux(socket_path, "send-keys", "-t", tmux_target, "BTab")
|
||||
settled = _read_settled_permission_mode(socket_path, tmux_target, previous=current)
|
||||
if settled is not None:
|
||||
current = settled
|
||||
seen.append(current)
|
||||
if current == mode:
|
||||
return current
|
||||
raise RuntimeError(
|
||||
f"Could not switch Claude Code to {mode!r} mode: cycled shift+tab "
|
||||
f"{_MODE_CYCLE_MAX_PRESSES} times and only reached {sorted(set(seen))}. "
|
||||
"The mode is not available in this session's cycle."
|
||||
)
|
||||
|
||||
|
||||
def confirm_dialog_if_open(bridge_dir: Path, *, hint: str) -> bool:
|
||||
"""
|
||||
Accept the *hint* dialog iff it is on screen RIGHT NOW; never blind-Enter.
|
||||
|
||||
Loop-safe building block for watchers that outlive a single injection
|
||||
(a mid-turn ``/model`` queues in Claude's composer and pops its confirm
|
||||
dialog only when the turn settles — minutes later). Unlike
|
||||
:func:`_confirm_tui_dialog` there is no timeout fallback Enter, so
|
||||
calling this every few seconds can never type into a surface that is
|
||||
not the named dialog.
|
||||
|
||||
:param bridge_dir: Bridge directory path.
|
||||
:param hint: Text the dialog renders, e.g.
|
||||
:data:`SWITCH_MODEL_DIALOG_HINT`.
|
||||
:returns: ``True`` when the dialog was on screen and confirmed.
|
||||
"""
|
||||
try:
|
||||
info = _wait_for_tmux_info(bridge_dir, timeout_s=1.0)
|
||||
except (RuntimeError, OSError):
|
||||
return False
|
||||
socket_path = info["socket_path"]
|
||||
tmux_target = info["tmux_target"]
|
||||
try:
|
||||
pane = _capture_pane(socket_path, tmux_target)
|
||||
if hint not in pane:
|
||||
return False
|
||||
_confirm_and_verify_dialog_closed(socket_path, tmux_target, hint=hint)
|
||||
except (RuntimeError, OSError):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _confirm_and_verify_dialog_closed(
|
||||
socket_path: str,
|
||||
tmux_target: str,
|
||||
@@ -3827,7 +4088,7 @@ def _wait_for_claude_prompt_ready(
|
||||
:param tmux_target: tmux pane target string, e.g. ``"main"``.
|
||||
:param timeout_s: Seconds to wait for the prompt, e.g. ``30.0``.
|
||||
:returns: None.
|
||||
:raises RuntimeError: If the prompt never renders within
|
||||
:raises ClaudePromptTimeout: If the prompt never renders within
|
||||
*timeout_s* (Claude failed to boot). The message carries a poll
|
||||
count, how many of those polls saw an empty capture, and the tail
|
||||
of the last non-empty capture the loop actually observed (see
|
||||
@@ -3864,7 +4125,7 @@ def _wait_for_claude_prompt_ready(
|
||||
# session is alive but capture-pane came back blank); non-empty captures
|
||||
# with no box point at Claude never rendering the prompt (a boot crash,
|
||||
# e.g. a ``JSON Parse error``, whose text the tail then surfaces).
|
||||
raise RuntimeError(
|
||||
raise ClaudePromptTimeout(
|
||||
f"Claude Code terminal did not become ready within {timeout_s}s "
|
||||
f"(input prompt never rendered in {polls} polls, "
|
||||
f"{empty_polls} empty captures). The message was not delivered."
|
||||
@@ -3920,7 +4181,7 @@ def _wait_for_tmux_info(bridge_dir: Path, *, timeout_s: float) -> dict[str, str]
|
||||
:param bridge_dir: Bridge directory path.
|
||||
:param timeout_s: Seconds to wait, e.g. ``30.0``.
|
||||
:returns: ``{"socket_path": ..., "tmux_target": ...}``.
|
||||
:raises RuntimeError: If the file never appears with valid
|
||||
:raises TmuxSessionNotAdvertised: If the file never appears with valid
|
||||
``socket_path`` and ``tmux_target`` fields.
|
||||
"""
|
||||
deadline = time.monotonic() + timeout_s
|
||||
@@ -3932,7 +4193,7 @@ def _wait_for_tmux_info(bridge_dir: Path, *, timeout_s: float) -> dict[str, str]
|
||||
if isinstance(socket_path, str) and isinstance(tmux_target, str):
|
||||
return {"socket_path": socket_path, "tmux_target": tmux_target}
|
||||
time.sleep(0.05)
|
||||
raise RuntimeError(
|
||||
raise TmuxSessionNotAdvertised(
|
||||
"Claude terminal tmux target is not advertised yet. Wait for the "
|
||||
"terminal to launch before sending messages from the web UI."
|
||||
)
|
||||
@@ -4993,6 +5254,32 @@ def _model_from_transcript_entry(entry: _JsonObject) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _custom_title_from_transcript_entry(entry: _JsonObject) -> str | None:
|
||||
"""
|
||||
Return ``customTitle`` from a ``custom-title`` transcript record.
|
||||
|
||||
Claude Code appends this metadata record when the operator renames
|
||||
the session from the pane (``/rename``). It carries no ``message``,
|
||||
so it renders no conversation item; the forwarder mirrors it onto the
|
||||
Omnigent session title instead.
|
||||
|
||||
Only the explicit user title is read. Claude also writes an
|
||||
``aiTitle`` record holding its own generated summary, which is
|
||||
ignored here because Omnigent runs its own background titler and two
|
||||
auto-titlers would fight over one field.
|
||||
|
||||
:param entry: One decoded transcript JSONL record.
|
||||
:returns: The operator-chosen title, e.g. ``"auth-refactor"``, or
|
||||
``None`` for other record types and blank values.
|
||||
"""
|
||||
if entry.get("type") != "custom-title":
|
||||
return None
|
||||
title = entry.get("customTitle")
|
||||
if isinstance(title, str) and title.strip():
|
||||
return title
|
||||
return None
|
||||
|
||||
|
||||
def read_claude_context_state(bridge_dir: Path) -> _JsonObject | None:
|
||||
"""
|
||||
Read the most recent statusLine snapshot from ``context.json``.
|
||||
@@ -5027,6 +5314,31 @@ def read_claude_context_state(bridge_dir: Path) -> _JsonObject | None:
|
||||
return parsed
|
||||
|
||||
|
||||
def read_permission_mode(bridge_dir: Path) -> str | None:
|
||||
"""
|
||||
Read the permission mode currently rendered in the Claude pane.
|
||||
|
||||
Non-blocking and best-effort: the forwarder calls this every poll so an
|
||||
in-pane shift+tab switch reaches the web UI, which otherwise never sees it
|
||||
(only UI-driven switches stamp the mode label). Returns ``None`` when the
|
||||
terminal isn't up or the pane shows no mode footer, so a caller can treat
|
||||
"unknown" as "no fresh observation" rather than a change.
|
||||
|
||||
:param bridge_dir: Bridge directory path, e.g.
|
||||
``/tmp/omnigent/claude-native/<digest>``.
|
||||
:returns: The ``--permission-mode`` value rendered in the pane, e.g.
|
||||
``"auto"``, or ``None`` when it cannot be determined.
|
||||
"""
|
||||
payload = _read_json_file(bridge_dir / _TMUX_FILE)
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
socket_path = payload.get("socket_path")
|
||||
tmux_target = payload.get("tmux_target")
|
||||
if not isinstance(socket_path, str) or not isinstance(tmux_target, str):
|
||||
return None
|
||||
return _permission_mode_from_pane(_capture_pane(socket_path, tmux_target))
|
||||
|
||||
|
||||
def read_claude_status_model(bridge_dir: Path) -> str | None:
|
||||
"""
|
||||
Read the active model id from the statusLine snapshot ``context.json``.
|
||||
|
||||
@@ -36,6 +36,7 @@ from omnigent.claude_native_bridge import (
|
||||
read_hook_events_from_offset,
|
||||
read_hook_events_since_with_position,
|
||||
read_message_deltas_from_offset,
|
||||
read_permission_mode,
|
||||
read_transcript_items_from_offset,
|
||||
read_transcript_items_since_with_position,
|
||||
read_transcript_path,
|
||||
@@ -82,6 +83,11 @@ _SUBAGENT_IDLE_QUIESCENCE_S = 5.0
|
||||
# ``agent-<id>.jsonl`` transcript.
|
||||
_SUBAGENT_META_GLOB = "agent-*.meta.json"
|
||||
_DEFAULT_POLL_INTERVAL_S = 0.25
|
||||
# Minimum spacing between permission-mode pane reads. Unlike the model mirror
|
||||
# (which reads a JSON file), this spawns a ``tmux capture-pane`` subprocess, so
|
||||
# it runs well below the poll interval; a mode switch is a human action and 2s
|
||||
# of lag is imperceptible.
|
||||
_PERMISSION_MODE_POLL_INTERVAL_S = 2.0
|
||||
# Hard ceiling on one poll iteration of the forward loop. A silently stalled
|
||||
# await anywhere in the pipeline used to stop mirroring, status and the busy
|
||||
# signal forever; the deadline cancels the stall (the traceback names it) and
|
||||
@@ -490,16 +496,15 @@ class _ForwardDedupeState:
|
||||
:param usage: Last ``message.usage`` snapshot POSTed via
|
||||
``external_session_usage``, or ``None`` if none yet.
|
||||
:param context_window: Last context-window POSTed, or ``None``.
|
||||
:param observed_model: Last tier alias seen in the transcript,
|
||||
sticky across polls (the incremental window often carries no
|
||||
fresh ``message.model``), e.g. ``"opus"``. ``None`` until first
|
||||
seen.
|
||||
:param posted_model: Last tier alias POSTed via
|
||||
``external_model_change``. Seeded from the first observation
|
||||
WITHOUT a POST so a passive spawn default never overwrites a
|
||||
pending silent model handoff; only a later in-TUI switch is
|
||||
mirrored. Left behind ``observed_model`` on a failed POST so the
|
||||
next poll retries. ``None`` until the first observation.
|
||||
:param observed_model: Last VERBATIM model seen (statusLine or
|
||||
transcript), sticky across polls (the incremental window often
|
||||
carries no fresh ``message.model``), e.g.
|
||||
``"claude-opus-4-8[1m]"``. ``None`` until first seen.
|
||||
:param posted_model: Last verbatim model POSTed via
|
||||
``external_model_change``. Every observation posts — the first
|
||||
one is the launch report that seeds the session's
|
||||
``reported_model``. Left behind ``observed_model`` on a failed
|
||||
POST so the next poll retries. ``None`` until the first post.
|
||||
:param posted_cost: Last DISPLAY cost (USD) POSTed as
|
||||
``cumulative_cost_usd`` — the statusLine total ``S`` verbatim.
|
||||
``None`` until the first cost post. Used to dedupe so a steady
|
||||
@@ -510,6 +515,15 @@ class _ForwardDedupeState:
|
||||
from ``posted_cost`` because it advances mid-turn (with in-flight
|
||||
sub-agent spend) while ``S`` stays frozen. ``None`` until first
|
||||
post.
|
||||
:param observed_title: Last ``custom-title`` seen in the transcript,
|
||||
sticky across polls, e.g. ``"auth-refactor"``. ``None`` until the
|
||||
operator runs ``/rename``.
|
||||
:param posted_title: Last title POSTed via
|
||||
``external_session_title``. Unlike ``posted_model`` this is NOT
|
||||
seeded without a POST — a ``custom-title`` record only exists
|
||||
because the operator renamed the session, so the first
|
||||
observation is a real change worth mirroring. Left behind
|
||||
``observed_title`` on a failed POST so the next poll retries.
|
||||
:param recorded_token_usage: Last token counters recorded on a
|
||||
``claude_native.usage`` span as ``gen_ai.usage.*``. Deduped
|
||||
separately from ``usage`` because that snapshot also moves on
|
||||
@@ -523,6 +537,8 @@ class _ForwardDedupeState:
|
||||
recorded_token_usage: dict[str, int] | None = None
|
||||
observed_model: str | None = None
|
||||
posted_model: str | None = None
|
||||
observed_title: str | None = None
|
||||
posted_title: str | None = None
|
||||
# Last DISPLAY cost (USD) POSTed as ``cumulative_cost_usd`` — the
|
||||
# statusLine total ``S`` verbatim (matches /cost in the Claude TUI).
|
||||
# Kept to suppress duplicate posts when S hasn't advanced.
|
||||
@@ -532,6 +548,13 @@ class _ForwardDedupeState:
|
||||
# sub-agent spend so the gate can block mid-turn. Separate baseline
|
||||
# because it can advance while ``posted_cost`` (S) is frozen.
|
||||
posted_policy_cost: float | None = None
|
||||
# Last permission mode POSTed as ``external_permission_mode_change`` —
|
||||
# mirrors the launch mode and any in-pane shift+tab switch, neither of
|
||||
# which the web UI can observe on its own.
|
||||
posted_permission_mode: str | None = None
|
||||
# Monotonic deadline before which the next pane read is skipped, so the
|
||||
# subprocess spawn runs at _PERMISSION_MODE_POLL_INTERVAL_S, not every poll.
|
||||
permission_mode_next_read: float = 0.0
|
||||
# Turn-settle latch driving the scheduled-wake boundary. The Stop edge
|
||||
# records the ended turn's id as PENDING; it activates (moves to
|
||||
# ``settled_response_id``) only once a fully-consumed transcript batch
|
||||
@@ -1001,6 +1024,14 @@ async def forward_claude_transcript_to_session(
|
||||
bridge_dir=bridge_dir,
|
||||
dedupe=dedupe,
|
||||
)
|
||||
# Same rationale for the permission mode: a shift+tab in
|
||||
# the pane emits no event, so poll the footer.
|
||||
await _forward_permission_mode_from_pane(
|
||||
client=client,
|
||||
session_id=current_session_id,
|
||||
bridge_dir=bridge_dir,
|
||||
dedupe=dedupe,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except TimeoutError:
|
||||
@@ -3561,19 +3592,27 @@ async def _forward_available_items(
|
||||
_http_status_for_log(exc),
|
||||
exc_info=True,
|
||||
)
|
||||
# Mirror a TUI-side `/model` switch to the web picker. The transcript
|
||||
# records the resolved concrete id (e.g. "claude-opus-4-8"); collapse
|
||||
# it to the picker's tier alias. This transcript-derived observation
|
||||
# only fires when a turn produces a fresh ``message.model``, so it lags
|
||||
# an in-pane switch by one turn — the per-poll statusLine sync
|
||||
# (:func:`_forward_model_from_status`) is the primary, low-latency
|
||||
# source; this stays as a fallback for cold-resume before the first
|
||||
# statusLine render. Both share ``dedupe`` so neither double-posts.
|
||||
# Report the transcript's model verbatim. This transcript-derived
|
||||
# observation only fires when a turn produces a fresh
|
||||
# ``message.model``, so it lags an in-pane switch by one turn — the
|
||||
# per-poll statusLine sync (:func:`_forward_model_from_status`) is the
|
||||
# primary, low-latency source; this stays as a fallback for cold-resume
|
||||
# before the first statusLine render. Both share ``dedupe`` so neither
|
||||
# double-posts.
|
||||
await _post_model_change_if_new(
|
||||
client,
|
||||
session_id=session_id,
|
||||
dedupe=dedupe,
|
||||
alias=_model_alias_for(result.latest_model),
|
||||
model=result.latest_model,
|
||||
)
|
||||
# Mirror a TUI-side `/rename` to the web session list. Claude writes the
|
||||
# operator's title as a `custom-title` metadata record, which renders no
|
||||
# conversation item, so this is the only path that surfaces it.
|
||||
await _post_title_change_if_new(
|
||||
client,
|
||||
session_id=session_id,
|
||||
dedupe=dedupe,
|
||||
title=result.latest_custom_title,
|
||||
)
|
||||
return updated
|
||||
|
||||
@@ -4157,38 +4196,81 @@ def _gen_ai_usage_tokens(usage: Mapping[str, float | str] | None) -> dict[str, i
|
||||
return tokens
|
||||
|
||||
|
||||
def _model_alias_for(model: str | None) -> str | None:
|
||||
async def _post_external_permission_mode_change(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
session_id: str,
|
||||
mode: str,
|
||||
) -> None:
|
||||
"""
|
||||
Collapse a concrete Claude model id to the picker's tier alias.
|
||||
Post one ``external_permission_mode_change`` event to the Sessions API.
|
||||
|
||||
The web model picker speaks Claude Code's version-agnostic aliases
|
||||
(``"fable"`` / ``"opus"`` / ``"sonnet"`` / ``"haiku"``), plus the one
|
||||
extra concrete-id slot ``"sonnet_5"`` (see
|
||||
:data:`omnigent.claude_native._UCODE_CLAUDE_CUSTOM_TIER`) for the newer
|
||||
Sonnet generation offered alongside the default ``"sonnet"`` tier; the
|
||||
transcript records the resolved concrete id (e.g.
|
||||
``"claude-opus-4-8"`` or ``"databricks-claude-sonnet-5"``).
|
||||
Mapping to the tier keeps the mirrored value in the picker's
|
||||
vocabulary and makes a web→TUI round-trip a no-op. The older Sonnet
|
||||
(``sonnet-4-6``) collapses to the generic ``"sonnet"`` alias — it is the
|
||||
default that row is bound to.
|
||||
Lets the web mode picker reflect a shift+tab switch made inside the Claude
|
||||
Code terminal, which Omnigent has no other way to observe.
|
||||
|
||||
:param model: Concrete model id from the transcript, e.g.
|
||||
``"claude-opus-4-8"``; ``None`` when none observed yet.
|
||||
:returns: ``"fable"`` / ``"opus"`` / ``"sonnet"`` / ``"sonnet_5"`` /
|
||||
``"haiku"`` when the id carries a known tier token, else ``None``
|
||||
(the caller skips the post rather than surface an id the picker
|
||||
can't render).
|
||||
:param client: Omnigent HTTP client.
|
||||
:param session_id: Omnigent session/conversation id, e.g. ``"conv_abc123"``.
|
||||
:param mode: Permission mode the pane now shows, e.g. ``"auto"``.
|
||||
:raises httpx.HTTPError: If the Omnigent request fails or is rejected.
|
||||
"""
|
||||
if not model:
|
||||
return None
|
||||
lowered = model.lower()
|
||||
if "sonnet-5" in lowered or "sonnet_5" in lowered:
|
||||
return "sonnet_5"
|
||||
for tier in ("fable", "opus", "sonnet", "haiku"):
|
||||
if tier in lowered:
|
||||
return tier
|
||||
return None
|
||||
resp = await client.post(
|
||||
f"/v1/sessions/{session_id}/events",
|
||||
json={"type": "external_permission_mode_change", "data": {"permission_mode": mode}},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
async def _forward_permission_mode_from_pane(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
session_id: str,
|
||||
bridge_dir: Path,
|
||||
dedupe: _ForwardDedupeState,
|
||||
) -> None:
|
||||
"""
|
||||
Mirror the pane's permission-mode footer to the session label each poll.
|
||||
|
||||
A shift+tab pressed inside the TUI produces no event Omnigent can see, so
|
||||
without this the web picker shows a stale mode until the next UI-driven
|
||||
switch. Polling the footer is the only signal available: Claude Code emits
|
||||
nothing on a mode change, and hook payloads only arrive on tool use.
|
||||
|
||||
The launch mode is posted too, not just later switches: a session started
|
||||
in manual mode carries no ``--permission-mode`` arg and no mode label, so
|
||||
with nothing posted the web picker has no mode to render and hides itself.
|
||||
Best-effort and idempotent — the server ignores a mode equal to the stored
|
||||
label, an unchanged mode or unreadable pane is a no-op, and a failed POST
|
||||
is retried next poll.
|
||||
|
||||
:param client: Omnigent HTTP client.
|
||||
:param session_id: Omnigent session/conversation id.
|
||||
:param bridge_dir: Native Claude bridge directory.
|
||||
:param dedupe: Shared per-session dedupe state; mutated in place.
|
||||
"""
|
||||
# Throttled: this spawns a tmux subprocess, unlike the file-backed model
|
||||
# mirror that shares this poll loop.
|
||||
now = time.monotonic()
|
||||
if now < dedupe.permission_mode_next_read:
|
||||
return
|
||||
dedupe.permission_mode_next_read = now + _PERMISSION_MODE_POLL_INTERVAL_S
|
||||
mode = await asyncio.to_thread(read_permission_mode, bridge_dir)
|
||||
if mode is None or mode == dedupe.posted_permission_mode:
|
||||
return
|
||||
try:
|
||||
await _post_external_permission_mode_change(
|
||||
client,
|
||||
session_id=session_id,
|
||||
mode=mode,
|
||||
)
|
||||
except httpx.HTTPError:
|
||||
_logger.debug(
|
||||
"external_permission_mode_change post failed; session=%s mode=%s",
|
||||
session_id,
|
||||
mode,
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
dedupe.posted_permission_mode = mode
|
||||
|
||||
|
||||
async def _post_external_model_change(
|
||||
@@ -4200,13 +4282,15 @@ async def _post_external_model_change(
|
||||
"""
|
||||
Post one ``external_model_change`` event to the Sessions API.
|
||||
|
||||
Lets the web model picker reflect a model switch made inside the
|
||||
Claude Code terminal (a ``/model`` command or the in-TUI picker).
|
||||
Reports the model the pane is actually on — the launch's own model
|
||||
included — so every surface renders the harness's truth.
|
||||
|
||||
:param client: Omnigent HTTP client.
|
||||
:param session_id: Omnigent session/conversation id, e.g.
|
||||
``"conv_abc123"``.
|
||||
:param model: Tier alias the session is now on, e.g. ``"opus"``.
|
||||
:param model: The harness's VERBATIM model, e.g.
|
||||
``"claude-opus-4-8[1m]"`` — never collapsed to a picker alias
|
||||
(a family word claims a generation the pane may not be on).
|
||||
:raises httpx.HTTPError: If the Omnigent request fails or is rejected.
|
||||
"""
|
||||
resp = await client.post(
|
||||
@@ -4216,45 +4300,121 @@ async def _post_external_model_change(
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
async def _post_external_session_title(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
session_id: str,
|
||||
title: str,
|
||||
) -> None:
|
||||
"""
|
||||
Post one ``external_session_title`` event to the Sessions API.
|
||||
|
||||
Mirrors a ``/rename`` typed in the Claude Code pane onto the Omnigent
|
||||
session title so the web session list stops showing the stale
|
||||
auto-generated one.
|
||||
|
||||
:param client: Omnigent HTTP client.
|
||||
:param session_id: Omnigent session/conversation id, e.g.
|
||||
``"conv_abc123"``.
|
||||
:param title: Operator-chosen title, e.g. ``"auth-refactor"``.
|
||||
:raises httpx.HTTPError: If the Omnigent request fails or is rejected.
|
||||
"""
|
||||
resp = await client.post(
|
||||
f"/v1/sessions/{session_id}/events",
|
||||
json={"type": "external_session_title", "data": {"title": title}},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
async def _post_title_change_if_new(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
session_id: str,
|
||||
dedupe: _ForwardDedupeState,
|
||||
title: str | None,
|
||||
) -> None:
|
||||
"""
|
||||
Mirror an observed ``/rename`` title to the session, deduped.
|
||||
|
||||
Unlike :func:`_post_model_change_if_new`, the FIRST observation is
|
||||
posted rather than used to seed the baseline silently: a
|
||||
``custom-title`` record exists only because the operator ran
|
||||
``/rename``, so there is no passive spawn default to protect.
|
||||
|
||||
A steady-state poll reads only records past its byte cursor, so the
|
||||
dedupe is not for the ordinary case — it covers the cursor rewind /
|
||||
restart path that re-reads an already-posted record, and it is what
|
||||
makes the retry below safe to attempt on every poll.
|
||||
|
||||
Best-effort: a failed POST leaves ``posted_title`` behind
|
||||
``observed_title`` so the next poll retries. ``observed_title`` is
|
||||
sticky for exactly this reason — the retry must survive polls whose
|
||||
own window carries no rename.
|
||||
|
||||
:param client: Omnigent HTTP client.
|
||||
:param session_id: Omnigent session/conversation id.
|
||||
:param dedupe: Shared per-session dedupe state; mutated in place.
|
||||
:param title: Title just observed, or ``None`` when this poll's
|
||||
window carried no ``custom-title`` record. ``observed_title`` is
|
||||
sticky, so ``None`` does not clear it — a previously-observed but
|
||||
unposted title is still retried here.
|
||||
"""
|
||||
if title is not None:
|
||||
dedupe.observed_title = title
|
||||
if dedupe.observed_title is None or dedupe.observed_title == dedupe.posted_title:
|
||||
return
|
||||
try:
|
||||
await _post_external_session_title(
|
||||
client,
|
||||
session_id=session_id,
|
||||
title=dedupe.observed_title,
|
||||
)
|
||||
dedupe.posted_title = dedupe.observed_title
|
||||
except httpx.HTTPError:
|
||||
# Leave posted_title behind observed_title so the next poll retries.
|
||||
_logger.warning(
|
||||
"Failed to mirror /rename to Omnigent session=%s; the web session "
|
||||
"list may show a stale title until the next poll",
|
||||
session_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
async def _post_model_change_if_new(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
session_id: str,
|
||||
dedupe: _ForwardDedupeState,
|
||||
alias: str | None,
|
||||
model: str | None,
|
||||
) -> None:
|
||||
"""
|
||||
Mirror an observed model tier alias to ``model_override``, deduped.
|
||||
Report the observed model to ``reported_model``, verbatim and deduped.
|
||||
|
||||
Shared by the transcript-driven path (:func:`_forward_available_items`)
|
||||
and the statusLine-driven per-poll path
|
||||
(:func:`_forward_model_from_status`). The FIRST observation is the
|
||||
session's spawn default, not a switch, so it seeds the dedupe baseline
|
||||
WITHOUT posting (posting it could clobber a pending silent model
|
||||
handoff). Every later change posts ``external_model_change``. Both
|
||||
callers pass the same ``dedupe`` so whichever observes a switch first
|
||||
posts it and the other no-ops. Best-effort: a failed POST leaves
|
||||
``posted_model`` behind ``observed_model`` so the next poll retries.
|
||||
(:func:`_forward_model_from_status`). EVERY observation posts — the
|
||||
first one is the launch report that seeds the session's
|
||||
``reported_model``, so surfaces show the pane's truth within seconds
|
||||
of spawn; the server dedupes by equality, so a steady model costs one
|
||||
POST total. Both callers pass the same ``dedupe`` so whichever
|
||||
observes a change first posts it and the other no-ops. Best-effort: a
|
||||
failed POST leaves ``posted_model`` behind ``observed_model`` so the
|
||||
next poll retries.
|
||||
|
||||
:param client: Omnigent HTTP client.
|
||||
:param session_id: Omnigent session/conversation id.
|
||||
:param dedupe: Shared per-session dedupe state; mutated in place.
|
||||
:param alias: Tier alias just observed (``"opus"`` / ``"sonnet"`` /
|
||||
…), or ``None`` when this source carried no recognizable model on
|
||||
this poll. ``observed_model`` is sticky across polls, so passing
|
||||
``None`` does NOT clear it — it just means "no fresh observation,"
|
||||
and a previously-observed-but-unposted model is still reconciled
|
||||
(retried) here.
|
||||
:param model: The harness's verbatim model just observed (e.g.
|
||||
``"claude-opus-4-8[1m]"``), or ``None`` when this source carried
|
||||
no model on this poll. ``observed_model`` is sticky across
|
||||
polls, so passing ``None`` does NOT clear it — it just means "no
|
||||
fresh observation," and a previously-observed-but-unposted model
|
||||
is still reconciled (retried) here.
|
||||
"""
|
||||
if alias is not None:
|
||||
dedupe.observed_model = alias
|
||||
if model is not None:
|
||||
dedupe.observed_model = model
|
||||
if dedupe.observed_model is None or dedupe.observed_model == dedupe.posted_model:
|
||||
return
|
||||
if dedupe.posted_model is None:
|
||||
# First observation = the spawn default; seed the baseline without
|
||||
# posting so it can't clobber a pending silent model handoff.
|
||||
dedupe.posted_model = dedupe.observed_model
|
||||
return
|
||||
try:
|
||||
await _post_external_model_change(
|
||||
client,
|
||||
@@ -4280,7 +4440,7 @@ async def _forward_model_from_status(
|
||||
dedupe: _ForwardDedupeState,
|
||||
) -> None:
|
||||
"""
|
||||
Mirror the statusLine-reported active model to ``model_override`` each poll.
|
||||
Report the statusLine's active model to ``reported_model`` each poll.
|
||||
|
||||
Claude Code rewrites the statusLine stdin on every TUI render — including
|
||||
right after an in-pane ``/model`` switch, BEFORE the next turn runs. The
|
||||
@@ -4292,8 +4452,9 @@ async def _forward_model_from_status(
|
||||
turn later, which is what happened when the model was derived solely
|
||||
from the next turn's transcript ``message.model``.
|
||||
|
||||
Best-effort and idempotent: shares ``dedupe`` with the transcript path,
|
||||
so a no-op when the model is unchanged.
|
||||
The value posts VERBATIM — the harness's own spelling, never collapsed
|
||||
to a picker alias. Best-effort and idempotent: shares ``dedupe`` with
|
||||
the transcript path, so a no-op when the model is unchanged.
|
||||
|
||||
:param client: Omnigent HTTP client.
|
||||
:param session_id: Omnigent session/conversation id.
|
||||
@@ -4304,12 +4465,11 @@ async def _forward_model_from_status(
|
||||
if status_state is None:
|
||||
return
|
||||
model = status_state.get("model")
|
||||
alias = _model_alias_for(model if isinstance(model, str) else None)
|
||||
await _post_model_change_if_new(
|
||||
client,
|
||||
session_id=session_id,
|
||||
dedupe=dedupe,
|
||||
alias=alias,
|
||||
model=model.strip() if isinstance(model, str) and model.strip() else None,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+189
-8
@@ -1781,6 +1781,98 @@ def _set_log_to_stderr(
|
||||
return value
|
||||
|
||||
|
||||
def _finish_cli_profile(profiler: Any, output_path: Path) -> None: # type: ignore[explicit-any]
|
||||
"""Stop a requested cProfile run and render a focused bottleneck summary."""
|
||||
import pstats
|
||||
|
||||
profiler.disable()
|
||||
profiler.dump_stats(output_path)
|
||||
stats = pstats.Stats(profiler)
|
||||
stats_state = vars(stats)
|
||||
raw_stats = cast(
|
||||
dict[tuple[str, int, str], tuple[int, int, float, float, object]],
|
||||
stats_state["stats"],
|
||||
)
|
||||
total_time = float(stats_state["total_tt"])
|
||||
total_calls = int(stats_state["total_calls"])
|
||||
primitive_calls = int(stats_state["prim_calls"])
|
||||
package_root = Path(__file__).resolve().parent
|
||||
source_root = package_root.parent
|
||||
|
||||
# (filename, line, function, primitive calls, total calls, self, cumulative)
|
||||
rows = [(*key, cc, nc, tt, ct) for key, (cc, nc, tt, ct, _) in raw_stats.items()]
|
||||
|
||||
def _location(filename: str, line: int, function: str) -> str:
|
||||
path = Path(filename).resolve()
|
||||
try:
|
||||
display = str(path.relative_to(source_root))
|
||||
except ValueError:
|
||||
display = path.name
|
||||
return f"{display}:{line}({function})"
|
||||
|
||||
def _duration(seconds: float) -> str:
|
||||
if seconds < 0.01:
|
||||
return f"{seconds * 1_000:.2f} ms"
|
||||
if seconds < 1:
|
||||
return f"{seconds * 1_000:.1f} ms"
|
||||
return f"{seconds:.3f} s"
|
||||
|
||||
def _print_rows(
|
||||
title: str,
|
||||
selected: list[tuple[str, int, str, int, int, float, float]],
|
||||
) -> None:
|
||||
click.echo(f"\n{title}", err=True)
|
||||
click.echo(f" {'self':>9} {'cumulative':>10} {'calls':>9} function", err=True)
|
||||
for filename, line, function, primitive, calls, self_time, cumulative in selected[:10]:
|
||||
call_count = str(calls) if calls == primitive else f"{calls}/{primitive}"
|
||||
click.echo(
|
||||
f" {_duration(self_time):>9} {_duration(cumulative):>10} "
|
||||
f"{call_count:>9} {_location(filename, line, function)}",
|
||||
err=True,
|
||||
)
|
||||
|
||||
omnigent_rows = [
|
||||
row for row in rows if Path(row[0]).resolve().is_relative_to(package_root) and row[6] > 0
|
||||
]
|
||||
omnigent_rows.sort(key=lambda row: row[6], reverse=True)
|
||||
self_time_rows = [row for row in omnigent_rows if row[5] > 0]
|
||||
self_time_rows.sort(key=lambda row: row[5], reverse=True)
|
||||
|
||||
click.echo(
|
||||
f"\nCLI profile: {_duration(total_time)}, "
|
||||
f"{total_calls:,} calls ({primitive_calls:,} primitive)",
|
||||
err=True,
|
||||
)
|
||||
_print_rows("Top Omnigent call paths", omnigent_rows)
|
||||
_print_rows("Top Omnigent functions by self time", self_time_rows)
|
||||
click.echo(f"\nFull profile data: {output_path}", err=True)
|
||||
|
||||
|
||||
def _start_cli_profile(
|
||||
ctx: click.Context,
|
||||
_param: click.Parameter,
|
||||
value: bool,
|
||||
) -> bool:
|
||||
"""Start cProfile early and finish it after the selected command exits."""
|
||||
if not value:
|
||||
return value
|
||||
|
||||
import cProfile
|
||||
|
||||
timestamp = time.strftime("%Y%m%d-%H%M%S")
|
||||
microseconds = time.time_ns() // 1_000 % 1_000_000
|
||||
profile_dir = data_dir() / "profiles"
|
||||
profile_dir.mkdir(parents=True, exist_ok=True)
|
||||
output_path = profile_dir / (f"omnigent-cli-{timestamp}-{microseconds:06d}-{os.getpid()}.prof")
|
||||
profiler = cProfile.Profile()
|
||||
profiler.enable()
|
||||
ctx.call_on_close(lambda: _finish_cli_profile(profiler, output_path))
|
||||
# Click closes callbacks last-in-first-out, so measurement stops before
|
||||
# rendering the summary above.
|
||||
ctx.call_on_close(profiler.disable)
|
||||
return value
|
||||
|
||||
|
||||
def _extract_global_logging_flags(argv: list[str]) -> tuple[list[str], bool, bool]:
|
||||
"""Remove global logging flags before run-shorthand rewriting."""
|
||||
debug_logging = False
|
||||
@@ -1801,6 +1893,17 @@ def _extract_global_logging_flags(argv: list[str]) -> tuple[list[str], bool, boo
|
||||
|
||||
|
||||
@click.group(cls=_OmnigentCLI)
|
||||
@click.option(
|
||||
"--profiling",
|
||||
is_flag=True,
|
||||
is_eager=True,
|
||||
expose_value=False,
|
||||
callback=_start_cli_profile,
|
||||
help=(
|
||||
"Profile CLI execution, print a summary, and write a timestamped .prof file. "
|
||||
"Place before COMMAND."
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--debug",
|
||||
"debug_logging",
|
||||
@@ -2031,7 +2134,17 @@ def main() -> None:
|
||||
# intentionally tiny (currently only help/version); runner flags live on
|
||||
# ``run``. Treat a leading non-top-level flag as bare-run shorthand so
|
||||
# users can type the natural no-AGENT launcher form.
|
||||
if argv and argv[0].startswith("-") and argv[0] not in {"--help", "-h", "--version"}:
|
||||
if (
|
||||
argv
|
||||
and argv[0].startswith("-")
|
||||
and argv[0]
|
||||
not in {
|
||||
"--help",
|
||||
"-h",
|
||||
"--version",
|
||||
"--profiling",
|
||||
}
|
||||
):
|
||||
argv = ["run", *argv]
|
||||
|
||||
# Shorthand: ``omnigent myagent.yaml [opts]`` → ``run myagent.yaml [opts]``.
|
||||
@@ -2201,6 +2314,10 @@ def _is_removed_ad_hoc_invocation(argv: list[str]) -> bool:
|
||||
# help listing subcommands, not the legacy argparse help.
|
||||
if argv[0] in {"--help", "-h", "--version"}:
|
||||
return False
|
||||
# A root profiling flag may precede an eager help/version flag or stand
|
||||
# alone. These are valid Click invocations, not removed ad-hoc chat.
|
||||
if all(token in {"--profiling", "--help", "-h", "--version"} for token in argv):
|
||||
return False
|
||||
# Skip leading flags to find the first positional. If all
|
||||
# tokens are flags (e.g. ``omnigent --system-prompt "..."``),
|
||||
# treat it as removed ad-hoc chat rather than handing it to click
|
||||
@@ -2863,6 +2980,7 @@ def _spawn_host_daemon_process(
|
||||
proc = subprocess.Popen(
|
||||
args,
|
||||
env=env,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=log_fh,
|
||||
stderr=log_fh,
|
||||
**_proc.spawn_kwargs(),
|
||||
@@ -9200,6 +9318,50 @@ def _stop_daemon_sessions(
|
||||
return stopped
|
||||
|
||||
|
||||
def _signal_daemon_pid(record: _HostDaemonRecord, sig: int) -> bool:
|
||||
"""
|
||||
Signal a daemon's recorded PID, tolerating a stale foreign entry.
|
||||
|
||||
A daemon registry record can outlive the process it names. The recorded
|
||||
PID may since have been reused by an unrelated process — often owned by
|
||||
another user — or the real daemon may have been started under a different
|
||||
account (e.g. ``sudo``). In both cases the PID is no longer this user's
|
||||
daemon and must not be killed.
|
||||
|
||||
``os.kill`` raises ``PermissionError`` (EPERM) when the caller lacks
|
||||
permission to signal the target — which, since we only ever signal our
|
||||
own daemons, means the record is stale and points at someone else's
|
||||
process. ``_pid_alive`` reports such a PID as alive (``psutil`` maps the
|
||||
same permission failure to ``AccessDenied``), so without this guard
|
||||
``_terminate_daemon`` would fall through to ``os.kill`` and crash on the
|
||||
unsuppressed EPERM — ``--force`` included, since it dies before the
|
||||
SIGKILL path. Log a warning and treat the record as stale instead.
|
||||
|
||||
:param record: Daemon record whose PID should be signalled.
|
||||
:param sig: Signal number to send, e.g. ``signal.SIGTERM``.
|
||||
:returns: ``True`` if the record is stale and the caller should drop it
|
||||
and stop (either the PID is not ours, or it already exited); ``False``
|
||||
if the signal was delivered and termination should proceed as usual.
|
||||
"""
|
||||
try:
|
||||
os.kill(record.pid, sig)
|
||||
except ProcessLookupError:
|
||||
# The process exited between the liveness check and the signal —
|
||||
# nothing left to kill, so the record is stale.
|
||||
return True
|
||||
except PermissionError:
|
||||
# Not our daemon: the record points at another user's process (PID
|
||||
# reuse, or a daemon started under a different account). Drop the
|
||||
# stale record and warn rather than crashing the CLI on the EPERM.
|
||||
click.echo(
|
||||
f"Skipping stale daemon record for {record.target!r}: pid "
|
||||
f"{record.pid} is owned by another user and is not this daemon.",
|
||||
err=True,
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _terminate_daemon(record: _HostDaemonRecord, *, force: bool) -> None:
|
||||
"""
|
||||
Terminate one local daemon process.
|
||||
@@ -9211,8 +9373,9 @@ def _terminate_daemon(record: _HostDaemonRecord, *, force: bool) -> None:
|
||||
if not _pid_alive(record.pid):
|
||||
_delete_daemon_record(record)
|
||||
return
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
os.kill(record.pid, signal.SIGTERM)
|
||||
if _signal_daemon_pid(record, signal.SIGTERM):
|
||||
_delete_daemon_record(record)
|
||||
return
|
||||
deadline = time.monotonic() + _HOST_DAEMON_STOP_GRACE_S
|
||||
while time.monotonic() < deadline:
|
||||
if not _pid_alive(record.pid):
|
||||
@@ -9220,8 +9383,9 @@ def _terminate_daemon(record: _HostDaemonRecord, *, force: bool) -> None:
|
||||
return
|
||||
time.sleep(0.1)
|
||||
if force:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
os.kill(record.pid, getattr(signal, "SIGKILL", signal.SIGTERM))
|
||||
if _signal_daemon_pid(record, getattr(signal, "SIGKILL", signal.SIGTERM)):
|
||||
_delete_daemon_record(record)
|
||||
return
|
||||
deadline = time.monotonic() + 2.0
|
||||
while time.monotonic() < deadline:
|
||||
if not _pid_alive(record.pid):
|
||||
@@ -10904,6 +11068,12 @@ def _run_databricks_browser_login(workspace_host: str, org_id: str | None = None
|
||||
the workspace rejects it).
|
||||
:raises click.ClickException: When the Databricks CLI binary is
|
||||
missing or the login exits non-zero.
|
||||
|
||||
The login writes to a ``.databrickscfg`` profile named after the
|
||||
workspace's first DNS label (e.g. ``acme`` for
|
||||
``acme.cloud.databricks.com``), keeping distinct workspaces off the
|
||||
shared ``DEFAULT`` profile. The OAuth grant itself stays host-keyed,
|
||||
so :func:`_databricks_workspace_token` still resolves it by host.
|
||||
"""
|
||||
databricks_bin = shutil.which("databricks")
|
||||
if databricks_bin is None:
|
||||
@@ -10912,14 +11082,21 @@ def _run_databricks_browser_login(workspace_host: str, org_id: str | None = None
|
||||
"Install it first: https://docs.databricks.com/dev-tools/cli/install.html"
|
||||
)
|
||||
login_host = _host_with_org(workspace_host, org_id)
|
||||
click.echo(f"Opening browser to log in to {login_host} ...")
|
||||
# Pin the grant to a profile named for the workspace's first DNS label so
|
||||
# distinct workspaces don't clobber each other under the CLI's ``DEFAULT``.
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
split = urlsplit(workspace_host.rstrip("/"))
|
||||
host = split.hostname or split.netloc or split.path
|
||||
profile = host.split(".")[0]
|
||||
click.echo(f"Opening browser to log in to {login_host} (profile {profile}) ...")
|
||||
result = subprocess.run(
|
||||
[databricks_bin, "auth", "login", "--host", login_host],
|
||||
[databricks_bin, "auth", "login", "--host", login_host, "--profile", profile],
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise click.ClickException(
|
||||
f"`databricks auth login --host {login_host}` failed "
|
||||
f"`databricks auth login --host {login_host} --profile {profile}` failed "
|
||||
f"(exit {result.returncode}). If the workspace is unreachable from "
|
||||
"this machine (VPN / IP access lists), resolve that and retry."
|
||||
)
|
||||
@@ -11142,6 +11319,9 @@ def login(server_url: str) -> None:
|
||||
token=token,
|
||||
user_id=user_id,
|
||||
expires_at=_time.time() + expires_in,
|
||||
# Login-issued refresh grant (newer servers) — lets the
|
||||
# host/CLI renew past session expiry unattended.
|
||||
refresh_token=result.get("refresh_token"),
|
||||
)
|
||||
click.echo(f"Logged in as {user_id}")
|
||||
_remember_default_server(server)
|
||||
@@ -11221,6 +11401,7 @@ def _accounts_login(server: str) -> None:
|
||||
token=token,
|
||||
user_id=user_id,
|
||||
expires_at=_time.time() + expires_in,
|
||||
refresh_token=body.get("refresh_token"),
|
||||
)
|
||||
click.echo(f"Logged in as {user_id}.")
|
||||
|
||||
|
||||
+263
-10
@@ -21,11 +21,13 @@ from __future__ import annotations
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import stat
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.parse
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
@@ -35,6 +37,14 @@ if TYPE_CHECKING:
|
||||
_logger = logging.getLogger(__name__)
|
||||
_TOKEN_FILE_NAME = "auth_tokens.json"
|
||||
|
||||
# Treat a stored token with less than this much life left as needing
|
||||
# renewal. Shared by the "is it still usable" read path and the refresh
|
||||
# path's already-renewed check, so a caller that decides to refresh is
|
||||
# never handed back the same near-expiry token it wanted to replace.
|
||||
# Comfortably longer than a WebSocket handshake, far shorter than the
|
||||
# server's 1-hour access-token TTL.
|
||||
REFRESH_MIN_REMAINING_SECONDS = 90.0
|
||||
|
||||
|
||||
def _token_file_path() -> Path:
|
||||
"""Return the path to the auth token storage file.
|
||||
@@ -134,6 +144,10 @@ def _store_entry(server_url: str, entry: dict[str, str | float]) -> None:
|
||||
except (json.JSONDecodeError, OSError):
|
||||
data = {}
|
||||
|
||||
# Corrupt token files (non-dict JSON) read as empty — never crash.
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
|
||||
data[_normalize_server_url(server_url)] = entry
|
||||
|
||||
_write_tokens_file(path, data)
|
||||
@@ -144,6 +158,7 @@ def store_token(
|
||||
token: str,
|
||||
user_id: str,
|
||||
expires_at: float,
|
||||
refresh_token: str | None = None,
|
||||
) -> None:
|
||||
"""Persist a session token for a server.
|
||||
|
||||
@@ -153,15 +168,19 @@ def store_token(
|
||||
:param user_id: The authenticated user's email, e.g.
|
||||
``"alice@example.com"``.
|
||||
:param expires_at: Unix timestamp when the token expires.
|
||||
:param refresh_token: Login-issued refresh grant token, when the
|
||||
server handed one out. Lets :func:`refresh_stored_token` renew
|
||||
the access token past expiry without a human re-running
|
||||
``omnigent login``.
|
||||
"""
|
||||
_store_entry(
|
||||
server_url,
|
||||
{
|
||||
"token": token,
|
||||
"user_id": user_id,
|
||||
"expires_at": expires_at,
|
||||
},
|
||||
)
|
||||
entry: dict[str, str | float] = {
|
||||
"token": token,
|
||||
"user_id": user_id,
|
||||
"expires_at": expires_at,
|
||||
}
|
||||
if refresh_token is not None:
|
||||
entry["refresh_token"] = refresh_token
|
||||
_store_entry(server_url, entry)
|
||||
|
||||
|
||||
def store_databricks_auth(
|
||||
@@ -217,11 +236,15 @@ def _load_entry(server_url: str) -> dict[str, str | float] | None:
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
|
||||
# A token file holding valid JSON of the wrong shape (``[]``, ``null``,
|
||||
# a bare string) is corrupt, not fatal — read as "nothing stored".
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
entry = data.get(_normalize_server_url(server_url))
|
||||
return entry if isinstance(entry, dict) else None
|
||||
|
||||
|
||||
def load_token(server_url: str) -> str | None:
|
||||
def load_token(server_url: str, *, min_remaining_seconds: float = 0.0) -> str | None:
|
||||
"""Load a stored session token for a server.
|
||||
|
||||
Returns ``None`` if no token is stored, the token has expired,
|
||||
@@ -231,6 +254,11 @@ def load_token(server_url: str) -> str | None:
|
||||
|
||||
:param server_url: The server URL, e.g.
|
||||
``"http://localhost:6767"``.
|
||||
:param min_remaining_seconds: Require at least this much remaining
|
||||
lifetime. ``0`` (the default) accepts any not-yet-expired token —
|
||||
the historical behaviour. A caller that can renew passes
|
||||
:data:`REFRESH_MIN_REMAINING_SECONDS` so a token about to lapse
|
||||
mid-handshake is refreshed instead of used.
|
||||
:returns: The session JWT string, or ``None``.
|
||||
"""
|
||||
entry = _load_entry(server_url)
|
||||
@@ -239,13 +267,238 @@ def load_token(server_url: str) -> str | None:
|
||||
|
||||
expires_at = entry.get("expires_at", 0)
|
||||
if isinstance(expires_at, (int, float)) and expires_at < time.time():
|
||||
_logger.debug("Stored token for %s has expired", _normalize_server_url(server_url))
|
||||
_warn_expired_once(server_url, expires_at, has_refresh="refresh_token" in entry)
|
||||
return None
|
||||
# Near-expiry but still valid: decline quietly (no expiry warning — it
|
||||
# has not expired) so the caller can choose to renew.
|
||||
if (
|
||||
min_remaining_seconds > 0
|
||||
and isinstance(expires_at, (int, float))
|
||||
and expires_at - time.time() < min_remaining_seconds
|
||||
):
|
||||
return None
|
||||
|
||||
token = entry.get("token")
|
||||
return token if isinstance(token, str) else None
|
||||
|
||||
|
||||
# Servers already warned about an expired stored token, so a poll/retry
|
||||
# loop doesn't repeat the warning every few seconds.
|
||||
_warned_expired_servers: set[str] = set()
|
||||
|
||||
|
||||
def _warn_expired_once(server_url: str, expires_at: float, *, has_refresh: bool) -> None:
|
||||
"""Warn (once per process per server) that a stored token expired.
|
||||
|
||||
Expiry used to be a DEBUG line, which left the host dialing
|
||||
unauthenticated into a misleading 403 with no breadcrumb — an
|
||||
operator's first actionable signal must name the cause and the
|
||||
remedy.
|
||||
"""
|
||||
normalized = _normalize_server_url(server_url)
|
||||
if normalized in _warned_expired_servers:
|
||||
return
|
||||
_warned_expired_servers.add(normalized)
|
||||
expired_on = time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime(expires_at))
|
||||
if has_refresh:
|
||||
_logger.warning(
|
||||
"Stored login session for %s expired on %s; refresh will be "
|
||||
"attempted on the next command if possible",
|
||||
normalized,
|
||||
expired_on,
|
||||
)
|
||||
else:
|
||||
_logger.warning(
|
||||
"Stored login session for %s expired on %s and holds no refresh "
|
||||
"material. Run `omnigent login %s` to re-authenticate.",
|
||||
normalized,
|
||||
expired_on,
|
||||
normalized,
|
||||
)
|
||||
|
||||
|
||||
def stored_token_status(server_url: str) -> str:
|
||||
"""Classify the stored auth state for a server.
|
||||
|
||||
Lets callers distinguish "never logged in" from "logged in but the
|
||||
session lapsed" — the difference between proceeding unauthenticated
|
||||
(header-mode servers accept that) and surfacing an actionable
|
||||
re-login message.
|
||||
|
||||
:param server_url: The server URL.
|
||||
:returns: ``"ok"`` (valid token stored), ``"expired"`` (entry exists
|
||||
but the token lapsed), or ``"absent"`` (no token entry at all;
|
||||
includes Databricks pointer records, which hold no token).
|
||||
"""
|
||||
entry = _load_entry(server_url)
|
||||
if entry is None or not isinstance(entry.get("token"), str):
|
||||
return "absent"
|
||||
expires_at = entry.get("expires_at", 0)
|
||||
if isinstance(expires_at, (int, float)) and expires_at < time.time():
|
||||
return "expired"
|
||||
return "ok"
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _token_file_lock() -> Iterator[None]:
|
||||
"""Exclusive advisory lock over token-file read-modify-write cycles.
|
||||
|
||||
Serializes concurrent refreshes on one machine (host + CLI sharing
|
||||
``auth_tokens.json``) so only one performs the network exchange and
|
||||
the other picks up its result. Best-effort on platforms without
|
||||
``fcntl`` (Windows): the refresh still works, only the local
|
||||
serialization is lost.
|
||||
|
||||
:raises OSError: If the lock file cannot be created or opened — the
|
||||
caller degrades to "cannot refresh" (a state directory we cannot
|
||||
write is one we could not persist the result to either).
|
||||
"""
|
||||
lock_path = _token_file_path().with_suffix(".lock")
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError:
|
||||
yield
|
||||
return
|
||||
with open(lock_path, "w") as lock_file:
|
||||
fcntl.flock(lock_file, fcntl.LOCK_EX)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(lock_file, fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def refresh_stored_token(server_url: str, *, timeout: float = 10.0) -> str | None:
|
||||
"""Renew the stored access token from its login-issued refresh grant.
|
||||
|
||||
POSTs ``grant_type=refresh_token`` to the server's ``/oauth/token``,
|
||||
persists the result, and returns the fresh access token. Safe to call
|
||||
opportunistically: returns ``None`` when there is nothing to refresh
|
||||
(no entry / no refresh material) or when the server refuses (grant
|
||||
revoked, past its absolute lifetime, or an older server without the
|
||||
endpoint).
|
||||
|
||||
Runs under the token-file lock and re-checks state after acquiring
|
||||
it, so of two concurrent callers only one performs the network
|
||||
refresh and the other returns the already-renewed token.
|
||||
|
||||
:param server_url: The server URL, e.g. ``"http://localhost:6767"``.
|
||||
:param timeout: HTTP timeout in seconds.
|
||||
:returns: A valid access token, or ``None``.
|
||||
"""
|
||||
normalized = _normalize_server_url(server_url)
|
||||
# Cheap pre-check BEFORE touching the lock file: with no refresh
|
||||
# material there is nothing to do, and creating a lock file would
|
||||
# raise on a read-only state directory — masking the caller's other
|
||||
# credential sources (e.g. the Databricks SDK fallback).
|
||||
pre = _load_entry(server_url)
|
||||
if pre is None or not isinstance(pre.get("refresh_token"), str) or not pre["refresh_token"]:
|
||||
return None
|
||||
try:
|
||||
with _token_file_lock():
|
||||
return _refresh_locked(server_url, normalized, timeout)
|
||||
except OSError as exc:
|
||||
# Cannot lock/persist (read-only or full state dir) — a refresh we
|
||||
# could not store is worse than none, so decline and let the caller
|
||||
# fall through to its other credential sources.
|
||||
_logger.debug("Token refresh for %s skipped, state dir unusable: %s", normalized, exc)
|
||||
return None
|
||||
|
||||
|
||||
def _refresh_locked(server_url: str, normalized: str, timeout: float) -> str | None:
|
||||
"""Perform the refresh exchange; caller holds the token-file lock."""
|
||||
entry = _load_entry(server_url)
|
||||
if entry is None:
|
||||
return None
|
||||
# Another process may have refreshed while we waited on the lock. A
|
||||
# freshly minted token is far from expiry, so this cleanly separates
|
||||
# "someone already renewed" from "this is the same near-expiry token".
|
||||
expires_at = entry.get("expires_at", 0)
|
||||
token = entry.get("token")
|
||||
if (
|
||||
isinstance(token, str)
|
||||
and isinstance(expires_at, (int, float))
|
||||
and expires_at - time.time() > REFRESH_MIN_REMAINING_SECONDS
|
||||
):
|
||||
return token
|
||||
refresh_token = entry.get("refresh_token")
|
||||
if not isinstance(refresh_token, str) or not refresh_token:
|
||||
return None
|
||||
|
||||
import httpx
|
||||
|
||||
try:
|
||||
resp = httpx.post(
|
||||
f"{normalized}/oauth/token",
|
||||
data={"grant_type": "refresh_token", "refresh_token": refresh_token},
|
||||
timeout=timeout,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
_logger.warning("Token refresh against %s failed: %s", normalized, exc)
|
||||
return None
|
||||
if resp.status_code != 200:
|
||||
_logger.warning(
|
||||
"Token refresh against %s refused (HTTP %d) — run `omnigent login %s` "
|
||||
"to re-authenticate.",
|
||||
normalized,
|
||||
resp.status_code,
|
||||
normalized,
|
||||
)
|
||||
return None
|
||||
try:
|
||||
payload = resp.json()
|
||||
except ValueError:
|
||||
_logger.warning("Token refresh against %s returned a malformed response", normalized)
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
_logger.warning("Token refresh against %s returned a malformed response", normalized)
|
||||
return None
|
||||
access_token = payload.get("access_token")
|
||||
new_refresh = payload.get("refresh_token")
|
||||
# Only overwrite the stored pair with genuinely usable material —
|
||||
# a null/non-string field must never clobber a working credential.
|
||||
if not isinstance(access_token, str) or not access_token:
|
||||
_logger.warning("Token refresh against %s returned no access token", normalized)
|
||||
return None
|
||||
if not isinstance(new_refresh, str) or not new_refresh:
|
||||
# A server that renews without returning refresh material keeps the
|
||||
# one we already hold (login grants deliberately do not rotate).
|
||||
new_refresh = refresh_token
|
||||
expires_in = _coerce_expires_in(payload.get("expires_in"))
|
||||
|
||||
user_id = entry.get("user_id")
|
||||
store_token(
|
||||
server_url,
|
||||
token=access_token,
|
||||
user_id=user_id if isinstance(user_id, str) else "",
|
||||
expires_at=time.time() + expires_in,
|
||||
refresh_token=new_refresh,
|
||||
)
|
||||
# A fresh token means any earlier expiry warning is stale; allow
|
||||
# a new one if this credential ever lapses again.
|
||||
_warned_expired_servers.discard(normalized)
|
||||
_logger.info("Refreshed login session for %s", normalized)
|
||||
return access_token
|
||||
|
||||
|
||||
def _coerce_expires_in(raw: object) -> float:
|
||||
"""Return a sane access-token lifetime in seconds from *raw*.
|
||||
|
||||
Falls back to one hour for anything missing, non-numeric, or
|
||||
non-finite — ``float("NaN")``/``float("Infinity")`` parse happily but
|
||||
would yield an expiry that never compares as expired, pinning a dead
|
||||
token forever.
|
||||
"""
|
||||
default = 3600.0
|
||||
try:
|
||||
value = float(raw) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
if not math.isfinite(value) or value <= 0:
|
||||
return default
|
||||
return value
|
||||
|
||||
|
||||
def load_databricks_workspace_host(server_url: str) -> str | None:
|
||||
"""Load the workspace host from a Databricks Apps pointer record.
|
||||
|
||||
|
||||
@@ -853,11 +853,14 @@ async def _prepare_codex_terminal_via_daemon(
|
||||
if session_bundle is None:
|
||||
raise click.ClickException("Creating a Codex session requires a session bundle.")
|
||||
_update_startup_progress(startup_progress, "Creating Codex session...")
|
||||
session_id = await _create_codex_session(
|
||||
client,
|
||||
session_bundle,
|
||||
bridge_id=None,
|
||||
terminal_launch_args=persist_args or None,
|
||||
session_id, _ = await asyncio.gather(
|
||||
_create_codex_session(
|
||||
client,
|
||||
session_bundle,
|
||||
bridge_id=None,
|
||||
terminal_launch_args=persist_args or None,
|
||||
),
|
||||
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
|
||||
)
|
||||
else:
|
||||
_update_startup_progress(startup_progress, "Loading Codex session...")
|
||||
@@ -910,7 +913,8 @@ async def _prepare_codex_terminal_via_daemon(
|
||||
f"({resp.status_code}): {error_text(resp)}"
|
||||
)
|
||||
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
if not fresh_session:
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
_update_startup_progress(startup_progress, "Starting runner...")
|
||||
runner_id = await launch_or_reuse_daemon_runner(
|
||||
client,
|
||||
@@ -1196,6 +1200,7 @@ async def _prepare_codex_terminal(
|
||||
socket_path=codex_ws_url,
|
||||
thread_id=thread_id,
|
||||
codex_home=str(codex_home),
|
||||
cwd=str(Path.cwd()),
|
||||
),
|
||||
)
|
||||
if runner_id is not None:
|
||||
@@ -1417,6 +1422,7 @@ async def _initialize_fresh_terminal_thread(
|
||||
socket_path=app_server_url,
|
||||
thread_id=thread_id,
|
||||
codex_home=str(codex_home_for_bridge_dir(prepared.bridge_dir)),
|
||||
cwd=str(Path.cwd()),
|
||||
),
|
||||
)
|
||||
return thread_id
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -30,6 +31,7 @@ if TYPE_CHECKING:
|
||||
from omnigent.onboarding.provider_config import ProviderEntry
|
||||
from omnigent.spec.types import AgentSpec
|
||||
|
||||
from omnigent.codex_model_vocabulary import codex_spawn_model
|
||||
from omnigent.codex_native_bridge import write_policy_hook_config
|
||||
from omnigent.codex_native_process_registry import (
|
||||
CodexNativeProcessOwnerLock,
|
||||
@@ -57,6 +59,7 @@ from omnigent.inner.codex_executor import (
|
||||
codex_router_session_id,
|
||||
codex_routing_hook_skip_reason,
|
||||
materialize_codex_provider_config,
|
||||
read_codex_model_catalog,
|
||||
write_codex_hooks_file,
|
||||
)
|
||||
from omnigent.inner.databricks_executor import _databricks_gateway_host
|
||||
@@ -110,6 +113,7 @@ _MIN_POLICY_HOOK_CODEX_VERSION = (0, 129, 0)
|
||||
# (including a version we could not parse) the flag is omitted and the
|
||||
# interactive trust prompt may appear instead.
|
||||
_MIN_BYPASS_HOOK_TRUST_CODEX_VERSION = (0, 131, 0)
|
||||
_MODEL_MIGRATION_CATALOG_TIMEOUT_SECONDS = 3.0
|
||||
|
||||
|
||||
def _string_object_dict(value: object) -> _JsonObject | None:
|
||||
@@ -386,6 +390,43 @@ def _sync_codex_developer_instructions(
|
||||
config_path.write_text(tomlkit.dumps(document), encoding="utf-8")
|
||||
|
||||
|
||||
def _codex_model_upgrade_target(catalog: object, model: str) -> str | None:
|
||||
"""Return Codex's replacement for *model*, when the catalog declares one."""
|
||||
if not isinstance(catalog, dict):
|
||||
return None
|
||||
models = catalog.get("models")
|
||||
if not isinstance(models, list):
|
||||
return None
|
||||
for entry in models:
|
||||
if not isinstance(entry, dict) or entry.get("slug") != model:
|
||||
continue
|
||||
upgrade = entry.get("upgrade")
|
||||
if not isinstance(upgrade, dict):
|
||||
return None
|
||||
target = upgrade.get("model") or upgrade.get("id")
|
||||
if isinstance(target, str) and target and target != model:
|
||||
return target
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _acknowledge_codex_model_migration(codex_home: Path, model: str, target: str) -> None:
|
||||
"""Suppress one model-migration prompt in a private runner-owned config."""
|
||||
config_path = codex_home / "config.toml"
|
||||
existing = config_path.read_text(encoding="utf-8") if config_path.exists() else ""
|
||||
document = tomlkit.parse(existing) if existing else tomlkit.document()
|
||||
notice = document.get("notice")
|
||||
if notice is None:
|
||||
notice = tomlkit.table()
|
||||
document["notice"] = notice
|
||||
migrations = notice.get("model_migrations")
|
||||
if migrations is None:
|
||||
migrations = tomlkit.table()
|
||||
notice["model_migrations"] = migrations
|
||||
migrations[model] = target
|
||||
config_path.write_text(tomlkit.dumps(document), encoding="utf-8")
|
||||
|
||||
|
||||
def _inject_mcp_server_config(
|
||||
codex_home: Path,
|
||||
bridge_dir: Path,
|
||||
@@ -745,13 +786,18 @@ async def _start_codex_model_discovery_process(
|
||||
listen_url: str,
|
||||
env: dict[str, str],
|
||||
cwd: Path,
|
||||
config_overrides: Sequence[str] = (),
|
||||
) -> asyncio.subprocess.Process:
|
||||
"""Start the isolated Codex process used only for model discovery."""
|
||||
override_args: list[str] = []
|
||||
for override in config_overrides:
|
||||
override_args.extend(("-c", override))
|
||||
return await asyncio.create_subprocess_exec(
|
||||
codex_path,
|
||||
"app-server",
|
||||
"--listen",
|
||||
listen_url,
|
||||
*override_args,
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
@@ -789,6 +835,189 @@ async def _wait_for_discovery_listener(
|
||||
raise TimeoutError("Timed out waiting for Codex model discovery app-server")
|
||||
|
||||
|
||||
def _probe_codex_home(config_overrides: Sequence[str]) -> Path:
|
||||
"""
|
||||
Persistent probe ``CODEX_HOME`` for one provider configuration.
|
||||
|
||||
Persistent (unlike the hermetic discovery's temp dir) so Codex's own
|
||||
``models_cache.json`` ETag handling makes repeat probes cheap; keyed by
|
||||
the override set so a provider change never replays another provider's
|
||||
cache. The account's real ``auth.json`` is symlinked in, the same way
|
||||
a session launch links it: the credential decides which models the
|
||||
account's catalog lists (login-gated entries, the account default), so
|
||||
a credential-less probe answers for a catalog no session will see.
|
||||
|
||||
:param config_overrides: The probe's ``-c`` overrides.
|
||||
:returns: The created ``CODEX_HOME`` directory.
|
||||
"""
|
||||
key = hashlib.sha256("\n".join(config_overrides).encode("utf-8")).hexdigest()[:12]
|
||||
home = Path.home() / ".omnigent" / "cache" / "codex-model-probe" / key
|
||||
home.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
real_auth = _codex_home_config_source_from_env() / "auth.json"
|
||||
probe_auth = home / "auth.json"
|
||||
if real_auth.exists():
|
||||
with contextlib.suppress(OSError):
|
||||
if probe_auth.is_symlink() or probe_auth.exists():
|
||||
probe_auth.unlink()
|
||||
probe_auth.symlink_to(real_auth)
|
||||
return home
|
||||
|
||||
|
||||
def mark_launch_default(rows: list[_JsonObject], pinned_model: str | None) -> list[_JsonObject]:
|
||||
"""
|
||||
Reduce ``model/list`` rows to exactly one ``isDefault`` marker.
|
||||
|
||||
The launch-pinned model wins when a row names it (either spelling);
|
||||
otherwise Codex's own first default stands. Rows are otherwise verbatim.
|
||||
|
||||
Codex's own ``isDefault`` is its built-in preference, which says nothing
|
||||
about the model this session launched on, so a picker that trusted it
|
||||
would name a model the pane is not running.
|
||||
|
||||
:param rows: Raw ``model/list`` rows.
|
||||
:param pinned_model: The model the session runs, or ``None``.
|
||||
:returns: The rows with a single default marked.
|
||||
"""
|
||||
from omnigent.codex_model_vocabulary import comparable_model_id
|
||||
|
||||
codex_default_index: int | None = None
|
||||
pinned_index: int | None = None
|
||||
pinned_key = comparable_model_id(pinned_model) if pinned_model else None
|
||||
marked: list[_JsonObject] = []
|
||||
for index, row in enumerate(rows):
|
||||
cleaned = {key: value for key, value in row.items() if key != "isDefault"}
|
||||
marked.append(cleaned)
|
||||
if codex_default_index is None and row.get("isDefault") is True:
|
||||
codex_default_index = index
|
||||
if pinned_index is None and pinned_key is not None:
|
||||
for spelling in (row.get("id"), row.get("model")):
|
||||
if isinstance(spelling, str) and comparable_model_id(spelling) == pinned_key:
|
||||
pinned_index = index
|
||||
break
|
||||
default_index = pinned_index if pinned_index is not None else codex_default_index
|
||||
if default_index is not None:
|
||||
marked[default_index]["isDefault"] = True
|
||||
return marked
|
||||
|
||||
|
||||
async def probe_codex_model_options(*, codex_path: str | None = None) -> list[_JsonObject]:
|
||||
"""
|
||||
Ask a session-configured Codex app-server for its own model list.
|
||||
|
||||
The harness is the source of truth for what a session's ``/model``
|
||||
picker would offer, so the probe boots ``codex app-server`` with the
|
||||
SAME materialization a session launch gets — for every launch shape.
|
||||
A Databricks profile contributes its provider overrides (gateway base
|
||||
URL + minted auth + model pin) and ``DATABRICKS_HOST``; other provider
|
||||
shapes carry their resolved ``-c`` overrides verbatim; the plain
|
||||
Codex-login shape probes bare, which yields the ACCOUNT's visible
|
||||
catalog: the probe home is isolated (never the user's real
|
||||
``~/.codex``) but links the real ``auth.json`` in the way a session
|
||||
launch does, so login-gated entries and the account default match what
|
||||
a live session will offer.
|
||||
|
||||
:param codex_path: Optional Codex executable override.
|
||||
:returns: The probe rows with a single default marked.
|
||||
:raises ImportError: When the Codex CLI is unavailable.
|
||||
:raises OSError: When a Databricks profile resolves no workspace host.
|
||||
:raises RuntimeError: When the probe app-server exits before connecting.
|
||||
:raises TimeoutError: When the probe app-server does not become ready.
|
||||
"""
|
||||
launch = await asyncio.to_thread(resolve_native_codex_launch, model=None)
|
||||
resolved_codex = codex_path or _find_codex_cli()
|
||||
if not resolved_codex:
|
||||
raise ImportError("Native Codex model probing requires the 'codex' CLI on PATH.")
|
||||
config_overrides = list(launch.config_overrides)
|
||||
pinned_model = launch.model
|
||||
env = _clean_codex_env()
|
||||
if launch.profile is not None:
|
||||
databricks = await asyncio.to_thread(
|
||||
_databricks_launch_materialization, model=launch.model, profile=launch.profile
|
||||
)
|
||||
config_overrides.extend(databricks.config_overrides)
|
||||
env["DATABRICKS_HOST"] = databricks.host
|
||||
pinned_model = databricks.model
|
||||
codex_home = await asyncio.to_thread(_probe_codex_home, config_overrides)
|
||||
env["CODEX_HOME"] = str(codex_home)
|
||||
port = _allocate_loopback_port()
|
||||
listen_url = f"ws://127.0.0.1:{port}"
|
||||
process = await _start_codex_model_discovery_process(
|
||||
codex_path=resolved_codex,
|
||||
listen_url=listen_url,
|
||||
env=env,
|
||||
cwd=codex_home,
|
||||
config_overrides=config_overrides,
|
||||
)
|
||||
client: CodexAppServerClient | None = None
|
||||
try:
|
||||
await _wait_for_discovery_listener(process, port)
|
||||
client = CodexAppServerClient(
|
||||
ws_url=listen_url,
|
||||
client_name="omnigent-codex-model-probe",
|
||||
)
|
||||
await client.connect()
|
||||
rows = await list_codex_model_options(client)
|
||||
finally:
|
||||
if client is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await client.close()
|
||||
_proc.terminate_tree(process)
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=5.0)
|
||||
except TimeoutError:
|
||||
_proc.kill_tree(process)
|
||||
await process.wait()
|
||||
return mark_launch_default(rows, pinned_model)
|
||||
|
||||
|
||||
def codex_catalog_fingerprint(launch: NativeCodexLaunch) -> str:
|
||||
"""The launch-config fingerprint keying codex's shared model catalog.
|
||||
|
||||
One formula for every consumer (host boot probe, runner launch, live
|
||||
write-back), so they read and write the same catalog file. Callers
|
||||
fingerprint the SHAPE — a ``model=None`` resolution — so per-session
|
||||
picks never fragment the catalog.
|
||||
|
||||
:param launch: The resolved launch (``resolve_native_codex_launch``).
|
||||
:returns: A stable fingerprint string.
|
||||
"""
|
||||
from omnigent.model_catalog_store import fingerprint_of
|
||||
|
||||
return fingerprint_of(
|
||||
"codex-native", launch.profile, launch.model, tuple(launch.config_overrides)
|
||||
)
|
||||
|
||||
|
||||
async def codex_launch_catalog(*, codex_path: str | None = None) -> list[_JsonObject] | None:
|
||||
"""
|
||||
The shared codex catalog for this host's default shape: store, then probe.
|
||||
|
||||
Reads the on-disk catalog for the ``model=None`` launch shape; a miss
|
||||
pays one session-shaped probe (real auth linked in) and persists the
|
||||
answer for every later consumer.
|
||||
|
||||
:param codex_path: Optional Codex executable override.
|
||||
:returns: Catalog rows, or ``None`` when no catalog could be obtained.
|
||||
"""
|
||||
from omnigent import model_catalog_store
|
||||
|
||||
try:
|
||||
launch = await asyncio.to_thread(resolve_native_codex_launch, model=None)
|
||||
except Exception: # noqa: BLE001 — a broken provider config means no catalog
|
||||
_logger.warning("codex catalog: launch shape resolution failed", exc_info=True)
|
||||
return None
|
||||
fingerprint = codex_catalog_fingerprint(launch)
|
||||
|
||||
async def _probe() -> list[_JsonObject] | None:
|
||||
try:
|
||||
return await probe_codex_model_options(codex_path=codex_path)
|
||||
except Exception: # noqa: BLE001 — probe failure means "no catalog", never a crash
|
||||
_logger.warning("codex catalog probe failed", exc_info=True)
|
||||
return None
|
||||
|
||||
return await model_catalog_store.ensure_catalog("codex-native", fingerprint, _probe)
|
||||
|
||||
|
||||
def _build_native_codex_app_server_argv(
|
||||
*,
|
||||
tagged_argv0: str,
|
||||
@@ -920,6 +1149,15 @@ class CodexNativeAppServer:
|
||||
self.router_hooks_registered = router_bridge_dir is not None and policy_hooks_supported
|
||||
routed_spawns = router_bridge_dir is not None
|
||||
config_source = _codex_home_config_source_from_env()
|
||||
model_migration_target: str | None = None
|
||||
if self.trust_project and self.pinned_model:
|
||||
catalog = await asyncio.to_thread(
|
||||
read_codex_model_catalog,
|
||||
self.codex_path,
|
||||
config_source,
|
||||
timeout=_MODEL_MIGRATION_CATALOG_TIMEOUT_SECONDS,
|
||||
)
|
||||
model_migration_target = _codex_model_upgrade_target(catalog, self.pinned_model)
|
||||
# Off the loop: this copies/symlinks a home AND (on a Smart Routing
|
||||
# session) shells out to ``codex debug models`` with a 10s timeout. Run
|
||||
# inline it stalled every other session sharing this event loop for that
|
||||
@@ -944,6 +1182,12 @@ class CodexNativeAppServer:
|
||||
)
|
||||
if self.pinned_model:
|
||||
_pin_codex_config_model(self.codex_home, self.pinned_model)
|
||||
if model_migration_target is not None:
|
||||
_acknowledge_codex_model_migration(
|
||||
self.codex_home,
|
||||
self.pinned_model,
|
||||
model_migration_target,
|
||||
)
|
||||
_sync_codex_developer_instructions(
|
||||
self.codex_home,
|
||||
self.developer_instructions,
|
||||
@@ -1722,6 +1966,63 @@ def _trust_codex_project(codex_home: Path, cwd: Path) -> None:
|
||||
config_path.write_text(tomlkit.dumps(document), encoding="utf-8")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _DatabricksLaunchMaterialization:
|
||||
"""
|
||||
The Databricks-profile pieces of a Codex launch, shared by the real
|
||||
app-server build and the model-options probe so the two cannot drift.
|
||||
|
||||
:param config_overrides: ``-c`` overrides routing Codex through the
|
||||
profile's AI Gateway (provider block + auth command + model pin).
|
||||
:param model: The model the overrides pin, e.g. ``"databricks-gpt-5-4"``
|
||||
— the explicit *model* when given, else the catalog default.
|
||||
:param host: The profile's workspace origin for ``DATABRICKS_HOST``.
|
||||
"""
|
||||
|
||||
config_overrides: list[str]
|
||||
model: str
|
||||
host: str
|
||||
|
||||
|
||||
def _databricks_launch_materialization(
|
||||
*, model: str | None, profile: str
|
||||
) -> _DatabricksLaunchMaterialization:
|
||||
"""
|
||||
Resolve the Databricks-profile routing pieces of a Codex launch.
|
||||
|
||||
Uses the profile's own host so the gateway base URL matches the token
|
||||
the profile-pinned auth command mints; a ``DATABRICKS_HOST`` override in
|
||||
the runner env must not point the base URL at another workspace.
|
||||
|
||||
:param model: Optional explicit model pin; ``None`` resolves the
|
||||
catalog default.
|
||||
:param profile: ``~/.databrickscfg`` profile name, e.g. ``"oss"``.
|
||||
:returns: The materialized overrides, pinned model, and host.
|
||||
:raises OSError: When the profile resolves no workspace host.
|
||||
"""
|
||||
host = _databricks_gateway_host(profile)
|
||||
if not host:
|
||||
raise OSError(
|
||||
f"Native Codex with Databricks profile {profile!r} (from your "
|
||||
"provider config) requires a matching ~/.databrickscfg section "
|
||||
"with a host visible to the runner process."
|
||||
)
|
||||
host = host.rstrip("/")
|
||||
# Resolve against what the workspace actually serves (live UC listing →
|
||||
# ucode state → bundled catalog), never the bundled catalog alone — its
|
||||
# legacy ``databricks-`` spellings can 501 on today's gateway.
|
||||
resolved_model = _resolve_databricks_codex_model(host, profile, model)
|
||||
return _DatabricksLaunchMaterialization(
|
||||
config_overrides=_databricks_codex_config_overrides(
|
||||
model=resolved_model,
|
||||
base_url=_databricks_codex_base_url(host),
|
||||
auth_command=_databricks_codex_auth_command(host, profile),
|
||||
),
|
||||
model=resolved_model,
|
||||
host=host,
|
||||
)
|
||||
|
||||
|
||||
# DATABRICKS-PATCH(codex-live-model-discovery)
|
||||
def _resolve_databricks_codex_model(host: str, profile: str, requested: str | None) -> str:
|
||||
"""Resolve the codex launch model against what the workspace serves.
|
||||
@@ -1856,26 +2157,18 @@ def build_codex_native_server(
|
||||
)
|
||||
env = _clean_codex_env()
|
||||
config_overrides: list[str] = []
|
||||
pinned_model = model
|
||||
if profile is not None:
|
||||
# Use the profile's own host so the gateway base URL matches the token
|
||||
# the profile-pinned auth command mints; a DATABRICKS_HOST override in
|
||||
# the runner env must not point the base URL at another workspace.
|
||||
host = _databricks_gateway_host(profile)
|
||||
if not host:
|
||||
raise OSError(
|
||||
f"Native Codex with Databricks profile {profile!r} (from your "
|
||||
"provider config) requires a matching ~/.databrickscfg section "
|
||||
"with a host visible to the runner process."
|
||||
)
|
||||
host = host.rstrip("/")
|
||||
config_overrides.extend(
|
||||
_databricks_codex_config_overrides(
|
||||
model=_resolve_databricks_codex_model(host, profile, model),
|
||||
base_url=_databricks_codex_base_url(host),
|
||||
auth_command=_databricks_codex_auth_command(host, profile),
|
||||
)
|
||||
)
|
||||
env["DATABRICKS_HOST"] = host
|
||||
databricks = _databricks_launch_materialization(model=model, profile=profile)
|
||||
config_overrides.extend(databricks.config_overrides)
|
||||
env["DATABRICKS_HOST"] = databricks.host
|
||||
# A launch that names no model still routes through the profile's
|
||||
# resolved model via ``-c model=``, which outranks the config.toml
|
||||
# copied from the user's shared home. Pin that model in codex's own
|
||||
# spelling — the vocabulary config.toml and its readers use — so the
|
||||
# forwarder mirror and cost gate report the model this session runs
|
||||
# instead of whatever the shared file was last left on.
|
||||
pinned_model = codex_spawn_model(databricks.model) or databricks.model
|
||||
if extra_config_overrides:
|
||||
config_overrides.extend(extra_config_overrides)
|
||||
if bypass_sandbox:
|
||||
@@ -1889,6 +2182,15 @@ def build_codex_native_server(
|
||||
'sandbox_mode="danger-full-access"',
|
||||
]
|
||||
)
|
||||
# Every launch is explicit: the resolved model rides argv (``-c model=``)
|
||||
# AND the private config copy's ``model =`` line (pinned in ``start``),
|
||||
# both written from this one value — so a stale line copied from the
|
||||
# user's shared config can never govern a session, and the two artifacts
|
||||
# cannot drift.
|
||||
if pinned_model and not any(
|
||||
override.split("=", 1)[0] == "model" for override in config_overrides
|
||||
):
|
||||
config_overrides.append(f"model={json.dumps(pinned_model)}")
|
||||
return CodexNativeAppServer(
|
||||
codex_path=resolved_codex,
|
||||
socket_path=socket_path,
|
||||
@@ -1901,7 +2203,7 @@ def build_codex_native_server(
|
||||
ap_server_url=ap_server_url,
|
||||
ap_auth_headers=ap_auth_headers,
|
||||
python_executable=python_executable,
|
||||
pinned_model=model,
|
||||
pinned_model=pinned_model,
|
||||
trust_project=trust_project,
|
||||
)
|
||||
|
||||
|
||||
@@ -76,6 +76,8 @@ class CodexNativeBridgeState:
|
||||
``"0196..."``.
|
||||
:param codex_home: Private per-session ``CODEX_HOME`` path, e.g.
|
||||
``"/home/user/.omnigent/codex-native/x/codex-home"``.
|
||||
:param cwd: Native Codex thread working directory, e.g.
|
||||
``"/home/user/project"``.
|
||||
:param active_turn_id: Current Codex turn id, if one is running,
|
||||
e.g. ``"turn_abc123"``.
|
||||
"""
|
||||
@@ -85,6 +87,7 @@ class CodexNativeBridgeState:
|
||||
thread_id: str
|
||||
codex_home: str
|
||||
active_turn_id: str | None = None
|
||||
cwd: str | None = None
|
||||
|
||||
|
||||
def bridge_dir_for_bridge_id(bridge_id: str) -> Path:
|
||||
@@ -339,9 +342,23 @@ def read_codex_config_model(bridge_dir: Path) -> str | None:
|
||||
:returns: The top-level ``model`` from ``config.toml`` (e.g.
|
||||
``"gpt-5.4"``), or ``None`` when undeterminable.
|
||||
"""
|
||||
config_path = codex_home_for_bridge_dir(bridge_dir) / "config.toml"
|
||||
return read_codex_home_config_model(codex_home_for_bridge_dir(bridge_dir))
|
||||
|
||||
|
||||
def read_codex_home_config_model(codex_home: Path) -> str | None:
|
||||
"""
|
||||
Read the active model straight from a session's ``CODEX_HOME``.
|
||||
|
||||
Same value and fail-safe behaviour as :func:`read_codex_config_model`,
|
||||
for callers that hold the ``CODEX_HOME`` path (e.g. a live bridge
|
||||
state) rather than the bridge directory.
|
||||
|
||||
:param codex_home: The session's private ``CODEX_HOME`` directory.
|
||||
:returns: The top-level ``model`` from ``config.toml`` (e.g.
|
||||
``"gpt-5.4"``), or ``None`` when undeterminable.
|
||||
"""
|
||||
try:
|
||||
data = tomllib.loads(config_path.read_text())
|
||||
data = tomllib.loads((codex_home / "config.toml").read_text())
|
||||
except (OSError, tomllib.TOMLDecodeError):
|
||||
return None
|
||||
model = data.get("model")
|
||||
@@ -425,6 +442,7 @@ def write_bridge_state(bridge_dir: Path, state: CodexNativeBridgeState) -> None:
|
||||
"thread_id": state.thread_id,
|
||||
"codex_home": state.codex_home,
|
||||
"active_turn_id": state.active_turn_id,
|
||||
"cwd": state.cwd,
|
||||
},
|
||||
handle,
|
||||
sort_keys=True,
|
||||
@@ -686,6 +704,7 @@ def read_bridge_state(bridge_dir: Path) -> CodexNativeBridgeState | None:
|
||||
thread_id = raw.get("thread_id")
|
||||
codex_home = raw.get("codex_home")
|
||||
active_turn_id = raw.get("active_turn_id")
|
||||
cwd = raw.get("cwd")
|
||||
if (
|
||||
not isinstance(session_id, str)
|
||||
or not session_id
|
||||
@@ -706,6 +725,7 @@ def read_bridge_state(bridge_dir: Path) -> CodexNativeBridgeState | None:
|
||||
thread_id=thread_id,
|
||||
codex_home=codex_home,
|
||||
active_turn_id=parsed_active_turn_id,
|
||||
cwd=cwd if isinstance(cwd, str) and cwd else None,
|
||||
)
|
||||
|
||||
|
||||
@@ -729,6 +749,7 @@ def update_active_turn_id(bridge_dir: Path, active_turn_id: str | None) -> None:
|
||||
thread_id=state.thread_id,
|
||||
codex_home=state.codex_home,
|
||||
active_turn_id=active_turn_id,
|
||||
cwd=state.cwd,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -757,6 +778,7 @@ def update_thread_id(bridge_dir: Path, thread_id: str, active_turn_id: str | Non
|
||||
thread_id=thread_id,
|
||||
codex_home=state.codex_home,
|
||||
active_turn_id=active_turn_id,
|
||||
cwd=state.cwd,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -802,6 +824,7 @@ def clear_active_turn_id_if_matches(bridge_dir: Path, completed_turn_id: str | N
|
||||
thread_id=state.thread_id,
|
||||
codex_home=state.codex_home,
|
||||
active_turn_id=None,
|
||||
cwd=state.cwd,
|
||||
),
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -192,14 +192,10 @@ _CODEX_ELICITATION_REQUEST_METHODS = frozenset(
|
||||
}
|
||||
)
|
||||
|
||||
# Turn-error surfacing. A failed Codex turn arrives as ``turn/completed``
|
||||
# (or ``turn/failed``) with ``turn.status == "failed"`` and a ``turn.error``
|
||||
# object ``{message, codexErrorInfo?, additionalDetails?}``; keying status off
|
||||
# the method alone mapped such turns to ``idle`` — a "silent success". The
|
||||
# forwarder inspects ``turn.status``/``turn.error``, forces ``failed``, and
|
||||
# surfaces the reason. As a fallback it also catches an ``error`` ThreadItem in
|
||||
# ``turn.items``: both shapes exist in the app-server type system and the wire
|
||||
# shape varies by version, so detecting either keeps the fix robust.
|
||||
# Turn-error surfacing. Codex reports failures through a standalone ``error``
|
||||
# notification and on terminal turn boundaries via ``turn.error`` / failed
|
||||
# status. The forwarder handles both, plus the older ``error`` ThreadItem
|
||||
# fallback, so every non-retrying failure reaches the session UI.
|
||||
#
|
||||
# ``codexErrorInfo`` is the app-server's structured classification (e.g.
|
||||
# ``unauthorized``, ``usage_limit_exceeded``); auth-class values get a re-auth
|
||||
@@ -359,6 +355,9 @@ class _CodexForwarderState:
|
||||
:param synced_item_keys: Stable item keys already posted to Omnigent this
|
||||
connection, e.g. ``{"thread_c:turn_c:item-1"}``. In-memory only;
|
||||
guards replay-vs-live overlap within one forwarder lifetime.
|
||||
:param surfaced_terminal_error_turns: Turn ids whose standalone terminal
|
||||
``error`` notification was already surfaced. Used to suppress a later
|
||||
terminal boundary for the same turn.
|
||||
:param posted_user_turns: Turn ids whose ``userMessage`` has been
|
||||
posted to Omnigent this connection, e.g. ``{"turn_123"}``. Used to
|
||||
enforce user-before-assistant ordering: before posting a turn's
|
||||
@@ -406,6 +405,7 @@ class _CodexForwarderState:
|
||||
pending_child_threads: dict[str, str | None] = field(default_factory=dict)
|
||||
subscribed_child_threads: set[str] = field(default_factory=set)
|
||||
synced_item_keys: set[str] = field(default_factory=set)
|
||||
surfaced_terminal_error_turns: set[str] = field(default_factory=set)
|
||||
posted_user_turns: set[str] = field(default_factory=set)
|
||||
posted_tool_calls: set[str] = field(default_factory=set)
|
||||
partial_text_by_turn: dict[str, list[_PartialTextBuffer]] = field(default_factory=dict)
|
||||
@@ -1006,6 +1006,15 @@ def _terminal_error_from_turn(params: _JsonObject) -> _CodexTerminalError | None
|
||||
return _CodexTerminalError(message=message, kind=_classify_codex_error(payload, message))
|
||||
|
||||
|
||||
def _terminal_error_from_notification(params: _JsonObject) -> _CodexTerminalError | None:
|
||||
"""Return the failure carried by Codex's standalone ``error`` notification."""
|
||||
payload = params.get("error")
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
message = _error_payload_message(payload)
|
||||
return _CodexTerminalError(message=message, kind=_classify_codex_error(payload, message))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _CodexTurnStatusEdge:
|
||||
"""
|
||||
@@ -2985,6 +2994,41 @@ async def _maybe_handle_turn_event(
|
||||
:param forwarder_state: Optional forwarder state.
|
||||
:returns: ``True`` when this event was handled.
|
||||
"""
|
||||
if method == "error":
|
||||
if params.get("willRetry") is True:
|
||||
_logger.info(
|
||||
"Codex forwarder observed retryable turn error: turn_id=%s",
|
||||
_turn_id_from_payload(params),
|
||||
)
|
||||
return True
|
||||
if delta_coalescer is not None:
|
||||
await delta_coalescer.flush()
|
||||
error = _terminal_error_from_notification(params)
|
||||
if error is None:
|
||||
_logger.warning("Codex forwarder ignored malformed error notification")
|
||||
return True
|
||||
turn_id = _turn_id_from_payload(params)
|
||||
if forwarder_state is not None and turn_id is not None:
|
||||
if turn_id in forwarder_state.surfaced_terminal_error_turns:
|
||||
_logger.info(
|
||||
"Codex forwarder ignored duplicate terminal error: turn_id=%s",
|
||||
turn_id,
|
||||
)
|
||||
return True
|
||||
forwarder_state.surfaced_terminal_error_turns.add(turn_id)
|
||||
clear_active_turn_id_if_matches(bridge_dir, turn_id)
|
||||
await _post_turn_status_edge(
|
||||
client,
|
||||
session_id,
|
||||
_CodexTurnStatusEdge(
|
||||
status="failed",
|
||||
turn_id=turn_id,
|
||||
source="error",
|
||||
error=error,
|
||||
),
|
||||
)
|
||||
await usage_coalescer.flush()
|
||||
return True
|
||||
if method == "turn/started":
|
||||
if delta_coalescer is not None:
|
||||
await delta_coalescer.flush()
|
||||
@@ -3230,7 +3274,14 @@ async def _handle_terminal_turn_boundary(
|
||||
params=params,
|
||||
forwarder_state=forwarder_state,
|
||||
)
|
||||
handled = await _handle_terminal_turn_event(client, session_id, bridge_dir, method, params)
|
||||
handled = await _handle_terminal_turn_event(
|
||||
client,
|
||||
session_id,
|
||||
bridge_dir,
|
||||
method,
|
||||
params,
|
||||
forwarder_state=forwarder_state,
|
||||
)
|
||||
if handled:
|
||||
await elicitation_tracker.resolve_by_terminal_turn_event(
|
||||
client,
|
||||
@@ -4124,21 +4175,38 @@ async def _handle_terminal_turn_event(
|
||||
bridge_dir: Path,
|
||||
method: str,
|
||||
params: _JsonObject,
|
||||
*,
|
||||
forwarder_state: _CodexForwarderState | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Forward a terminal-observed Codex turn completion/failure event.
|
||||
Handle a terminal-observed Codex turn completion/failure event.
|
||||
|
||||
:param client: HTTP client for Omnigent event posts.
|
||||
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
|
||||
:param bridge_dir: Native Codex bridge directory.
|
||||
:param method: Codex method, e.g. ``"turn/completed"``.
|
||||
:param params: Codex turn event params.
|
||||
:returns: ``True`` when the terminal event belonged to the active
|
||||
turn and was forwarded, ``False`` when it was stale.
|
||||
:param forwarder_state: Optional connection state used to suppress a
|
||||
terminal boundary whose standalone error was already surfaced.
|
||||
:returns: ``True`` when the terminal event belonged to the active turn
|
||||
and its lifecycle was handled, ``False`` when it was stale.
|
||||
"""
|
||||
terminal_turn_id = _terminal_turn_id_from_params(params)
|
||||
if (
|
||||
forwarder_state is not None
|
||||
and terminal_turn_id is not None
|
||||
and terminal_turn_id in forwarder_state.surfaced_terminal_error_turns
|
||||
):
|
||||
clear_active_turn_id_if_matches(bridge_dir, terminal_turn_id)
|
||||
_logger.info(
|
||||
"Codex forwarder suppressed terminal boundary after standalone error: "
|
||||
"method=%s turn_id=%s",
|
||||
method,
|
||||
terminal_turn_id,
|
||||
)
|
||||
return True
|
||||
edge = _terminal_turn_status_edge(bridge_dir, method, params)
|
||||
if edge is None:
|
||||
terminal_turn_id = _terminal_turn_id_from_params(params)
|
||||
_logger.info(
|
||||
"Codex forwarder ignored stale terminal turn event: method=%s turn_id=%s",
|
||||
method,
|
||||
@@ -6278,7 +6346,9 @@ def _session_usage_data_from_params(params: _JsonObject) -> dict[str, int] | Non
|
||||
if not isinstance(total, dict):
|
||||
return None
|
||||
cumulative_input_tokens = total.get("inputTokens")
|
||||
context_window = total.get("contextWindow")
|
||||
context_window = token_usage.get("modelContextWindow")
|
||||
if not isinstance(context_window, int) or context_window <= 0:
|
||||
context_window = total.get("contextWindow")
|
||||
output_tokens = total.get("outputTokens")
|
||||
cached_input_tokens = total.get("cachedInputTokens")
|
||||
data: dict[str, int] = {}
|
||||
|
||||
@@ -511,10 +511,13 @@ async def _prepare_cursor_terminal_via_daemon(
|
||||
if session_bundle is None:
|
||||
raise click.ClickException("Creating a Cursor session requires a session bundle.")
|
||||
_update_startup_progress(startup_progress, "Creating Cursor session...")
|
||||
session_id = await _create_cursor_session(
|
||||
client,
|
||||
session_bundle,
|
||||
terminal_launch_args=persist_args or None,
|
||||
session_id, _ = await asyncio.gather(
|
||||
_create_cursor_session(
|
||||
client,
|
||||
session_bundle,
|
||||
terminal_launch_args=persist_args or None,
|
||||
),
|
||||
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
|
||||
)
|
||||
# Persist the model pin before the runner binds and launches the
|
||||
# TUI (it reads model_override from the snapshot to build --model).
|
||||
@@ -575,7 +578,8 @@ async def _prepare_cursor_terminal_via_daemon(
|
||||
_update_startup_progress(startup_progress, "Updating Cursor session...")
|
||||
await _patch_cursor_session(client, session_id, patch)
|
||||
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
if not fresh_session:
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
_update_startup_progress(startup_progress, "Starting runner...")
|
||||
runner_id = await launch_or_reuse_daemon_runner(
|
||||
client,
|
||||
|
||||
@@ -1455,6 +1455,9 @@ class SqlScheduledTask(OmnigentBase):
|
||||
# mirror the matching conversations.* override columns.
|
||||
model_override: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
reasoning_effort: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
# Per-firing cost budget in USD. When set, the fire path attaches a
|
||||
# cost_budget policy to each spawned session. NULL = no per-firing cap.
|
||||
max_cost_usd: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
workspace: Mapped[str | None] = mapped_column(String(2048), nullable=True)
|
||||
# Git base ref a firing branches from when it creates a worktree at fire
|
||||
# time (mirrors session-create's git.base_branch input). None when unset.
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""add max_cost_usd column to scheduled_tasks
|
||||
|
||||
Revision ID: za1b2c3d4e5f
|
||||
Revises: za2b3c4d5e6f
|
||||
Create Date: 2026-08-13 00:00:00.000000
|
||||
|
||||
Adds an optional ``max_cost_usd`` (FLOAT, nullable) column to
|
||||
``scheduled_tasks``. When set, the fire path attaches a ``cost_budget``
|
||||
policy to each spawned session capping cumulative spend at this limit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "za1b2c3d4e5f"
|
||||
down_revision: str | None = "za2b3c4d5e6f"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add max_cost_usd to scheduled_tasks."""
|
||||
op.add_column(
|
||||
"scheduled_tasks",
|
||||
sa.Column("max_cost_usd", sa.Float(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove max_cost_usd from scheduled_tasks."""
|
||||
with op.batch_alter_table("scheduled_tasks") as batch_op:
|
||||
batch_op.drop_column("max_cost_usd")
|
||||
@@ -10,6 +10,7 @@ import time
|
||||
import uuid
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import AbstractContextManager, contextmanager
|
||||
from contextvars import ContextVar
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -553,6 +554,54 @@ def clear_engine_cache() -> None:
|
||||
# ── Managed session ────────────────────────────────────
|
||||
|
||||
|
||||
# Ambient per-engine sessions for a read-only "share one checkout" scope. When
|
||||
# active (see :func:`shared_read_scope`), ``managed_session()`` reuses the
|
||||
# scope's session for its engine instead of opening a fresh pool checkout,
|
||||
# collapsing several back-to-back reads (e.g. the access-control check's
|
||||
# permission + conversation lookups) into a single connection round-trip.
|
||||
# Keyed by ``id(engine)`` so distinct engines (split-DB) still get independent
|
||||
# checkouts. Unset outside a scope, so it is a strict no-op for every ordinary
|
||||
# caller.
|
||||
_shared_read_sessions: ContextVar[dict[int, Session] | None] = ContextVar(
|
||||
"omnigent_shared_read_sessions", default=None
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def shared_read_scope() -> Iterator[None]:
|
||||
"""Collapse back-to-back reads into one pool checkout per engine.
|
||||
|
||||
Within this scope, ``managed_session()`` reuses a single session per
|
||||
engine rather than checking out a fresh pooled connection (plus a
|
||||
``pool_pre_ping`` round-trip) on every store call. Intended for a short,
|
||||
strictly READ-ONLY burst — an access-control check, a snapshot assembly —
|
||||
where the per-call checkout dominates the actual query time.
|
||||
|
||||
Nesting reuses the outer scope. Write makers (``immediate=True``) never
|
||||
participate, so they keep their own ``BEGIN IMMEDIATE`` isolation even
|
||||
when nested here. Never hold this open across network I/O: it pins a
|
||||
pooled connection for the scope's whole duration.
|
||||
"""
|
||||
if _shared_read_sessions.get() is not None:
|
||||
# Already inside a scope — the outer one owns the sessions.
|
||||
yield
|
||||
return
|
||||
sessions: dict[int, Session] = {}
|
||||
token = _shared_read_sessions.set(sessions)
|
||||
try:
|
||||
yield
|
||||
for session in sessions.values():
|
||||
session.commit()
|
||||
except BaseException:
|
||||
for session in sessions.values():
|
||||
session.rollback()
|
||||
raise
|
||||
finally:
|
||||
for session in sessions.values():
|
||||
session.close()
|
||||
_shared_read_sessions.reset(token)
|
||||
|
||||
|
||||
def make_managed_session_maker(
|
||||
engine: Engine,
|
||||
*,
|
||||
@@ -592,7 +641,27 @@ def make_managed_session_maker(
|
||||
Commits on clean exit, rolls back on exception. For SQLite
|
||||
backends, enables foreign key enforcement and sets a
|
||||
busy timeout before yielding.
|
||||
|
||||
Inside a :func:`shared_read_scope` (and only for read makers), the
|
||||
scope's per-engine session is reused instead of a fresh checkout;
|
||||
the scope — not this block — owns its commit/close.
|
||||
"""
|
||||
if not immediate:
|
||||
shared = _shared_read_sessions.get()
|
||||
if shared is not None:
|
||||
key = id(engine)
|
||||
session = shared.get(key)
|
||||
if session is None:
|
||||
session = factory()
|
||||
# Register before the PRAGMAs: those executes force the pool
|
||||
# checkout, so if one raises the scope must already track the
|
||||
# session to close it (otherwise the connection would leak).
|
||||
shared[key] = session
|
||||
if is_sqlite:
|
||||
session.execute(text("PRAGMA foreign_keys = ON"))
|
||||
session.execute(text("PRAGMA busy_timeout = 20000")) # 20s
|
||||
yield session
|
||||
return
|
||||
with factory() as session:
|
||||
try:
|
||||
if is_sqlite:
|
||||
|
||||
@@ -103,9 +103,15 @@ class Conversation:
|
||||
(alongside the runner-binding primitive of the Alpha
|
||||
runner-state design). Both paths validate the value against
|
||||
the supported set; invalid values fail with ``invalid_input``.
|
||||
:param model_override: Per-session LLM model override,
|
||||
e.g. ``"claude-opus-4-7"``. ``None`` means use the agent
|
||||
default from the spec's ``llm.model``. Mutable via
|
||||
:param reported_model: The model the harness last REPORTED the
|
||||
session is actually on, verbatim in the harness's own
|
||||
spelling, e.g. ``"claude-opus-4-8[1m]"``. Written only by
|
||||
harness reports (``external_model_change``); never by user
|
||||
picks. The only model value UI surfaces display. ``None``
|
||||
means no report has arrived yet.
|
||||
:param model_override: Per-session LLM model override — the user's
|
||||
REQUEST, e.g. ``"claude-opus-4-7"``. ``None`` means use the
|
||||
agent default from the spec's ``llm.model``. Mutable via
|
||||
``PATCH /v1/sessions/{id}`` and the REPL's ``/model``
|
||||
command. Mirrors the persistence shape of
|
||||
``reasoning_effort`` so the web UI and the TUI stay
|
||||
@@ -221,6 +227,7 @@ class Conversation:
|
||||
session_usage: dict[str, Any] = field(default_factory=dict)
|
||||
reasoning_effort: str | None = None
|
||||
model_override: str | None = None
|
||||
reported_model: str | None = None
|
||||
cost_control_mode_override: str | None = None
|
||||
subagent_routing_override: str | None = None
|
||||
harness_override: str | None = None
|
||||
|
||||
@@ -45,6 +45,11 @@ class ScheduledTask:
|
||||
``"claude-opus-4-7"``. ``None`` means use the agent default.
|
||||
:param reasoning_effort: Per-task reasoning-effort hint, e.g. ``"high"``.
|
||||
``None`` means use the agent default.
|
||||
:param max_cost_usd: Optional per-firing cost budget in USD. When set, the
|
||||
fire path attaches a ``cost_budget`` policy to each spawned session that
|
||||
blocks all models once cumulative spend reaches this limit. ``None``
|
||||
means no per-firing cost cap (the session runs unconstrained unless the
|
||||
agent spec or server-wide defaults impose one).
|
||||
:param workspace: Absolute existing path where a fired session's connected
|
||||
host runner should start. ``None`` only for legacy or invalid rows.
|
||||
:param base_branch: Reserved legacy column; scheduled tasks currently do
|
||||
@@ -74,6 +79,7 @@ class ScheduledTask:
|
||||
workspace_id: int = 0
|
||||
model_override: str | None = None
|
||||
reasoning_effort: str | None = None
|
||||
max_cost_usd: float | None = None
|
||||
workspace: str | None = None
|
||||
base_branch: str | None = None
|
||||
execution_target: str = "connected_host"
|
||||
|
||||
@@ -326,10 +326,13 @@ async def _prepare_goose_terminal_via_daemon(
|
||||
if session_bundle is None:
|
||||
raise click.ClickException("Creating a Goose session requires a session bundle.")
|
||||
_update_startup_progress(startup_progress, "Creating Goose session...")
|
||||
session_id = await _create_goose_session(
|
||||
client,
|
||||
session_bundle,
|
||||
terminal_launch_args=persist_args or None,
|
||||
session_id, _ = await asyncio.gather(
|
||||
_create_goose_session(
|
||||
client,
|
||||
session_bundle,
|
||||
terminal_launch_args=persist_args or None,
|
||||
),
|
||||
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
|
||||
)
|
||||
else:
|
||||
_update_startup_progress(startup_progress, "Loading Goose session...")
|
||||
@@ -372,7 +375,8 @@ async def _prepare_goose_terminal_via_daemon(
|
||||
f"({resp.status_code}): {error_text(resp)}"
|
||||
)
|
||||
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
if not fresh_session:
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
_update_startup_progress(startup_progress, "Starting runner...")
|
||||
runner_id = await launch_or_reuse_daemon_runner(
|
||||
client,
|
||||
|
||||
@@ -324,10 +324,13 @@ async def _prepare_hermes_terminal_via_daemon(
|
||||
if session_bundle is None:
|
||||
raise click.ClickException("Creating a Hermes session requires a session bundle.")
|
||||
_update_startup_progress(startup_progress, "Creating Hermes session...")
|
||||
session_id = await _create_hermes_session(
|
||||
client,
|
||||
session_bundle,
|
||||
terminal_launch_args=persist_args or None,
|
||||
session_id, _ = await asyncio.gather(
|
||||
_create_hermes_session(
|
||||
client,
|
||||
session_bundle,
|
||||
terminal_launch_args=persist_args or None,
|
||||
),
|
||||
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
|
||||
)
|
||||
else:
|
||||
_update_startup_progress(startup_progress, "Loading Hermes session...")
|
||||
@@ -370,7 +373,8 @@ async def _prepare_hermes_terminal_via_daemon(
|
||||
f"({resp.status_code}): {error_text(resp)}"
|
||||
)
|
||||
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
if not fresh_session:
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
_update_startup_progress(startup_progress, "Starting runner...")
|
||||
runner_id = await launch_or_reuse_daemon_runner(
|
||||
client,
|
||||
|
||||
+303
-132
@@ -29,7 +29,7 @@ from websockets.exceptions import ConnectionClosed, InvalidStatus, InvalidURI
|
||||
from omnigent._platform import IS_POSIX, WINDOWS_ENV_PASSTHROUGH
|
||||
from omnigent.env_credentials import env_names_with_omnigent_prefix
|
||||
from omnigent.gateway_inference import gateway_inference_map
|
||||
from omnigent.harness_aliases import canonicalize_harness
|
||||
from omnigent.harness_aliases import canonicalize_harness, is_claude_sdk_harness_name
|
||||
from omnigent.harness_availability import HARNESS_BINARY_MISSING, HarnessAvailability
|
||||
from omnigent.host import HOST_FATAL_EXIT_CODE
|
||||
from omnigent.host.frames import (
|
||||
@@ -128,6 +128,7 @@ from omnigent.runner.transports.ws_tunnel.limits import (
|
||||
TUNNEL_KEEPALIVE_PING_INTERVAL_S,
|
||||
TUNNEL_KEEPALIVE_PING_TIMEOUT_S,
|
||||
)
|
||||
from omnigent.suspend_watch import watch_for_resume
|
||||
from omnigent.tls import client_ssl_context
|
||||
from omnigent.version import VERSION
|
||||
|
||||
@@ -326,15 +327,24 @@ def _connection_refused(exc: BaseException) -> bool:
|
||||
|
||||
|
||||
_RECONNECT_BASE_S = 0.5
|
||||
_RECONNECT_CAP_S = 10.0
|
||||
_RECONNECT_CAP_S = 3.0
|
||||
_RECONNECT_JITTER = 0.5
|
||||
# Keep first startup tolerant of a cold server, but do not spend the library's
|
||||
# full default timeout on each reconnect after an established tunnel drops.
|
||||
_INITIAL_CONNECT_OPEN_TIMEOUT_S = 10.0
|
||||
_RECONNECT_OPEN_TIMEOUT_S = 3.0
|
||||
# Fresh hosts get a short auth-retry window for Databricks OAuth refreshes.
|
||||
# Established hosts retry auth failures indefinitely to preserve sessions.
|
||||
_MAX_CONSECUTIVE_AUTH_ERRORS = 3
|
||||
# Consecutive connection-refused failures against a loopback server before the
|
||||
# host exits (~5 minutes at the backoff cap). Refused on loopback means no
|
||||
# process listens on the port — the local server is gone, not unreachable.
|
||||
_LOOPBACK_REFUSED_FATAL_ATTEMPTS = 30
|
||||
_LOOPBACK_REFUSED_FATAL_ATTEMPTS = 100
|
||||
|
||||
# Consecutive post-connect 401/403 rejections (~5 min at the backoff cap)
|
||||
# before the retry loop escalates from "check your VPN" to a re-auth prompt.
|
||||
# Operator-facing only — the host keeps retrying and never exits.
|
||||
_AUTH_REJECT_ESCALATE_ATTEMPTS = 30
|
||||
|
||||
# Consecutive accepted-then-silent connections (upgrade completed, then the
|
||||
# socket died without one inbound frame) before the reconnect loop treats the
|
||||
@@ -773,6 +783,19 @@ def _paginate_list_dir(
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelOptionsResult:
|
||||
"""One resolved model listing: picker rows + the settable-but-unlisted ids.
|
||||
|
||||
:param models: Verbatim catalog rows (id/model/displayName/isDefault…).
|
||||
:param routable_models: Ids a launch can pin that the picker does not
|
||||
list (older generations the endpoint still serves).
|
||||
"""
|
||||
|
||||
models: list[dict[str, object]]
|
||||
routable_models: list[str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _RunnerHandle:
|
||||
"""A spawned runner subprocess and where its output lands.
|
||||
@@ -898,6 +921,15 @@ class HostProcess:
|
||||
# Strong refs to in-flight frame tasks (create_task results are
|
||||
# otherwise GC-able); each discards itself on completion.
|
||||
self._frame_tasks: set[asyncio.Task[None]] = set()
|
||||
# Background watcher that force-drops a stale tunnel on wake from system
|
||||
# suspend (laptop sleep) so the reconnect loop reattaches at once
|
||||
# instead of waiting out the ~90s keepalive timeout. See run() /
|
||||
# _on_resume_from_suspend.
|
||||
self._suspend_task: asyncio.Task[None] | None = None
|
||||
# Set by _on_resume_from_suspend when it aborts a live tunnel after a
|
||||
# detected resume; read+cleared in run()'s reconnect handler to force a
|
||||
# prompt reconnect (skip the backoff).
|
||||
self._woke_from_suspend = False
|
||||
|
||||
def _tracked_runner_pids(self) -> set[int]:
|
||||
"""PIDs of runners this host spawned and still tracks directly.
|
||||
@@ -1244,18 +1276,37 @@ class HostProcess:
|
||||
self._auth_retry_streak < _MAX_CONSECUTIVE_AUTH_ERRORS
|
||||
)
|
||||
if should_retry:
|
||||
_logger.warning("%s Retrying — check your VPN/network.", cause)
|
||||
if should_retry and self._auth_retry_streak == 1:
|
||||
# The warning above lands only in the CLI log file; print once
|
||||
# per outage so a foreground `omnigent host` isn't silent.
|
||||
print(
|
||||
f"⚠ {cause} Retrying — this usually means the VPN or "
|
||||
"network dropped. It will reconnect automatically once "
|
||||
"connectivity returns.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
if should_retry:
|
||||
# A sustained streak (vs. a brief VPN blip) means the credential
|
||||
# is very likely permanently rejected: escalate the operator
|
||||
# signal and name re-auth, but keep retrying so a real outage
|
||||
# self-heals.
|
||||
if (
|
||||
self._auth_retry_streak >= _AUTH_REJECT_ESCALATE_ATTEMPTS
|
||||
and self._auth_retry_streak % _AUTH_REJECT_ESCALATE_ATTEMPTS == 0
|
||||
):
|
||||
escalated = (
|
||||
f"{cause} The server has rejected it "
|
||||
f"{self._auth_retry_streak} times in a row — this is no "
|
||||
"longer a transient network blip. If it persists, the "
|
||||
"stored credential is likely no longer valid: run "
|
||||
f"`omnigent login {self._server_url}` and restart the "
|
||||
"host. Still retrying."
|
||||
)
|
||||
_logger.warning("%s", escalated)
|
||||
print(f"⚠ {escalated}", file=sys.stderr, flush=True)
|
||||
else:
|
||||
_logger.warning("%s Retrying — check your VPN/network.", cause)
|
||||
if self._auth_retry_streak == 1:
|
||||
# The warning above lands only in the CLI log file;
|
||||
# print once per outage so a foreground `omnigent host`
|
||||
# isn't silent.
|
||||
print(
|
||||
f"⚠ {cause} Retrying — this usually means the VPN or "
|
||||
"network dropped. It will reconnect automatically "
|
||||
"once connectivity returns.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
return None
|
||||
if status == 401:
|
||||
return HostConnectError(
|
||||
@@ -1266,6 +1317,20 @@ class HostProcess:
|
||||
+ self._login_fix_hint()
|
||||
)
|
||||
if status == 403:
|
||||
# An expired stored login is the common way to land here: the
|
||||
# token loader yields nothing, the dial goes out
|
||||
# unauthenticated, and the server's refusal looks like an
|
||||
# authorization or version-skew problem. Name the real cause.
|
||||
from omnigent.cli_auth import stored_token_status
|
||||
|
||||
if stored_token_status(self._server_url) == "expired":
|
||||
return HostConnectError(
|
||||
"Connection refused (HTTP 403): your stored login "
|
||||
f"session for {self._server_url} has EXPIRED, so the "
|
||||
"tunnel was dialed without credentials. Run `omnigent "
|
||||
f"login {self._server_url}` to re-authenticate, then "
|
||||
"restart the host."
|
||||
)
|
||||
return HostConnectError(
|
||||
"Connection refused (HTTP 403): the server repeatedly rejected the host "
|
||||
"tunnel. Either your "
|
||||
@@ -2216,6 +2281,70 @@ class HostProcess:
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
async def _prewarm_model_options(self) -> None:
|
||||
"""
|
||||
Fill the on-disk model catalogs for the probing harnesses at boot.
|
||||
|
||||
Runs both harness probes CONCURRENTLY, as detached background work —
|
||||
nothing (the tunnel, registration, readiness reporting, launches)
|
||||
ever waits on this. A picker request racing the boot probe joins the
|
||||
same single-flight probe through the shared store instead of
|
||||
starting a second one.
|
||||
|
||||
:returns: None. Probe failures are absorbed by the probe wrappers.
|
||||
"""
|
||||
await asyncio.gather(
|
||||
self._probed_codex_model_options(),
|
||||
self._probed_claude_model_options(),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
async def _probed_codex_model_options(self) -> ModelOptionsResult | None:
|
||||
"""
|
||||
Store-backed harness-truth Codex listing, or ``None`` on failure.
|
||||
|
||||
Every launch shape is answered from the shared on-disk catalog
|
||||
(probed from the configured Codex binary on a miss). There is no
|
||||
curated fallback: no catalog means an honest empty answer.
|
||||
|
||||
:returns: The catalog listing, or ``None`` when unavailable.
|
||||
"""
|
||||
from omnigent.codex_native_app_server import codex_launch_catalog
|
||||
|
||||
try:
|
||||
rows = await codex_launch_catalog()
|
||||
except Exception: # noqa: BLE001 — no catalog, never a crash
|
||||
_logger.warning("Codex model catalog unavailable", exc_info=True)
|
||||
return None
|
||||
if rows is None:
|
||||
return None
|
||||
routable = [row["id"] for row in rows if isinstance(row.get("id"), str) and row["id"]]
|
||||
return ModelOptionsResult(models=rows, routable_models=routable)
|
||||
|
||||
async def _probed_claude_model_options(self) -> ModelOptionsResult | None:
|
||||
"""
|
||||
Store-backed harness-truth Claude listing, or ``None`` on failure.
|
||||
|
||||
The shared catalog is keyed by the resolved launch config's
|
||||
fingerprint — the same file the runner reads at launch and serves in
|
||||
the session gear, so the pre-launch picker and the session cannot
|
||||
drift.
|
||||
|
||||
:returns: The catalog listing, or ``None`` when unavailable.
|
||||
"""
|
||||
from omnigent.claude_native import claude_launch_catalog, resolve_native_claude_config
|
||||
|
||||
try:
|
||||
config = await asyncio.to_thread(resolve_native_claude_config, spec=None)
|
||||
rows = await claude_launch_catalog(config)
|
||||
except Exception: # noqa: BLE001 — no catalog, never a crash
|
||||
_logger.warning("Claude model catalog unavailable", exc_info=True)
|
||||
return None
|
||||
if rows is None:
|
||||
return None
|
||||
routable = list(config.routable_models) if config is not None else []
|
||||
return ModelOptionsResult(models=rows, routable_models=routable)
|
||||
|
||||
async def _handle_model_options(
|
||||
self,
|
||||
frame: HostModelOptionsFrame,
|
||||
@@ -2230,107 +2359,23 @@ class HostProcess:
|
||||
"""
|
||||
harness = canonicalize_harness(frame.harness) or frame.harness
|
||||
if harness == "codex-native":
|
||||
try:
|
||||
from omnigent.codex_native_app_server import (
|
||||
discover_codex_model_options,
|
||||
resolve_native_codex_launch,
|
||||
)
|
||||
from omnigent.model_catalog import (
|
||||
is_direct_openai_provider,
|
||||
list_models_for_worker,
|
||||
resolve_catalog_model,
|
||||
resolve_model_provider,
|
||||
)
|
||||
from omnigent.spec.types import AgentSpec, ExecutorSpec
|
||||
|
||||
launch = await asyncio.to_thread(resolve_native_codex_launch, model=None)
|
||||
spec = AgentSpec(
|
||||
spec_version=1,
|
||||
name="codex-native-prelaunch",
|
||||
executor=ExecutorSpec(
|
||||
type="omnigent",
|
||||
config={
|
||||
"harness": "codex-native",
|
||||
**({"profile": launch.profile} if launch.profile else {}),
|
||||
},
|
||||
),
|
||||
)
|
||||
listing = await asyncio.to_thread(list_models_for_worker, spec, "codex-native")
|
||||
default_model = launch.model
|
||||
if default_model is None and launch.profile is not None:
|
||||
default_model = (
|
||||
await asyncio.to_thread(
|
||||
resolve_catalog_model,
|
||||
"databricks",
|
||||
family="openai",
|
||||
)
|
||||
).model_id
|
||||
default_id = (
|
||||
default_model if default_model in {m.id for m in listing.models} else None
|
||||
)
|
||||
provider = (
|
||||
resolve_model_provider(spec, "codex-native")
|
||||
if listing.source == "openai-compatible"
|
||||
else None
|
||||
)
|
||||
models: list[dict[str, object]]
|
||||
if provider is not None and is_direct_openai_provider(provider):
|
||||
available_ids = {model.id for model in listing.models}
|
||||
models = []
|
||||
seen: set[str] = set()
|
||||
selected_default = False
|
||||
try:
|
||||
codex_options = await discover_codex_model_options()
|
||||
except Exception:
|
||||
_logger.exception("Failed to discover Codex-compatible pre-launch models")
|
||||
codex_options = []
|
||||
for option in codex_options:
|
||||
raw_id = option.get("model") or option.get("id")
|
||||
if (
|
||||
not isinstance(raw_id, str)
|
||||
or raw_id not in available_ids
|
||||
or raw_id in seen
|
||||
):
|
||||
continue
|
||||
seen.add(raw_id)
|
||||
display_name = option.get("displayName")
|
||||
is_default = raw_id == default_id or (
|
||||
default_model is None
|
||||
and not selected_default
|
||||
and option.get("isDefault") is True
|
||||
)
|
||||
selected_default = selected_default or is_default
|
||||
models.append(
|
||||
{
|
||||
"id": raw_id,
|
||||
"displayName": (
|
||||
display_name
|
||||
if isinstance(display_name, str) and display_name
|
||||
else raw_id
|
||||
),
|
||||
**({"isDefault": True} if is_default else {}),
|
||||
}
|
||||
)
|
||||
else:
|
||||
models = [
|
||||
{
|
||||
"id": model.id,
|
||||
"displayName": model.id,
|
||||
**({"isDefault": True} if model.id == default_id else {}),
|
||||
}
|
||||
for model in listing.models
|
||||
]
|
||||
except Exception:
|
||||
_logger.exception("Failed to resolve pre-launch Codex model options")
|
||||
# Harness-truth lane: every launch shape is answered from the
|
||||
# shared catalog, probed from the configured Codex binary itself.
|
||||
# No curated fallback and no serving-endpoints listing — a probe
|
||||
# that cannot run yields an honest empty answer with the reason.
|
||||
probed = await self._probed_codex_model_options()
|
||||
if probed is not None:
|
||||
return HostModelOptionsResultFrame(
|
||||
request_id=frame.request_id,
|
||||
status="failed",
|
||||
error="failed to resolve Codex model options",
|
||||
status="ok",
|
||||
models=probed.models,
|
||||
routable_models=probed.routable_models,
|
||||
)
|
||||
return HostModelOptionsResultFrame(
|
||||
request_id=frame.request_id,
|
||||
status="ok",
|
||||
models=models,
|
||||
models=[],
|
||||
error="the codex model probe failed — see the host log",
|
||||
)
|
||||
|
||||
if harness == "pi-native":
|
||||
@@ -2351,34 +2396,67 @@ class HostProcess:
|
||||
models=pi_models,
|
||||
)
|
||||
|
||||
if is_claude_sdk_harness_name(harness):
|
||||
# SDK-mode Claude is a pass-through client with no model catalog
|
||||
# of its own, so the endpoint listing IS the harness truth — the
|
||||
# ids are already in the exact spelling the SDK sends.
|
||||
try:
|
||||
from omnigent.model_catalog import list_models_for_worker
|
||||
from omnigent.spec.types import AgentSpec, ExecutorSpec
|
||||
|
||||
sdk_spec = AgentSpec(
|
||||
spec_version=1,
|
||||
name="claude-sdk-prelaunch",
|
||||
executor=ExecutorSpec(
|
||||
type="omnigent",
|
||||
config={"harness": "claude-sdk"},
|
||||
),
|
||||
)
|
||||
listing = await asyncio.to_thread(list_models_for_worker, sdk_spec, "claude-sdk")
|
||||
except Exception:
|
||||
_logger.exception("Failed to resolve pre-launch Claude SDK model options")
|
||||
return HostModelOptionsResultFrame(
|
||||
request_id=frame.request_id,
|
||||
status="failed",
|
||||
error="failed to resolve Claude SDK model options",
|
||||
)
|
||||
if not listing.models:
|
||||
# Subscription / CLI-login providers list nothing endpoint-side.
|
||||
# The SDK drives the claude CLI, so the CLI's own probed rows
|
||||
# (its aliases resolve inside the harness) are the truth here.
|
||||
probed = await self._probed_claude_model_options()
|
||||
if probed is not None:
|
||||
return HostModelOptionsResultFrame(
|
||||
request_id=frame.request_id,
|
||||
status="ok",
|
||||
models=probed.models,
|
||||
routable_models=probed.routable_models,
|
||||
)
|
||||
return HostModelOptionsResultFrame(
|
||||
request_id=frame.request_id,
|
||||
status="ok",
|
||||
models=[{"id": model.id, "displayName": model.id} for model in listing.models],
|
||||
routable_models=[model.id for model in listing.models],
|
||||
)
|
||||
if harness != "claude-native":
|
||||
return HostModelOptionsResultFrame(
|
||||
request_id=frame.request_id,
|
||||
status="failed",
|
||||
error=f"model options are unsupported for harness {frame.harness!r}",
|
||||
)
|
||||
try:
|
||||
from omnigent.claude_native import (
|
||||
claude_native_model_options,
|
||||
resolve_native_claude_config,
|
||||
)
|
||||
|
||||
config = await asyncio.to_thread(resolve_native_claude_config, spec=None)
|
||||
models = await asyncio.to_thread(claude_native_model_options, config)
|
||||
except Exception:
|
||||
_logger.exception("Failed to resolve pre-launch Claude model options")
|
||||
probed = await self._probed_claude_model_options()
|
||||
if probed is not None:
|
||||
return HostModelOptionsResultFrame(
|
||||
request_id=frame.request_id,
|
||||
status="failed",
|
||||
error="failed to resolve Claude model options",
|
||||
status="ok",
|
||||
models=probed.models,
|
||||
routable_models=probed.routable_models,
|
||||
)
|
||||
return HostModelOptionsResultFrame(
|
||||
request_id=frame.request_id,
|
||||
status="ok",
|
||||
models=models,
|
||||
# The picker names the newest model of each family; the endpoint
|
||||
# serves older generations too, and a launch takes an exact id.
|
||||
routable_models=list(config.routable_models) if config is not None else [],
|
||||
models=[],
|
||||
error="the claude model probe failed — see the host log",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -2634,6 +2712,12 @@ class HostProcess:
|
||||
self._reaper_task = asyncio.create_task(
|
||||
self._orphan_reaper_loop(), name="host-orphan-reaper"
|
||||
)
|
||||
# Detect wake from system suspend (laptop sleep) and force-drop the
|
||||
# then-dead tunnel so the reconnect loop reattaches within seconds
|
||||
# instead of waiting out the ~90s keepalive ping timeout.
|
||||
self._suspend_task = asyncio.create_task(
|
||||
watch_for_resume(self._on_resume_from_suspend), name="host-suspend-watch"
|
||||
)
|
||||
# Warm the runner zygote now: start() blocks on its one-time import
|
||||
# of the runner graph (~1-2s), which otherwise lands inside the first
|
||||
# session launch of the daemon's life. Best-effort — a failure
|
||||
@@ -2749,16 +2833,29 @@ class HostProcess:
|
||||
# A silent-connect streak overrides the recycle fast path:
|
||||
# prompt reconnects are for endpoints that answer.
|
||||
silent_churn = self._silent_connect_streak >= _SILENT_CONNECT_ESCALATE_ATTEMPTS
|
||||
recycle = (
|
||||
explicit_recycle
|
||||
or (ingress_recycle and not _url_is_loopback(self._server_url))
|
||||
) and not silent_churn
|
||||
# A resume from system suspend (laptop wake) always reconnects
|
||||
# promptly: _on_resume_from_suspend already aborted the dead
|
||||
# tunnel, but the abrupt "no close frame" that abort produces
|
||||
# counts as a benign recycle only on a REMOTE server — a local
|
||||
# server would otherwise ride the escalating backoff. OR woke in
|
||||
# outside the silent-churn gate so wake never takes the slow path.
|
||||
woke = self._woke_from_suspend
|
||||
self._woke_from_suspend = False
|
||||
recycle = woke or (
|
||||
(
|
||||
explicit_recycle
|
||||
or (ingress_recycle and not _url_is_loopback(self._server_url))
|
||||
)
|
||||
and not silent_churn
|
||||
)
|
||||
wait_s = _RECONNECT_BASE_S if recycle else backoff
|
||||
_logger.warning(
|
||||
"Host tunnel disconnected: %s. Reconnecting in %.1fs%s",
|
||||
exc,
|
||||
wait_s,
|
||||
" (recycle — prompt reconnect)" if recycle else "",
|
||||
" (resumed from suspend — prompt reconnect)"
|
||||
if woke
|
||||
else (" (recycle — prompt reconnect)" if recycle else ""),
|
||||
)
|
||||
await asyncio.sleep(wait_s)
|
||||
import random
|
||||
@@ -2780,6 +2877,11 @@ class HostProcess:
|
||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||
await self._reaper_task
|
||||
self._reaper_task = None
|
||||
if self._suspend_task is not None:
|
||||
self._suspend_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||
await self._suspend_task
|
||||
self._suspend_task = None
|
||||
if self._zygote_prestart_task is not None:
|
||||
self._zygote_prestart_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||
@@ -2802,6 +2904,43 @@ class HostProcess:
|
||||
self._zygote.stop()
|
||||
self._zygote = None
|
||||
|
||||
def _on_resume_from_suspend(self, gap_s: float) -> None:
|
||||
"""Force-drop the tunnel after a detected wake from system suspend.
|
||||
|
||||
On laptop sleep the WebSocket becomes a half-open socket the server
|
||||
already dropped; without this the reconnect loop waits out the ~90s
|
||||
keepalive ping timeout (:data:`TUNNEL_KEEPALIVE_PING_TIMEOUT_S`),
|
||||
leaving the host — and every session it owns — offline that whole
|
||||
time. Aborting the transport makes :meth:`_serve_frames`' ``recv``
|
||||
raise ``ConnectionClosed`` now, and the flag makes :meth:`run` skip the
|
||||
backoff so the reconnect is prompt.
|
||||
|
||||
No-op when no connection is live (e.g. the wake landed during a
|
||||
reconnect backoff): there is nothing to abort, and the pending backoff
|
||||
sleep's deadline is already past so it reconnects immediately anyway.
|
||||
The flag is only set when a live tunnel was actually aborted, so a
|
||||
wake-during-backoff never triggers a spurious prompt reconnect.
|
||||
|
||||
Runs synchronously on the event loop (invoked by the suspend watcher),
|
||||
so reading ``self._ws`` and aborting is atomic w.r.t. ``_serve_frames``
|
||||
— no lock needed.
|
||||
|
||||
:param gap_s: Approximate seconds the machine was asleep (for logging).
|
||||
:returns: None.
|
||||
"""
|
||||
ws = self._ws
|
||||
if ws is None:
|
||||
return
|
||||
self._woke_from_suspend = True
|
||||
_logger.info(
|
||||
"Resumed from suspend (~%.0fs); dropping stale host tunnel to reconnect",
|
||||
gap_s,
|
||||
)
|
||||
transport = getattr(ws, "transport", None)
|
||||
if transport is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
transport.abort()
|
||||
|
||||
def _cleanup_runners(self) -> None:
|
||||
"""Terminate all live runners on shutdown.
|
||||
|
||||
@@ -2842,6 +2981,11 @@ class HostProcess:
|
||||
additional_headers=headers,
|
||||
max_size=100 * 1024 * 1024,
|
||||
ssl=ssl_ctx,
|
||||
open_timeout=(
|
||||
_RECONNECT_OPEN_TIMEOUT_S
|
||||
if self._ever_connected
|
||||
else _INITIAL_CONNECT_OPEN_TIMEOUT_S
|
||||
),
|
||||
# Align the host->server tunnel's protocol keepalive to the same
|
||||
# 90 s app-level budget as the runner tunnel (not the 20 s library
|
||||
# default that drops a busy-but-healthy tunnel with 1011 — #1116).
|
||||
@@ -3002,7 +3146,18 @@ class HostProcess:
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Readiness refresh runs in its own task, never on this receive loop:
|
||||
# a harness probe that blocks (a hung CLI ``--version`` / ``auth
|
||||
# status``) must not delay ``ws.recv()`` or the inline keepalive pong
|
||||
# the server's watchdog counts as liveness, or it closes the tunnel
|
||||
# with ``4003 ping timeout``.
|
||||
readiness_task = asyncio.create_task(self._harness_readiness_loop(ws))
|
||||
# Warm the pre-launch model listings once a server can actually ask
|
||||
# for them, so the first picker open is served from cache instead of
|
||||
# waiting on a harness probe. Cache-fresh reconnects are a no-op.
|
||||
prewarm_task = asyncio.create_task(
|
||||
self._prewarm_model_options(), name="host-model-options-prewarm"
|
||||
)
|
||||
try:
|
||||
while True:
|
||||
raw = await ws.recv()
|
||||
@@ -3023,6 +3178,9 @@ class HostProcess:
|
||||
# _runner_lifecycle_lock in _dispatch_host_frame.
|
||||
self._start_frame_task(ws, raw)
|
||||
finally:
|
||||
prewarm_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||
await prewarm_task
|
||||
readiness_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||
await readiness_task
|
||||
@@ -3231,7 +3389,20 @@ class HostProcess:
|
||||
fs_result = await asyncio.to_thread(self._handle_fs_request, frame)
|
||||
await ws.send(encode_host_frame(fs_result))
|
||||
elif isinstance(frame, HostModelOptionsFrame):
|
||||
await ws.send(encode_host_frame(await self._handle_model_options(frame)))
|
||||
# Every dispatched frame already runs on its own task (see
|
||||
# _start_frame_task), so a cold harness probe here cannot stall
|
||||
# the receive loop — answer inline, with a crash converted to an
|
||||
# honest failed frame so the server's request future settles.
|
||||
try:
|
||||
options_result = await self._handle_model_options(frame)
|
||||
except Exception:
|
||||
_logger.exception("Model options resolution crashed for %r", frame.harness)
|
||||
options_result = HostModelOptionsResultFrame(
|
||||
request_id=frame.request_id,
|
||||
status="failed",
|
||||
error=f"model options resolution crashed for {frame.harness!r}",
|
||||
)
|
||||
await ws.send(encode_host_frame(options_result))
|
||||
|
||||
|
||||
def run_host_process(
|
||||
|
||||
+130
-33
@@ -93,6 +93,11 @@ class _PolicyVerdict(Protocol):
|
||||
|
||||
_PolicyEvaluator: TypeAlias = Callable[[str, _AcpJsonObject], Awaitable[_PolicyVerdict]]
|
||||
_ElicitationHandler: TypeAlias = Callable[[str, _AcpJsonObject], Awaitable[bool]]
|
||||
# Choice-aware elicitation: offers the agent's own permission options and returns
|
||||
# the chosen label (``None`` = declined). Optional; falls back to the yes/no form.
|
||||
_ElicitationChoiceHandler: TypeAlias = Callable[
|
||||
[str, _AcpJsonObject, Sequence[str]], Awaitable[str | None]
|
||||
]
|
||||
_ToolExecutor: TypeAlias = Callable[[str, _AcpJsonObject], Awaitable[_AcpJsonObject]]
|
||||
|
||||
# ACP error code an agent maps to a filesystem "not found" (ENOENT) when a
|
||||
@@ -185,6 +190,11 @@ class AcpAgentConfig:
|
||||
the agent authenticates with — an agent that reads a variable must name
|
||||
it here (or in ``os_env.sandbox.env_passthrough``) or it starts
|
||||
unauthenticated. Names only; values come from the host environment.
|
||||
:param permission_mode: Omnigent permission stance, e.g. ``"auto"``
|
||||
(default) or ``"bypassPermissions"``. Only the latter changes anything:
|
||||
it skips the human approval card for a request no policy had an opinion
|
||||
on, matching claude-sdk's ``can_use_tool`` gate. Policy still runs in
|
||||
every mode, so a DENY still blocks and an explicit ASK still prompts.
|
||||
"""
|
||||
|
||||
command: str
|
||||
@@ -194,6 +204,7 @@ class AcpAgentConfig:
|
||||
send_model_in_session_new: bool = False
|
||||
omnigent_mcp: bool = True
|
||||
env_passthrough: tuple[str, ...] = ()
|
||||
permission_mode: str = "auto"
|
||||
|
||||
|
||||
class _AcpRequestError(Exception):
|
||||
@@ -348,6 +359,7 @@ class AcpExecutor(Executor):
|
||||
# wired (standalone / unit tests) → permission falls back to allow.
|
||||
self._policy_evaluator: _PolicyEvaluator | None = None
|
||||
self._elicitation_handler: _ElicitationHandler | None = None
|
||||
self._elicitation_choice_handler: _ElicitationChoiceHandler | None = None
|
||||
# Adapter-injected tool-execution bridge (the same ``_tool_executor``
|
||||
# attribute the SDK harnesses use); backs the Omnigent MCP relay.
|
||||
self._tool_executor: _ToolExecutor | None = None
|
||||
@@ -716,8 +728,8 @@ class AcpExecutor(Executor):
|
||||
error: _AcpJsonObject | None = None
|
||||
try:
|
||||
if method == _AGENT_REQUEST_REQUEST_PERMISSION:
|
||||
allow = await self._decide_permission(params)
|
||||
result = self._permission_outcome(params, allow=allow)
|
||||
allow, option_id = await self._decide_permission(params)
|
||||
result = self._permission_outcome(params, allow=allow, option_id=option_id)
|
||||
elif method == "fs/read_text_file" and self._fs_delegation:
|
||||
result = await self._handle_fs_read(params)
|
||||
elif method == "fs/write_text_file" and self._fs_delegation:
|
||||
@@ -831,23 +843,106 @@ class AcpExecutor(Executor):
|
||||
args = cached if isinstance(cached, dict) else {}
|
||||
return str(name), args
|
||||
|
||||
async def _decide_permission(self, params: _AcpJsonObject) -> bool:
|
||||
"""Decide allow/deny for a permission request — policy then elicitation.
|
||||
@property
|
||||
def _bypass_permissions(self) -> bool:
|
||||
"""Whether the user opted out of approval cards for this agent.
|
||||
|
||||
Mirrors :class:`~omnigent.inner.claude_sdk_executor.ClaudeSDKExecutor`'s
|
||||
``can_use_tool`` stance: ``"bypassPermissions"`` and nothing else, so the ``"auto"``
|
||||
default keeps prompting. (Cursor also treats ``"auto"`` as no-prompt;
|
||||
ACP agents ask only about actions they consider permission-worthy, so
|
||||
silencing the default would drop meaningful prompts.)
|
||||
"""
|
||||
return self._config.permission_mode == "bypassPermissions"
|
||||
|
||||
@staticmethod
|
||||
def _permission_options(params: _AcpJsonObject) -> list[_AcpJsonObject]:
|
||||
"""The agent's offered options, each an ``{optionId, name, kind}`` dict."""
|
||||
return [o for o in (params.get("options") or []) if isinstance(o, dict)]
|
||||
|
||||
def _scoped_options(self, params: _AcpJsonObject) -> list[tuple[str, _AcpJsonObject]] | None:
|
||||
"""Label the agent's options for a choice card, or ``None`` if unusable.
|
||||
|
||||
Unusable means: fewer than two options, a blank or duplicated label (the
|
||||
reply names the label, so duplicates are ambiguous), or no ``reject_*``
|
||||
option — a choice card replaces the Approve/Reject buttons, so without one
|
||||
the user would have no way to say no.
|
||||
"""
|
||||
labeled = [(str(o.get("name") or "").strip(), o) for o in self._permission_options(params)]
|
||||
if len(labeled) < 2 or any(not name for name, _ in labeled):
|
||||
return None
|
||||
labels = [name for name, _ in labeled]
|
||||
if len(set(labels)) != len(labels):
|
||||
return None
|
||||
if not any("reject" in str(o.get("kind", "")) for _, o in labeled):
|
||||
return None
|
||||
return labeled
|
||||
|
||||
async def _ask_user(
|
||||
self, tool_name: str, tool_input: _AcpJsonObject, params: _AcpJsonObject
|
||||
) -> tuple[bool, str | None]:
|
||||
"""Route a permission request to the user; return ``(allowed, option_id)``.
|
||||
|
||||
Prefers the choice bridge, which puts the agent's *own* options on the
|
||||
card: picking "allow this command for the session" is one click the agent
|
||||
then honors itself, so the same command class stops re-prompting. Falls
|
||||
back to the yes/no bridge, whose grant stays once-scoped.
|
||||
"""
|
||||
choice_handler = self._elicitation_choice_handler
|
||||
labeled = self._scoped_options(params) if choice_handler is not None else None
|
||||
if choice_handler is not None and labeled is not None:
|
||||
chosen = await choice_handler(tool_name, tool_input, [name for name, _ in labeled])
|
||||
if chosen is None:
|
||||
return False, None
|
||||
picked = next((o for name, o in labeled if name == chosen), None)
|
||||
if picked is None:
|
||||
logger.warning(
|
||||
"acp permission choice %r was not offered; denying tool=%s", chosen, tool_name
|
||||
)
|
||||
return False, None
|
||||
option_id = picked.get("optionId")
|
||||
allowed = "allow" in str(picked.get("kind", ""))
|
||||
logger.info(
|
||||
"acp permission %s by user (scope=%s): tool=%s",
|
||||
"allowed" if allowed else "denied",
|
||||
option_id,
|
||||
tool_name,
|
||||
)
|
||||
return allowed, (option_id if isinstance(option_id, str) else None)
|
||||
|
||||
handler = self._elicitation_handler
|
||||
if handler is None:
|
||||
return False, None
|
||||
return bool(await handler(tool_name, tool_input)), None
|
||||
|
||||
async def _decide_permission(self, params: _AcpJsonObject) -> tuple[bool, str | None]:
|
||||
"""Decide a permission request — policy then elicitation.
|
||||
|
||||
1. **TOOL_CALL policy** (:attr:`_policy_evaluator`): a hard
|
||||
``POLICY_ACTION_DENY`` denies; ``POLICY_ACTION_ASK`` defers to
|
||||
elicitation (and **fails closed** when no handler is wired);
|
||||
``ALLOW`` / unspecified falls through.
|
||||
2. **Human-consent elicitation** (:attr:`_elicitation_handler`): routes
|
||||
to the user via a web approval card and returns their accept/deny.
|
||||
2. **Human-consent elicitation**: the agent's own options via
|
||||
:attr:`_elicitation_choice_handler`, else a yes/no card via
|
||||
:attr:`_elicitation_handler`. Skipped under
|
||||
``permission_mode="bypassPermissions"`` — but only for a request no
|
||||
policy had an opinion on, so a DENY still blocks and a policy that
|
||||
says ASK still prompts.
|
||||
|
||||
When neither bridge is wired (standalone / unit tests), falls back to
|
||||
allow so direct use of the executor isn't blocked. In normal runner
|
||||
operation the adapter installs both, so destructive actions are gated.
|
||||
|
||||
:returns: ``(allowed, option_id)`` — *option_id* is the scope the user
|
||||
picked from the agent's options, or ``None`` to let
|
||||
:meth:`_permission_outcome` choose the narrowest grant.
|
||||
"""
|
||||
tool_name, tool_input = self._extract_tool_call(params)
|
||||
handler = getattr(self, "_elicitation_handler", None)
|
||||
policy_eval = getattr(self, "_policy_evaluator", None)
|
||||
# Either bridge can carry the question to the user.
|
||||
can_ask = (
|
||||
self._elicitation_handler is not None or self._elicitation_choice_handler is not None
|
||||
)
|
||||
|
||||
if policy_eval is not None:
|
||||
action: str | None
|
||||
@@ -861,45 +956,47 @@ class AcpExecutor(Executor):
|
||||
action = None
|
||||
if action == "POLICY_ACTION_DENY":
|
||||
logger.info("acp permission denied by policy: tool=%s", tool_name)
|
||||
return False
|
||||
return False, None
|
||||
if action == "POLICY_ACTION_ASK":
|
||||
if handler is None:
|
||||
if not can_ask:
|
||||
logger.warning(
|
||||
"acp TOOL_CALL policy ASK with no elicitation handler; denying tool=%s",
|
||||
tool_name,
|
||||
)
|
||||
return False
|
||||
allowed = bool(await handler(tool_name, tool_input))
|
||||
logger.info(
|
||||
"acp permission %s by user (policy ASK): tool=%s",
|
||||
"allowed" if allowed else "denied",
|
||||
tool_name,
|
||||
)
|
||||
return allowed
|
||||
return False, None
|
||||
return await self._ask_user(tool_name, tool_input, params)
|
||||
# ALLOW / UNSPECIFIED / unknown → fall through to elicitation.
|
||||
|
||||
if handler is not None:
|
||||
allowed = bool(await handler(tool_name, tool_input))
|
||||
logger.info(
|
||||
"acp permission %s by user: tool=%s",
|
||||
"allowed" if allowed else "denied",
|
||||
tool_name,
|
||||
)
|
||||
return allowed
|
||||
if can_ask and not self._bypass_permissions:
|
||||
return await self._ask_user(tool_name, tool_input, params)
|
||||
if can_ask:
|
||||
# bypassPermissions: no policy had an opinion and the user asked not
|
||||
# to be prompted. Logged at info so the audit trail still names what
|
||||
# ran unreviewed. Answered per-request (never the agent's own bypass
|
||||
# option) so every later call stays visible to policy.
|
||||
logger.info("acp permission allowed (bypassPermissions): tool=%s", tool_name)
|
||||
return True, None
|
||||
|
||||
logger.debug("acp permission allowed (no policy/elicitation wired): tool=%s", tool_name)
|
||||
return True
|
||||
return True, None
|
||||
|
||||
@staticmethod
|
||||
def _permission_outcome(params: _AcpJsonObject, *, allow: bool) -> _AcpJsonObject:
|
||||
"""Map an allow/deny decision to an ACP permission ``outcome``.
|
||||
def _permission_outcome(
|
||||
params: _AcpJsonObject, *, allow: bool, option_id: str | None = None
|
||||
) -> _AcpJsonObject:
|
||||
"""Map a decision to an ACP permission ``outcome``.
|
||||
|
||||
On allow, prefer a once-scoped grant (``allow_once``) over
|
||||
``allow_always`` so we never persist a blanket "always allow". On deny,
|
||||
pick a ``reject_*`` option, or ``cancelled`` when none is offered. The
|
||||
agent's options carry both ``optionId`` and ``kind`` (e.g. ``allow_once``).
|
||||
*option_id* is a scope the user picked from the agent's own options; it is
|
||||
echoed only after confirming the agent offered it, so we never send an id
|
||||
it doesn't know. Without one: on allow prefer a once-scoped grant
|
||||
(``allow_once``) over ``allow_always``, so a blanket "always allow" is
|
||||
only ever sent because the user chose it; on deny pick a ``reject_*``
|
||||
option, or ``cancelled`` when none is offered. The agent's options carry
|
||||
both ``optionId`` and ``kind`` (e.g. ``allow_once``).
|
||||
"""
|
||||
options = [o for o in (params.get("options") or []) if isinstance(o, dict)]
|
||||
options = AcpExecutor._permission_options(params)
|
||||
if option_id is not None and any(o.get("optionId") == option_id for o in options):
|
||||
return {"outcome": {"outcome": "selected", "optionId": option_id}}
|
||||
|
||||
def _pick(*kinds: str) -> _AcpJsonObject | None:
|
||||
for kind in kinds:
|
||||
|
||||
@@ -34,6 +34,9 @@ Env vars read at startup:
|
||||
value is read from this process's own environment.
|
||||
- ``HARNESS_ACP_PROMPT_TIMEOUT_S``: optional idle (time-without-progress) deadline in
|
||||
seconds for a prompt turn (default 300); must be positive and finite or the child aborts.
|
||||
- ``HARNESS_ACP_PERMISSION_MODE``: Omnigent permission stance, ``auto`` (default) or
|
||||
``bypassPermissions`` — the latter skips the approval card for a tool call no
|
||||
policy had an opinion on, so a headless agent runs without parking on prompts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -60,6 +63,8 @@ _ENV_OMNIGENT_MCP = "HARNESS_ACP_OMNIGENT_MCP"
|
||||
_ENV_CWD = "HARNESS_ACP_CWD"
|
||||
_ENV_OS_ENV = "HARNESS_ACP_OS_ENV"
|
||||
_ENV_ENV_PASSTHROUGH = "HARNESS_ACP_ENV_PASSTHROUGH"
|
||||
_ENV_PERMISSION_MODE = "HARNESS_ACP_PERMISSION_MODE"
|
||||
_DEFAULT_PERMISSION_MODE = "auto"
|
||||
|
||||
|
||||
def _env_enabled(name: str, *, default: bool) -> bool:
|
||||
@@ -127,6 +132,7 @@ def _build_acp_executor() -> Executor:
|
||||
send_model = _env_enabled(_ENV_SEND_MODEL, default=False)
|
||||
omnigent_mcp = _env_enabled(_ENV_OMNIGENT_MCP, default=True)
|
||||
cwd = os.environ.get(_ENV_CWD) or os.environ.get("OMNIGENT_RUNNER_WORKSPACE") or None
|
||||
permission_mode = os.environ.get(_ENV_PERMISSION_MODE, "").strip() or _DEFAULT_PERMISSION_MODE
|
||||
|
||||
config = AcpAgentConfig(
|
||||
command=command,
|
||||
@@ -136,6 +142,7 @@ def _build_acp_executor() -> Executor:
|
||||
send_model_in_session_new=send_model,
|
||||
omnigent_mcp=omnigent_mcp,
|
||||
env_passthrough=_env_passthrough_names(),
|
||||
permission_mode=permission_mode,
|
||||
)
|
||||
return AcpExecutor(config=config, cwd=cwd, os_env=_resolve_os_env())
|
||||
|
||||
|
||||
@@ -13,8 +13,11 @@ from omnigent.claude_native_bridge import (
|
||||
BRIDGE_DIR_ENV_VAR,
|
||||
REQUEST_SESSION_ID_ENV_VAR,
|
||||
SWITCH_MODEL_DIALOG_HINT,
|
||||
ClaudePromptTimeout,
|
||||
TmuxSessionNotAdvertised,
|
||||
inject_slash_command,
|
||||
inject_user_message,
|
||||
kill_session,
|
||||
read_active_session_id,
|
||||
read_claude_status_model,
|
||||
read_launch_model,
|
||||
@@ -192,11 +195,29 @@ class ClaudeNativeExecutor(Executor):
|
||||
self._bridge_dir,
|
||||
content=text,
|
||||
)
|
||||
except ClaudePromptTimeout as exc:
|
||||
cleanup_error = self._reap_failed_turn()
|
||||
message = describe_exception(exc)
|
||||
if cleanup_error is not None:
|
||||
message = f"{message} Cleanup also failed: {cleanup_error}"
|
||||
yield ExecutorError(message=message)
|
||||
return
|
||||
except RuntimeError as exc:
|
||||
yield ExecutorError(message=describe_exception(exc))
|
||||
return
|
||||
yield TurnComplete(response=None)
|
||||
|
||||
def _reap_failed_turn(self) -> str | None:
|
||||
"""Kill the Claude pane before a delivery timeout becomes ``failed``."""
|
||||
try:
|
||||
kill_session(self._bridge_dir, timeout_s=1.0)
|
||||
except TmuxSessionNotAdvertised:
|
||||
_logger.debug("claude-native: timed-out session already disappeared")
|
||||
except RuntimeError as exc:
|
||||
_logger.warning("claude-native: failed to reap timed-out session", exc_info=True)
|
||||
return describe_exception(exc)
|
||||
return None
|
||||
|
||||
def _model_command_arg(self, wanted_model: str | None) -> str | None:
|
||||
"""
|
||||
Return the ``/model`` argument for this turn, or ``None`` to skip.
|
||||
|
||||
@@ -35,6 +35,7 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol, TypeAlias, cast
|
||||
|
||||
from omnigent import _native_forwarder_health as native_forwarder_health
|
||||
from omnigent import model_catalog
|
||||
from omnigent._platform import resolve_cli_binary
|
||||
from omnigent.codex_model_vocabulary import (
|
||||
@@ -115,6 +116,11 @@ CodexToolExecutor: TypeAlias = Callable[
|
||||
# but keep waiting — a long-running tool or model call can legitimately
|
||||
# block events far longer than any fixed deadline.
|
||||
_TURN_EVENT_WARN_SECONDS = 600.0
|
||||
# The idle wait polls on this shorter interval so a fatal gateway error the
|
||||
# stderr loop sets mid-wait is acted on promptly, rather than only when the
|
||||
# 600s warn window elapses. Chosen to divide the warn window evenly so the
|
||||
# warning cadence is unchanged.
|
||||
_TURN_EVENT_POLL_SECONDS = 5.0
|
||||
_TURN_COMPLETED_DRAIN_SECONDS = 1.0
|
||||
# Wall-clock budget for the ``codex --version`` probe. A broken codex
|
||||
# build that blocks (e.g. on stdin) must not stall session startup — on
|
||||
@@ -153,6 +159,62 @@ _CODEX_PROVIDER_CONFIG_PREFIX = "model_providers."
|
||||
# developer API key that would charge separately.
|
||||
_CODEX_ENV_DENY_EXACT: frozenset[str] = frozenset({"OPENAI_API_KEY"})
|
||||
|
||||
# The codex CLI logs a rejected gateway request to stderr as
|
||||
# ``unexpected status <code> <reason>: {...}, url: <url>`` and precedes it with
|
||||
# ``Reconnecting... N/5`` retry lines. These parse that shape so the head can
|
||||
# attribute the real gateway error to a turn that otherwise emits no events.
|
||||
_CODEX_STDERR_STATUS_RE = re.compile(
|
||||
r"unexpected status (?P<code>\d{3})(?:\s+(?P<reason>[A-Za-z][A-Za-z ]*?))?\s*[:,]"
|
||||
)
|
||||
_CODEX_STDERR_URL_RE = re.compile(r"url:\s*(?P<url>\S+)")
|
||||
_CODEX_STDERR_RETRY_EXHAUSTED_RE = re.compile(
|
||||
r"Reconnecting\.{0,3}\s*(?P<n>\d+)\s*/\s*(?P<total>\d+)"
|
||||
)
|
||||
# HTTP statuses that are not transient — a retry can never fix them, so the
|
||||
# head fails the turn fast instead of riding the full idle watchdog.
|
||||
_CODEX_STDERR_FATAL_STATUSES: frozenset[int] = frozenset({401, 403})
|
||||
|
||||
|
||||
class _CodexGatewayError:
|
||||
"""A parsed gateway rejection read off the codex CLI's stderr.
|
||||
|
||||
``code`` is the HTTP status; ``fatal`` marks an auth-class status that a
|
||||
retry cannot fix (the turn should fail fast rather than stall).
|
||||
"""
|
||||
|
||||
__slots__ = ("code", "fatal", "reason", "url")
|
||||
|
||||
def __init__(self, code: int, reason: str | None, url: str | None) -> None:
|
||||
self.code = code
|
||||
self.reason = reason
|
||||
self.url = url
|
||||
self.fatal = code in _CODEX_STDERR_FATAL_STATUSES
|
||||
|
||||
def detail(self, *, model: str | None = None) -> str:
|
||||
"""A concise, actionable one-line cause for the turn-failure message."""
|
||||
reason = f" {self.reason}" if self.reason else ""
|
||||
target = f" for {model}" if model else ""
|
||||
where = f" at {self.url}" if self.url else ""
|
||||
hint = " (auth likely expired/misconfigured)" if self.fatal else ""
|
||||
return f"gateway returned {self.code}{reason}{target}{where}{hint}"
|
||||
|
||||
|
||||
def _parse_codex_gateway_error(line: str) -> _CodexGatewayError | None:
|
||||
"""Return a parsed gateway rejection from a codex stderr *line*, else None.
|
||||
|
||||
Only lines carrying an ``unexpected status <code>`` shape are classified;
|
||||
ordinary stderr (including the ``Reconnecting`` retry lines) returns None.
|
||||
"""
|
||||
match = _CODEX_STDERR_STATUS_RE.search(line)
|
||||
if match is None:
|
||||
return None
|
||||
code = int(match.group("code"))
|
||||
raw_reason = match.group("reason")
|
||||
reason = raw_reason.strip() if raw_reason else None
|
||||
url_match = _CODEX_STDERR_URL_RE.search(line)
|
||||
url = url_match.group("url").rstrip(",") if url_match else None
|
||||
return _CodexGatewayError(code, reason, url)
|
||||
|
||||
|
||||
def _extract_codex_last_turn_usage(params: object, model: str | None) -> dict[str, object] | None:
|
||||
"""Map a ``thread/tokenUsage/updated`` payload's ``last`` breakdown
|
||||
@@ -2042,6 +2104,16 @@ class _CodexAppServerSession:
|
||||
# field (it is silently dropped), hence the separate settings update.
|
||||
self._applied_effort: str | None = None
|
||||
self._recent_stderr: list[str] = []
|
||||
# Auth-class gateway rejection tracking, read off the CLI's stderr so a
|
||||
# turn that emits no events fails fast with the real cause instead of
|
||||
# stalling to the idle watchdog. ``_pending`` holds a parsed fatal
|
||||
# (401/403) rejection; ``_saw_retries_exhausted`` records a final
|
||||
# ``Reconnecting N/N``. Fast-fail arms (``_fatal_gateway_error``) only
|
||||
# when BOTH are seen, so a blip the CLI recovers from can't kill a
|
||||
# healthy turn. All three reset at turn start.
|
||||
self._pending_fatal_gateway_error: _CodexGatewayError | None = None
|
||||
self._saw_retries_exhausted = False
|
||||
self._fatal_gateway_error: _CodexGatewayError | None = None
|
||||
self._recent_events: list[CodexMessage] = []
|
||||
self._process_cwd: Path | None = None
|
||||
# Private CODEX_HOME so the subprocess never writes to the user's ~/.codex/.
|
||||
@@ -2051,6 +2123,9 @@ class _CodexAppServerSession:
|
||||
# on the next ``turn/completed`` so each TurnComplete carries the
|
||||
# usage for the turn that just finished.
|
||||
self._last_turn_usage: dict[str, object] | None = None
|
||||
# Serialize concurrent writes to the subprocess stdin so that parallel
|
||||
# tool-call responses don't interleave bytes on the pipe.
|
||||
self._stdin_lock = asyncio.Lock()
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._started:
|
||||
@@ -2335,6 +2410,14 @@ class _CodexAppServerSession:
|
||||
await self.start()
|
||||
assert self._proc is not None
|
||||
|
||||
# Fresh turn: forget any prior turn's gateway-error signals and clear
|
||||
# the shared watchdog slot so a resolved earlier failure can't be
|
||||
# misattributed to this turn.
|
||||
self._pending_fatal_gateway_error = None
|
||||
self._saw_retries_exhausted = False
|
||||
self._fatal_gateway_error = None
|
||||
native_forwarder_health.note_post_success()
|
||||
|
||||
is_new_thread = self.thread_id is None
|
||||
if is_new_thread:
|
||||
params: CodexParams = {
|
||||
@@ -2498,14 +2581,30 @@ class _CodexAppServerSession:
|
||||
while True:
|
||||
event_task = asyncio.ensure_future(self._events.get())
|
||||
idle_seconds = 0.0
|
||||
seconds_since_warn = 0.0
|
||||
fatal_gateway_error: _CodexGatewayError | None = None
|
||||
# Poll on the shorter of the two intervals so a fatal gateway
|
||||
# error set mid-wait is acted on promptly; when a test shrinks
|
||||
# the warn window below the poll interval, poll at the warn
|
||||
# window so the warning cadence is preserved.
|
||||
poll_interval = min(_TURN_EVENT_POLL_SECONDS, _TURN_EVENT_WARN_SECONDS)
|
||||
try:
|
||||
while True:
|
||||
done, _ = await asyncio.wait(
|
||||
{event_task}, timeout=_TURN_EVENT_WARN_SECONDS
|
||||
)
|
||||
done, _ = await asyncio.wait({event_task}, timeout=poll_interval)
|
||||
if event_task in done:
|
||||
break
|
||||
idle_seconds += _TURN_EVENT_WARN_SECONDS
|
||||
# The stderr loop sets this once the CLI has exhausted
|
||||
# its retries on an auth-class gateway rejection. Fail
|
||||
# fast with the real cause rather than stalling to the
|
||||
# idle watchdog on a turn that will never emit events.
|
||||
if self._fatal_gateway_error is not None:
|
||||
fatal_gateway_error = self._fatal_gateway_error
|
||||
break
|
||||
idle_seconds += poll_interval
|
||||
seconds_since_warn += poll_interval
|
||||
if seconds_since_warn < _TURN_EVENT_WARN_SECONDS:
|
||||
continue
|
||||
seconds_since_warn = 0.0
|
||||
pending_tool_summaries = [
|
||||
{
|
||||
"call_id": call_id,
|
||||
@@ -2531,6 +2630,18 @@ class _CodexAppServerSession:
|
||||
with suppress(BaseException):
|
||||
await event_task
|
||||
raise
|
||||
if fatal_gateway_error is not None:
|
||||
event_task.cancel()
|
||||
with suppress(BaseException):
|
||||
await event_task
|
||||
try:
|
||||
await asyncio.wait_for(self.interrupt_turn(), timeout=0.5)
|
||||
except Exception as exc: # noqa: BLE001 — interrupt is best-effort
|
||||
logger.debug("Codex auth-failure turn interrupt failed: %s", exc)
|
||||
yield ExecutorError(
|
||||
message=fatal_gateway_error.detail(model=model), retryable=False
|
||||
)
|
||||
return
|
||||
message = event_task.result()
|
||||
|
||||
self._record_event(message)
|
||||
@@ -2839,8 +2950,9 @@ class _CodexAppServerSession:
|
||||
|
||||
async def _send_message(self, payload: CodexMessage) -> None:
|
||||
assert self._proc is not None and self._proc.stdin is not None
|
||||
self._proc.stdin.write((json.dumps(payload) + "\n").encode("utf-8"))
|
||||
await self._proc.stdin.drain()
|
||||
async with self._stdin_lock:
|
||||
self._proc.stdin.write((json.dumps(payload) + "\n").encode("utf-8"))
|
||||
await self._proc.stdin.drain()
|
||||
|
||||
@staticmethod
|
||||
async def _iter_stream_chunks(stream: asyncio.StreamReader) -> AsyncIterator[bytes]:
|
||||
@@ -2901,9 +3013,31 @@ class _CodexAppServerSession:
|
||||
if len(self._recent_stderr) > 20:
|
||||
self._recent_stderr.pop(0)
|
||||
logger.debug("codex app-server stderr: %s", text)
|
||||
self._note_stderr_gateway_error(text)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
|
||||
def _note_stderr_gateway_error(self, text: str) -> None:
|
||||
"""Attribute (and, when fatal, arm fast-fail for) a gateway rejection.
|
||||
|
||||
The gateway cause is recorded into the shared watchdog slot the moment
|
||||
it is seen, so a stalled turn surfaces the real error. An auth-class
|
||||
(401/403) rejection arms fast-fail only once the CLI has also exhausted
|
||||
its own retry budget (a final ``Reconnecting N/N``); the two signals can
|
||||
arrive in either order, so both are tracked and fast-fail arms when both
|
||||
hold — a single blip the CLI recovers from never kills a healthy turn.
|
||||
"""
|
||||
retry = _CODEX_STDERR_RETRY_EXHAUSTED_RE.search(text)
|
||||
if retry is not None and retry.group("n") == retry.group("total"):
|
||||
self._saw_retries_exhausted = True
|
||||
error = _parse_codex_gateway_error(text)
|
||||
if error is not None:
|
||||
native_forwarder_health.record_transport_failure(error.detail())
|
||||
if error.fatal:
|
||||
self._pending_fatal_gateway_error = error
|
||||
if self._pending_fatal_gateway_error is not None and self._saw_retries_exhausted:
|
||||
self._fatal_gateway_error = self._pending_fatal_gateway_error
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CodexSessionState:
|
||||
|
||||
@@ -40,7 +40,11 @@ from omnigent.inner.native_attachments import (
|
||||
parse_data_uri,
|
||||
unresolved_attachment_marker,
|
||||
)
|
||||
from omnigent.reasoning_effort import CODEX_EFFORTS, effort_for_model_switch, validate_effort
|
||||
from omnigent.reasoning_effort import (
|
||||
CODEX_NATIVE_EFFORTS,
|
||||
effort_for_model_switch,
|
||||
validate_effort,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -319,6 +323,12 @@ class CodexNativeExecutor(Executor):
|
||||
turn_params: dict[str, object] = {
|
||||
"threadId": state.thread_id,
|
||||
"input": input_items,
|
||||
"environments": [
|
||||
{
|
||||
"environmentId": "local",
|
||||
"cwd": state.cwd or str(Path.cwd()),
|
||||
}
|
||||
],
|
||||
}
|
||||
response = await client.request("turn/start", turn_params)
|
||||
result = _json_object(response.get("result"))
|
||||
@@ -373,7 +383,7 @@ def _model_effort_overrides(config: ExecutorConfig | None) -> dict[str, object]:
|
||||
overrides["model"] = model
|
||||
raw_effort = config.extra.get("reasoning_effort")
|
||||
try:
|
||||
effort = validate_effort(raw_effort, "codex", CODEX_EFFORTS)
|
||||
effort = validate_effort(raw_effort, "codex", CODEX_NATIVE_EFFORTS)
|
||||
except ValueError:
|
||||
# A bad effort must not sink the turn — drop it and keep Codex's
|
||||
# current effort rather than failing the whole dispatch.
|
||||
|
||||
@@ -93,6 +93,7 @@ RawToolItem: TypeAlias = Any # type: ignore[explicit-any]
|
||||
# an optional import at type-check time — the executor only constructs
|
||||
# one when instantiated.
|
||||
AsyncOpenAIClient: TypeAlias = Any # type: ignore[explicit-any]
|
||||
ReasoningItemIdPolicy: TypeAlias = Literal["preserve", "omit"]
|
||||
|
||||
# Tool executor callable wired in by ``omnigent.Session``. The result
|
||||
# is JSON-ish (dict[str, Any]) but the static type leaks ``Any`` through
|
||||
@@ -1026,6 +1027,7 @@ class OpenAIAgentsSDKExecutor(Executor):
|
||||
base_url_override: str | None = None,
|
||||
gateway_host: str | None = None,
|
||||
gateway_auth_command: str | None = None,
|
||||
reasoning_item_id_policy: ReasoningItemIdPolicy | None = None,
|
||||
) -> None:
|
||||
"""Create an OpenAIAgentsSDKExecutor.
|
||||
|
||||
@@ -1071,7 +1073,15 @@ class OpenAIAgentsSDKExecutor(Executor):
|
||||
``"databricks auth token --host https://example.databricks.com ..."``
|
||||
or ``"printf %s sk-..."``. Set from
|
||||
``HARNESS_OPENAI_AGENTS_GATEWAY_AUTH_COMMAND``.
|
||||
:param reasoning_item_id_policy: Optional Responses API replay policy.
|
||||
``"preserve"`` retains reasoning item IDs; ``"omit"`` is available
|
||||
for legacy providers that reject orphaned reasoning items. ``None``
|
||||
leaves the setting unspecified and uses the SDK default.
|
||||
:raises ValueError: If *reasoning_item_id_policy* is not ``"preserve"``
|
||||
or ``"omit"``.
|
||||
"""
|
||||
if reasoning_item_id_policy not in (None, "preserve", "omit"):
|
||||
raise ValueError("reasoning_item_id_policy must be 'preserve', 'omit', or unset")
|
||||
self._retry_policy = retry_policy if retry_policy is not None else RetryPolicy()
|
||||
raw_client = (
|
||||
client
|
||||
@@ -1100,6 +1110,7 @@ class OpenAIAgentsSDKExecutor(Executor):
|
||||
self._profile = profile
|
||||
self._use_responses = use_responses
|
||||
self._model_override = model
|
||||
self._reasoning_item_id_policy = reasoning_item_id_policy
|
||||
self._databricks = _is_databricks_openai_client(self._client)
|
||||
self._tool_executor: ToolExecutor | None = None
|
||||
self._session_states: dict[str, _AgentsSessionState] = {}
|
||||
@@ -1525,13 +1536,15 @@ class OpenAIAgentsSDKExecutor(Executor):
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
current_item_count = len(await state.sdk_session.get_items())
|
||||
run_config = agents_sdk.RunConfig(
|
||||
model=model,
|
||||
model_provider=provider,
|
||||
tracing_disabled=True,
|
||||
reasoning_item_id_policy="omit",
|
||||
call_model_input_filter=self._filter_model_input,
|
||||
)
|
||||
run_config_kwargs: dict[str, object] = {
|
||||
"model": model,
|
||||
"model_provider": provider,
|
||||
"tracing_disabled": True,
|
||||
"call_model_input_filter": self._filter_model_input,
|
||||
}
|
||||
if self._reasoning_item_id_policy is not None:
|
||||
run_config_kwargs["reasoning_item_id_policy"] = self._reasoning_item_id_policy
|
||||
run_config = agents_sdk.RunConfig(**run_config_kwargs)
|
||||
max_turns = 1 if stepwise_internal_turns else int(cfg.extra.get("max_turns", 1000))
|
||||
|
||||
# ── LLM_REQUEST policy evaluation ────────────────────────
|
||||
|
||||
@@ -83,17 +83,24 @@ Env vars read at startup:
|
||||
default. An explicit env-var value still wins as the
|
||||
highest-priority switch, so bad specs fail loudly at the gateway
|
||||
instead of being silently rewritten.
|
||||
- ``HARNESS_OPENAI_AGENTS_REASONING_ITEM_ID_POLICY``: optional Responses
|
||||
replay policy, either ``"preserve"`` or ``"omit"``. Unset uses the
|
||||
OpenAI Agents SDK default.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import cast
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from omnigent.inner.executor import Executor
|
||||
from omnigent.inner.openai_agents_sdk_executor import OpenAIAgentsSDKExecutor
|
||||
from omnigent.inner.openai_agents_sdk_executor import (
|
||||
OpenAIAgentsSDKExecutor,
|
||||
ReasoningItemIdPolicy,
|
||||
)
|
||||
from omnigent.runtime.harnesses._executor_adapter import ExecutorAdapter
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
@@ -107,6 +114,7 @@ _ENV_GATEWAY_HOST = "HARNESS_OPENAI_AGENTS_GATEWAY_HOST"
|
||||
_ENV_USE_RESPONSES = "HARNESS_OPENAI_AGENTS_USE_RESPONSES"
|
||||
_ENV_GATEWAY_BASE_URL = "HARNESS_OPENAI_AGENTS_GATEWAY_BASE_URL"
|
||||
_ENV_GATEWAY_AUTH_COMMAND = "HARNESS_OPENAI_AGENTS_GATEWAY_AUTH_COMMAND"
|
||||
_ENV_REASONING_ITEM_ID_POLICY = "HARNESS_OPENAI_AGENTS_REASONING_ITEM_ID_POLICY"
|
||||
# Direct OpenAI-compatible API key set when the agent spec declares
|
||||
# executor.auth: {type: api_key, api_key: …}. Takes precedence over
|
||||
# ambient OPENAI_API_KEY in the caller's environment.
|
||||
@@ -210,6 +218,10 @@ def _build_openai_agents_sdk_executor() -> Executor:
|
||||
_ENV_USE_RESPONSES,
|
||||
default=default_use_responses,
|
||||
)
|
||||
reasoning_item_id_policy = cast(
|
||||
ReasoningItemIdPolicy | None,
|
||||
os.environ.get(_ENV_REASONING_ITEM_ID_POLICY) or None,
|
||||
)
|
||||
return OpenAIAgentsSDKExecutor(
|
||||
profile=profile,
|
||||
api_key=api_key,
|
||||
@@ -218,6 +230,7 @@ def _build_openai_agents_sdk_executor() -> Executor:
|
||||
base_url_override=os.environ.get(_ENV_GATEWAY_BASE_URL) or None,
|
||||
gateway_host=os.environ.get(_ENV_GATEWAY_HOST) or None,
|
||||
gateway_auth_command=os.environ.get(_ENV_GATEWAY_AUTH_COMMAND) or None,
|
||||
reasoning_item_id_policy=reasoning_item_id_policy,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -294,8 +294,9 @@ def _tmux_input_option_commands(scrollback: int) -> list[list[str]]:
|
||||
Build tmux options for scrollback and pane input behavior.
|
||||
|
||||
``history-limit`` is generated per terminal because it comes from
|
||||
``TerminalEnvSpec.scrollback``. ``mouse on`` makes the attached web
|
||||
terminal scrollable. ``focus-events on`` lets interactive programs
|
||||
``TerminalEnvSpec.scrollback``. ``set-clipboard external`` exports tmux
|
||||
copy-mode selections without trusting pane OSC 52 requests. ``mouse on``
|
||||
makes the attached web terminal scrollable. ``focus-events on`` lets interactive programs
|
||||
observe pane focus changes. ``extended-keys`` with CSI-u formatting
|
||||
lets programs inside tmux receive Kitty Keyboard Protocol keys such
|
||||
as Shift+Enter when the attached terminal supports them. Terminals
|
||||
@@ -311,6 +312,9 @@ def _tmux_input_option_commands(scrollback: int) -> list[list[str]]:
|
||||
["set-option", "-g", "history-limit", str(scrollback)],
|
||||
["set-option", "-sq", "extended-keys", "on"],
|
||||
["set-option", "-sq", "extended-keys-format", "csi-u"],
|
||||
# Export tmux copy-mode selections to attached terminals without letting
|
||||
# pane applications create tmux buffers through OSC 52.
|
||||
["set-option", "-sq", "set-clipboard", "external"],
|
||||
["set-option", "-g", "mouse", "on"],
|
||||
["set-option", "-g", "focus-events", "on"],
|
||||
["set-option", "-g", "escape-time", "0"],
|
||||
|
||||
@@ -333,10 +333,13 @@ async def _prepare_kimi_terminal_via_daemon(
|
||||
if session_bundle is None:
|
||||
raise click.ClickException("Creating a Kimi session requires a session bundle.")
|
||||
_update_startup_progress(startup_progress, "Creating Kimi session...")
|
||||
session_id = await _create_kimi_session(
|
||||
client,
|
||||
session_bundle,
|
||||
terminal_launch_args=persist_args or None,
|
||||
session_id, _ = await asyncio.gather(
|
||||
_create_kimi_session(
|
||||
client,
|
||||
session_bundle,
|
||||
terminal_launch_args=persist_args or None,
|
||||
),
|
||||
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
|
||||
)
|
||||
else:
|
||||
_update_startup_progress(startup_progress, "Loading Kimi session...")
|
||||
@@ -385,7 +388,8 @@ async def _prepare_kimi_terminal_via_daemon(
|
||||
f"({resp.status_code}): {error_text(resp)}"
|
||||
)
|
||||
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
if not fresh_session:
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
_update_startup_progress(startup_progress, "Starting runner...")
|
||||
runner_id = await launch_or_reuse_daemon_runner(
|
||||
client,
|
||||
|
||||
@@ -364,10 +364,13 @@ async def _prepare_kiro_terminal_via_daemon(
|
||||
if session_bundle is None:
|
||||
raise click.ClickException("Creating a Kiro session requires a session bundle.")
|
||||
_update_startup_progress(startup_progress, "Creating Kiro session...")
|
||||
session_id = await _create_kiro_session(
|
||||
client,
|
||||
session_bundle,
|
||||
terminal_launch_args=persist_args or None,
|
||||
session_id, _ = await asyncio.gather(
|
||||
_create_kiro_session(
|
||||
client,
|
||||
session_bundle,
|
||||
terminal_launch_args=persist_args or None,
|
||||
),
|
||||
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
|
||||
)
|
||||
else:
|
||||
_update_startup_progress(startup_progress, "Loading Kiro session...")
|
||||
@@ -409,7 +412,8 @@ async def _prepare_kiro_terminal_via_daemon(
|
||||
f"({resp.status_code}): {error_text(resp)}"
|
||||
)
|
||||
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
if not fresh_session:
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
_update_startup_progress(startup_progress, "Starting runner...")
|
||||
runner_id = await launch_or_reuse_daemon_runner(
|
||||
client,
|
||||
|
||||
+15
-29
@@ -46,7 +46,6 @@ from cachetools import TTLCache
|
||||
from omnigent._platform import default_shell_argv
|
||||
from omnigent.json_types import JsonObject as _JsonObject
|
||||
from omnigent.llms.anthropic_model_metadata import parse_anthropic_model_metadata
|
||||
from omnigent.model_fallbacks import StaticModelFallback, static_model_fallback
|
||||
from omnigent.model_metadata import (
|
||||
ModelCapability,
|
||||
ModelCostTier,
|
||||
@@ -206,15 +205,12 @@ class ModelListing:
|
||||
:param models: The enumerated models, e.g.
|
||||
``(ModelEntry(id="databricks-gpt-5-4", family="openai"),)``.
|
||||
:param note: Human-readable provenance / failure explanation.
|
||||
:param static_fallback: Ownership metadata for a release-curated fallback;
|
||||
``None`` for live or empty listings.
|
||||
"""
|
||||
|
||||
source: str
|
||||
verified: bool
|
||||
models: tuple[ModelEntry, ...]
|
||||
note: str
|
||||
static_fallback: StaticModelFallback | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -897,8 +893,7 @@ def _listing_payload(listing: ModelListing) -> _JsonObject:
|
||||
"""Serialize a :class:`ModelListing` into the tool's JSON row shape.
|
||||
|
||||
:param listing: The listing to serialize.
|
||||
:returns: Row dict; ``context_window`` and ``static_fallback`` appear only
|
||||
when known.
|
||||
:returns: Row dict; ``context_window`` appears only when known.
|
||||
"""
|
||||
models: list[_JsonObject] = []
|
||||
for entry in listing.models:
|
||||
@@ -934,12 +929,6 @@ def _listing_payload(listing: ModelListing) -> _JsonObject:
|
||||
"models": models,
|
||||
"note": listing.note,
|
||||
}
|
||||
if listing.static_fallback is not None:
|
||||
payload["static_fallback"] = {
|
||||
"owner": listing.static_fallback.owner,
|
||||
"provenance": listing.static_fallback.provenance,
|
||||
"discovery_gap": listing.static_fallback.discovery_gap,
|
||||
}
|
||||
return payload
|
||||
|
||||
|
||||
@@ -1061,23 +1050,24 @@ def _fetch_cursor_cli_listing(provider: ResolvedModelProvider) -> ModelListing:
|
||||
|
||||
|
||||
def _static_subscription_listing(provider: ResolvedModelProvider) -> ModelListing:
|
||||
"""Build the curated static listing for a subscription CLI login.
|
||||
"""Build the (empty) pre-launch listing for a subscription CLI login.
|
||||
|
||||
Subscription logins expose no model-listing API, and the curated
|
||||
stand-ins this used to serve are gone — the live harness probes are the
|
||||
source of truth, so a path that cannot probe reports nothing rather
|
||||
than a plausible-but-stale list.
|
||||
|
||||
:param provider: A ``kind="subscription"`` provider descriptor.
|
||||
:returns: A ``source="static"`` listing with ``verified=False``.
|
||||
:returns: A ``source="static"`` listing with no models.
|
||||
"""
|
||||
fallback = static_model_fallback(SUBSCRIPTION_KIND, provider.cli or "")
|
||||
ids = fallback.model_ids if fallback is not None else ()
|
||||
return ModelListing(
|
||||
source="static",
|
||||
verified=False,
|
||||
models=tuple(ModelEntry(id=i, family=model_family_token(i)) for i in ids),
|
||||
models=(),
|
||||
note=(
|
||||
f"curated aliases for the {provider.cli or 'unknown'} CLI login "
|
||||
"(subscription logins expose no model-listing API; availability "
|
||||
"depends on the logged-in plan)"
|
||||
f"the {provider.cli or 'unknown'} CLI login exposes no model-listing "
|
||||
"API before launch; the live listing comes from probing the harness"
|
||||
),
|
||||
static_fallback=fallback,
|
||||
)
|
||||
|
||||
|
||||
@@ -1091,20 +1081,16 @@ def _static_cli_config_listing(provider: ResolvedModelProvider) -> ModelListing:
|
||||
resolve — not a "no credentials" preflight failure.
|
||||
|
||||
:param provider: A ``kind="cli-config"`` provider descriptor.
|
||||
:returns: A ``source="static"`` listing with ``verified=False``.
|
||||
:returns: A ``source="static"`` listing with no models.
|
||||
"""
|
||||
fallback = static_model_fallback(CLI_CONFIG_KIND, provider.cli or "")
|
||||
ids = fallback.model_ids if fallback is not None else ()
|
||||
return ModelListing(
|
||||
source="static",
|
||||
verified=False,
|
||||
models=tuple(ModelEntry(id=i, family=model_family_token(i)) for i in ids),
|
||||
models=(),
|
||||
note=(
|
||||
f"curated ids for {provider.detail}; its credential lives in the "
|
||||
"CLI's own config file and is resolved by the CLI at launch, so "
|
||||
"it cannot be verified from here"
|
||||
f"{provider.detail} enumerates its models only from the CLI's own "
|
||||
"config at launch; the live listing comes from probing the harness"
|
||||
),
|
||||
static_fallback=fallback,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
"""The shared on-disk model-catalog store (model-flows-design.md §1.2).
|
||||
|
||||
One probe result, many consumers: whoever ran a harness's ``list_models``
|
||||
(the host at boot, the runner at launch when the file is absent, a live
|
||||
codex session writing back) persists the catalog here, keyed by harness and
|
||||
a launch-config fingerprint, and every surface — the pre-launch picker, the
|
||||
in-session gear, launch resolution and validation — reads the same bytes.
|
||||
Because writer and readers share one file, host/runner drift and
|
||||
probe-vs-session mismatch are impossible by construction.
|
||||
|
||||
The store holds only verbatim harness answers; nothing else ever writes it.
|
||||
A fingerprint mismatch is a miss (never a "close enough" hit), so an answer
|
||||
probed under one config can never serve another.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def fingerprint_of(*parts: object) -> str:
|
||||
"""
|
||||
Stable fingerprint of a resolved harness configuration.
|
||||
|
||||
:param parts: Hashable configuration facets — resolved overrides, env
|
||||
pairs, binary identity. Stringified in order.
|
||||
:returns: A short hex digest.
|
||||
"""
|
||||
digest = hashlib.sha256()
|
||||
for part in parts:
|
||||
digest.update(repr(part).encode("utf-8"))
|
||||
digest.update(b"\x00")
|
||||
return digest.hexdigest()[:16]
|
||||
|
||||
|
||||
#: Catalog entries older than this get a background refresh on read (the
|
||||
#: readers decide; the store only reports staleness).
|
||||
CATALOG_STALE_AFTER_S = 3600.0
|
||||
|
||||
|
||||
def _data_dir() -> Path:
|
||||
"""Return the omnigent data dir (must stay in lock-step with
|
||||
``omnigent.host.local_server._local_data_dir`` /
|
||||
``omnigent.chat._omnigent_persistent_dir``).
|
||||
|
||||
:returns: ``$OMNIGENT_DATA_DIR`` when set, else ``~/.omnigent``.
|
||||
"""
|
||||
value = os.environ.get("OMNIGENT_DATA_DIR")
|
||||
if value:
|
||||
return Path(value).expanduser()
|
||||
return Path.home() / ".omnigent"
|
||||
|
||||
|
||||
def catalog_path(harness: str, fingerprint: str) -> Path:
|
||||
"""Return the catalog file path for one (harness, fingerprint).
|
||||
|
||||
:param harness: Canonical harness name, e.g. ``"claude-native"``.
|
||||
:param fingerprint: The launch-config fingerprint (:func:`fingerprint_of`).
|
||||
:returns: ``<data-dir>/cache/model-catalogs/<harness>-<fingerprint>.json``.
|
||||
"""
|
||||
return _data_dir() / "cache" / "model-catalogs" / f"{harness}-{fingerprint}.json"
|
||||
|
||||
|
||||
def read_catalog(harness: str, fingerprint: str) -> list[dict[str, Any]] | None:
|
||||
"""Read the stored catalog rows for one (harness, fingerprint).
|
||||
|
||||
:param harness: Canonical harness name.
|
||||
:param fingerprint: The launch-config fingerprint.
|
||||
:returns: The verbatim rows, or ``None`` on a miss / damaged file.
|
||||
"""
|
||||
path = catalog_path(harness, fingerprint)
|
||||
try:
|
||||
payload = json.loads(path.read_text())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
rows = payload.get("models") if isinstance(payload, dict) else None
|
||||
if not isinstance(rows, list):
|
||||
return None
|
||||
return [row for row in rows if isinstance(row, dict) and row.get("id")]
|
||||
|
||||
|
||||
def catalog_age_s(harness: str, fingerprint: str) -> float | None:
|
||||
"""Age of the stored catalog in seconds, or ``None`` on a miss."""
|
||||
path = catalog_path(harness, fingerprint)
|
||||
try:
|
||||
return max(0.0, time.time() - path.stat().st_mtime)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def write_catalog(harness: str, fingerprint: str, rows: list[dict[str, Any]]) -> None:
|
||||
"""Persist catalog rows atomically (best-effort; failures only log).
|
||||
|
||||
:param harness: Canonical harness name.
|
||||
:param fingerprint: The launch-config fingerprint.
|
||||
:param rows: Verbatim harness rows to persist.
|
||||
"""
|
||||
path = catalog_path(harness, fingerprint)
|
||||
payload = {
|
||||
"harness": harness,
|
||||
"fingerprint": fingerprint,
|
||||
"written_at": time.time(),
|
||||
"models": rows,
|
||||
}
|
||||
try:
|
||||
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
handle, tmp_name = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(handle, "w") as tmp:
|
||||
json.dump(payload, tmp, separators=(",", ":"))
|
||||
os.replace(tmp_name, path)
|
||||
except BaseException:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(tmp_name)
|
||||
raise
|
||||
except OSError:
|
||||
_logger.warning("could not persist the %s model catalog", harness, exc_info=True)
|
||||
|
||||
|
||||
#: In-flight probes, keyed (harness, fingerprint) — the thin single-flight
|
||||
#: wrapper the design keeps process-side: concurrent misses join one probe
|
||||
#: instead of each spawning CLI processes.
|
||||
_inflight: dict[tuple[str, str], asyncio.Task[list[dict[str, Any]] | None]] = {}
|
||||
|
||||
|
||||
async def ensure_catalog(
|
||||
harness: str,
|
||||
fingerprint: str,
|
||||
resolve: Callable[[], Awaitable[list[dict[str, Any]] | None]],
|
||||
) -> list[dict[str, Any]] | None:
|
||||
"""Store-first catalog access with a single probe in flight per key.
|
||||
|
||||
A hit serves immediately; a miss runs *resolve* once (concurrent
|
||||
callers join it), persists a non-empty answer, and returns it.
|
||||
|
||||
:param harness: Canonical harness name.
|
||||
:param fingerprint: The launch-config fingerprint.
|
||||
:param resolve: Probe coroutine factory producing verbatim rows.
|
||||
:returns: Catalog rows, or ``None`` when no catalog could be obtained.
|
||||
"""
|
||||
cached = read_catalog(harness, fingerprint)
|
||||
if cached is not None:
|
||||
return cached
|
||||
key = (harness, fingerprint)
|
||||
task = _inflight.get(key)
|
||||
if task is None or task.done():
|
||||
|
||||
async def _run() -> list[dict[str, Any]] | None:
|
||||
try:
|
||||
rows = await resolve()
|
||||
finally:
|
||||
_inflight.pop(key, None)
|
||||
if rows:
|
||||
write_catalog(harness, fingerprint, rows)
|
||||
return rows
|
||||
|
||||
task = asyncio.create_task(_run(), name=f"model-catalog-{harness}")
|
||||
_inflight[key] = task
|
||||
return await asyncio.shield(task)
|
||||
|
||||
|
||||
def default_row(rows: list[dict[str, Any]]) -> dict[str, Any] | None:
|
||||
"""Return the catalog's single ``isDefault`` row, if any.
|
||||
|
||||
:param rows: Catalog rows.
|
||||
:returns: The default row, or ``None``.
|
||||
"""
|
||||
return next((row for row in rows if row.get("isDefault") is True), None)
|
||||
|
||||
|
||||
def catalog_contains(rows: list[dict[str, Any]], token: str) -> bool:
|
||||
"""Whether *token* names a catalog row (by ``id`` or wire ``model``).
|
||||
|
||||
:param rows: Catalog rows.
|
||||
:param token: A picker row id or wire model id.
|
||||
:returns: ``True`` when some row's ``id`` or ``model`` equals *token*.
|
||||
"""
|
||||
return any(row.get("id") == token or row.get("model") == token for row in rows)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CATALOG_STALE_AFTER_S",
|
||||
"catalog_age_s",
|
||||
"catalog_contains",
|
||||
"catalog_path",
|
||||
"default_row",
|
||||
"ensure_catalog",
|
||||
"fingerprint_of",
|
||||
"read_catalog",
|
||||
"write_catalog",
|
||||
]
|
||||
+37
-43
@@ -1,10 +1,16 @@
|
||||
"""Owned static model fallbacks for CLI surfaces without discovery."""
|
||||
"""Owned static model tables for Smart Routing.
|
||||
|
||||
Pre-launch picker listings carry no static stand-ins anymore — the live
|
||||
harness probes (see ``omnigent.host.connect``) are their source of truth.
|
||||
What remains here is the router's operational data: rankings, arm menus,
|
||||
and probed exclusions that no discovery API can provide.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from omnigent.onboarding.provider_config import CLI_CONFIG_KIND, SUBSCRIPTION_KIND
|
||||
from omnigent.onboarding.provider_config import SUBSCRIPTION_KIND
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -17,56 +23,44 @@ class StaticModelFallback:
|
||||
discovery_gap: str
|
||||
|
||||
|
||||
_CLAUDE_SUBSCRIPTION_MODELS = (
|
||||
"claude-fable-5",
|
||||
"claude-opus-5",
|
||||
"claude-opus-4-8",
|
||||
"claude-sonnet-5",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-haiku-4-5",
|
||||
#: Curated preference ORDER for codex's current arms — a ranking hint only
|
||||
#: (preferred first), consumed by the Databricks live-discovery ranker to
|
||||
#: sort servable ids. It never invents picker rows: ids absent from the live
|
||||
#: listing are simply not ranked by it.
|
||||
_CODEX_ARM_PREFERENCE = StaticModelFallback(
|
||||
model_ids=("gpt-5.6-sol", "gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.5"),
|
||||
owner="Databricks model discovery (omnigent.databricks_model_discovery)",
|
||||
provenance="Omnigent's release-curated Codex arm ordering",
|
||||
discovery_gap="a workspace listing ranks models by neither recency nor capability",
|
||||
)
|
||||
|
||||
#: Codex's own model slugs, which spell the version with a DOT
|
||||
#: (``gpt-5.6-sol``). These reach codex's ChatGPT-account backend directly, so
|
||||
#: the Databricks serving spelling (``databricks-gpt-5-6-sol``, hyphens only)
|
||||
#: is rejected here with a 400 — unlike the gateway catalogs below, which are
|
||||
#: correctly hyphenated. Ordered cheapest-safe default first.
|
||||
_CODEX_MODELS = ("gpt-5.6-sol", "gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.5")
|
||||
|
||||
_STATIC_MODEL_FALLBACKS = {
|
||||
(SUBSCRIPTION_KIND, "claude"): StaticModelFallback(
|
||||
model_ids=_CLAUDE_SUBSCRIPTION_MODELS,
|
||||
owner="Claude subscription adapter",
|
||||
provenance="Omnigent's release-curated Claude Code alias catalog",
|
||||
discovery_gap="Claude subscription logins expose no model-listing API",
|
||||
),
|
||||
(SUBSCRIPTION_KIND, "codex"): StaticModelFallback(
|
||||
model_ids=_CODEX_MODELS,
|
||||
owner="Codex subscription adapter",
|
||||
provenance="Omnigent's release-curated Codex alias catalog",
|
||||
discovery_gap="Codex subscription availability is not exposed before launch",
|
||||
),
|
||||
(CLI_CONFIG_KIND, "codex"): StaticModelFallback(
|
||||
model_ids=_CODEX_MODELS,
|
||||
owner="Codex CLI-config adapter",
|
||||
provenance="Omnigent's release-curated Codex alias catalog",
|
||||
discovery_gap=(
|
||||
"Custom model_provider entries live in Codex config.toml and cannot "
|
||||
"be enumerated on this catalog path"
|
||||
),
|
||||
),
|
||||
_STATIC_MODEL_FALLBACKS: dict[tuple[str, str], StaticModelFallback] = {
|
||||
(SUBSCRIPTION_KIND, "codex"): _CODEX_ARM_PREFERENCE,
|
||||
}
|
||||
|
||||
|
||||
def static_model_fallback(provider_kind: str, cli: str) -> StaticModelFallback | None:
|
||||
"""Return the owned fallback for a provider kind and CLI, if registered."""
|
||||
"""Return the owned fallback table for a provider kind and CLI, if registered."""
|
||||
return _STATIC_MODEL_FALLBACKS.get((provider_kind, cli))
|
||||
|
||||
|
||||
#: Codex's launch default when nothing else names a model. The bundled OpenAI
|
||||
#: catalog's newest row is a bare family alias (``gpt-5.6``) that codex rejects,
|
||||
#: so a codex launch defaults to a concrete variant from its own catalog.
|
||||
CODEX_DEFAULT_MODEL = _STATIC_MODEL_FALLBACKS[(SUBSCRIPTION_KIND, "codex")].model_ids[0]
|
||||
#: Codex's launch default when nothing else names a model. The bundled
|
||||
#: OpenAI catalog's newest row is a bare family alias (``gpt-5.6``) that
|
||||
#: codex rejects, so a codex launch defaults to a concrete variant from
|
||||
#: codex's own catalog — dotted spelling, since the Databricks hyphenated
|
||||
#: form 400s against codex's own backend.
|
||||
_CODEX_LAUNCH_DEFAULT = StaticModelFallback(
|
||||
model_ids=("gpt-5.6-sol",),
|
||||
owner="Codex native launch (omnigent.inner.codex_executor)",
|
||||
provenance="codex's own catalog slug for the cheapest current arm",
|
||||
discovery_gap=(
|
||||
"the launch default is resolved before any app-server probe can "
|
||||
"answer, and codex rejects the bundled catalog's newest row (a bare "
|
||||
"family alias)"
|
||||
),
|
||||
)
|
||||
|
||||
CODEX_DEFAULT_MODEL = _CODEX_LAUNCH_DEFAULT.model_ids[0]
|
||||
|
||||
|
||||
# ── Smart Routing ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -16,7 +16,7 @@ creates the Job — an init container prepares the workspace (``mkdir`` + option
|
||||
which dials back over the existing managed launch-token tunnel. Because the host
|
||||
is never started by ``exec``-ing into an already-running container, this launcher
|
||||
needs no ``pods/exec`` rights and no exec transport — it implements only
|
||||
``prepare`` / ``provision`` / ``start_host`` / ``terminate``.
|
||||
``prepare`` / ``provision`` / ``start_host`` / ``resume`` / ``terminate``.
|
||||
|
||||
Platform notes that shape this launcher:
|
||||
|
||||
@@ -982,7 +982,9 @@ class KubernetesSandboxLauncher(SandboxHostLauncher):
|
||||
Server-managed only and entrypoint-as-host: :meth:`provision` reserves a Job
|
||||
name, :meth:`start_host` creates a per-Job token Secret and a Job whose Pod
|
||||
template's init container prepares the workspace and whose main container runs
|
||||
``omnigent host``, and :meth:`terminate` deletes both. The Job uses
|
||||
``omnigent host``. :meth:`resume` removes a dormant Job and its stale token
|
||||
Secret so the managed-host wake path can recreate both under the same sandbox
|
||||
id, while :meth:`terminate` permanently deletes them. The Job uses
|
||||
``restartPolicy: OnFailure`` so the kubelet automatically restarts a crashed
|
||||
host container, providing automatic failover within the Job's
|
||||
``backoffLimit``. All transport rides the official ``kubernetes`` client's
|
||||
@@ -992,6 +994,7 @@ class KubernetesSandboxLauncher(SandboxHostLauncher):
|
||||
"""
|
||||
|
||||
provider: ClassVar[str] = "kubernetes"
|
||||
can_resume: ClassVar[bool] = True
|
||||
|
||||
@property
|
||||
def capabilities(self) -> SandboxCapabilities:
|
||||
@@ -999,7 +1002,7 @@ class KubernetesSandboxLauncher(SandboxHostLauncher):
|
||||
cli_bootstrap=False,
|
||||
managed_launch=True,
|
||||
local_port_forward=False,
|
||||
resume_stopped=False,
|
||||
resume_stopped=True,
|
||||
programmatic_terminate=True,
|
||||
classifies_runner_by_agent=True,
|
||||
)
|
||||
@@ -1739,6 +1742,23 @@ class KubernetesSandboxLauncher(SandboxHostLauncher):
|
||||
if first_error is not None:
|
||||
raise first_error
|
||||
|
||||
def resume(self, sandbox_id: str) -> None:
|
||||
"""
|
||||
Prepare a dormant Kubernetes sandbox for recreation in place.
|
||||
|
||||
Kubernetes Jobs cannot be restarted after their host process exits.
|
||||
Remove the old Job and launch-token Secret so the shared managed-host
|
||||
wake path can call :meth:`start_host` with the same sandbox id and a
|
||||
freshly armed token. Operator-managed PVCs are external resources and
|
||||
are not touched.
|
||||
|
||||
:param sandbox_id: The dormant Job name to recreate.
|
||||
:raises click.ClickException: On an API delete failure other than
|
||||
not-found.
|
||||
"""
|
||||
click.echo(f"▸ Resuming Kubernetes sandbox '{sandbox_id}'")
|
||||
self.terminate(sandbox_id)
|
||||
|
||||
def _delete_with_retry(self, kind: str, name: str, delete: Callable[[], object]) -> None:
|
||||
"""
|
||||
Run *delete* with bounded retries on a transient timeout/connection
|
||||
|
||||
@@ -295,8 +295,11 @@ async def _prepare_opencode_terminal_via_daemon( # pragma: no cover
|
||||
"Creating an OpenCode session requires a session bundle."
|
||||
)
|
||||
_update_startup_progress(startup_progress, "Creating OpenCode session...")
|
||||
session_id = await _create_opencode_session(
|
||||
client, session_bundle, terminal_launch_args=persist_args or None
|
||||
session_id, _ = await asyncio.gather(
|
||||
_create_opencode_session(
|
||||
client, session_bundle, terminal_launch_args=persist_args or None
|
||||
),
|
||||
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
|
||||
)
|
||||
else:
|
||||
_update_startup_progress(startup_progress, "Loading OpenCode session...")
|
||||
@@ -337,7 +340,8 @@ async def _prepare_opencode_terminal_via_daemon( # pragma: no cover
|
||||
f"({resp.status_code}): {error_text(resp)}"
|
||||
)
|
||||
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
if not fresh_session:
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
_update_startup_progress(startup_progress, "Starting runner...")
|
||||
runner_id = await launch_or_reuse_daemon_runner(
|
||||
client,
|
||||
|
||||
@@ -377,10 +377,13 @@ async def _prepare_pi_terminal_via_daemon(
|
||||
if session_bundle is None:
|
||||
raise click.ClickException("Creating a Pi session requires a session bundle.")
|
||||
_update_startup_progress(startup_progress, "Creating Pi session...")
|
||||
session_id = await _create_pi_session(
|
||||
client,
|
||||
session_bundle,
|
||||
terminal_launch_args=persist_args or None,
|
||||
session_id, _ = await asyncio.gather(
|
||||
_create_pi_session(
|
||||
client,
|
||||
session_bundle,
|
||||
terminal_launch_args=persist_args or None,
|
||||
),
|
||||
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
|
||||
)
|
||||
else:
|
||||
_update_startup_progress(startup_progress, "Loading Pi session...")
|
||||
@@ -421,7 +424,8 @@ async def _prepare_pi_terminal_via_daemon(
|
||||
f"({resp.status_code}): {error_text(resp)}"
|
||||
)
|
||||
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
if not fresh_session:
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
_update_startup_progress(startup_progress, "Starting runner...")
|
||||
runner_id = await launch_or_reuse_daemon_runner(
|
||||
client,
|
||||
|
||||
@@ -324,10 +324,13 @@ async def _prepare_qwen_terminal_via_daemon(
|
||||
if session_bundle is None:
|
||||
raise click.ClickException("Creating a qwen session requires a session bundle.")
|
||||
_update_startup_progress(startup_progress, "Creating qwen session...")
|
||||
session_id = await _create_qwen_session(
|
||||
client,
|
||||
session_bundle,
|
||||
terminal_launch_args=persist_args or None,
|
||||
session_id, _ = await asyncio.gather(
|
||||
_create_qwen_session(
|
||||
client,
|
||||
session_bundle,
|
||||
terminal_launch_args=persist_args or None,
|
||||
),
|
||||
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
|
||||
)
|
||||
else:
|
||||
_update_startup_progress(startup_progress, "Loading qwen session...")
|
||||
@@ -370,7 +373,8 @@ async def _prepare_qwen_terminal_via_daemon(
|
||||
f"({resp.status_code}): {error_text(resp)}"
|
||||
)
|
||||
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
if not fresh_session:
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
_update_startup_progress(startup_progress, "Starting runner...")
|
||||
runner_id = await launch_or_reuse_daemon_runner(
|
||||
client,
|
||||
|
||||
@@ -8,23 +8,29 @@ from types import MappingProxyType
|
||||
|
||||
from omnigent.llms.errors import PermanentLLMError
|
||||
|
||||
EFFORT_VALUES = frozenset({"none", "minimal", "low", "medium", "high", "xhigh", "max"})
|
||||
EFFORT_VALUES = frozenset({"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"})
|
||||
EFFORT_CLEAR_VALUES = frozenset({"default", "off", "reset"})
|
||||
|
||||
# Deprecated / vendor-written effort values mapped to the canonical value to
|
||||
# use instead. The ChatGPT desktop app writes ``model_reasoning_effort =
|
||||
# "ultra"`` into ``~/.codex/config.toml``, and the codex CLI forwards it as
|
||||
# the retired ``max`` wire value — the OpenAI Responses API accepts neither
|
||||
# (its ladder tops out at ``xhigh``). ``validate_effort`` coerces an alias
|
||||
# only when the raw value is unsupported but the canonical value IS
|
||||
# supported, so providers that genuinely support ``max`` (Anthropic) keep it
|
||||
# unchanged.
|
||||
# Fold a value to a canonical one, but only where the target ladder lacks it:
|
||||
# ``validate_effort`` applies an alias only when the raw value is unsupported
|
||||
# but the canonical one is. On the SDK/Responses codex ladder (``CODEX_EFFORTS``,
|
||||
# capped at ``xhigh``) the ChatGPT app's ``ultra`` / retired ``max`` fold to
|
||||
# ``xhigh``; ladders that carry them (codex-native ``CODEX_NATIVE_EFFORTS``,
|
||||
# Anthropic's ``max``) keep them unchanged.
|
||||
EFFORT_ALIASES: dict[str, str] = {"ultra": "xhigh", "max": "xhigh"}
|
||||
|
||||
OPENAI_EFFORTS = frozenset({"none", "minimal", "low", "medium", "high", "xhigh"})
|
||||
ANTHROPIC_EFFORTS = frozenset({"low", "medium", "high", "xhigh", "max"})
|
||||
CLAUDE_EFFORTS = ANTHROPIC_EFFORTS
|
||||
CODEX_EFFORTS = OPENAI_EFFORTS
|
||||
# Codex-native drives the real codex process, which is the per-model authority
|
||||
# on reasoning levels — it advertises them via ``model/list`` and validates the
|
||||
# pairing itself. Sol reaches ``ultra``; the picker already gates which levels a
|
||||
# model offers, so accept codex's full ladder here rather than re-clamping a
|
||||
# valid pick down to ``xhigh``.
|
||||
CODEX_NATIVE_EFFORTS = frozenset(
|
||||
{"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"}
|
||||
)
|
||||
OPENAI_AGENTS_EFFORTS = OPENAI_EFFORTS
|
||||
GEMINI_EFFORTS = frozenset({"low", "medium", "high"})
|
||||
ANTIGRAVITY_EFFORTS = GEMINI_EFFORTS
|
||||
@@ -36,7 +42,7 @@ COPILOT_EFFORTS = frozenset({"low", "medium", "high", "xhigh"})
|
||||
|
||||
def format_supported(values: Iterable[str]) -> str:
|
||||
"""Return a stable comma-separated supported-values string."""
|
||||
order = ["none", "minimal", "low", "medium", "high", "xhigh", "max"]
|
||||
order = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]
|
||||
values_set = set(values)
|
||||
return ", ".join(value for value in order if value in values_set)
|
||||
|
||||
@@ -61,8 +67,10 @@ def unsupported_effort_message(effort: str, provider: str, supported: Iterable[s
|
||||
# above stay frozen: they are the wire APIs' own vocabularies.
|
||||
_MODEL_EFFORT_FALLBACK: Mapping[str, str] = MappingProxyType({"glm-5-2": "medium"})
|
||||
# Efforts a fallback model cannot accept, so a pinned high value coerces down.
|
||||
# GLM tops out at ``high``, so every rung above it (``xhigh``/``max``/``ultra``)
|
||||
# is unsupported.
|
||||
_MODEL_EFFORT_UNSUPPORTED: Mapping[str, frozenset[str]] = MappingProxyType(
|
||||
{"glm-5-2": frozenset({"xhigh", "max"})}
|
||||
{"glm-5-2": frozenset({"xhigh", "max", "ultra"})}
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -4814,7 +4814,7 @@ async def _cmd_theme(
|
||||
host.output(_build_preview(selected.name))
|
||||
|
||||
|
||||
_EFFORT_VALUES = ("none", "minimal", "low", "medium", "high", "xhigh", "max")
|
||||
_EFFORT_VALUES = ("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra")
|
||||
_EFFORT_CLEAR_ALIASES = {"default", "off", "reset"}
|
||||
|
||||
|
||||
@@ -4880,7 +4880,8 @@ async def _cmd_effort(
|
||||
host.output(
|
||||
Text.from_markup(
|
||||
" [bold red]Invalid effort: "
|
||||
f"{value} · expected none, minimal, low, medium, high, xhigh, max, or default[/]"
|
||||
f"{value} · expected none, minimal, low, medium, high, "
|
||||
"xhigh, max, ultra, or default[/]"
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
+14
-11
@@ -221,17 +221,20 @@ def _dispatch_by_runtime(
|
||||
"""
|
||||
from omnigent.db.db_models import InvalidUuidError, uuid_to_bytes
|
||||
|
||||
# Resolve the id the argument contains, then canonicalize to bare hex
|
||||
# before any lookup: a paste drags punctuation along (trailing period,
|
||||
# wrapping quotes or backticks), and none of it can ever be part of a
|
||||
# valid id — so strip it and resume rather than erroring. A malformed
|
||||
# id would otherwise surface as a raw StatementError traceback from
|
||||
# the local store's Uuid16 bind, and downstream consumers key
|
||||
# sessions on the bare spelling.
|
||||
try:
|
||||
target = uuid_to_bytes(target.strip(_PASTE_PUNCTUATION)).hex()
|
||||
except InvalidUuidError as exc:
|
||||
raise click.ClickException("Invalid session id.") from exc
|
||||
# Paste punctuation (trailing period, wrapping quotes/backticks) is never
|
||||
# part of an id, so strip it. Only the local path binds the id to the sqlite
|
||||
# store's Uuid16 column, so it must be a real uuid — reject a malformed one
|
||||
# loudly rather than surfacing a raw StatementError. The remote server owns
|
||||
# its id space (a managed deployment keys sessions on non-uuid ids) and
|
||||
# validates the id itself, so forward it untouched, like the runner and SDK.
|
||||
stripped = target.strip(_PASTE_PUNCTUATION)
|
||||
if server is None:
|
||||
try:
|
||||
target = uuid_to_bytes(stripped).hex()
|
||||
except InvalidUuidError as exc:
|
||||
raise click.ClickException("Invalid session id.") from exc
|
||||
else:
|
||||
target = stripped
|
||||
|
||||
if server is not None:
|
||||
wrapper = _read_wrapper_label_remote(server=server, conv_id=target)
|
||||
|
||||
@@ -613,11 +613,34 @@ def _make_auth_token_factory(
|
||||
"""
|
||||
# Check stored OIDC token first.
|
||||
if resolved_server_url:
|
||||
from omnigent.cli_auth import load_token
|
||||
from omnigent.cli_auth import (
|
||||
REFRESH_MIN_REMAINING_SECONDS,
|
||||
load_token,
|
||||
refresh_stored_token,
|
||||
)
|
||||
|
||||
oidc_token = load_token(resolved_server_url)
|
||||
# Require enough remaining life that the token cannot lapse
|
||||
# mid-handshake; a token inside that window falls through to
|
||||
# the renewal path below rather than being used and rejected.
|
||||
oidc_token = load_token(
|
||||
resolved_server_url,
|
||||
min_remaining_seconds=REFRESH_MIN_REMAINING_SECONDS,
|
||||
)
|
||||
if oidc_token:
|
||||
return oidc_token
|
||||
# Expired or near-lapse: renew from the login-issued refresh
|
||||
# grant when one exists. This is what keeps an unattended host
|
||||
# alive past session-JWT expiry — the tunnel rebuilds headers
|
||||
# through this factory on every reconnect.
|
||||
refreshed = refresh_stored_token(resolved_server_url)
|
||||
if refreshed:
|
||||
return refreshed
|
||||
# Nothing to renew with: a near-expiry token that has NOT
|
||||
# actually lapsed still authenticates, so prefer it over
|
||||
# falling through to no credential at all.
|
||||
still_valid = load_token(resolved_server_url)
|
||||
if still_valid:
|
||||
return still_valid
|
||||
return _sdk_token()
|
||||
|
||||
# Probe once to check if a user credential is available.
|
||||
|
||||
+365
-28
@@ -170,6 +170,25 @@ from omnigent.tools.builtins.load_skill import (
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Claude-native session model listing: how long one request waits inline for
|
||||
# the probe before answering 503-pending, and how long the probe may stay
|
||||
# pending before the configured rows are served instead. Module-level so
|
||||
# tests can patch the pacing.
|
||||
_CLAUDE_MODEL_OPTIONS_INLINE_WAIT_S = 2.5
|
||||
|
||||
# Claude-native model switch confirmation: how long to watch the pane's
|
||||
# statusLine snapshot for the switched model after typing ``/model``, and how
|
||||
# often to re-read it. Module-level so tests can patch the pacing.
|
||||
_CLAUDE_MODEL_CONFIRM_TIMEOUT_S = 10.0
|
||||
_CLAUDE_MODEL_CONFIRM_POLL_S = 0.25
|
||||
|
||||
# How long the detached watcher keeps answering a /model confirm dialog that
|
||||
# pops after the active turn settles (a mid-turn switch queues in the
|
||||
# composer), and how often it looks. Long turns are common; the watch is
|
||||
# cheap (one tmux capture per poll) and never types blind.
|
||||
_CLAUDE_MODEL_LATE_DIALOG_BUDGET_S = 1200.0
|
||||
_CLAUDE_MODEL_LATE_DIALOG_POLL_S = 2.0
|
||||
|
||||
|
||||
def _warn_unresolved_sub_agent(session_id: str | None, sub_agent_name: str) -> None:
|
||||
"""
|
||||
@@ -2070,6 +2089,10 @@ def create_runner_app(
|
||||
_active_turns: dict[str, asyncio.Task[None] | None] = {}
|
||||
app.state.active_turns = _active_turns
|
||||
_native_pane_status: dict[str, str] = {}
|
||||
app.state.native_pane_status = _native_pane_status
|
||||
# Detached watchers answering a /model confirm dialog that pops after
|
||||
# the active turn settles (a mid-turn switch queues in the composer).
|
||||
_model_dialog_watchers: set[asyncio.Task[None]] = set()
|
||||
_session_message_buffers: dict[str, list[dict[str, Any]]] = {}
|
||||
app.state.session_message_buffers = _session_message_buffers
|
||||
_author_attribution_sessions: set[str] = set()
|
||||
@@ -2092,6 +2115,7 @@ def create_runner_app(
|
||||
app.state.desync_terminalized = _desync_terminalized
|
||||
_background_tasks: set[asyncio.Task[Any]] = set()
|
||||
_subagent_wake_pending: set[str] = set()
|
||||
_last_rewake_notice: dict[str, str] = {}
|
||||
|
||||
_session_histories = _session_histories_ref
|
||||
_last_server_item_id: dict[str, str] = {}
|
||||
@@ -2273,6 +2297,8 @@ def create_runner_app(
|
||||
app.state.session_resource_registry = resource_registry
|
||||
|
||||
def _publish_terminal_activity(session_id: str, terminal_id: str) -> None:
|
||||
if process_manager is not None:
|
||||
process_manager.note_activity(session_id)
|
||||
_publish_event(
|
||||
session_id,
|
||||
{
|
||||
@@ -3501,6 +3527,7 @@ def create_runner_app(
|
||||
_session_event_queues.pop(session_id, None)
|
||||
_session_inboxes.pop(session_id, None)
|
||||
_subagent_wake_pending.discard(session_id)
|
||||
_last_rewake_notice.pop(session_id, None)
|
||||
_session_sub_agent_names.pop(session_id, None)
|
||||
unregister_child_session(session_id)
|
||||
unregister_subagent_work_for_session(session_id)
|
||||
@@ -4075,7 +4102,16 @@ def create_runner_app(
|
||||
return Response(status_code=204)
|
||||
state = await _codex_native_bridge_state_for_session(conv_id, action="settings update")
|
||||
if state is None:
|
||||
return Response(status_code=204)
|
||||
# No loaded Codex bridge means nothing applied the settings; a
|
||||
# silent 204 here would let the caller claim a switch the
|
||||
# app-server never saw.
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"error": "codex_native_settings_update_failed",
|
||||
"detail": "Codex-native settings update requires a loaded Codex bridge.",
|
||||
},
|
||||
)
|
||||
|
||||
codex_client = client_for_transport(
|
||||
state.socket_path,
|
||||
@@ -4126,7 +4162,13 @@ def create_runner_app(
|
||||
if resp.status_code == 200:
|
||||
snapshot = resp.json()
|
||||
if isinstance(snapshot, dict):
|
||||
raw_model = snapshot.get("model_override") or snapshot.get("llm_model")
|
||||
# ``llm_model`` is the harness's own report — the model
|
||||
# the pane is actually on. ``model_override`` is only a
|
||||
# request and may predate a relaunch or an unconfirmed
|
||||
# switch, so it is the fallback, not the lead: a
|
||||
# plan-mode toggle must re-assert the pane's real
|
||||
# model, never resurrect a stale ask.
|
||||
raw_model = snapshot.get("llm_model") or snapshot.get("model_override")
|
||||
if isinstance(raw_model, str) and raw_model.strip():
|
||||
model = raw_model.strip()
|
||||
raw_effort = snapshot.get("reasoning_effort")
|
||||
@@ -4188,7 +4230,9 @@ def create_runner_app(
|
||||
from omnigent.codex_native_app_server import (
|
||||
client_for_transport,
|
||||
list_codex_model_options,
|
||||
mark_launch_default,
|
||||
)
|
||||
from omnigent.codex_native_bridge import read_codex_home_config_model
|
||||
|
||||
state = await _codex_native_bridge_state_for_session(
|
||||
conv_id,
|
||||
@@ -4204,10 +4248,48 @@ def create_runner_app(
|
||||
)
|
||||
try:
|
||||
await codex_client.connect()
|
||||
return await list_codex_model_options(codex_client)
|
||||
rows = await list_codex_model_options(codex_client)
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
await codex_client.close()
|
||||
active_model = await asyncio.to_thread(
|
||||
read_codex_home_config_model,
|
||||
Path(state.codex_home),
|
||||
)
|
||||
marked = mark_launch_default(rows, active_model)
|
||||
# Write the live account rows back to the shared catalog store so the
|
||||
# pre-launch picker converges to account truth after the first
|
||||
# session — keeping the SHAPE's stored default (a session's own pin
|
||||
# must not become the host-wide default).
|
||||
asyncio.get_running_loop().create_task(
|
||||
_write_back_codex_catalog([dict(row) for row in rows])
|
||||
)
|
||||
return marked
|
||||
|
||||
async def _write_back_codex_catalog(rows: list[_JsonObject]) -> None:
|
||||
try:
|
||||
from omnigent import model_catalog_store
|
||||
from omnigent.codex_native_app_server import (
|
||||
codex_catalog_fingerprint,
|
||||
mark_launch_default,
|
||||
resolve_native_codex_launch,
|
||||
)
|
||||
|
||||
launch = await asyncio.to_thread(resolve_native_codex_launch, model=None)
|
||||
fingerprint = codex_catalog_fingerprint(launch)
|
||||
stored = model_catalog_store.read_catalog("codex-native", fingerprint)
|
||||
stored_default = next(
|
||||
(row.get("id") for row in stored or [] if row.get("isDefault") is True),
|
||||
None,
|
||||
)
|
||||
shaped = mark_launch_default(
|
||||
rows, stored_default if isinstance(stored_default, str) else None
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
model_catalog_store.write_catalog, "codex-native", fingerprint, shaped
|
||||
)
|
||||
except Exception: # noqa: BLE001 — write-back is best-effort
|
||||
_logger.debug("codex model-catalog write-back skipped", exc_info=True)
|
||||
|
||||
async def _handle_pi_native_model_change(
|
||||
conv_id: str,
|
||||
@@ -4267,6 +4349,50 @@ def create_runner_app(
|
||||
publish_event=_publish_event,
|
||||
)
|
||||
|
||||
async def _handle_claude_native_permission_mode_change(
|
||||
conv_id: str,
|
||||
mode: str | None,
|
||||
) -> Response:
|
||||
"""
|
||||
Switch a live claude-native session's permission mode.
|
||||
|
||||
Claude Code can only set the mode at launch (``--permission-mode``)
|
||||
or from its own shift+tab cycle, so the bridge drives that cycle
|
||||
and verifies the pane landed on *mode*. A 200 carries the mode now
|
||||
rendered, which the Omnigent server persists as the session's
|
||||
current mode.
|
||||
"""
|
||||
from omnigent.claude_native_bridge import (
|
||||
bridge_dir_for_bridge_id,
|
||||
set_permission_mode,
|
||||
)
|
||||
|
||||
if mode is None or not mode.strip():
|
||||
return Response(status_code=204)
|
||||
bridge_id = await _claude_native_bridge_id_for_session(
|
||||
server_client=server_client,
|
||||
session_id=conv_id,
|
||||
)
|
||||
bridge_dir = bridge_dir_for_bridge_id(bridge_id)
|
||||
try:
|
||||
settled = await asyncio.to_thread(
|
||||
set_permission_mode,
|
||||
bridge_dir,
|
||||
mode=mode.strip(),
|
||||
timeout_s=1.0,
|
||||
)
|
||||
except (RuntimeError, ValueError) as exc:
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"error": "claude_native_permission_mode_failed",
|
||||
"detail": _client_safe_error_detail(
|
||||
exc, context="claude-native permission mode change"
|
||||
),
|
||||
},
|
||||
)
|
||||
return JSONResponse(status_code=200, content={"permission_mode": settled})
|
||||
|
||||
async def _handle_claude_native_effort_change(
|
||||
conv_id: str,
|
||||
effort: str | None,
|
||||
@@ -4310,6 +4436,48 @@ def create_runner_app(
|
||||
)
|
||||
return Response(status_code=204)
|
||||
|
||||
async def _watch_late_model_dialog(
|
||||
conv_id: str,
|
||||
bridge_dir: Path,
|
||||
expected: set[str],
|
||||
) -> None:
|
||||
"""Answer a ``/model`` confirm dialog that pops after the active turn.
|
||||
|
||||
A mid-turn switch queues in Claude's composer; the confirm dialog
|
||||
renders only when the turn settles — potentially minutes after the
|
||||
injection's own watch and the request's confirm window. This watcher
|
||||
presses Enter ONLY when the model dialog is verifiably on screen
|
||||
(never blind), stops as soon as the statusLine reports one of the
|
||||
expected spellings, and gives up quietly after its budget — the
|
||||
persisted request and the forwarder's verbatim report remain the
|
||||
authoritative record either way.
|
||||
"""
|
||||
from omnigent.claude_native_bridge import (
|
||||
SWITCH_MODEL_DIALOG_HINT,
|
||||
confirm_dialog_if_open,
|
||||
read_claude_status_model,
|
||||
)
|
||||
|
||||
deadline = time.monotonic() + _CLAUDE_MODEL_LATE_DIALOG_BUDGET_S
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
current = await asyncio.to_thread(read_claude_status_model, bridge_dir)
|
||||
if current and current in expected:
|
||||
return
|
||||
await asyncio.to_thread(
|
||||
confirm_dialog_if_open, bridge_dir, hint=SWITCH_MODEL_DIALOG_HINT
|
||||
)
|
||||
except Exception: # noqa: BLE001 — best-effort; the report reconciles
|
||||
_logger.debug(
|
||||
"late model-dialog watch errored for session=%s", conv_id, exc_info=True
|
||||
)
|
||||
return
|
||||
await asyncio.sleep(_CLAUDE_MODEL_LATE_DIALOG_POLL_S)
|
||||
_logger.info(
|
||||
"late model-dialog watch for session=%s ended without a confirmed switch",
|
||||
conv_id,
|
||||
)
|
||||
|
||||
async def _handle_claude_native_model_change(
|
||||
conv_id: str,
|
||||
model: str | None,
|
||||
@@ -4321,7 +4489,9 @@ def create_runner_app(
|
||||
from omnigent.claude_native_bridge import (
|
||||
SWITCH_MODEL_DIALOG_HINT,
|
||||
bridge_dir_for_bridge_id,
|
||||
confirm_dialog_if_open,
|
||||
inject_slash_command,
|
||||
read_claude_status_model,
|
||||
read_model_env,
|
||||
)
|
||||
|
||||
@@ -4362,6 +4532,7 @@ def create_runner_app(
|
||||
},
|
||||
)
|
||||
command = f"/model {model_arg}"
|
||||
baseline = await asyncio.to_thread(read_claude_status_model, bridge_dir)
|
||||
try:
|
||||
# Accepted trade-off: ``/model <id>`` also saves the pick as the
|
||||
# person's global default in ``~/.claude/settings.json``. Driving
|
||||
@@ -4383,7 +4554,80 @@ def create_runner_app(
|
||||
"detail": _client_safe_error_detail(exc, context="claude-native model change"),
|
||||
},
|
||||
)
|
||||
return Response(status_code=204)
|
||||
# Verify against the statusLine snapshot the forwarder already polls:
|
||||
# Claude rewrites it on every render, including right after ``/model``.
|
||||
# Expected spellings come from this session's own catalog rows (every
|
||||
# row's ``model`` is the harness's own resolution), plus the typed arg
|
||||
# and its selection mapping. Success replies only after the pane
|
||||
# actually switched; the swallowed-dialog case answers non-2xx so the
|
||||
# server surfaces it instead of the row silently claiming the pick.
|
||||
expected = {value for value in (resolved_model, model_arg) if value}
|
||||
for row in _claude_model_options_rows.get(conv_id) or []:
|
||||
if row.get("id") in (selected_model, resolved_model) or row.get("model") in (
|
||||
selected_model,
|
||||
resolved_model,
|
||||
):
|
||||
row_model = row.get("model")
|
||||
if isinstance(row_model, str) and row_model:
|
||||
expected.add(row_model)
|
||||
deadline = time.monotonic() + _CLAUDE_MODEL_CONFIRM_TIMEOUT_S
|
||||
while True:
|
||||
current = await asyncio.to_thread(read_claude_status_model, bridge_dir)
|
||||
if current and (current in expected or (baseline and current != baseline)):
|
||||
# The pane switched. When it landed somewhere other than the
|
||||
# expected spelling, the forwarder's verbatim report is the
|
||||
# truth the UI will settle on — the command still took effect.
|
||||
return Response(status_code=204)
|
||||
if baseline is None and current is None:
|
||||
# No statusLine snapshot on either side of the injection: a
|
||||
# live wrapper-managed pane writes one on every render, so
|
||||
# this is a shape without the wrapper — the switch is
|
||||
# unverifiable, not failed. Report success and leave the
|
||||
# forwarder to reconcile the row.
|
||||
_logger.warning(
|
||||
"claude-native model change for session=%s could not be verified: "
|
||||
"no statusLine snapshot in %s",
|
||||
conv_id,
|
||||
bridge_dir,
|
||||
)
|
||||
return Response(status_code=204)
|
||||
# The confirm dialog can render well after the injection's own
|
||||
# short watch (a warm repaint, or a queued command surfacing) —
|
||||
# answer it whenever it shows inside the window.
|
||||
await asyncio.to_thread(
|
||||
confirm_dialog_if_open, bridge_dir, hint=SWITCH_MODEL_DIALOG_HINT
|
||||
)
|
||||
if time.monotonic() >= deadline:
|
||||
break
|
||||
await asyncio.sleep(_CLAUDE_MODEL_CONFIRM_POLL_S)
|
||||
if _native_pane_status.get(conv_id) in ("running", "waiting"):
|
||||
# Mid-turn switch: Claude queues the typed command and applies it
|
||||
# when the turn settles — its confirm dialog can pop minutes from
|
||||
# now. Not a failure: answer success, keep a detached watcher on
|
||||
# the late dialog, and let the forwarder's report settle the
|
||||
# picker when the switch actually lands.
|
||||
watcher = asyncio.create_task(
|
||||
_watch_late_model_dialog(conv_id, bridge_dir, expected),
|
||||
name=f"claude-model-dialog-{conv_id}",
|
||||
)
|
||||
_model_dialog_watchers.add(watcher)
|
||||
watcher.add_done_callback(_model_dialog_watchers.discard)
|
||||
_logger.info(
|
||||
"claude-native model change for session=%s is queued behind an active "
|
||||
"turn; watching for the late confirm dialog",
|
||||
conv_id,
|
||||
)
|
||||
return Response(status_code=204)
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"error": "claude_native_model_unconfirmed",
|
||||
"detail": (
|
||||
f"the terminal did not confirm the switch to {model_arg} within "
|
||||
f"{_CLAUDE_MODEL_CONFIRM_TIMEOUT_S:.0f}s — a dialog may be open in the pane"
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
async def _apply_claude_native_plan_verdict(
|
||||
conv_id: str,
|
||||
@@ -5386,12 +5630,20 @@ def create_runner_app(
|
||||
_cond.notify_all()
|
||||
|
||||
async def _post_subagent_wake_notice(
|
||||
parent_id: str, notice: str, child_id: str, created_by: str | None
|
||||
parent_id: str,
|
||||
notice: str,
|
||||
child_id: str,
|
||||
created_by: str | None,
|
||||
*,
|
||||
is_rewake: bool = False,
|
||||
) -> None:
|
||||
delivered = await _deliver_subagent_wake_post(
|
||||
server_client, parent_id, notice, created_by=created_by
|
||||
)
|
||||
if not delivered:
|
||||
if delivered:
|
||||
if is_rewake:
|
||||
_last_rewake_notice[parent_id] = notice
|
||||
else:
|
||||
_subagent_wake_pending.discard(parent_id)
|
||||
_logger.warning(
|
||||
"Sub-agent wake POST failed for parent=%s child=%s after %d attempt(s); "
|
||||
@@ -5401,7 +5653,7 @@ def create_runner_app(
|
||||
_WAKE_POST_MAX_ATTEMPTS,
|
||||
)
|
||||
|
||||
def _schedule_subagent_wake(entry: _SubagentWorkEntry) -> None:
|
||||
def _schedule_subagent_wake(entry: _SubagentWorkEntry, *, is_rewake: bool = False) -> None:
|
||||
if entry.parent_session_id == entry.child_session_id:
|
||||
return
|
||||
inbox = _session_inboxes.get(entry.parent_session_id)
|
||||
@@ -5413,30 +5665,39 @@ def create_runner_app(
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return
|
||||
_subagent_wake_pending.add(entry.parent_session_id)
|
||||
notice = _format_subagent_wake_notice(
|
||||
agent=entry.agent,
|
||||
title=entry.title,
|
||||
status=entry.status,
|
||||
pending=inbox.qsize(),
|
||||
)
|
||||
if is_rewake and notice == _last_rewake_notice.get(entry.parent_session_id):
|
||||
return
|
||||
_subagent_wake_pending.add(entry.parent_session_id)
|
||||
_wake_task = loop.create_task(
|
||||
_post_subagent_wake_notice(
|
||||
entry.parent_session_id,
|
||||
notice,
|
||||
entry.child_session_id,
|
||||
entry.created_by,
|
||||
is_rewake=is_rewake,
|
||||
)
|
||||
)
|
||||
_wake_task.add_done_callback(_background_tasks.discard)
|
||||
_background_tasks.add(_wake_task)
|
||||
|
||||
def _rewake_parent_if_inbox_stranded(parent_session_id: str) -> None:
|
||||
inbox = _session_inboxes.get(parent_session_id)
|
||||
drained = inbox is None or inbox.empty()
|
||||
if drained:
|
||||
# A drained inbox ends the stranding episode, so the recorded
|
||||
# re-wake no longer describes outstanding work; forget it or a
|
||||
# later episode's matching notice is wrongly deduped.
|
||||
_last_rewake_notice.pop(parent_session_id, None)
|
||||
if parent_session_id not in _subagent_wake_pending:
|
||||
return
|
||||
_subagent_wake_pending.discard(parent_session_id)
|
||||
inbox = _session_inboxes.get(parent_session_id)
|
||||
if inbox is None or inbox.empty():
|
||||
if drained:
|
||||
return
|
||||
entries = list_subagent_work(parent_session_id)
|
||||
if not entries:
|
||||
@@ -5445,7 +5706,7 @@ def create_runner_app(
|
||||
entries,
|
||||
key=lambda entry: entry.completed_at if entry.completed_at is not None else 0.0,
|
||||
)
|
||||
_schedule_subagent_wake(latest)
|
||||
_schedule_subagent_wake(latest, is_rewake=True)
|
||||
|
||||
def _mark_subagent_terminal_and_wake(
|
||||
child_session_id: str, *, status: str, output: str | None
|
||||
@@ -6939,6 +7200,24 @@ def create_runner_app(
|
||||
)
|
||||
return Response(status_code=204)
|
||||
|
||||
if body_type == "permission_mode_change":
|
||||
harness = _session_harness_name(conversation_id)
|
||||
if harness == "claude-native":
|
||||
mode = body.get("permission_mode") if isinstance(body, dict) else None
|
||||
if mode is not None and not isinstance(mode, str):
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "invalid_input",
|
||||
"detail": "Body 'permission_mode' must be a string or null",
|
||||
},
|
||||
)
|
||||
return await _handle_claude_native_permission_mode_change(
|
||||
conversation_id,
|
||||
mode,
|
||||
)
|
||||
return Response(status_code=204)
|
||||
|
||||
codex_goal_response = await codex_goal_runner.handle_event(
|
||||
conversation_id,
|
||||
body_type,
|
||||
@@ -7824,18 +8103,23 @@ def create_runner_app(
|
||||
override=transport,
|
||||
spec_transport=entry.instance.terminal_transport,
|
||||
)
|
||||
bridge = (
|
||||
bridge_tmux_control_to_websocket
|
||||
if resolved_transport == TERMINAL_TRANSPORT_CONTROL
|
||||
else bridge_tmux_pty_to_websocket
|
||||
)
|
||||
await bridge(
|
||||
websocket,
|
||||
socket_path=str(entry.instance.socket_path),
|
||||
tmux_target=entry.instance.tmux_target,
|
||||
read_only=read_only,
|
||||
on_client_interaction=entry.instance.note_client_interaction,
|
||||
)
|
||||
if resolved_transport == TERMINAL_TRANSPORT_CONTROL:
|
||||
await bridge_tmux_control_to_websocket(
|
||||
websocket,
|
||||
socket_path=str(entry.instance.socket_path),
|
||||
tmux_target=entry.instance.tmux_target,
|
||||
read_only=read_only,
|
||||
on_client_interaction=entry.instance.note_client_interaction,
|
||||
)
|
||||
else:
|
||||
await bridge_tmux_pty_to_websocket(
|
||||
websocket,
|
||||
socket_path=str(entry.instance.socket_path),
|
||||
tmux_target=entry.instance.tmux_target,
|
||||
read_only=read_only,
|
||||
on_client_interaction=entry.instance.note_client_interaction,
|
||||
allow_osc52_clipboard=not entry.instance.tmux_allow_passthrough,
|
||||
)
|
||||
|
||||
# Reused by the loopback direct-attach listener (see
|
||||
# ``omnigent.runner.direct_attach``): same attach handler served on a
|
||||
@@ -8538,10 +8822,23 @@ def create_runner_app(
|
||||
}
|
||||
return JSONResponse(status_code=200, content={"models": models})
|
||||
|
||||
# Claude's session listing IS the shared launch catalog: the same
|
||||
# fingerprint-keyed store file the launch resolved against and the
|
||||
# host's pre-launch picker serves — identical by construction, no
|
||||
# separate composition. Cached per session for its lifetime (the launch
|
||||
# config cannot change under it). A cold store pays one probe: a short
|
||||
# inline wait answers a warm one, past that the endpoint answers 503
|
||||
# (the server's fetch retries those) while the store's single-flight
|
||||
# probe completes in the background.
|
||||
_claude_model_options_rows: dict[str, list[dict[str, object]]] = {}
|
||||
|
||||
@app.get("/v1/sessions/{session_id}/claude-model-options")
|
||||
async def get_session_claude_model_options(session_id: str) -> JSONResponse:
|
||||
if _session_harness_name(session_id) != "claude-native":
|
||||
return JSONResponse(status_code=200, content={"models": []})
|
||||
cached = _claude_model_options_rows.get(session_id)
|
||||
if cached is not None:
|
||||
return JSONResponse(status_code=200, content={"models": cached})
|
||||
try:
|
||||
claude_config = await _resolve_session_claude_launch_config(session_id)
|
||||
except click.ClickException as exc:
|
||||
@@ -8573,12 +8870,52 @@ def create_runner_app(
|
||||
),
|
||||
},
|
||||
)
|
||||
from omnigent.claude_native import claude_native_model_options
|
||||
from omnigent.claude_native import claude_launch_catalog
|
||||
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"models": claude_native_model_options(claude_config)},
|
||||
)
|
||||
rows: list[dict[str, object]] | None
|
||||
try:
|
||||
# The store's single-flight probe survives this wait expiring
|
||||
# (ensure_catalog shields it), so a 503 here is genuinely
|
||||
# "pending", not "restarted".
|
||||
async with asyncio.timeout(_CLAUDE_MODEL_OPTIONS_INLINE_WAIT_S):
|
||||
rows = await claude_launch_catalog(claude_config)
|
||||
except TimeoutError:
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"error": "claude_native_model_options_pending",
|
||||
"detail": "the harness model probe is still resolving",
|
||||
},
|
||||
)
|
||||
if not rows:
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"error": "claude_native_model_options_failed",
|
||||
"detail": "the harness model probe failed; retrying",
|
||||
},
|
||||
)
|
||||
_claude_model_options_rows[session_id] = rows
|
||||
return JSONResponse(status_code=200, content={"models": rows})
|
||||
|
||||
@app.get("/v1/sessions/{session_id}/model-options")
|
||||
async def get_session_model_options(session_id: str) -> JSONResponse:
|
||||
"""One route for every harness family's session model listing.
|
||||
|
||||
The runner derives the harness from the session — the four
|
||||
harness-named routes above/below remain as compatibility aliases
|
||||
for older servers (deprecated; remove in 0.11.0).
|
||||
"""
|
||||
harness = _session_harness_name(session_id)
|
||||
if harness == "claude-native":
|
||||
return await get_session_claude_model_options(session_id)
|
||||
if harness in ("codex-native", "opencode-native"):
|
||||
return await get_session_codex_model_options(session_id)
|
||||
if harness == "cursor-native":
|
||||
return await get_session_cursor_model_options(session_id)
|
||||
if harness == "kiro-native":
|
||||
return await get_session_kiro_model_options(session_id)
|
||||
return JSONResponse(status_code=200, content={"models": []})
|
||||
|
||||
@app.post("/v1/sessions/{session_id}/skills/resolve")
|
||||
async def resolve_session_skill(session_id: str, request: Request) -> JSONResponse:
|
||||
|
||||
@@ -436,7 +436,11 @@ class NativeInterruptRunner:
|
||||
return Response(status_code=204)
|
||||
|
||||
async def _claude_stop(self, conv_id: str) -> Response:
|
||||
from omnigent.claude_native_bridge import bridge_dir_for_bridge_id, kill_session
|
||||
from omnigent.claude_native_bridge import (
|
||||
TmuxSessionNotAdvertised,
|
||||
bridge_dir_for_bridge_id,
|
||||
kill_session,
|
||||
)
|
||||
|
||||
bridge_id = await _claude_native_bridge_id_for_session(
|
||||
server_client=self._server_client,
|
||||
@@ -445,6 +449,8 @@ class NativeInterruptRunner:
|
||||
bridge_dir = bridge_dir_for_bridge_id(bridge_id)
|
||||
try:
|
||||
await asyncio.to_thread(kill_session, bridge_dir, timeout_s=1.0)
|
||||
except TmuxSessionNotAdvertised:
|
||||
self._logger.debug("claude-native stop: no live tmux for %s", conv_id)
|
||||
except RuntimeError as exc:
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
|
||||
@@ -3768,6 +3768,37 @@ async def _auto_create_codex_terminal(
|
||||
from omnigent.inner.codex_executor import _find_codex_cli
|
||||
|
||||
_codex_cli_path = _find_codex_cli()
|
||||
# Explicit launches (model-flows design §4): validate an explicit request
|
||||
# against the shared catalog, and give a Default launch on codex's own
|
||||
# login the ACCOUNT's real default — so the ``model =`` line copied from
|
||||
# the user's shared config can never govern a session (the stale-gpt-5.4
|
||||
# 400 class). Profile-backed shapes already resolve their default at
|
||||
# materialization time and are left alone.
|
||||
if launch_config.model_override or (
|
||||
_codex_launch.model is None and _codex_launch.profile is None
|
||||
):
|
||||
from dataclasses import replace as _dataclass_replace
|
||||
|
||||
from omnigent.codex_native_app_server import codex_launch_catalog
|
||||
from omnigent.model_catalog_store import catalog_contains, default_row
|
||||
|
||||
_codex_catalog = await codex_launch_catalog(codex_path=_codex_cli_path)
|
||||
if launch_config.model_override and _codex_catalog:
|
||||
if not catalog_contains(_codex_catalog, launch_config.model_override):
|
||||
raise click.ClickException(
|
||||
f"the requested model {launch_config.model_override!r} is not in "
|
||||
"this host's current model list — it may have changed since the "
|
||||
"pick. Pick again from the model menu."
|
||||
)
|
||||
if _codex_launch.model is None and _codex_launch.profile is None and _codex_catalog:
|
||||
_catalog_default = default_row(_codex_catalog)
|
||||
_default_id = (
|
||||
str(_catalog_default.get("id") or _catalog_default.get("model") or "") or None
|
||||
if _catalog_default is not None
|
||||
else None
|
||||
)
|
||||
if _default_id:
|
||||
_codex_launch = _dataclass_replace(_codex_launch, model=_default_id)
|
||||
# Cancel any surviving forwarder first so its teardown closes the OLD app-server,
|
||||
# not the one registered below — and so it can't mirror alongside the new one.
|
||||
await _cancel_auto_forwarder_task(session_id)
|
||||
@@ -4002,9 +4033,9 @@ async def _auto_create_codex_terminal(
|
||||
ap_server_url=launch_config.policy_server_url,
|
||||
ap_auth_headers=policy_headers,
|
||||
bypass_sandbox=launch_config.bypass_sandbox,
|
||||
# Codex 0.146 prompts for project trust before creating a thread.
|
||||
# This TUI runs detached for the web UI, so trust the runner-selected
|
||||
# workspace in the session-private config instead of blocking forever.
|
||||
# Codex can show project-trust and legacy-model migration prompts before
|
||||
# creating a thread. This TUI runs detached for the web UI, so persist
|
||||
# the runner-owned acknowledgements in the private session config.
|
||||
trust_project=True,
|
||||
**routed_spawn_extras,
|
||||
)
|
||||
@@ -4129,18 +4160,14 @@ async def _auto_create_codex_terminal(
|
||||
# hook sources itself; skip the interactive trust prompt
|
||||
# that headless sub-agents can never answer.
|
||||
#
|
||||
# Requires a *positively parsed* version, unlike the
|
||||
# hooks-file gate in ``codex_native_app_server``, which
|
||||
# treats an unknown version as supported. The two differ
|
||||
# because their failure modes do: an unsupported hooks
|
||||
# file is ignored by codex and caught downstream at the
|
||||
# trust check, whereas an unknown CLI flag aborts argv
|
||||
# parsing — so a transient ``codex --version`` hiccup on a
|
||||
# pre-0.131 codex would turn a recoverable trust prompt
|
||||
# into a dead terminal.
|
||||
# A failed version probe must not restore the interactive
|
||||
# gate: Omnigent's supported Codex floor is newer than the
|
||||
# release that added this flag. Otherwise a transient
|
||||
# ``codex --version`` failure strands the queued web
|
||||
# message behind the terminal-only review screen.
|
||||
bypass_hook_trust=(
|
||||
app_server.codex_cli_version is not None
|
||||
and app_server.codex_cli_version >= _MIN_BYPASS_HOOK_TRUST_CODEX_VERSION
|
||||
app_server.codex_cli_version is None
|
||||
or app_server.codex_cli_version >= _MIN_BYPASS_HOOK_TRUST_CODEX_VERSION
|
||||
),
|
||||
),
|
||||
env=codex_terminal_env(app_server),
|
||||
@@ -6454,6 +6481,43 @@ async def _auto_create_claude_terminal(
|
||||
or (claude_config.model if claude_config is not None else None),
|
||||
claude_config,
|
||||
)
|
||||
# Explicit launches (model-flows design §4): consult the shared catalog
|
||||
# only when it can change the outcome — to validate an explicit request,
|
||||
# or to resolve a Default launch that would otherwise pass no ``--model``
|
||||
# and leave the model to invisible CLI-private state.
|
||||
if session_model_override or launch_model is None:
|
||||
from omnigent.claude_native import claude_catalog_serves_model, claude_launch_catalog
|
||||
from omnigent.model_catalog_store import default_row
|
||||
|
||||
launch_catalog: list[dict[str, object]] | None = None
|
||||
try:
|
||||
launch_catalog = await claude_launch_catalog(claude_config)
|
||||
except Exception: # noqa: BLE001 — no catalog means no validation/default
|
||||
_logger.warning(
|
||||
"claude launch catalog unavailable for session=%s", session_id, exc_info=True
|
||||
)
|
||||
if session_model_override and launch_catalog:
|
||||
resolved_request = (
|
||||
resolve_claude_native_model_selection(session_model_override, claude_config)
|
||||
or session_model_override
|
||||
)
|
||||
# A pane's ``/model`` persists the exact id it runs; the catalog
|
||||
# may spell that model only by its family alias.
|
||||
if not (
|
||||
claude_catalog_serves_model(launch_catalog, session_model_override, claude_config)
|
||||
or claude_catalog_serves_model(launch_catalog, resolved_request, claude_config)
|
||||
):
|
||||
raise click.ClickException(
|
||||
f"the requested model {session_model_override!r} is not in this "
|
||||
"host's current model list — it may have changed since the pick. "
|
||||
"Pick again from the model menu."
|
||||
)
|
||||
if launch_model is None and launch_catalog:
|
||||
catalog_default = default_row(launch_catalog)
|
||||
if catalog_default is not None:
|
||||
launch_model = (
|
||||
str(catalog_default.get("model") or catalog_default.get("id") or "") or None
|
||||
)
|
||||
# Give an exact launch model (a Smart Routing pick is resolved before the
|
||||
# terminal exists) a spelling of its own in the picker, so a later
|
||||
# ``/model`` can return to it instead of stepping onto whatever the family
|
||||
@@ -6465,6 +6529,18 @@ async def _auto_create_claude_terminal(
|
||||
claude_config = claude_config_with_launch_model_pinned(claude_config, launch_model)
|
||||
if record_launch_config is not None:
|
||||
record_launch_config(session_id, claude_config)
|
||||
# Persist the vocabulary + launch model onto the bridge so mid-session
|
||||
# ``/model`` conversion reads THIS session's pins, not the runner's
|
||||
# ambient env (the CLI path records these at prepare time; the runner
|
||||
# resolves its config only after the bridge exists).
|
||||
from omnigent.claude_native_bridge import record_model_vocabulary
|
||||
|
||||
await asyncio.to_thread(
|
||||
record_model_vocabulary,
|
||||
bridge_dir,
|
||||
launch_env=claude_config.env if claude_config is not None else None,
|
||||
launch_model=launch_model,
|
||||
)
|
||||
_logger.info(
|
||||
"Claude terminal provider config resolved: session=%s configured=%s "
|
||||
"env_keys=%s api_key_helper_set=%s model_set=%s launch_model=%s",
|
||||
|
||||
@@ -20,6 +20,7 @@ Tool categories:
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import dataclasses
|
||||
import json
|
||||
import logging
|
||||
@@ -604,10 +605,240 @@ def build_native_relay_tool_schemas(spec: AgentSpec | None) -> list[_JsonObject]
|
||||
# sys_os_write, e.g. following the ``build-omnigent`` skill).
|
||||
_AGENT_CONFIG_SUBDIR = ".omnigent/agent-configs"
|
||||
|
||||
# Broad page size for the sys_agent_list fan-out reads. Orchestrators want
|
||||
# the full launchable surface in one call, not a 20-row default page.
|
||||
# Broad internal page size for discovery fan-out reads.
|
||||
_AGENT_LIST_PAGE_LIMIT = 1000
|
||||
|
||||
_DISCOVERY_LIST_MAX_LIMIT = 100
|
||||
# Match the existing default ceiling for ``sys_os_shell`` output. Discovery
|
||||
# tools stay below the same Omnigent-owned budget instead of guessing a
|
||||
# harness-specific limit.
|
||||
_DISCOVERY_LIST_OUTPUT_MAX_CHARS = 100_000
|
||||
_DISCOVERY_CURSOR_MAX_CHARS = 40_000
|
||||
_DISCOVERY_START = "start"
|
||||
_DISCOVERY_AT = "at"
|
||||
_DISCOVERY_END = "end"
|
||||
_DiscoveryState = tuple[str, str | None]
|
||||
|
||||
|
||||
def _discovery_list_window(
|
||||
args: _JsonObject,
|
||||
tool_name: str,
|
||||
page_sections: tuple[str, ...],
|
||||
filters: _JsonObject,
|
||||
) -> tuple[int | None, dict[str, _DiscoveryState], bool] | str:
|
||||
"""Validate discovery pagination and decode its opaque continuation cursor."""
|
||||
limit = args.get("limit")
|
||||
if "limit" in args and (
|
||||
not isinstance(limit, int)
|
||||
or isinstance(limit, bool)
|
||||
or not 1 <= limit <= _DISCOVERY_LIST_MAX_LIMIT
|
||||
):
|
||||
return json.dumps(
|
||||
{
|
||||
"error": (
|
||||
f"{tool_name}: 'limit' must be an integer between 1 and "
|
||||
f"{_DISCOVERY_LIST_MAX_LIMIT}"
|
||||
)
|
||||
}
|
||||
)
|
||||
cursor = args.get("cursor")
|
||||
state: dict[str, _DiscoveryState] = dict.fromkeys(page_sections, (_DISCOVERY_START, None))
|
||||
if cursor is None:
|
||||
return cast(int | None, limit), state, False
|
||||
if not isinstance(cursor, str) or not cursor:
|
||||
return json.dumps({"error": f"{tool_name}: 'cursor' must be a non-empty string"})
|
||||
if len(cursor) > _DISCOVERY_CURSOR_MAX_CHARS:
|
||||
return json.dumps({"error": f"{tool_name}: pagination cursor is too long"})
|
||||
try:
|
||||
padding = "=" * (-len(cursor) % 4)
|
||||
raw = base64.b64decode(cursor + padding, altchars=b"-_", validate=True)
|
||||
payload = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, ValueError, json.JSONDecodeError):
|
||||
return json.dumps({"error": f"{tool_name}: invalid pagination cursor"})
|
||||
if not isinstance(payload, dict) or set(payload) != {"v", "tool", "filters", "sections"}:
|
||||
return json.dumps({"error": f"{tool_name}: invalid pagination cursor"})
|
||||
if payload.get("v") != 1:
|
||||
return json.dumps({"error": f"{tool_name}: invalid pagination cursor"})
|
||||
if payload.get("tool") != tool_name:
|
||||
return json.dumps({"error": f"{tool_name}: cursor was minted by a different tool"})
|
||||
if payload.get("filters") != filters:
|
||||
return json.dumps({"error": f"{tool_name}: cursor uses different filter arguments"})
|
||||
sections = payload.get("sections")
|
||||
if not isinstance(sections, dict) or set(sections) != set(page_sections):
|
||||
return json.dumps({"error": f"{tool_name}: invalid pagination cursor"})
|
||||
for name in state:
|
||||
value = sections.get(name)
|
||||
if not isinstance(value, list) or len(value) != 2:
|
||||
return json.dumps({"error": f"{tool_name}: invalid pagination cursor"})
|
||||
tag, position = value
|
||||
if tag in {_DISCOVERY_START, _DISCOVERY_END}:
|
||||
if position is not None:
|
||||
return json.dumps({"error": f"{tool_name}: invalid pagination cursor"})
|
||||
elif tag == _DISCOVERY_AT:
|
||||
if not isinstance(position, str) or not position:
|
||||
return json.dumps({"error": f"{tool_name}: invalid pagination cursor"})
|
||||
else:
|
||||
return json.dumps({"error": f"{tool_name}: invalid pagination cursor"})
|
||||
state[name] = (tag, cast(str | None, position))
|
||||
return cast(int | None, limit), state, True
|
||||
|
||||
|
||||
def _encode_discovery_cursor(
|
||||
tool_name: str,
|
||||
filters: _JsonObject,
|
||||
state: dict[str, _DiscoveryState],
|
||||
) -> str | None:
|
||||
"""Serialize a discovery continuation cursor without exposing its shape."""
|
||||
payload = json.dumps(
|
||||
{
|
||||
"v": 1,
|
||||
"tool": tool_name,
|
||||
"filters": filters,
|
||||
"sections": {name: list(value) for name, value in state.items()},
|
||||
},
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
).encode("utf-8")
|
||||
cursor = base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=")
|
||||
return cursor if len(cursor) <= _DISCOVERY_CURSOR_MAX_CHARS else None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _DiscoveryPage:
|
||||
"""Rows and continuation state from one discovery source."""
|
||||
|
||||
rows: list[_JsonObject]
|
||||
has_more: bool
|
||||
next_after: str | None = None
|
||||
failed: bool = False
|
||||
|
||||
|
||||
def _parse_discovery_page(body: object) -> _DiscoveryPage:
|
||||
"""Validate the server envelope before trusting its continuation state."""
|
||||
if not isinstance(body, dict):
|
||||
return _DiscoveryPage([], False, failed=True)
|
||||
data = body.get("data")
|
||||
if not isinstance(data, list):
|
||||
return _DiscoveryPage([], False, failed=True)
|
||||
rows: list[_JsonObject] = []
|
||||
for row in data:
|
||||
if not isinstance(row, dict):
|
||||
return _DiscoveryPage([], False, failed=True)
|
||||
row_id = row.get("id")
|
||||
if not isinstance(row_id, str) or not row_id:
|
||||
return _DiscoveryPage([], False, failed=True)
|
||||
rows.append(row)
|
||||
has_more = body.get("has_more", False)
|
||||
last_id = body.get("last_id")
|
||||
if not isinstance(has_more, bool):
|
||||
return _DiscoveryPage([], False, failed=True)
|
||||
if last_id is not None and (not isinstance(last_id, str) or not last_id):
|
||||
return _DiscoveryPage([], False, failed=True)
|
||||
if has_more and last_id is None:
|
||||
return _DiscoveryPage([], False, failed=True)
|
||||
return _DiscoveryPage(rows, has_more, cast(str | None, last_id))
|
||||
|
||||
|
||||
def _bounded_discovery_result(
|
||||
sections: dict[str, list[_JsonObject]],
|
||||
*,
|
||||
limit: int | None,
|
||||
cursor_state: dict[str, _DiscoveryState],
|
||||
continued: bool,
|
||||
source_pages: dict[str, _DiscoveryPage],
|
||||
page_sections: tuple[str, ...] | None = None,
|
||||
tool_name: str,
|
||||
filters: _JsonObject,
|
||||
) -> str:
|
||||
"""Keep a small legacy result intact, otherwise return a fitting page."""
|
||||
complete = json.dumps(sections)
|
||||
if (
|
||||
limit is None
|
||||
and not continued
|
||||
and not any(page.failed for page in source_pages.values())
|
||||
and not any(page.has_more for page in source_pages.values())
|
||||
and len(complete) <= _DISCOVERY_LIST_OUTPUT_MAX_CHARS
|
||||
):
|
||||
return complete
|
||||
|
||||
requested_limit = limit or _DISCOVERY_LIST_MAX_LIMIT
|
||||
paged = page_sections or tuple(sections)
|
||||
|
||||
def _candidate(candidate_limit: int) -> str | None:
|
||||
page = dict(sections)
|
||||
has_more: dict[str, bool] = {}
|
||||
next_state = dict(cursor_state)
|
||||
for name in paged:
|
||||
rows = sections[name]
|
||||
page_rows = rows[:candidate_limit]
|
||||
page[name] = page_rows
|
||||
source_page = source_pages[name]
|
||||
if source_page.failed:
|
||||
# A failed read proves neither progress nor exhaustion. Keep
|
||||
# the incoming position so the continuation retries it.
|
||||
has_more[name] = True
|
||||
continue
|
||||
has_more[name] = len(rows) > candidate_limit or source_page.has_more
|
||||
if not has_more[name]:
|
||||
next_state[name] = (_DISCOVERY_END, None)
|
||||
elif page_rows:
|
||||
if name == "local_configs":
|
||||
position = _optional_string(page_rows[-1].get("path"))
|
||||
else:
|
||||
identifier = "agent_id" if name == "builtins" else "session_id"
|
||||
position = _optional_string(page_rows[-1].get(identifier))
|
||||
if position is not None:
|
||||
next_state[name] = (_DISCOVERY_AT, position)
|
||||
elif source_page.has_more and source_page.next_after is not None:
|
||||
next_state[name] = (_DISCOVERY_AT, source_page.next_after)
|
||||
metadata: dict[str, object] = {
|
||||
"limit": candidate_limit,
|
||||
"has_more": has_more,
|
||||
}
|
||||
if any(has_more.values()):
|
||||
cursor = _encode_discovery_cursor(tool_name, filters, next_state)
|
||||
if cursor is None:
|
||||
return None
|
||||
metadata["next_cursor"] = cursor
|
||||
return json.dumps({**page, "page": metadata})
|
||||
|
||||
# Cursor size depends on the last returned row, so serialized page size is
|
||||
# not monotonic in the row limit. Check the bounded public range directly.
|
||||
for candidate_limit in range(requested_limit, 0, -1):
|
||||
candidate = _candidate(candidate_limit)
|
||||
if candidate is not None and len(candidate) <= _DISCOVERY_LIST_OUTPUT_MAX_CHARS:
|
||||
return candidate
|
||||
|
||||
empty_page = _candidate(0)
|
||||
if empty_page is None:
|
||||
return json.dumps(
|
||||
{
|
||||
"error": (
|
||||
f"Discovery continuation exceeds the {_DISCOVERY_CURSOR_MAX_CHARS}-character "
|
||||
"cursor limit."
|
||||
)
|
||||
}
|
||||
)
|
||||
if len(empty_page) > _DISCOVERY_LIST_OUTPUT_MAX_CHARS:
|
||||
kind = "fixed_section"
|
||||
oversized_sections = [
|
||||
name for name, rows in sections.items() if name not in paged and rows
|
||||
]
|
||||
else:
|
||||
kind = "paginated_row"
|
||||
oversized_sections = [name for name in paged if sections[name]]
|
||||
return json.dumps(
|
||||
{
|
||||
"error": (
|
||||
f"Discovery result exceeds the {_DISCOVERY_LIST_OUTPUT_MAX_CHARS}-character "
|
||||
"output limit even at the smallest page."
|
||||
),
|
||||
"page": {"has_more": {name: source_pages[name].has_more for name in paged}},
|
||||
"oversized": {"kind": kind, "sections": oversized_sections},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Union of all locally-dispatched tools.
|
||||
_ALL_LOCAL_TOOLS = (
|
||||
_OS_ENV_TOOLS
|
||||
@@ -3738,7 +3969,24 @@ async def _execute_session_query_tool(
|
||||
return json.dumps({"error": f"{tool_name}: malformed JSON arguments"})
|
||||
|
||||
if tool_name == "sys_session_list":
|
||||
return await _session_list_via_rest(conversation_id, server_client, args.get("agent_name"))
|
||||
agent_name = args.get("agent_name")
|
||||
window = _discovery_list_window(
|
||||
args,
|
||||
tool_name,
|
||||
("sessions",),
|
||||
{"agent_name": agent_name if isinstance(agent_name, str) and agent_name else None},
|
||||
)
|
||||
if isinstance(window, str):
|
||||
return window
|
||||
limit, cursor_state, continued = window
|
||||
return await _session_list_via_rest(
|
||||
conversation_id,
|
||||
server_client,
|
||||
agent_name,
|
||||
limit=limit,
|
||||
cursor_state=cursor_state,
|
||||
continued=continued,
|
||||
)
|
||||
if tool_name == "sys_session_get_history":
|
||||
return await _session_get_history_via_rest(args, server_client)
|
||||
if tool_name == "sys_session_get_info":
|
||||
@@ -4051,11 +4299,23 @@ async def _execute_agent_tool(
|
||||
if server_client is None:
|
||||
return json.dumps({"error": f"{tool_name} requires server access"})
|
||||
if tool_name == "sys_agent_list":
|
||||
window = _discovery_list_window(
|
||||
args,
|
||||
tool_name,
|
||||
("builtins", "session_agents", "local_configs"),
|
||||
{},
|
||||
)
|
||||
if isinstance(window, str):
|
||||
return window
|
||||
limit, cursor_state, continued = window
|
||||
return await _agent_list_via_rest(
|
||||
server_client,
|
||||
agent_spec=agent_spec,
|
||||
conversation_id=conversation_id,
|
||||
runner_workspace=runner_workspace,
|
||||
limit=limit,
|
||||
cursor_state=cursor_state,
|
||||
continued=continued,
|
||||
)
|
||||
session_id = args.get("session_id")
|
||||
if not isinstance(session_id, str) or not session_id:
|
||||
@@ -4232,9 +4492,12 @@ async def _agent_download_via_rest(
|
||||
async def _agent_list_fetch(
|
||||
path: str,
|
||||
server_client: httpx.AsyncClient,
|
||||
) -> list[_JsonObject]:
|
||||
*,
|
||||
after: str | None,
|
||||
limit: int,
|
||||
) -> _DiscoveryPage:
|
||||
"""
|
||||
Fetch one page of a paginated list endpoint, returning its ``data``.
|
||||
Fetch one cursor page of a paginated list endpoint.
|
||||
|
||||
Best-effort: returns ``[]`` on transport error or non-200 so a single
|
||||
failing source degrades ``sys_agent_list`` to "that section is empty"
|
||||
@@ -4243,21 +4506,24 @@ async def _agent_list_fetch(
|
||||
:param path: The list endpoint path, e.g. ``"/v1/agents"`` or
|
||||
``"/v1/sessions"``.
|
||||
:param server_client: HTTP client pointed at the Omnigent server.
|
||||
:returns: The ``data`` list from the paginated response (possibly
|
||||
empty).
|
||||
:param after: Server cursor from the previous page, if any.
|
||||
:param limit: Maximum number of source rows to fetch.
|
||||
:returns: Rows and server continuation metadata.
|
||||
"""
|
||||
try:
|
||||
resp = await server_client.get(
|
||||
path,
|
||||
params={"limit": _AGENT_LIST_PAGE_LIMIT, "order": "desc"},
|
||||
timeout=30.0,
|
||||
)
|
||||
params: dict[str, str | int] = {"limit": limit, "order": "desc"}
|
||||
if after is not None:
|
||||
params["after"] = after
|
||||
resp = await server_client.get(path, params=params, timeout=30.0)
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
return _DiscoveryPage([], False, failed=True)
|
||||
if resp.status_code != 200:
|
||||
return []
|
||||
body = _string_object_dict(resp.json())
|
||||
return _json_object_list(body.get("data")) if body is not None else []
|
||||
return _DiscoveryPage([], False, failed=True)
|
||||
try:
|
||||
body = resp.json()
|
||||
except (UnicodeDecodeError, ValueError, json.JSONDecodeError):
|
||||
return _DiscoveryPage([], False, failed=True)
|
||||
return _parse_discovery_page(body)
|
||||
|
||||
|
||||
def _scan_local_agent_configs(configs_dir: Path) -> list[_JsonObject]:
|
||||
@@ -4419,6 +4685,9 @@ async def _agent_list_via_rest(
|
||||
agent_spec: AgentSpec | None,
|
||||
conversation_id: str | None,
|
||||
runner_workspace: Path | None,
|
||||
limit: int | None,
|
||||
cursor_state: dict[str, _DiscoveryState],
|
||||
continued: bool,
|
||||
) -> str:
|
||||
"""
|
||||
List launchable agents across built-ins, session-bound, and local.
|
||||
@@ -4448,19 +4717,70 @@ async def _agent_list_via_rest(
|
||||
resolution of the local-config scan.
|
||||
:param conversation_id: The caller's session id, for os_env cwd.
|
||||
:param runner_workspace: The runner workspace, authoritative cwd.
|
||||
:returns: JSON ``{builtins, session_agents, local_configs}``.
|
||||
:param limit: Optional maximum rows returned from each source. When
|
||||
omitted, the complete legacy result is preserved while it fits.
|
||||
:param cursor_state: Opaque continuation positions for each source.
|
||||
:returns: The legacy complete JSON result while it fits, otherwise a
|
||||
bounded page with continuation metadata.
|
||||
"""
|
||||
builtins_raw = await _agent_list_fetch("/v1/agents", server_client)
|
||||
sessions_raw = await _agent_list_fetch("/v1/sessions", server_client)
|
||||
source_limit = limit or _AGENT_LIST_PAGE_LIMIT
|
||||
builtins_page = (
|
||||
_DiscoveryPage([], False)
|
||||
if cursor_state["builtins"][0] == _DISCOVERY_END
|
||||
else await _agent_list_fetch(
|
||||
"/v1/agents",
|
||||
server_client,
|
||||
after=cursor_state["builtins"][1],
|
||||
limit=source_limit,
|
||||
)
|
||||
)
|
||||
sessions_page = (
|
||||
_DiscoveryPage([], False)
|
||||
if cursor_state["session_agents"][0] == _DISCOVERY_END
|
||||
else await _agent_list_fetch(
|
||||
"/v1/sessions",
|
||||
server_client,
|
||||
after=cursor_state["session_agents"][1],
|
||||
limit=source_limit,
|
||||
)
|
||||
)
|
||||
spec = _effective_runner_os_env_spec(agent_spec, conversation_id, runner_workspace)
|
||||
assert spec.cwd is not None
|
||||
configs_dir = Path(spec.cwd) / _AGENT_CONFIG_SUBDIR
|
||||
local_configs = await asyncio.to_thread(_scan_local_agent_configs, configs_dir)
|
||||
listing = _project_agent_list(builtins_raw, sessions_raw, local_configs)
|
||||
local_state, local_after = cursor_state["local_configs"]
|
||||
if local_state == _DISCOVERY_END:
|
||||
remaining_configs = []
|
||||
elif local_after is not None:
|
||||
remaining_configs = [
|
||||
row for row in local_configs if str(row.get("path", "")) > local_after
|
||||
]
|
||||
else:
|
||||
remaining_configs = local_configs
|
||||
listing = _project_agent_list(
|
||||
builtins_page.rows,
|
||||
sessions_page.rows,
|
||||
remaining_configs[:source_limit],
|
||||
)
|
||||
listing["builtins"] = _in_spawn_family(
|
||||
listing["builtins"], await _spawn_family(server_client, conversation_id)
|
||||
)
|
||||
return json.dumps(listing)
|
||||
return _bounded_discovery_result(
|
||||
listing,
|
||||
limit=limit,
|
||||
cursor_state=cursor_state,
|
||||
continued=continued,
|
||||
tool_name="sys_agent_list",
|
||||
filters={},
|
||||
source_pages={
|
||||
"builtins": builtins_page,
|
||||
"session_agents": sessions_page,
|
||||
"local_configs": _DiscoveryPage(
|
||||
listing["local_configs"],
|
||||
len(listing["local_configs"]) < len(remaining_configs),
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _project_agent_list(
|
||||
@@ -4511,6 +4831,10 @@ async def _session_list_via_rest(
|
||||
conversation_id: str,
|
||||
server_client: httpx.AsyncClient,
|
||||
agent_name: object = None,
|
||||
*,
|
||||
limit: int | None,
|
||||
cursor_state: dict[str, _DiscoveryState],
|
||||
continued: bool,
|
||||
) -> str:
|
||||
"""
|
||||
Return the two-view session list: ``sub_agents`` + global ``sessions``.
|
||||
@@ -4528,11 +4852,33 @@ async def _session_list_via_rest(
|
||||
:param server_client: HTTP client pointed at the Omnigent server.
|
||||
:param agent_name: Optional agent-name filter for the global
|
||||
``sessions`` view; ignored for ``sub_agents``.
|
||||
:returns: JSON ``{"sub_agents": [...], "sessions": [...]}``.
|
||||
:param limit: Optional maximum rows returned from the global sessions view. When
|
||||
omitted, the complete legacy result is preserved while it fits.
|
||||
:param cursor_state: Opaque continuation position for the global sessions view.
|
||||
:returns: The legacy complete JSON result while it fits, otherwise a
|
||||
bounded page with continuation metadata.
|
||||
"""
|
||||
sub_agents = await _collect_sub_agents(conversation_id, server_client)
|
||||
sessions = await _collect_global_sessions(server_client, agent_name)
|
||||
return json.dumps({"sub_agents": sub_agents, "sessions": sessions})
|
||||
sessions_page = (
|
||||
_DiscoveryPage([], False)
|
||||
if cursor_state["sessions"][0] == _DISCOVERY_END
|
||||
else await _collect_global_sessions(
|
||||
server_client,
|
||||
agent_name,
|
||||
after=cursor_state["sessions"][1],
|
||||
limit=limit or _AGENT_LIST_PAGE_LIMIT,
|
||||
)
|
||||
)
|
||||
return _bounded_discovery_result(
|
||||
{"sub_agents": cast(list[_JsonObject], sub_agents), "sessions": sessions_page.rows},
|
||||
limit=limit,
|
||||
cursor_state=cursor_state,
|
||||
continued=continued,
|
||||
tool_name="sys_session_list",
|
||||
filters={"agent_name": agent_name if isinstance(agent_name, str) and agent_name else None},
|
||||
source_pages={"sessions": sessions_page},
|
||||
page_sections=("sessions",),
|
||||
)
|
||||
|
||||
|
||||
async def _rename_current_session_via_rest(
|
||||
@@ -4668,7 +5014,10 @@ async def _resolve_runner_online_map(
|
||||
async def _collect_global_sessions(
|
||||
server_client: httpx.AsyncClient,
|
||||
agent_name: object,
|
||||
) -> list[_JsonObject]:
|
||||
*,
|
||||
after: str | None,
|
||||
limit: int,
|
||||
) -> _DiscoveryPage:
|
||||
"""
|
||||
Fetch the global session list via ``GET /v1/sessions``, with connectivity.
|
||||
|
||||
@@ -4683,38 +5032,50 @@ async def _collect_global_sessions(
|
||||
:param server_client: HTTP client pointed at the Omnigent server.
|
||||
:param agent_name: Optional agent-name filter; applied only when a
|
||||
non-empty string.
|
||||
:returns: The projected global session entries.
|
||||
:param after: Server cursor from the previous page, if any.
|
||||
:param limit: Maximum number of source rows to fetch.
|
||||
:returns: Projected global session entries and continuation metadata.
|
||||
"""
|
||||
params: dict[str, str | int] = {"limit": _AGENT_LIST_PAGE_LIMIT, "order": "desc"}
|
||||
params: dict[str, str | int] = {"limit": limit, "order": "desc"}
|
||||
if isinstance(agent_name, str) and agent_name:
|
||||
params["agent_name"] = agent_name
|
||||
if after is not None:
|
||||
params["after"] = after
|
||||
try:
|
||||
resp = await server_client.get("/v1/sessions", params=params, timeout=30.0)
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
return _DiscoveryPage([], False, failed=True)
|
||||
if resp.status_code != 200:
|
||||
return []
|
||||
body = _string_object_dict(resp.json())
|
||||
if body is None:
|
||||
return []
|
||||
rows = _json_object_list(body.get("data"))
|
||||
return _DiscoveryPage([], False, failed=True)
|
||||
try:
|
||||
body = resp.json()
|
||||
except (UnicodeDecodeError, ValueError, json.JSONDecodeError):
|
||||
return _DiscoveryPage([], False, failed=True)
|
||||
page = _parse_discovery_page(body)
|
||||
if page.failed:
|
||||
return page
|
||||
rows = page.rows
|
||||
online = await _resolve_runner_online_map(rows, server_client)
|
||||
return [
|
||||
{
|
||||
"session_id": r.get("id"),
|
||||
# Hide the internal ``-native-ui`` wrapper name (e.g.
|
||||
# ``pi-native-ui`` -> ``Pi``) in the global listing too, matching
|
||||
# ``sys_session_get_info``. The server-side ``agent_name`` filter
|
||||
# above still receives the caller's raw argument unchanged.
|
||||
"agent_name": public_agent_name(_optional_string(r.get("agent_name"))),
|
||||
"title": r.get("title"),
|
||||
"status": r.get("status"),
|
||||
"runner_id": r.get("runner_id"),
|
||||
"runner_online": online.get(_optional_string(r.get("runner_id")) or ""),
|
||||
"parent_session_id": r.get("parent_session_id"),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return _DiscoveryPage(
|
||||
[
|
||||
{
|
||||
"session_id": r.get("id"),
|
||||
# Hide the internal ``-native-ui`` wrapper name (e.g.
|
||||
# ``pi-native-ui`` -> ``Pi``) in the global listing too, matching
|
||||
# ``sys_session_get_info``. The server-side ``agent_name`` filter
|
||||
# above still receives the caller's raw argument unchanged.
|
||||
"agent_name": public_agent_name(_optional_string(r.get("agent_name"))),
|
||||
"title": r.get("title"),
|
||||
"status": r.get("status"),
|
||||
"runner_id": r.get("runner_id"),
|
||||
"runner_online": online.get(_optional_string(r.get("runner_id")) or ""),
|
||||
"parent_session_id": r.get("parent_session_id"),
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
page.has_more,
|
||||
page.next_after,
|
||||
)
|
||||
|
||||
|
||||
def _child_rows_to_entries(
|
||||
@@ -6998,6 +7359,53 @@ async def _execute_task_lifecycle_tool(
|
||||
)
|
||||
|
||||
|
||||
async def _post_session_stop(server_client: httpx.AsyncClient, task_id: str) -> str | None:
|
||||
"""Hard-stop one claude-native child through the server."""
|
||||
try:
|
||||
resp = await server_client.post(
|
||||
f"/v1/sessions/{task_id}/events",
|
||||
json={"type": "stop_session", "data": {}},
|
||||
timeout=30.0,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
return f"Error: sys_cancel_task stop_session failed: {type(exc).__name__}: {exc}"
|
||||
if resp.status_code >= 400:
|
||||
return (
|
||||
f"Error: sys_cancel_task stop_session returned {resp.status_code}: {resp.text[:200]}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def _cancel_evicted_claude_native_subagent(
|
||||
task_id: str,
|
||||
*,
|
||||
conversation_id: str,
|
||||
server_client: httpx.AsyncClient | None,
|
||||
) -> str:
|
||||
"""Stop an owned claude-native child after its work entry was evicted."""
|
||||
if server_client is None:
|
||||
return "Error: sys_cancel_task requires server access for sub-agent tasks"
|
||||
try:
|
||||
resp = await server_client.get(f"/v1/sessions/{task_id}", timeout=10.0)
|
||||
except httpx.HTTPError as exc:
|
||||
return f"Error: sys_cancel_task lookup failed: {type(exc).__name__}: {exc}"
|
||||
if resp.status_code != 200:
|
||||
return f"Error: no in-flight task with task_id {task_id}"
|
||||
snapshot = resp.json()
|
||||
labels = snapshot.get("labels") if isinstance(snapshot, dict) else None
|
||||
if (
|
||||
not isinstance(snapshot, dict)
|
||||
or snapshot.get("parent_session_id") != conversation_id
|
||||
or not isinstance(labels, dict)
|
||||
or labels.get(_SESSION_WRAPPER_LABEL_KEY) != CLAUDE_NATIVE_WRAPPER_VALUE
|
||||
):
|
||||
return f"Error: no in-flight task with task_id {task_id}"
|
||||
stop_error = await _post_session_stop(server_client, task_id)
|
||||
if stop_error is not None:
|
||||
return stop_error
|
||||
return json.dumps({"cancelled": True, "task_id": task_id, "status": "cancelled"})
|
||||
|
||||
|
||||
async def _cancel_subagent_task(
|
||||
args: _JsonObject,
|
||||
*,
|
||||
@@ -7040,14 +7448,22 @@ async def _cancel_subagent_task(
|
||||
if conversation_id is None:
|
||||
return "Error: sys_cancel_task requires conversation_id"
|
||||
entry = _runner_app.get_subagent_work(str(task_id))
|
||||
if entry is None or entry.parent_session_id != conversation_id:
|
||||
if entry is None:
|
||||
return await _cancel_evicted_claude_native_subagent(
|
||||
str(task_id),
|
||||
conversation_id=conversation_id,
|
||||
server_client=server_client,
|
||||
)
|
||||
if entry.parent_session_id != conversation_id:
|
||||
return f"Error: no in-flight task with task_id {task_id}"
|
||||
# A dispatched child sits in ``launching`` until its runtime emits a real
|
||||
# busy edge (see ``mark_subagent_work_started``). Cancellation must still
|
||||
# route to the child during that window — otherwise cancelling a slow-to-
|
||||
# start sub-agent would silently no-op and leave it running. Only terminal
|
||||
# states (``completed`` / ``failed`` / ``cancelled``) short-circuit here.
|
||||
if entry.status not in ("launching", "running", "waiting"):
|
||||
# start sub-agent would silently no-op and leave it running. A failed
|
||||
# claude-native entry still falls through because its pane may be alive.
|
||||
is_claude_native = entry.wrapper_label == CLAUDE_NATIVE_WRAPPER_VALUE
|
||||
can_stop_failed_claude = is_claude_native and entry.status == "failed"
|
||||
if entry.status not in ("launching", "running", "waiting") and not can_stop_failed_claude:
|
||||
return json.dumps(
|
||||
{
|
||||
"cancelled": entry.status == "cancelled",
|
||||
@@ -7060,9 +7476,7 @@ async def _cancel_subagent_task(
|
||||
|
||||
# claude-native is the only harness with a runner-side hard-stop; every
|
||||
# other harness 204 no-ops on stop_session, so route them to interrupt.
|
||||
event_type = (
|
||||
"stop_session" if entry.wrapper_label == CLAUDE_NATIVE_WRAPPER_VALUE else "interrupt"
|
||||
)
|
||||
event_type = "stop_session" if is_claude_native else "interrupt"
|
||||
|
||||
try:
|
||||
resp = await server_client.post(
|
||||
|
||||
@@ -53,6 +53,7 @@ from omnigent.runner.transports.ws_tunnel.limits import (
|
||||
TUNNEL_KEEPALIVE_PING_INTERVAL_S,
|
||||
TUNNEL_KEEPALIVE_PING_TIMEOUT_S,
|
||||
)
|
||||
from omnigent.suspend_watch import watch_for_resume
|
||||
from omnigent.tls import client_ssl_context
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
@@ -354,6 +355,15 @@ async def serve_tunnel(
|
||||
login_redirect_streak = 0
|
||||
http_auth_rejection_streak = 0
|
||||
|
||||
# Set by the per-connection suspend watcher (in _serve_tunnel_once) when it
|
||||
# aborts the live tunnel after a wake from system suspend. Read at the
|
||||
# bottom of the loop to force a prompt reconnect (skip the backoff).
|
||||
woke_from_suspend = False
|
||||
|
||||
def _note_resume_from_suspend() -> None:
|
||||
nonlocal woke_from_suspend
|
||||
woke_from_suspend = True
|
||||
|
||||
while True:
|
||||
if shutdown_event is not None and shutdown_event.is_set():
|
||||
# A shutdown requested between reconnect attempts (no live
|
||||
@@ -380,6 +390,7 @@ async def serve_tunnel(
|
||||
shutdown_event=shutdown_event,
|
||||
on_graceful_shutdown=on_graceful_shutdown,
|
||||
on_connected=_mark_connected,
|
||||
on_resume_note=_note_resume_from_suspend,
|
||||
direct_attach_port=direct_attach_port,
|
||||
direct_attach_token=direct_attach_token,
|
||||
**activity_kwargs,
|
||||
@@ -491,6 +502,15 @@ async def serve_tunnel(
|
||||
retry_reason = str(exc)
|
||||
except (ConnectionError, OSError, ValueError) as exc:
|
||||
retry_reason = str(exc)
|
||||
if woke_from_suspend:
|
||||
# A wake from system suspend already aborted the live tunnel (see
|
||||
# _serve_tunnel_once's watcher). The abrupt close would otherwise
|
||||
# ride the escalating backoff; force a prompt reconnect at the base
|
||||
# delay, like a server recycle, so the session reattaches at once.
|
||||
woke_from_suspend = False
|
||||
delay_s = _INITIAL_RECONNECT_DELAY_S
|
||||
recycle = True
|
||||
retry_reason = "resumed from system suspend; reconnecting promptly"
|
||||
jittered = delay_s * (
|
||||
1.0 + random.uniform(-_RECONNECT_JITTER_FRACTION, _RECONNECT_JITTER_FRACTION)
|
||||
)
|
||||
@@ -652,6 +672,7 @@ async def _serve_tunnel_once(
|
||||
shutdown_event: asyncio.Event | None = None,
|
||||
on_graceful_shutdown: Callable[[], None] | None = None,
|
||||
on_connected: Callable[[], None] | None = None,
|
||||
on_resume_note: Callable[[], None] | None = None,
|
||||
direct_attach_port: int | None = None,
|
||||
direct_attach_token: str | None = None,
|
||||
) -> None:
|
||||
@@ -681,6 +702,10 @@ async def _serve_tunnel_once(
|
||||
:param on_connected: Optional sync callback fired once the WS
|
||||
upgrade is accepted. ``serve_tunnel`` uses it to distinguish a
|
||||
runner that has authenticated from one that never has.
|
||||
:param on_resume_note: Optional sync callback fired when a wake from
|
||||
system suspend is detected on this connection (just before the dead
|
||||
socket is aborted). ``serve_tunnel`` uses it to force a prompt
|
||||
reconnect instead of the escalating backoff.
|
||||
:returns: None.
|
||||
"""
|
||||
import websockets
|
||||
@@ -742,6 +767,32 @@ async def _serve_tunnel_once(
|
||||
direct_attach_token=direct_attach_token,
|
||||
)
|
||||
_logger.info("runner %s connected to %s", runner_id, tunnel_url)
|
||||
|
||||
def _on_resume_from_suspend(gap_s: float) -> None:
|
||||
# Wake from system suspend: this socket is now half-open (the server
|
||||
# already dropped it), so abort it to make the read below raise at
|
||||
# once instead of waiting out the ~90s keepalive timeout;
|
||||
# serve_tunnel then reconnects promptly. Skip during a graceful
|
||||
# idle-reaper drain — aborting mid-drain would turn the clean
|
||||
# end-of-stream close into an abrupt runner_disconnected.
|
||||
if shutdown_event is not None and shutdown_event.is_set():
|
||||
return
|
||||
_logger.info(
|
||||
"runner %s resumed from suspend (~%.0fs); dropping tunnel to reconnect",
|
||||
runner_id,
|
||||
gap_s,
|
||||
)
|
||||
if on_resume_note is not None:
|
||||
on_resume_note()
|
||||
transport = getattr(ws, "transport", None)
|
||||
if transport is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
transport.abort()
|
||||
|
||||
suspend_task = asyncio.create_task(
|
||||
watch_for_resume(_on_resume_from_suspend),
|
||||
name=f"runner-suspend-watch:{runner_id}",
|
||||
)
|
||||
try:
|
||||
if shutdown_event is None:
|
||||
async for raw in ws:
|
||||
@@ -820,6 +871,9 @@ async def _serve_tunnel_once(
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await shutdown_wait
|
||||
finally:
|
||||
suspend_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await suspend_task
|
||||
await _cancel_dispatch_tasks(dispatch_tasks)
|
||||
await _cancel_ws_channels(ws_channels)
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import logging
|
||||
import secrets
|
||||
import uuid
|
||||
from collections import deque
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Sequence
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Response
|
||||
@@ -171,6 +171,15 @@ class ExecutorAdapter(HarnessApp):
|
||||
executor._tool_executor = self._stable_tool_executor # type: ignore[attr-defined]
|
||||
if getattr(executor, "_elicitation_handler", None) is None:
|
||||
executor._elicitation_handler = self._stable_elicitation_handler # type: ignore[attr-defined]
|
||||
# ACP-shaped executors also accept a choice bridge, to offer the agent's own
|
||||
# permission scopes; the others don't define the attribute at all.
|
||||
if (
|
||||
hasattr(executor, "_elicitation_choice_handler")
|
||||
and executor._elicitation_choice_handler is None # type: ignore[attr-defined]
|
||||
):
|
||||
executor._elicitation_choice_handler = ( # type: ignore[attr-defined]
|
||||
self._stable_elicitation_choice_handler
|
||||
)
|
||||
if getattr(executor, "_policy_evaluator", None) is None:
|
||||
executor._policy_evaluator = self._stable_policy_evaluator # type: ignore[attr-defined]
|
||||
self._current_ctx = ctx
|
||||
@@ -544,6 +553,25 @@ class ExecutorAdapter(HarnessApp):
|
||||
return False
|
||||
|
||||
elicitation_id = f"elicit_{secrets.token_hex(16)}"
|
||||
params = self._permission_card(tool_name, tool_input)
|
||||
result = await ctx.elicit(elicitation_id, params)
|
||||
if result.action == "decline":
|
||||
# Signal via ctx.cancelled (the SDK swallows exceptions from control-request tasks).
|
||||
ctx.cancelled.set()
|
||||
return result.action == "accept"
|
||||
|
||||
def _permission_card(
|
||||
self,
|
||||
tool_name: str,
|
||||
tool_input: dict[str, Any],
|
||||
*,
|
||||
requested_schema: dict[str, Any] | None = None,
|
||||
) -> ElicitationRequestParams:
|
||||
"""Build the approval-card params for a tool-permission elicitation.
|
||||
|
||||
With *requested_schema* the card renders one button per choice instead of
|
||||
Approve/Reject; without it, the usual binary card.
|
||||
"""
|
||||
# Build a concise preview: truncate long args so the UI widget
|
||||
# stays readable. 300 chars matches AP's policy-engine preview.
|
||||
try:
|
||||
@@ -553,21 +581,61 @@ class ExecutorAdapter(HarnessApp):
|
||||
preview = preview[:300]
|
||||
|
||||
label = self._harness_label
|
||||
policy_name = f"{label.lower()}_sdk_permission"
|
||||
params = ElicitationRequestParams(
|
||||
return ElicitationRequestParams(
|
||||
mode="form",
|
||||
message=f"{label} wants to use **{tool_name}**",
|
||||
requestedSchema=None,
|
||||
requestedSchema=requested_schema,
|
||||
url=None,
|
||||
phase="tool_call",
|
||||
policy_name=policy_name,
|
||||
policy_name=f"{label.lower()}_sdk_permission",
|
||||
content_preview=f"{tool_name}({preview})",
|
||||
)
|
||||
|
||||
async def _stable_elicitation_choice_handler(
|
||||
self,
|
||||
tool_name: str,
|
||||
tool_input: dict[str, Any],
|
||||
options: Sequence[str],
|
||||
) -> str | None:
|
||||
"""Stable bridge for a multiple-choice tool-permission elicitation.
|
||||
|
||||
Same gate as :meth:`_stable_elicitation_handler`, but the card offers the
|
||||
harness's own permission scopes as buttons and the chosen label comes back,
|
||||
so the user can grant the scope the agent already supports instead of
|
||||
re-approving the same action every time.
|
||||
|
||||
:returns: The chosen label, or ``None`` when declined, cancelled, or the
|
||||
reply carried no readable answer.
|
||||
"""
|
||||
ctx = self._current_ctx
|
||||
if ctx is None:
|
||||
# No active turn — decline by default, as the binary card does.
|
||||
_logger.error(
|
||||
"elicitation choice callback fired with no active turn context "
|
||||
"(tool=%s); declining by default",
|
||||
tool_name,
|
||||
)
|
||||
return None
|
||||
|
||||
elicitation_id = f"elicit_{secrets.token_hex(16)}"
|
||||
# An ``answer`` enum is what the approval card renders as one button per
|
||||
# option, and the reply names the chosen label in ``content["answer"]``.
|
||||
params = self._permission_card(
|
||||
tool_name,
|
||||
tool_input,
|
||||
requested_schema={
|
||||
"type": "object",
|
||||
"properties": {"answer": {"type": "string", "enum": list(options)}},
|
||||
"required": ["answer"],
|
||||
},
|
||||
)
|
||||
result = await ctx.elicit(elicitation_id, params)
|
||||
if result.action == "decline":
|
||||
# Signal via ctx.cancelled (the SDK swallows exceptions from control-request tasks).
|
||||
ctx.cancelled.set()
|
||||
return result.action == "accept"
|
||||
if result.action != "accept":
|
||||
if result.action == "decline":
|
||||
ctx.cancelled.set()
|
||||
return None
|
||||
answer = (result.content or {}).get("answer")
|
||||
return answer if isinstance(answer, str) else None
|
||||
|
||||
async def _stable_policy_evaluator(
|
||||
self,
|
||||
|
||||
@@ -989,6 +989,20 @@ class HarnessProcessManager:
|
||||
"""
|
||||
return conversation_id in self._in_flight_response_ids
|
||||
|
||||
def note_activity(self, conversation_id: str) -> None:
|
||||
"""Refresh the idle lease for an existing harness subprocess.
|
||||
|
||||
Native terminal turns do not pass through ``proxy_stream``, so their
|
||||
terminal activity calls this method instead. No-op when the
|
||||
conversation has no registered subprocess.
|
||||
|
||||
:param conversation_id: AP-allocated conversation id,
|
||||
e.g. ``"conv_abc123"``.
|
||||
"""
|
||||
entry = self._entries.get(conversation_id)
|
||||
if entry is not None:
|
||||
entry.last_used_at = time.monotonic()
|
||||
|
||||
def mark_in_flight(self, conversation_id: str, response_id: str) -> None:
|
||||
"""
|
||||
Record that *conversation_id* has a live harness turn.
|
||||
|
||||
@@ -1556,6 +1556,12 @@ def _build_acp_cli_spawn_env(
|
||||
os_env_payload = _serialize_os_env(spec.os_env)
|
||||
if os_env_payload is not None:
|
||||
env["HARNESS_ACP_OS_ENV"] = os_env_payload
|
||||
# Permission stance for approval cards. Absent leaves the harness wrap on its
|
||||
# ``auto`` default (prompt); ``bypassPermissions`` skips the card for a call no
|
||||
# policy had an opinion on, so a headless ACP worker doesn't park on a prompt.
|
||||
permission_mode = spec.executor.config.get("permission_mode")
|
||||
if permission_mode is not None:
|
||||
env["HARNESS_ACP_PERMISSION_MODE"] = str(permission_mode)
|
||||
return env
|
||||
|
||||
|
||||
@@ -1668,6 +1674,12 @@ def _build_acp_spawn_env(
|
||||
os_env_payload = _serialize_os_env(spec.os_env)
|
||||
if os_env_payload is not None:
|
||||
env["HARNESS_ACP_OS_ENV"] = os_env_payload
|
||||
# Permission stance for approval cards. Absent leaves the harness wrap on its
|
||||
# ``auto`` default (prompt); ``bypassPermissions`` skips the card for a call no
|
||||
# policy had an opinion on, so a headless ACP worker doesn't park on a prompt.
|
||||
permission_mode = spec.executor.config.get("permission_mode")
|
||||
if permission_mode is not None:
|
||||
env["HARNESS_ACP_PERMISSION_MODE"] = str(permission_mode)
|
||||
return env
|
||||
|
||||
|
||||
@@ -1735,6 +1747,18 @@ def _config_flag_is_true(value: object) -> bool:
|
||||
return str(value).strip().lower() in {"1", "true", "yes"}
|
||||
|
||||
|
||||
def _set_openai_agents_reasoning_item_id_policy_env(
|
||||
env: dict[str, str],
|
||||
value: object | None,
|
||||
) -> None:
|
||||
"""Validate and encode the OpenAI Agents SDK reasoning replay policy."""
|
||||
if value is None:
|
||||
return
|
||||
if not isinstance(value, str) or value not in {"preserve", "omit"}:
|
||||
raise ValueError("reasoning_item_id_policy must be 'preserve', 'omit', or unset")
|
||||
env["HARNESS_OPENAI_AGENTS_REASONING_ITEM_ID_POLICY"] = value
|
||||
|
||||
|
||||
def _build_openai_agents_sdk_spawn_env(spec: AgentSpec) -> dict[str, str]:
|
||||
"""
|
||||
Build the env-var dict the openai-agents harness wrap reads.
|
||||
@@ -1742,7 +1766,7 @@ def _build_openai_agents_sdk_spawn_env(spec: AgentSpec) -> dict[str, str]:
|
||||
Maps spec.executor fields → the ``HARNESS_OPENAI_AGENTS_*``
|
||||
env vars defined in
|
||||
``omnigent/inner/openai_agents_sdk_harness.py``. Threads
|
||||
model + auth + use_responses.
|
||||
model + auth + Responses replay settings.
|
||||
|
||||
Auth resolution order (highest priority first):
|
||||
|
||||
@@ -1770,6 +1794,10 @@ def _build_openai_agents_sdk_spawn_env(spec: AgentSpec) -> dict[str, str]:
|
||||
model = _resolve_spec_model(spec)
|
||||
if model is not None:
|
||||
env["HARNESS_OPENAI_AGENTS_MODEL"] = model
|
||||
_set_openai_agents_reasoning_item_id_policy_env(
|
||||
env,
|
||||
spec.executor.config.get("reasoning_item_id_policy"),
|
||||
)
|
||||
|
||||
# ── Auth resolution ────────────────────────────────────────────────
|
||||
# Priority: generic provider → spec.executor.auth → global config auth →
|
||||
|
||||
+38
-10
@@ -1268,6 +1268,7 @@ def create_app(
|
||||
agent_store=agent_store,
|
||||
conversation_store=conversation_store,
|
||||
permission_store=permission_store,
|
||||
policy_store=policy_store,
|
||||
host_store=host_store,
|
||||
host_registry=host_registry,
|
||||
agent_cache=agent_cache,
|
||||
@@ -2720,6 +2721,22 @@ def create_app(
|
||||
# auth routes and ``/v1/me`` share one roster. Consulted on each login
|
||||
# to promote listed identities — the only admin path for OIDC, and an
|
||||
# additive convenience for accounts.
|
||||
# Login-issued refresh grants: both server-mintable providers
|
||||
# (accounts, oidc) get a grant store so `omnigent login` can hand
|
||||
# the CLI refresh material — without it, an unattended host dies
|
||||
# permanently at session-JWT expiry (default 8 h). The store also
|
||||
# backs the opt-in RFC 8628 device flow below.
|
||||
device_grant_store = None
|
||||
if (
|
||||
isinstance(auth_provider, UnifiedAuthProvider)
|
||||
and auth_provider._source in ("accounts", "oidc")
|
||||
and permission_store is not None
|
||||
):
|
||||
from omnigent.server.device_grant_store import DeviceGrantStore
|
||||
|
||||
device_grant_store = DeviceGrantStore(permission_store.storage_location)
|
||||
auth_provider.set_grant_revocation_check(device_grant_store.is_revoked)
|
||||
|
||||
if (
|
||||
isinstance(auth_provider, UnifiedAuthProvider)
|
||||
and auth_provider._source == "accounts"
|
||||
@@ -2731,7 +2748,10 @@ def create_app(
|
||||
|
||||
app.include_router(
|
||||
create_accounts_auth_router(
|
||||
auth_provider, account_store, admin_list, permission_store
|
||||
auth_provider,
|
||||
account_store,
|
||||
admin_list,
|
||||
permission_store,
|
||||
),
|
||||
prefix="/auth",
|
||||
tags=["auth"],
|
||||
@@ -2762,6 +2782,7 @@ def create_app(
|
||||
admin_list,
|
||||
oidc_account_store,
|
||||
allowed_domains=frozenset(allowed_domains or ()) or None,
|
||||
device_grant_store=device_grant_store,
|
||||
),
|
||||
prefix="/auth",
|
||||
tags=["auth"],
|
||||
@@ -2773,24 +2794,20 @@ def create_app(
|
||||
)
|
||||
|
||||
# Device Authorization Grant (RFC 8628): opt-in, default-off via
|
||||
# OMNIGENT_DEVICE_GRANT_ENABLED, and accounts-mode only. OIDC delegates
|
||||
# login to the IdP (cli-ticket flow), so it neither needs nor mounts
|
||||
# these routes. Wires the revocation lookup into the auth provider so
|
||||
# revoking a grant immediately rejects its delegated access tokens.
|
||||
# See designs/DEVICE_AUTH.md.
|
||||
# OMNIGENT_DEVICE_GRANT_ENABLED, and accounts-mode only (the
|
||||
# in-browser consent flow needs the accounts login page). Wires
|
||||
# the full /oauth/* surface including the token endpoint. See
|
||||
# designs/DEVICE_AUTH.md.
|
||||
from omnigent.server.auth import env_var_is_truthy
|
||||
|
||||
if (
|
||||
env_var_is_truthy("OMNIGENT_DEVICE_GRANT_ENABLED", default=False)
|
||||
and isinstance(auth_provider, UnifiedAuthProvider)
|
||||
and auth_provider._source == "accounts"
|
||||
and permission_store is not None
|
||||
and device_grant_store is not None
|
||||
):
|
||||
from omnigent.server.device_grant_store import DeviceGrantStore
|
||||
from omnigent.server.routes.device_auth import create_device_auth_router
|
||||
|
||||
device_grant_store = DeviceGrantStore(permission_store.storage_location)
|
||||
auth_provider.set_grant_revocation_check(device_grant_store.is_revoked)
|
||||
app.include_router(
|
||||
create_device_auth_router(auth_provider, device_grant_store),
|
||||
tags=["oauth"],
|
||||
@@ -2811,6 +2828,17 @@ def create_app(
|
||||
"the server and its trusted client(s) to restrict initiation "
|
||||
"to authorized clients. See designs/DEVICE_AUTH.md.",
|
||||
)
|
||||
elif isinstance(auth_provider, UnifiedAuthProvider) and device_grant_store is not None:
|
||||
# No device flow, but login-issued refresh grants still need
|
||||
# their token/revoke endpoints — in OIDC mode and in accounts
|
||||
# mode without the flag alike.
|
||||
from omnigent.server.routes.device_auth import create_oauth_token_router
|
||||
|
||||
app.include_router(
|
||||
create_oauth_token_router(auth_provider, device_grant_store),
|
||||
tags=["oauth"],
|
||||
)
|
||||
_logger.info("login-grant: /oauth/token + /oauth/revoke enabled")
|
||||
|
||||
# Mount the built web SPA at "/" if a build is present. The SPA is
|
||||
# built into ``omnigent/server/static/web-ui/`` by ``web/``'s Vite
|
||||
|
||||
+15
-6
@@ -53,6 +53,9 @@ _TRUTHY_STRINGS = ("1", "true", "yes")
|
||||
# any path not covered here, so it can never touch admin / user-management
|
||||
# endpoints (``/auth/users``, ``/auth/invite``, ``/auth/setup`` …) even if
|
||||
# its underlying identity is an admin. Delegated clients only need these.
|
||||
# First-party login-grant tokens carry no ``scope`` and are NOT restricted
|
||||
# here — they renew the session JWT and keep its authority (see
|
||||
# ``_check_cookie`` and ``routes/device_auth.LOGIN_GRANT_CLIENT_ID``).
|
||||
_DELEGATED_ALLOWED_PREFIXES = (
|
||||
"/health",
|
||||
"/v1/agents",
|
||||
@@ -601,18 +604,24 @@ class UnifiedAuthProvider(AuthProvider):
|
||||
if not isinstance(user_id, str) or not user_id or user_id in _RESERVED_USERS:
|
||||
return None
|
||||
|
||||
# Delegated (device-grant) tokens carry a ``grant_id`` claim.
|
||||
# They get two extra, request-scoped checks — a fail-closed path
|
||||
# allowlist and a live revocation lookup — so they are never
|
||||
# served from the plain user-id cache (which would skip both).
|
||||
# Grant-derived tokens carry a ``grant_id`` claim. They get
|
||||
# request-scoped checks — a live revocation lookup, plus (for
|
||||
# restricted tokens) a fail-closed path allowlist — so they are
|
||||
# never served from the plain user-id cache (which would skip both).
|
||||
grant_id = payload.get("grant_id")
|
||||
if grant_id is not None:
|
||||
if not isinstance(grant_id, str):
|
||||
return None
|
||||
if not delegated_path_allowed(request.url.path):
|
||||
return None
|
||||
if self._grant_revoked is not None and self._grant_revoked(grant_id):
|
||||
return None
|
||||
# The allowlist restricts DELEGATED tokens — a third-party
|
||||
# client (e.g. Slack) acting on a user's behalf, marked by the
|
||||
# ``scope`` claim. A first-party login grant carries no scope:
|
||||
# its bearer is the user's own CLI/host, and the token renews
|
||||
# the session JWT it replaced, so it keeps that same authority
|
||||
# (still revocable via ``grant_id`` above).
|
||||
if payload.get("scope") is not None and not delegated_path_allowed(request.url.path):
|
||||
return None
|
||||
return user_id
|
||||
|
||||
# Cache for remaining lifetime of the token.
|
||||
|
||||
@@ -25,6 +25,7 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
from typing import cast
|
||||
|
||||
from sqlalchemy import and_, delete, or_, update
|
||||
@@ -135,6 +136,56 @@ class DeviceGrantStore:
|
||||
session.flush()
|
||||
return _to_device_grant(row)
|
||||
|
||||
def create_redeemed_grant(
|
||||
self,
|
||||
grant_id: str,
|
||||
*,
|
||||
user_id: str,
|
||||
client_id: str | None,
|
||||
refresh_token_hash: str,
|
||||
created_at: int,
|
||||
) -> DeviceGrant:
|
||||
"""Persist a grant born ``redeemed`` — no device-code consent step.
|
||||
|
||||
Backs login-issued refresh grants: the user just proved their
|
||||
identity interactively (IdP browser flow or password prompt), so
|
||||
the RFC 8628 pending → approved dance would re-ask for consent
|
||||
already given. The row starts ``redeemed`` with its refresh-token
|
||||
digest set, exactly as if it had completed the device flow.
|
||||
|
||||
The device_code/user_code columns are filled with discarded
|
||||
random material: no client ever polls with them, and ``pending``
|
||||
purge conditions never match a ``redeemed`` row.
|
||||
|
||||
:param grant_id: Opaque grant id (public — travels in JWTs).
|
||||
:param user_id: The authenticated identity (the token ``sub``).
|
||||
:param client_id: Public client name for audit, e.g.
|
||||
``"omnigent-cli"``.
|
||||
:param refresh_token_hash: HMAC digest of the initial refresh
|
||||
token. The store never sees the raw token.
|
||||
:param created_at: Unix epoch seconds; also ``approved_at``, the
|
||||
anchor for the grant's absolute lifetime.
|
||||
:returns: The created :class:`DeviceGrant`.
|
||||
"""
|
||||
with self._session("insert_redeemed_device_grant") as session:
|
||||
row = SqlDeviceGrant(
|
||||
id=grant_id,
|
||||
device_code_hash=secrets.token_urlsafe(32),
|
||||
user_code=secrets.token_urlsafe(16),
|
||||
status=encode_device_grant_status("redeemed"),
|
||||
client_id=client_id,
|
||||
user_id=user_id,
|
||||
refresh_token_hash=refresh_token_hash,
|
||||
prev_refresh_token_hash=None,
|
||||
created_at=created_at,
|
||||
expires_at=created_at,
|
||||
approved_at=created_at,
|
||||
last_polled_at=None,
|
||||
)
|
||||
session.add(row)
|
||||
session.flush()
|
||||
return _to_device_grant(row)
|
||||
|
||||
def get_by_user_code(self, user_code: str) -> DeviceGrant | None:
|
||||
"""Look up a grant by its short verification code.
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import dataclasses
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from omnigent.db.utils import shared_read_scope
|
||||
from omnigent.entities import Conversation
|
||||
from omnigent.errors import ErrorCode, OmnigentError
|
||||
from omnigent.server.auth import (
|
||||
@@ -290,52 +291,58 @@ def _require_access_and_level_sync(
|
||||
code=ErrorCode.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
# Single round-trip: admin flag + the user's and public grants on the
|
||||
# conversation the caller asked about. The displayed level is the direct
|
||||
# grant (no parent walk), matching get_permission_level exactly.
|
||||
access = permission_store.resolve_access(user_id, conversation_id)
|
||||
level = resolved_level(access)
|
||||
# One read-only burst: the permission resolve, the conversation lookup,
|
||||
# and any parent-chain walk all share a single pool checkout instead of
|
||||
# one per store call. On the per-streamed-event path this is re-run for a
|
||||
# session whose data is stable for the turn, so the checkout — plus
|
||||
# ``pool_pre_ping`` — is the cost that matters.
|
||||
with shared_read_scope():
|
||||
# Single round-trip: admin flag + the user's and public grants on the
|
||||
# conversation the caller asked about. The displayed level is the direct
|
||||
# grant (no parent walk), matching get_permission_level exactly.
|
||||
access = permission_store.resolve_access(user_id, conversation_id)
|
||||
level = resolved_level(access)
|
||||
|
||||
# Admins bypass the conversation lookup entirely (mirrors
|
||||
# check_session_access's admin short-circuit, which never reads the
|
||||
# conversation). A missing conversation is left for the snapshot builder
|
||||
# to 404 on, exactly as today.
|
||||
if access.is_admin:
|
||||
return SessionAccess(level=level, conversation=None)
|
||||
# Admins bypass the conversation lookup entirely (mirrors
|
||||
# check_session_access's admin short-circuit, which never reads the
|
||||
# conversation). A missing conversation is left for the snapshot builder
|
||||
# to 404 on, exactly as today.
|
||||
if access.is_admin:
|
||||
return SessionAccess(level=level, conversation=None)
|
||||
|
||||
conv = conversation_store.get_conversation(conversation_id)
|
||||
if conv is None:
|
||||
raise OmnigentError(
|
||||
"Conversation not found",
|
||||
code=ErrorCode.NOT_FOUND,
|
||||
)
|
||||
conv = conversation_store.get_conversation(conversation_id)
|
||||
if conv is None:
|
||||
raise OmnigentError(
|
||||
"Conversation not found",
|
||||
code=ErrorCode.NOT_FOUND,
|
||||
)
|
||||
|
||||
if conv.parent_conversation_id is None:
|
||||
# Top-level session: the access-governing grant lives on this same
|
||||
# conversation, so reuse the rows already fetched — no extra reads.
|
||||
allowed = resolved_allows(access, required_level)
|
||||
else:
|
||||
# Sub-agent: access delegates to the parent chain. Defer to the
|
||||
# canonical recursive checker (its own reads); sub-agents are rare
|
||||
# and the parent's grants are a different conversation's rows.
|
||||
allowed = check_session_access(
|
||||
user_id,
|
||||
conv.parent_conversation_id,
|
||||
required_level,
|
||||
permission_store,
|
||||
conversation_store,
|
||||
)
|
||||
if allowed:
|
||||
return SessionAccess(level=level, conversation=conv)
|
||||
if conv.parent_conversation_id is None:
|
||||
# Top-level session: the access-governing grant lives on this same
|
||||
# conversation, so reuse the rows already fetched — no extra reads.
|
||||
allowed = resolved_allows(access, required_level)
|
||||
else:
|
||||
# Sub-agent: access delegates to the parent chain. Defer to the
|
||||
# canonical recursive checker (its own reads); sub-agents are rare
|
||||
# and the parent's grants are a different conversation's rows.
|
||||
allowed = check_session_access(
|
||||
user_id,
|
||||
conv.parent_conversation_id,
|
||||
required_level,
|
||||
permission_store,
|
||||
conversation_store,
|
||||
)
|
||||
if allowed:
|
||||
return SessionAccess(level=level, conversation=conv)
|
||||
|
||||
# Denied — distinguish "has some access but not enough" (403) from
|
||||
# "no access at all" (404, to avoid leaking session existence).
|
||||
if conv.parent_conversation_id is None:
|
||||
has_any = resolved_allows(access, 1)
|
||||
else:
|
||||
has_any = check_session_access(
|
||||
user_id, conv.parent_conversation_id, 1, permission_store, conversation_store
|
||||
)
|
||||
# Denied — distinguish "has some access but not enough" (403) from
|
||||
# "no access at all" (404, to avoid leaking session existence).
|
||||
if conv.parent_conversation_id is None:
|
||||
has_any = resolved_allows(access, 1)
|
||||
else:
|
||||
has_any = check_session_access(
|
||||
user_id, conv.parent_conversation_id, 1, permission_store, conversation_store
|
||||
)
|
||||
if has_any:
|
||||
level_name = _LEVEL_NAMES.get(required_level, str(required_level))
|
||||
raise OmnigentError(
|
||||
|
||||
@@ -129,6 +129,12 @@ _EXTERNAL_SESSION_USAGE_TYPE: str = "external_session_usage"
|
||||
_EXTERNAL_MODEL_CHANGE_TYPE: str = "external_model_change"
|
||||
|
||||
|
||||
_EXTERNAL_PERMISSION_MODE_CHANGE_TYPE: str = "external_permission_mode_change"
|
||||
|
||||
|
||||
_EXTERNAL_SESSION_TITLE_TYPE: str = "external_session_title"
|
||||
|
||||
|
||||
_EXTERNAL_MODEL_OPTIONS_TYPE: str = "external_model_options"
|
||||
|
||||
|
||||
@@ -186,6 +192,21 @@ _EXTERNAL_CODEX_APPROVAL_MODE_CHANGE_TYPE: str = "external_codex_approval_mode_c
|
||||
_CODEX_NATIVE_COLLABORATION_MODES: frozenset[str] = frozenset({"default", "plan"})
|
||||
|
||||
|
||||
# Current permission mode of a live claude-native session.
|
||||
# ``terminal_launch_args`` records only the launch mode, so this label is what
|
||||
# the web UI reads back after a reload.
|
||||
_CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY = "omnigent.claude_native.permission_mode"
|
||||
|
||||
|
||||
# Permission modes switchable on a running session — the ones Claude
|
||||
# Code's shift+tab cycle can reach. Mirrors
|
||||
# ``claude_native_bridge.CYCLEABLE_PERMISSION_MODES``; ``dontAsk`` and
|
||||
# ``bypassPermissions`` are launch-only and rejected on PATCH.
|
||||
_CLAUDE_NATIVE_PERMISSION_MODES: frozenset[str] = frozenset(
|
||||
{"default", "acceptEdits", "plan", "auto"}
|
||||
)
|
||||
|
||||
|
||||
_CODEX_NATIVE_SUBAGENT_DISPLAY_FALLBACK = "Codex"
|
||||
|
||||
|
||||
@@ -416,7 +437,9 @@ _ALLOWED_EVENT_TYPES: frozenset[str] = frozenset(ITEM_TYPE_TO_DATA_CLS.keys()) |
|
||||
_EXTERNAL_MCP_STARTUP_TYPE,
|
||||
_EXTERNAL_MODEL_CHANGE_TYPE,
|
||||
_EXTERNAL_MODEL_OPTIONS_TYPE,
|
||||
_EXTERNAL_PERMISSION_MODE_CHANGE_TYPE,
|
||||
_EXTERNAL_REASONING_EFFORT_CHANGE_TYPE,
|
||||
_EXTERNAL_SESSION_TITLE_TYPE,
|
||||
_EXTERNAL_SESSION_TODOS_TYPE,
|
||||
_EXTERNAL_SUBAGENT_START_TYPE,
|
||||
_EXTERNAL_CODEX_SUBAGENT_START_TYPE,
|
||||
@@ -793,6 +816,8 @@ __all__ = [
|
||||
"_CLAUDE_NATIVE_MESSAGE_TIMEOUT_S",
|
||||
"_CLAUDE_NATIVE_MODEL",
|
||||
"_CLAUDE_NATIVE_PERMISSION_HOOK_TIMEOUT_S",
|
||||
"_CLAUDE_NATIVE_PERMISSION_MODES",
|
||||
"_CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY",
|
||||
"_CLAUDE_NATIVE_REMEMBER_INELIGIBLE_TOOLS",
|
||||
"_CLAUDE_NATIVE_SUBAGENT_ID_LABEL_KEY",
|
||||
"_CLAUDE_NATIVE_SUBAGENT_WRAPPER_LABEL_VALUE",
|
||||
@@ -838,11 +863,13 @@ __all__ = [
|
||||
"_EXTERNAL_MODEL_OPTIONS_TYPE",
|
||||
"_EXTERNAL_OUTPUT_REASONING_DELTA_TYPE",
|
||||
"_EXTERNAL_OUTPUT_TEXT_DELTA_TYPE",
|
||||
"_EXTERNAL_PERMISSION_MODE_CHANGE_TYPE",
|
||||
"_EXTERNAL_REASONING_EFFORT_CHANGE_TYPE",
|
||||
"_EXTERNAL_SESSION_INTERRUPTED_TYPE",
|
||||
"_EXTERNAL_SESSION_STATUS_TYPE",
|
||||
"_EXTERNAL_SESSION_STATUS_VALUES",
|
||||
"_EXTERNAL_SESSION_SUPERSEDED_TYPE",
|
||||
"_EXTERNAL_SESSION_TITLE_TYPE",
|
||||
"_EXTERNAL_SESSION_TODOS_TYPE",
|
||||
"_EXTERNAL_SESSION_USAGE_TYPE",
|
||||
"_EXTERNAL_STATUS_ASSISTANT_SCAN_LIMIT",
|
||||
|
||||
@@ -136,6 +136,8 @@ from omnigent.server.routes._sessions.common import ( # noqa: F401
|
||||
_CLAUDE_NATIVE_DESCRIPTION_LABEL_KEY,
|
||||
_CLAUDE_NATIVE_EDIT_TOOLS,
|
||||
_CLAUDE_NATIVE_HARNESS,
|
||||
_CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY,
|
||||
_CLAUDE_NATIVE_PERMISSION_MODES,
|
||||
_CLAUDE_NATIVE_REMEMBER_INELIGIBLE_TOOLS,
|
||||
_CLAUDE_NATIVE_SUBAGENT_ID_LABEL_KEY,
|
||||
_CLAUDE_NATIVE_SUBAGENT_WRAPPER_LABEL_VALUE,
|
||||
@@ -249,6 +251,7 @@ from omnigent.server.schemas import (
|
||||
SessionMcpStartupEvent,
|
||||
SessionModelEvent,
|
||||
SessionModelOptionsEvent,
|
||||
SessionPermissionModeEvent,
|
||||
SessionReasoningEffortEvent,
|
||||
SessionResourceListPage,
|
||||
SessionResourcePaginatedList,
|
||||
@@ -257,6 +260,7 @@ from omnigent.server.schemas import (
|
||||
SessionStatusEvent,
|
||||
SessionSupersededEvent,
|
||||
SessionTerminalPendingEvent,
|
||||
SessionTitleEvent,
|
||||
SessionTodosEvent,
|
||||
SkillSummary,
|
||||
ToolOutputDeltaEvent,
|
||||
@@ -310,6 +314,22 @@ def _publish_collaboration_mode(session_id: str, mode: str) -> None:
|
||||
session_stream.publish(session_id, event.model_dump())
|
||||
|
||||
|
||||
def _publish_permission_mode(session_id: str, mode: str) -> None:
|
||||
"""
|
||||
Publish the live claude-native permission mode for a session.
|
||||
|
||||
:param session_id: Session/conversation identifier, e.g. ``"conv_abc123"``.
|
||||
:param mode: The active permission mode, e.g. ``"auto"``.
|
||||
:returns: None.
|
||||
"""
|
||||
event = SessionPermissionModeEvent(
|
||||
type="session.permission_mode",
|
||||
conversation_id=session_id,
|
||||
permission_mode=mode,
|
||||
)
|
||||
session_stream.publish(session_id, event.model_dump())
|
||||
|
||||
|
||||
def _publish_policy_denied(session_id: str, reason: str, phase: str) -> None:
|
||||
"""
|
||||
Publish a native policy-DENY signal on the session stream.
|
||||
@@ -2125,28 +2145,32 @@ async def _persist_external_model_change(
|
||||
conversation_store: ConversationStore,
|
||||
) -> None:
|
||||
"""
|
||||
Persist and broadcast a model switch made inside the terminal.
|
||||
Persist and broadcast the model the harness reports it is running.
|
||||
|
||||
Mirrors a ``/model`` change typed into a claude-native session's
|
||||
Claude Code pane (or picked via its in-TUI model picker) onto the
|
||||
Omnigent session: writes ``model_override`` so the value survives reload
|
||||
and publishes a ``session.model`` SSE event so the web picker
|
||||
updates live. Unlike the PATCH path
|
||||
(:func:`update_session`), this deliberately does NOT forward a
|
||||
``model_change`` back to the runner — the terminal is already on
|
||||
the model, so re-injecting ``/model`` would loop.
|
||||
Mirrors a harness-side model report — the launch's own model, or a
|
||||
``/model`` change made inside the pane — onto the Omnigent session:
|
||||
writes ``reported_model`` VERBATIM (the harness's own spelling,
|
||||
never collapsed to a picker alias) so the value survives reload,
|
||||
and publishes a ``session.model`` SSE event so every surface
|
||||
re-renders from it. The user's request (``model_override``) is
|
||||
deliberately untouched: requests and reports are separate roles,
|
||||
and only reports are ever displayed. Unlike the PATCH path
|
||||
(:func:`update_session`), this does NOT forward a ``model_change``
|
||||
back to the runner — the terminal is already on the model, so
|
||||
re-injecting ``/model`` would loop.
|
||||
|
||||
No-ops (no write, no event) when the observed model already equals
|
||||
the persisted ``model_override`` — the common case on the web→TUI
|
||||
round-trip where the web PATCH set the override moments earlier.
|
||||
No-ops (no write, no event) when the reported model already equals
|
||||
the persisted ``reported_model`` — the steady state between real
|
||||
changes, since forwarders re-observe on every poll.
|
||||
|
||||
:param session_id: Session/conversation identifier, e.g.
|
||||
``"conv_abc123"``.
|
||||
:param conv: Conversation row for ``session_id`` (read at the route
|
||||
boundary); ``conv.model_override`` is the dedupe baseline.
|
||||
boundary); ``conv.reported_model`` is the dedupe baseline.
|
||||
:param body: External model-change event body. ``data.model`` must
|
||||
be a non-empty string tier alias, e.g. ``"opus"``.
|
||||
:param conversation_store: Store used to upsert ``model_override``.
|
||||
be a non-empty string — the harness's verbatim model, e.g.
|
||||
``"claude-opus-4-8[1m]"`` or ``"gpt-5.6-luna"``.
|
||||
:param conversation_store: Store used to upsert ``reported_model``.
|
||||
:raises OmnigentError: If ``data.model`` is missing or not a
|
||||
non-empty string.
|
||||
"""
|
||||
@@ -2157,12 +2181,12 @@ async def _persist_external_model_change(
|
||||
code=ErrorCode.INVALID_INPUT,
|
||||
)
|
||||
model = raw_model.strip()
|
||||
if conv.model_override == model:
|
||||
if conv.reported_model == model:
|
||||
return
|
||||
await asyncio.to_thread(
|
||||
conversation_store.update_conversation,
|
||||
session_id,
|
||||
model_override=model,
|
||||
reported_model=model,
|
||||
)
|
||||
event = SessionModelEvent(
|
||||
type="session.model",
|
||||
@@ -2172,6 +2196,78 @@ async def _persist_external_model_change(
|
||||
session_stream.publish(session_id, event.model_dump())
|
||||
|
||||
|
||||
async def _persist_external_session_title(
|
||||
session_id: str,
|
||||
conv: Conversation,
|
||||
body: SessionEventInput,
|
||||
conversation_store: ConversationStore,
|
||||
) -> None:
|
||||
"""
|
||||
Persist and broadcast a session rename made inside the terminal.
|
||||
|
||||
Mirrors a ``/rename`` typed into a claude-native session's Claude Code
|
||||
pane onto the Omnigent session: writes ``title`` so the new name
|
||||
survives reload and publishes a ``session.title`` SSE event so the
|
||||
web session list updates live.
|
||||
|
||||
The rename is authoritative — an operator typing ``/rename`` is an
|
||||
explicit act, so it overwrites whatever title the session currently
|
||||
carries, including one set from the web UI. This is why it uses a
|
||||
plain ``update_conversation`` rather than the seed-only
|
||||
compare-and-swap behind ``POST /sessions/{id}/auto-title``, which
|
||||
exists to stop an *automatic* titler from clobbering a human's name.
|
||||
|
||||
No-ops (no write, no event) when the title already matches, so a
|
||||
forwarder that re-sends after a cursor rewind, or a rename echoing
|
||||
back a name the web UI just set, costs nothing.
|
||||
|
||||
Declined for child sessions, whose titles are structural rather than
|
||||
display text: ``sys_session_send`` writes them as ``"<agent>:<label>"``
|
||||
and the sub-agent tooling parses them back apart. That also covers the
|
||||
legacy ``:closed:`` title marker, which only ever lands on a child row
|
||||
(both writers reject a non-sub-agent title).
|
||||
|
||||
:param session_id: Session/conversation identifier, e.g.
|
||||
``"conv_abc123"``.
|
||||
:param conv: Conversation row for ``session_id`` (read at the route
|
||||
boundary); ``conv.title`` is the dedupe baseline.
|
||||
:param body: External title event body. ``data.title`` must be a
|
||||
non-empty single-line string, e.g. ``"auth-refactor"``.
|
||||
:param conversation_store: Store used to upsert ``title``.
|
||||
:raises OmnigentError: If ``data.title`` is not a non-empty single line.
|
||||
"""
|
||||
raw_title = body.data.get("title")
|
||||
# Newlines are rejected outright rather than folded into spaces — a
|
||||
# multi-line title means the sender is confused, not that it wants one
|
||||
# long line. Mirrors ``POST /sessions/{id}/auto-title``.
|
||||
if not isinstance(raw_title, str) or "\n" in raw_title or "\r" in raw_title:
|
||||
raise OmnigentError(
|
||||
"external_session_title requires data.title to be a single-line string",
|
||||
code=ErrorCode.INVALID_INPUT,
|
||||
)
|
||||
title = " ".join(raw_title.split())
|
||||
if not title:
|
||||
raise OmnigentError(
|
||||
"external_session_title requires data.title to be non-empty",
|
||||
code=ErrorCode.INVALID_INPUT,
|
||||
)
|
||||
if conv.parent_conversation_id is not None:
|
||||
return
|
||||
if conv.title == title:
|
||||
return
|
||||
await asyncio.to_thread(
|
||||
conversation_store.update_conversation,
|
||||
session_id,
|
||||
title=title,
|
||||
)
|
||||
event = SessionTitleEvent(
|
||||
type="session.title",
|
||||
conversation_id=session_id,
|
||||
title=title,
|
||||
)
|
||||
session_stream.publish(session_id, event.model_dump())
|
||||
|
||||
|
||||
def _persist_external_model_options(
|
||||
session_id: str,
|
||||
conv: Conversation,
|
||||
@@ -2352,6 +2448,51 @@ async def _persist_external_codex_collaboration_mode_change(
|
||||
_publish_collaboration_mode(session_id, mode)
|
||||
|
||||
|
||||
async def _persist_external_permission_mode_change(
|
||||
session_id: str,
|
||||
conv: Conversation,
|
||||
body: SessionEventInput,
|
||||
conversation_store: ConversationStore,
|
||||
) -> None:
|
||||
"""
|
||||
Persist a pane-observed claude-native permission mode as a session label.
|
||||
|
||||
The forwarder posts this when the pane's mode footer differs from what it
|
||||
last reported — i.e. the user pressed shift+tab in the TUI. Unlike the
|
||||
PATCH path this needs no runner confirmation: the pane IS the source, so
|
||||
the mode is already in effect.
|
||||
|
||||
:param session_id: Session/conversation identifier, e.g. ``"conv_abc123"``.
|
||||
:param conv: Conversation row for ``session_id`` at the route boundary.
|
||||
:param body: Event body; ``data.permission_mode`` must be a switchable mode.
|
||||
:param conversation_store: Store used to upsert the mode label.
|
||||
:returns: None.
|
||||
:raises OmnigentError: If ``data.permission_mode`` is missing or unsupported.
|
||||
"""
|
||||
raw_mode = body.data.get("permission_mode")
|
||||
if not isinstance(raw_mode, str) or not raw_mode.strip():
|
||||
raise OmnigentError(
|
||||
"external_permission_mode_change requires data.permission_mode "
|
||||
"to be a non-empty string",
|
||||
code=ErrorCode.INVALID_INPUT,
|
||||
)
|
||||
mode = raw_mode.strip()
|
||||
if mode not in _CLAUDE_NATIVE_PERMISSION_MODES:
|
||||
raise OmnigentError(
|
||||
"external_permission_mode_change requires data.permission_mode in "
|
||||
f"{sorted(_CLAUDE_NATIVE_PERMISSION_MODES)}; got {mode!r}",
|
||||
code=ErrorCode.INVALID_INPUT,
|
||||
)
|
||||
if conv.labels.get(_CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY) == mode:
|
||||
return
|
||||
await asyncio.to_thread(
|
||||
conversation_store.set_labels,
|
||||
session_id,
|
||||
{_CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY: mode},
|
||||
)
|
||||
_publish_permission_mode(session_id, mode)
|
||||
|
||||
|
||||
async def _persist_external_codex_approval_mode_change(
|
||||
session_id: str,
|
||||
conv: Conversation,
|
||||
@@ -3652,6 +3793,58 @@ def _require_collaboration_mode_forward(
|
||||
)
|
||||
|
||||
|
||||
def _require_permission_mode_forward(
|
||||
session_id: str,
|
||||
mode: str,
|
||||
runner_result: _RunnerForwardResult | None,
|
||||
) -> str:
|
||||
"""
|
||||
Fail when a live claude-native permission-mode switch wasn't applied.
|
||||
|
||||
The mode lives in the running TUI, so persisting the label without a
|
||||
confirmed 2xx forward would let the UI claim auto mode while Claude still
|
||||
prompts on every edit. Returns the mode the runner actually reached, so
|
||||
the caller stores what the pane shows rather than what was asked for.
|
||||
|
||||
:param session_id: Session/conversation identifier, e.g.
|
||||
``"conv_abc123"``.
|
||||
:param mode: Requested permission mode, e.g. ``"auto"``.
|
||||
:param runner_result: HTTP result returned by the runner, or ``None``
|
||||
when no runner could be reached.
|
||||
:returns: The mode the runner reports the pane is now in — the
|
||||
requested *mode* when the runner didn't echo one back.
|
||||
:raises OmnigentError: If no runner was reachable or the runner could
|
||||
not switch the session into *mode*.
|
||||
"""
|
||||
if runner_result is None:
|
||||
raise OmnigentError(
|
||||
f"Could not switch to {mode} mode: no live Claude runner is available "
|
||||
f"for session {session_id!r}. Reconnect the session and try again.",
|
||||
code=ErrorCode.RUNNER_UNAVAILABLE,
|
||||
)
|
||||
if not 200 <= runner_result.status_code < 300:
|
||||
# The runner's body carries why the cycle failed (e.g. the mode
|
||||
# isn't in this session's cycle); surface it so the UI banner
|
||||
# explains the failure instead of showing a bare status code.
|
||||
detail = ""
|
||||
try:
|
||||
payload = json.loads(runner_result.body)
|
||||
except (TypeError, ValueError):
|
||||
payload = None
|
||||
if isinstance(payload, dict) and isinstance(payload.get("detail"), str):
|
||||
detail = f" {payload['detail']}"
|
||||
raise OmnigentError(
|
||||
f"Could not switch to {mode} mode for session {session_id!r}.{detail}",
|
||||
code=ErrorCode.RUNNER_UNAVAILABLE,
|
||||
)
|
||||
try:
|
||||
body = json.loads(runner_result.body)
|
||||
except (TypeError, ValueError):
|
||||
return mode
|
||||
settled = body.get("permission_mode") if isinstance(body, dict) else None
|
||||
return settled if isinstance(settled, str) and settled else mode
|
||||
|
||||
|
||||
def _publish_status(
|
||||
session_id: str,
|
||||
status: str,
|
||||
@@ -5149,6 +5342,13 @@ def _surface_model_change_forward_failure(
|
||||
if 200 <= runner_result.status_code < 300:
|
||||
return
|
||||
reason = f"the runner returned status {runner_result.status_code}"
|
||||
# The runner's own detail names the concrete cause (e.g. "a dialog may
|
||||
# be open in the pane"); carry it into the visible notice when present.
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
parsed_body = json.loads(runner_result.body)
|
||||
detail = parsed_body.get("detail") if isinstance(parsed_body, dict) else None
|
||||
if isinstance(detail, str) and detail.strip():
|
||||
reason = f"{reason} ({detail.strip()})"
|
||||
_logger.warning(
|
||||
"Model change not applied to the terminal for session=%s model=%r: %s (body=%s)",
|
||||
session_id,
|
||||
@@ -9178,14 +9378,19 @@ async def _load_model_options(
|
||||
runner_client: httpx.AsyncClient,
|
||||
session_id: str,
|
||||
path: str,
|
||||
fallback_path: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Background single-flight fetch of a session's native model catalog.
|
||||
|
||||
:param runner_client: HTTP client pointed at the bound runner.
|
||||
:param session_id: Session/conversation identifier, e.g. ``"conv_abc"``.
|
||||
:param path: Runner route to query, e.g.
|
||||
``"/v1/sessions/conv_abc/cursor-model-options"``.
|
||||
:param path: Runner route to query — the unified
|
||||
``"/v1/sessions/conv_abc/model-options"``.
|
||||
:param fallback_path: Legacy harness-named route to fall back to when
|
||||
*path* 404s (an older runner without the unified route), e.g.
|
||||
``"/v1/sessions/conv_abc/cursor-model-options"``. ``None`` disables
|
||||
the fallback.
|
||||
"""
|
||||
# Read the retry schedule off the facade so tests patching
|
||||
# ``sessions._MODEL_OPTIONS_RETRY_DELAYS_S`` reach this impl.
|
||||
@@ -9199,6 +9404,11 @@ async def _load_model_options(
|
||||
_logger.debug("Runner model-options query failed for %s", session_id)
|
||||
return
|
||||
if resp.status_code != 200:
|
||||
# An older runner has no unified route; drop to the harness-named
|
||||
# one without consuming a retry.
|
||||
if resp.status_code == 404 and fallback_path and path != fallback_path:
|
||||
path = fallback_path
|
||||
continue
|
||||
# 503 means the native backend (Codex app-server bridge / cursor
|
||||
# login) is still booting. Keep the background single-flight alive
|
||||
# so the web picker fills without a second manual refresh.
|
||||
@@ -9328,7 +9538,10 @@ def prefetch_session_routing_catalogs(
|
||||
options_task = asyncio.create_task(
|
||||
_run_catalog_prefetch(
|
||||
_load_model_options(
|
||||
runner_client, session_id, f"/v1/sessions/{session_id}/{endpoint}"
|
||||
runner_client,
|
||||
session_id,
|
||||
f"/v1/sessions/{session_id}/model-options",
|
||||
fallback_path=f"/v1/sessions/{session_id}/{endpoint}",
|
||||
),
|
||||
session_id,
|
||||
)
|
||||
@@ -9526,7 +9739,9 @@ __all__ = [
|
||||
"_persist_external_codex_collaboration_mode_change",
|
||||
"_persist_external_model_change",
|
||||
"_persist_external_model_options",
|
||||
"_persist_external_permission_mode_change",
|
||||
"_persist_external_reasoning_effort_change",
|
||||
"_persist_external_session_title",
|
||||
"_persist_external_subagent_start",
|
||||
"_persist_native_policy_notice",
|
||||
"_persist_policy_deny_sentinel",
|
||||
@@ -9561,6 +9776,7 @@ __all__ = [
|
||||
"_publish_interrupted",
|
||||
"_publish_mcp_startup",
|
||||
"_publish_model_options",
|
||||
"_publish_permission_mode",
|
||||
"_publish_policy_denied",
|
||||
"_publish_policy_deny",
|
||||
"_publish_runner_skills",
|
||||
@@ -9586,6 +9802,7 @@ __all__ = [
|
||||
"_require_declared_subagent",
|
||||
"_require_external_status_forward",
|
||||
"_require_host_conn_for_worktree",
|
||||
"_require_permission_mode_forward",
|
||||
"_reset_runner_resources_after_switch",
|
||||
"_reset_runner_resources_after_switch_impl",
|
||||
"_resolve_harness",
|
||||
|
||||
@@ -1221,7 +1221,13 @@ def _accumulate_session_usage(
|
||||
llm_model = (
|
||||
usage_model
|
||||
if isinstance(usage_model, str) and usage_model
|
||||
else (conv.model_override if conv and conv.model_override else _resolve_llm_model(conv))
|
||||
else (
|
||||
conv.reported_model
|
||||
if conv and conv.reported_model
|
||||
else (
|
||||
conv.model_override if conv and conv.model_override else _resolve_llm_model(conv)
|
||||
)
|
||||
)
|
||||
)
|
||||
if llm_model:
|
||||
if isinstance(provider_cost, (int, float)):
|
||||
@@ -2538,7 +2544,7 @@ async def _mark_runner_sessions_offline_impl(
|
||||
# turn edges), falling back to the row for a session whose live state
|
||||
# was published before a restart.
|
||||
live = _session_status_cache.get(conv.id, conv.live_status)
|
||||
interrupted = live in ("running", "waiting")
|
||||
interrupted = live in _MID_TURN_STATUSES
|
||||
dead_on_arrival = fail_idle_top_level and conv.kind != "sub_agent"
|
||||
if not interrupted and not dead_on_arrival:
|
||||
continue
|
||||
@@ -4253,7 +4259,12 @@ async def _refresh_stale_native_model_options(
|
||||
if inflight is None:
|
||||
endpoint = _MODEL_OPTIONS_ENDPOINT_BY_WRAPPER[_CLAUDE_NATIVE_WRAPPER_LABEL_VALUE]
|
||||
inflight = asyncio.create_task(
|
||||
_load_model_options(runner_client, session_id, f"/v1/sessions/{session_id}/{endpoint}")
|
||||
_load_model_options(
|
||||
runner_client,
|
||||
session_id,
|
||||
f"/v1/sessions/{session_id}/model-options",
|
||||
fallback_path=f"/v1/sessions/{session_id}/{endpoint}",
|
||||
)
|
||||
)
|
||||
_model_options_inflight[session_id] = inflight
|
||||
inflight.add_done_callback(
|
||||
@@ -5589,6 +5600,12 @@ async def _dispatch_session_event_to_runner_impl(
|
||||
RUNNER_DISCONNECT_GRACE_S: float = 10.0
|
||||
# Delay between relay stream reconnect attempts inside the grace window.
|
||||
_RELAY_RETRY_INTERVAL_S: float = 0.5
|
||||
# Session statuses that mean a turn was in flight. A runner going away
|
||||
# only interrupts work in one of these states; from any other state the
|
||||
# departure is a benign disconnect, carried by liveness rather than a
|
||||
# failure. ``waiting`` counts because the turn's background work (shells,
|
||||
# sub-agents) outlives the turn and dies with the runner.
|
||||
_MID_TURN_STATUSES = ("running", "waiting")
|
||||
|
||||
|
||||
class _RelayTransportLost(Exception):
|
||||
@@ -5604,6 +5621,48 @@ class _RelayTransportLost(Exception):
|
||||
self.intentional = intentional
|
||||
|
||||
|
||||
async def _runner_drop_interrupted_turn(
|
||||
session_id: str,
|
||||
conversation_store: ConversationStore,
|
||||
) -> bool:
|
||||
"""
|
||||
Report whether a departing runner caught this session mid-turn.
|
||||
|
||||
Prefers the relay-fed cache — the replica holding the runner's tunnel
|
||||
saw the turn edges — and falls back to the row for a session whose live
|
||||
state was published before a restart, so a deploy mid-turn does not
|
||||
downgrade a real interruption to a benign one.
|
||||
|
||||
An unreadable or missing row leaves the question open, and this runs
|
||||
inside the disconnect handler: answering "not mid-turn" there would
|
||||
both swallow the failure and let the error escape the handler, killing
|
||||
the relay without publishing anything — the silent truncation the
|
||||
failed status exists to prevent. So an indeterminate answer reports the
|
||||
drop, as the ungated relay always did.
|
||||
|
||||
:param session_id: Session/conversation identifier,
|
||||
e.g. ``"conv_abc123"``.
|
||||
:param conversation_store: Store used to read the durable live status.
|
||||
:returns: ``True`` when a turn was in flight
|
||||
(:data:`_MID_TURN_STATUSES`) or the state is indeterminate.
|
||||
"""
|
||||
cached = _session_status_cache.get(session_id)
|
||||
if cached is not None:
|
||||
return cached in _MID_TURN_STATUSES
|
||||
try:
|
||||
conv = await asyncio.to_thread(conversation_store.get_conversation, session_id)
|
||||
except Exception: # noqa: BLE001 — an unreadable row must not kill the relay
|
||||
_logger.warning(
|
||||
"Relay: live-status read failed for session=%s; reporting the drop",
|
||||
session_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return True
|
||||
if conv is None:
|
||||
return True
|
||||
return conv.live_status in _MID_TURN_STATUSES
|
||||
|
||||
|
||||
async def _relay_runner_stream(
|
||||
session_id: str,
|
||||
runner_client: httpx.AsyncClient,
|
||||
@@ -5616,9 +5675,14 @@ async def _relay_runner_stream(
|
||||
Transport drops from ingress recycles and sleep-wake reconnects
|
||||
re-register the runner within :data:`RUNNER_DISCONNECT_GRACE_S`, so a
|
||||
lost stream retries inside that window instead of failing the
|
||||
session. The ``failed`` status (with durable ``runner_disconnected``
|
||||
labels) publishes only when the runner stays gone past the grace; an
|
||||
intentional Stop still exits quietly at once.
|
||||
session. An intentional Stop exits quietly at once.
|
||||
|
||||
Past the grace the runner is genuinely gone, and only a session it
|
||||
caught mid-turn (:func:`_runner_drop_interrupted_turn`) gets the
|
||||
``failed`` status and durable ``runner_disconnected`` labels — the same
|
||||
rule :func:`_mark_runner_sessions_offline_impl` applies to the runner's
|
||||
other sessions. An idle session had no work to interrupt, so it stays
|
||||
idle and the disconnect surfaces through liveness instead.
|
||||
|
||||
:param session_id: Session/conversation identifier,
|
||||
e.g. ``"conv_abc123"``.
|
||||
@@ -5674,6 +5738,20 @@ async def _relay_runner_stream(
|
||||
None,
|
||||
conversation_store,
|
||||
)
|
||||
elif not await _runner_drop_interrupted_turn(session_id, conversation_store):
|
||||
# The runner went away while this session sat idle (host
|
||||
# asleep, host restart, `omnigent host` stopped). Nothing was
|
||||
# interrupted, so there is no error to report: publishing one
|
||||
# lit a red "connection to the host dropped" banner over a
|
||||
# session that had simply finished its last turn. The absence
|
||||
# is already carried by liveness (``clear_runner_liveness``),
|
||||
# which drives the reconnect affordance. Stay silent — no
|
||||
# status edge, and no clearing of labels either, so a genuine
|
||||
# earlier failure keeps its error.
|
||||
_logger.info(
|
||||
"Relay: runner gone for idle session=%s; no failure to report",
|
||||
session_id,
|
||||
)
|
||||
else:
|
||||
# Publish a failed status so the client's SSE stream sees a
|
||||
# clean error event instead of silent truncation (#1114).
|
||||
@@ -8910,8 +8988,16 @@ async def _fetch_model_options(
|
||||
if cached is not None and session_id not in _model_options_stale:
|
||||
return cached
|
||||
if session_id not in _model_options_inflight:
|
||||
path = f"/v1/sessions/{session_id}/{endpoint}"
|
||||
task = asyncio.create_task(_load_model_options(runner_client, session_id, path))
|
||||
# Unified route first; the harness-named route is the fallback for
|
||||
# an older runner (deprecated aliases, removed in 0.11.0).
|
||||
task = asyncio.create_task(
|
||||
_load_model_options(
|
||||
runner_client,
|
||||
session_id,
|
||||
f"/v1/sessions/{session_id}/model-options",
|
||||
fallback_path=f"/v1/sessions/{session_id}/{endpoint}",
|
||||
)
|
||||
)
|
||||
_model_options_inflight[session_id] = task
|
||||
|
||||
def _clear_runner_options_inflight(_task: asyncio.Task[None]) -> None:
|
||||
@@ -9142,6 +9228,12 @@ async def _get_session_snapshot(
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
# The harness's own report is the display authority: when a session has
|
||||
# a verbatim ``reported_model``, it supersedes the spec-derived value on
|
||||
# the wire's ``llm_model`` field (the web renders and highlights only
|
||||
# from this).
|
||||
if conv.reported_model:
|
||||
llm_model = conv.reported_model
|
||||
# Skills are runner-owned: the bound runner discovers them against its
|
||||
# own filesystem (bundled skills + host skills under the session's
|
||||
# workspace and ``~/.claude/skills/``) — the host where the harness
|
||||
|
||||
@@ -306,11 +306,15 @@ def create_accounts_auth_router(
|
||||
# against a row that exists. Defensive-coding the dereference
|
||||
# would only mask a SqlAlchemy bug, which we want to surface.
|
||||
assert user is not None
|
||||
body_payload = {
|
||||
body_payload: dict[str, object] = {
|
||||
"token": session_jwt,
|
||||
"expires_in": _session_max_age,
|
||||
"user": {"id": user.id, "is_admin": user.is_admin},
|
||||
}
|
||||
# Browser login must never receive refresh material — only CLI/device
|
||||
# flows do (via /auth/cli-poll or device-grant callback). This is
|
||||
# enforced server-side so an XSS or form-hijack cannot obtain
|
||||
# long-lived unattended credentials.
|
||||
resp = JSONResponse(status_code=200, content=body_payload)
|
||||
_set_session_cookie(
|
||||
resp,
|
||||
|
||||
@@ -30,6 +30,7 @@ from omnigent.server.auth import (
|
||||
_RESERVED_USERS,
|
||||
UnifiedAuthProvider,
|
||||
)
|
||||
from omnigent.server.device_grant_store import DeviceGrantStore
|
||||
from omnigent.server.oidc import (
|
||||
_GITHUB_EMAILS_ENDPOINT,
|
||||
derive_code_challenge,
|
||||
@@ -37,6 +38,7 @@ from omnigent.server.oidc import (
|
||||
mint_session_cookie,
|
||||
)
|
||||
from omnigent.server.oidc_access import OidcAdmissionPolicy, resolve_allowed_domains_path
|
||||
from omnigent.server.routes.device_auth import issue_login_grant
|
||||
from omnigent.stores.permission_store import PermissionStore
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
@@ -67,11 +69,16 @@ class _CliTicket:
|
||||
fulfills the ticket. ``None`` while pending.
|
||||
:param user_id: The authenticated user's email, set when
|
||||
fulfilled. ``None`` while pending.
|
||||
:param refresh_token: Login-issued refresh grant material, set at
|
||||
fulfillment when a grant store is wired. ``None`` while pending
|
||||
or when grants are unavailable. Handed to the CLI exactly once
|
||||
by the poll response.
|
||||
"""
|
||||
|
||||
created_at: float = field(default_factory=time.time)
|
||||
token: str | None = None
|
||||
user_id: str | None = None
|
||||
refresh_token: str | None = None
|
||||
|
||||
|
||||
def create_auth_router(
|
||||
@@ -80,6 +87,7 @@ def create_auth_router(
|
||||
admin_list: AdminList,
|
||||
account_store: SqlAlchemyAccountStore | None = None,
|
||||
allowed_domains: frozenset[str] | None = None,
|
||||
device_grant_store: DeviceGrantStore | None = None,
|
||||
) -> APIRouter:
|
||||
"""Create an :class:`APIRouter` with OIDC login/callback/logout routes.
|
||||
|
||||
@@ -100,6 +108,12 @@ def create_auth_router(
|
||||
``allowed_domains:`` key, union'd with
|
||||
``OMNIGENT_OIDC_ALLOWED_DOMAINS`` and the runtime-editable file
|
||||
in the admission policy.
|
||||
:param device_grant_store: When set, a CLI-ticket login also issues
|
||||
a refresh grant (see
|
||||
:func:`omnigent.server.routes.device_auth.issue_login_grant`)
|
||||
so hosts and CLIs can renew without a human re-running
|
||||
``omnigent login``. ``None`` keeps the legacy
|
||||
session-JWT-only response.
|
||||
:returns: A FastAPI router with ``/login``, ``/callback``,
|
||||
``/logout`` (and ``/invite`` when invites are enabled).
|
||||
"""
|
||||
@@ -367,6 +381,19 @@ def create_auth_router(
|
||||
ticket = _cli_tickets[ticket_id]
|
||||
ticket.token = session_jwt
|
||||
ticket.user_id = email
|
||||
# A CLI login is a long-lived unattended credential holder
|
||||
# (hosts especially) — issue a refresh grant so it can renew
|
||||
# instead of dying at session-JWT expiry. Best-effort: a
|
||||
# grant-store failure must not break login itself.
|
||||
if device_grant_store is not None:
|
||||
try:
|
||||
ticket.refresh_token = issue_login_grant(
|
||||
device_grant_store,
|
||||
user_id=email,
|
||||
cookie_secret=config.cookie_secret,
|
||||
)
|
||||
except Exception:
|
||||
_logger.exception("cli-login: refresh grant issuance failed")
|
||||
# Return a simple HTML page — the CLI is polling
|
||||
# /auth/cli-poll and will pick up the token.
|
||||
import html as _html
|
||||
@@ -560,15 +587,18 @@ def create_auth_router(
|
||||
# Fulfilled — return the token and clean up.
|
||||
token = ticket.token
|
||||
user_id = ticket.user_id
|
||||
refresh_token = ticket.refresh_token
|
||||
del _cli_tickets[ticket_id]
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"token": token,
|
||||
"user_id": user_id,
|
||||
"expires_in": config.session_ttl_hours * 3600,
|
||||
},
|
||||
)
|
||||
content: dict[str, object] = {
|
||||
"token": token,
|
||||
"user_id": user_id,
|
||||
"expires_in": config.session_ttl_hours * 3600,
|
||||
}
|
||||
# Only present when a grant store is wired — old CLIs ignore the
|
||||
# extra key, new CLIs against old servers see it absent.
|
||||
if refresh_token is not None:
|
||||
content["refresh_token"] = refresh_token
|
||||
return JSONResponse(status_code=200, content=content)
|
||||
|
||||
# ── Admin: read-only user list ────────────────────────────────
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ import logging
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
|
||||
import jwt
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
@@ -80,6 +81,26 @@ _CLIENT_SECRET_HEADER = "X-Omnigent-Client-Secret"
|
||||
# refuses admin / user-management paths for a token carrying this scope.
|
||||
DELEGATED_SCOPE = "sessions"
|
||||
|
||||
# Reserved ``client_id`` for a first-party login grant (issued by
|
||||
# ``omnigent login``, not the RFC 8628 device flow). It is a
|
||||
# security-decision key here, so the device-authorize endpoint REFUSES a
|
||||
# request that names it — a third-party device client can never obtain a
|
||||
# grant tagged this way, and a refresh of such a grant is therefore safe
|
||||
# to treat as first-party. See :func:`issue_login_grant`,
|
||||
# ``_is_login_grant``, and the reservation guard in ``device_authorize``.
|
||||
LOGIN_GRANT_CLIENT_ID = "omnigent-cli"
|
||||
|
||||
|
||||
def _is_login_grant(client_id: str | None) -> bool:
|
||||
"""Return True for a first-party login grant (vs. a device grant).
|
||||
|
||||
Trustworthy because :data:`LOGIN_GRANT_CLIENT_ID` is reserved: the
|
||||
device-authorize path rejects it, so only the server-side login flow
|
||||
can create a grant carrying it.
|
||||
"""
|
||||
return client_id == LOGIN_GRANT_CLIENT_ID
|
||||
|
||||
|
||||
# RFC 8628 timings.
|
||||
_DEVICE_CODE_TTL_SECONDS = 600 # 10 min — bounds the unapproved window.
|
||||
_POLL_INTERVAL_SECONDS = 5 # minimum client poll interval.
|
||||
@@ -91,6 +112,41 @@ _ACCESS_TOKEN_TTL_SECONDS = 3600
|
||||
# re-consent through the flow. Bounds the blast radius of a leaked/phished
|
||||
# grant to this window even if revocation is never called.
|
||||
_GRANT_MAX_LIFETIME_SECONDS = 30 * 24 * 3600 # 30 days
|
||||
# Operator override for the grant lifetime, in whole days. Deployments
|
||||
# running unattended hosts can extend the re-consent window deliberately;
|
||||
# the 30-day default stays the safe posture.
|
||||
_GRANT_MAX_LIFETIME_ENV = "OMNIGENT_GRANT_MAX_LIFETIME_DAYS"
|
||||
|
||||
|
||||
def _grant_max_lifetime_seconds() -> int:
|
||||
"""Return the grant's absolute lifetime, honoring the env override.
|
||||
|
||||
Invalid or non-positive values fall back to the default — auth
|
||||
lifetimes must never fail open to "unbounded" on a typo.
|
||||
"""
|
||||
raw = os.environ.get(_GRANT_MAX_LIFETIME_ENV, "").strip()
|
||||
if raw:
|
||||
try:
|
||||
days = int(raw)
|
||||
except ValueError:
|
||||
_logger.warning(
|
||||
"%s=%r is not an integer — using the %d-day default",
|
||||
_GRANT_MAX_LIFETIME_ENV,
|
||||
raw,
|
||||
_GRANT_MAX_LIFETIME_SECONDS // 86400,
|
||||
)
|
||||
else:
|
||||
if days > 0:
|
||||
return days * 86400
|
||||
_logger.warning(
|
||||
"%s=%r must be positive — using the %d-day default",
|
||||
_GRANT_MAX_LIFETIME_ENV,
|
||||
raw,
|
||||
_GRANT_MAX_LIFETIME_SECONDS // 86400,
|
||||
)
|
||||
return _GRANT_MAX_LIFETIME_SECONDS
|
||||
|
||||
|
||||
# user_code alphabet excludes easily-confused chars (0/O, 1/I/L).
|
||||
_USER_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
||||
|
||||
@@ -106,10 +162,16 @@ def _client_id(body: dict[str, object]) -> str | None:
|
||||
|
||||
A public string naming the requesting application (e.g. Slack passes
|
||||
``"slack"``), the same for every grant that application initiates.
|
||||
Display + audit only — never an authorization key.
|
||||
Display + audit only for device grants — but see
|
||||
:data:`LOGIN_GRANT_CLIENT_ID`, which is reserved and refused here.
|
||||
|
||||
Non-string values (``{"client_id": 123}``) read as absent rather than
|
||||
raising, so a malformed body is a clean 400 and not a 500.
|
||||
"""
|
||||
value = body.get("client_id")
|
||||
return value.strip() or None if isinstance(value, str) else None
|
||||
raw = body.get("client_id")
|
||||
if not isinstance(raw, str):
|
||||
return None
|
||||
return raw.strip() or None
|
||||
|
||||
|
||||
def _mint_refresh_token() -> str:
|
||||
@@ -126,34 +188,38 @@ def mint_delegated_token(
|
||||
grant_id: str,
|
||||
client_id: str,
|
||||
jti: str,
|
||||
scope: str = DELEGATED_SCOPE,
|
||||
scope: str | None = DELEGATED_SCOPE,
|
||||
) -> str:
|
||||
"""Mint a delegated access token for a device-authorization grant.
|
||||
"""Mint a grant-derived access token.
|
||||
|
||||
Same HS256 shape as
|
||||
:func:`omnigent.server.oidc.mint_session_token` (so
|
||||
:meth:`UnifiedAuthProvider._check_cookie` validates it unchanged),
|
||||
plus four delegated-only claims:
|
||||
plus grant claims:
|
||||
|
||||
- ``scope`` — restricts the token to the session APIs; the auth
|
||||
layer rejects admin endpoints when this claim is present.
|
||||
- ``grant_id`` — the device grant this token was issued from,
|
||||
checked against the revocation denylist so revoking the grant
|
||||
immediately kills the token.
|
||||
- ``scope`` — present ⇒ the auth layer restricts the token to the
|
||||
session APIs and refuses admin endpoints. ``None`` omits the claim,
|
||||
giving the token the SAME authority as the session JWT it renews —
|
||||
used ONLY for first-party login grants, whose bearer is the
|
||||
authenticated user's own CLI/host, not a third-party client.
|
||||
- ``grant_id`` — the grant this token was issued from, checked against
|
||||
the revocation denylist so revoking the grant immediately kills the
|
||||
token. Carried regardless of scope.
|
||||
- ``jti`` — unique token id, for audit/log correlation.
|
||||
- ``act`` — provenance (RFC 8693 style), ``{"client_id": "<app>"}``,
|
||||
naming the application that obtained the grant so every delegated
|
||||
action is attributable to it.
|
||||
naming the application that obtained the grant so every action is
|
||||
attributable to it.
|
||||
|
||||
:param user_id: The Omnigent identity the token acts as (``sub``).
|
||||
:param cookie_secret: HMAC key for HS256 signing.
|
||||
:param ttl_seconds: Token lifetime in seconds (kept short — ≤ 1 h).
|
||||
:param provider: Identity provider name (informational claim).
|
||||
:param grant_id: The device grant id.
|
||||
:param client_id: The RFC 8628 client id (the requesting application,
|
||||
:param grant_id: The grant id.
|
||||
:param client_id: The client id (the requesting application,
|
||||
e.g. ``"slack"``); recorded in the ``act`` claim for audit.
|
||||
:param jti: Unique token id.
|
||||
:param scope: Granted scope; defaults to :data:`DELEGATED_SCOPE`.
|
||||
:param scope: Granted scope, or ``None`` for a full-authority
|
||||
(login-grant) token. Defaults to :data:`DELEGATED_SCOPE`.
|
||||
:returns: An HS256-signed JWT string.
|
||||
"""
|
||||
now = int(time.time())
|
||||
@@ -162,11 +228,12 @@ def mint_delegated_token(
|
||||
"iat": now,
|
||||
"exp": now + ttl_seconds,
|
||||
"provider": provider,
|
||||
"scope": scope,
|
||||
"grant_id": grant_id,
|
||||
"jti": jti,
|
||||
"act": {"client_id": client_id},
|
||||
}
|
||||
if scope is not None:
|
||||
payload["scope"] = scope
|
||||
return jwt.encode(payload, cookie_secret, algorithm="HS256")
|
||||
|
||||
|
||||
@@ -257,6 +324,308 @@ class _SlidingWindowRateLimiter:
|
||||
return True
|
||||
|
||||
|
||||
def _resolve_signing_config(auth_provider: UnifiedAuthProvider) -> tuple[bytes, str]:
|
||||
"""Return ``(cookie_secret, provider_name)`` for token minting.
|
||||
|
||||
Works for both server-mintable providers: ``accounts`` and ``oidc``.
|
||||
Header mode has no server-held signing identity, so grant routes
|
||||
cannot be built for it.
|
||||
"""
|
||||
if auth_provider._source == "accounts":
|
||||
config = auth_provider._accounts_config
|
||||
assert config is not None, "accounts mode must have an accounts config"
|
||||
return config.cookie_secret, auth_provider._source
|
||||
if auth_provider._source == "oidc":
|
||||
oidc_config = auth_provider._oidc_config
|
||||
assert oidc_config is not None, "oidc mode must have an oidc config"
|
||||
return oidc_config.cookie_secret, auth_provider._source
|
||||
raise RuntimeError(
|
||||
f"grant routes require accounts or oidc auth (got {auth_provider._source!r})"
|
||||
)
|
||||
|
||||
|
||||
def _make_client_secret_gate() -> Callable[[Request], bool]:
|
||||
"""Build the optional shared-secret check for client-facing endpoints.
|
||||
|
||||
Reads the env once at mount (toggling requires a restart, consistent
|
||||
with the other auth env vars). Open when no secret is configured.
|
||||
"""
|
||||
client_secret = os.environ.get(_CLIENT_SECRET_ENV, "").strip() or None
|
||||
if client_secret is not None:
|
||||
_logger.info("device-auth: client-secret enforcement enabled")
|
||||
|
||||
def _client_secret_ok(request: Request) -> bool:
|
||||
if client_secret is None:
|
||||
return True
|
||||
# Compare on bytes: compare_digest raises TypeError on non-ASCII str
|
||||
# operands, and ASGI decodes header bytes as latin-1, so a crafted
|
||||
# non-ASCII header would otherwise 500 instead of cleanly failing.
|
||||
presented = request.headers.get(_CLIENT_SECRET_HEADER, "")
|
||||
return hmac.compare_digest(presented.encode("utf-8"), client_secret.encode("utf-8"))
|
||||
|
||||
return _client_secret_ok
|
||||
|
||||
|
||||
def issue_login_grant(
|
||||
device_grant_store: DeviceGrantStore,
|
||||
*,
|
||||
user_id: str,
|
||||
cookie_secret: bytes,
|
||||
) -> str:
|
||||
"""Create a redeemed refresh grant for an interactive login.
|
||||
|
||||
Called by the login flows (OIDC cli-ticket fulfillment, accounts
|
||||
``/auth/login``) so the CLI walks away with refresh material and can
|
||||
renew its access without a human re-running ``omnigent login``. The
|
||||
interactive login *is* the consent step, so the grant is born
|
||||
``redeemed`` and tagged with the reserved
|
||||
:data:`LOGIN_GRANT_CLIENT_ID` — which the device-authorize path
|
||||
refuses, so this authority class can only originate here.
|
||||
|
||||
:param device_grant_store: Grant persistence.
|
||||
:param user_id: The just-authenticated identity.
|
||||
:param cookie_secret: HMAC key for hashing the refresh token.
|
||||
:returns: The raw refresh token to hand to the client (stored hashed).
|
||||
"""
|
||||
refresh_token = _mint_refresh_token()
|
||||
device_grant_store.create_redeemed_grant(
|
||||
secrets.token_urlsafe(24),
|
||||
user_id=user_id,
|
||||
client_id=LOGIN_GRANT_CLIENT_ID,
|
||||
refresh_token_hash=hash_secret(refresh_token, cookie_secret),
|
||||
created_at=int(time.time()),
|
||||
)
|
||||
return refresh_token
|
||||
|
||||
|
||||
def create_oauth_token_router(
|
||||
auth_provider: UnifiedAuthProvider,
|
||||
device_grant_store: DeviceGrantStore,
|
||||
*,
|
||||
handle_device_code: Callable[[str], Response] | None = None,
|
||||
client_secret_ok: Callable[[Request], bool] | None = None,
|
||||
) -> APIRouter:
|
||||
"""Build the ``/oauth/token`` + ``/oauth/revoke`` router.
|
||||
|
||||
The refresh/revoke half of the grant machinery, mountable on its own:
|
||||
login-issued refresh grants (see :func:`issue_login_grant`) need these
|
||||
endpoints in **both** accounts and OIDC modes, independent of the
|
||||
RFC 8628 device-code consent flow (which stays accounts-only behind
|
||||
``OMNIGENT_DEVICE_GRANT_ENABLED``).
|
||||
|
||||
:param auth_provider: The active provider — ``accounts`` or ``oidc``.
|
||||
:param device_grant_store: Persistence for grants.
|
||||
:param handle_device_code: Optional device-code grant handler.
|
||||
:func:`create_device_auth_router` passes its polling closure so
|
||||
the full flow keeps one token endpoint; standalone mounts leave
|
||||
it ``None`` and ``device_code`` exchanges get
|
||||
``unsupported_grant_type``.
|
||||
:param client_secret_ok: Optional client-secret gate (callable that validates
|
||||
the request). When ``None`` (standalone mounts), builds the gate from
|
||||
the ``OMNIGENT_DEVICE_CLIENT_SECRET`` env var. When provided
|
||||
(from :func:`create_device_auth_router`), reuses the gate to avoid
|
||||
duplication.
|
||||
:returns: APIRouter to mount at the app root.
|
||||
"""
|
||||
cookie_secret, provider_name = _resolve_signing_config(auth_provider)
|
||||
_client_secret_ok = client_secret_ok or _make_client_secret_gate()
|
||||
# Resolve grant max lifetime once at mount time (not on every refresh).
|
||||
_grant_max_lifetime = _grant_max_lifetime_seconds()
|
||||
router = APIRouter()
|
||||
# Throttle for the opportunistic grant purge (bounds the table without a
|
||||
# separate scheduler). Mutable one-field dict so the closures can update
|
||||
# it. ``0.0`` forces a purge on the first refresh after boot.
|
||||
_last_purge = {"at": 0.0}
|
||||
|
||||
def _maybe_purge() -> None:
|
||||
"""Purge expired/aged grants at most once per interval.
|
||||
|
||||
The device flow purges on ``/oauth/device/authorize``, but the
|
||||
standalone token router (OIDC, or accounts without the device flow)
|
||||
has no authorize route — so login grants would otherwise accumulate
|
||||
one row per login. Piggyback the purge on refresh instead.
|
||||
"""
|
||||
now_wall = time.time()
|
||||
if now_wall - _last_purge["at"] < _PURGE_MIN_INTERVAL_SECONDS:
|
||||
return
|
||||
_last_purge["at"] = now_wall
|
||||
try:
|
||||
device_grant_store.purge_expired(
|
||||
int(now_wall), max_lifetime_seconds=_grant_max_lifetime
|
||||
)
|
||||
except Exception: # noqa: BLE001 — housekeeping must never fail a refresh
|
||||
_logger.debug("oauth/token: opportunistic grant purge failed", exc_info=True)
|
||||
|
||||
def _issue_access_token(grant_id: str, user_id: str, client_id: str) -> str:
|
||||
# A first-party login grant renews with the SAME authority as the
|
||||
# session JWT it replaces (scope=None); a third-party device grant
|
||||
# stays restricted to the delegated allowlist.
|
||||
scope = None if _is_login_grant(client_id) else DELEGATED_SCOPE
|
||||
return mint_delegated_token(
|
||||
user_id,
|
||||
cookie_secret,
|
||||
_ACCESS_TOKEN_TTL_SECONDS,
|
||||
provider_name,
|
||||
grant_id=grant_id,
|
||||
client_id=client_id or "",
|
||||
jti=secrets.token_urlsafe(16),
|
||||
scope=scope,
|
||||
)
|
||||
|
||||
@router.post("/oauth/token", dependencies=[])
|
||||
async def token(request: Request) -> Response:
|
||||
"""Exchange a device_code or refresh_token for an access token.
|
||||
|
||||
RFC 8628 / 6749 error shapes: ``authorization_pending``,
|
||||
``slow_down``, ``expired_token``, ``access_denied``,
|
||||
``invalid_grant``, ``unsupported_grant_type``.
|
||||
"""
|
||||
form = await request.form()
|
||||
grant_type = str(form.get("grant_type") or "")
|
||||
|
||||
if grant_type == "urn:ietf:params:oauth:grant-type:device_code":
|
||||
# The device-code exchange mints from the ephemeral device_code
|
||||
# alone, so it stays behind the client-secret gate. Refresh
|
||||
# presents the refresh token itself as the credential, so it is
|
||||
# not additionally gated (a CLI/host renewing its own login has
|
||||
# no way to carry the device client secret).
|
||||
if not _client_secret_ok(request):
|
||||
return _oauth_error("invalid_client", status_code=401)
|
||||
if handle_device_code is None:
|
||||
return _oauth_error("unsupported_grant_type")
|
||||
return handle_device_code(str(form.get("device_code") or ""))
|
||||
if grant_type == "refresh_token":
|
||||
return _handle_refresh_grant(str(form.get("refresh_token") or ""))
|
||||
return _oauth_error("unsupported_grant_type")
|
||||
|
||||
def _handle_refresh_grant(refresh_token: str) -> Response:
|
||||
if not refresh_token:
|
||||
return _oauth_error("invalid_request")
|
||||
# Opportunistic housekeeping so login-grant rows (one per login)
|
||||
# don't accumulate where no device-authorize purge runs.
|
||||
_maybe_purge()
|
||||
presented_hash = hash_secret(refresh_token, cookie_secret)
|
||||
# A refresh token doesn't name its grant, so locate it by digest.
|
||||
# Only a live (redeemed, non-revoked) grant holds a matching hash.
|
||||
grant = device_grant_store.get_by_refresh_hash(presented_hash)
|
||||
if grant is None:
|
||||
# Not the current token. If it matches a grant's *previous*
|
||||
# token, a stale token was replayed — reuse/theft. Revoke the
|
||||
# whole grant so the attacker's freshly-rotated token dies too.
|
||||
# (Login grants don't rotate, so they never populate the prev
|
||||
# hash and can't reach this revoke.)
|
||||
stale = device_grant_store.get_by_prev_refresh_hash(presented_hash)
|
||||
if stale is not None:
|
||||
device_grant_store.revoke(stale.id)
|
||||
_logger.warning(
|
||||
"oauth/token: refresh reuse detected on grant %s — revoked", stale.id
|
||||
)
|
||||
return _oauth_error("invalid_grant")
|
||||
# Refuse to refresh a grant past its absolute lifetime — the user
|
||||
# must re-consent. Checked before rotating so an aged grant simply
|
||||
# stops working (it is NOT reuse, so it must not revoke/oscillate).
|
||||
if grant.approved_at is not None and (
|
||||
int(time.time()) - grant.approved_at >= _grant_max_lifetime
|
||||
):
|
||||
return _oauth_error("expired_token")
|
||||
if grant.user_id is None:
|
||||
return _oauth_error("invalid_grant")
|
||||
|
||||
if _is_login_grant(grant.client_id):
|
||||
# First-party login grants do NOT rotate. The bearer is an
|
||||
# unattended host/CLI, so a lost refresh response (network blip,
|
||||
# crash between the server committing and the client persisting)
|
||||
# must not brick the grant via reuse detection. The same refresh
|
||||
# token stays valid for the grant lifetime; only the short-lived
|
||||
# access token is renewed. Revocation + the absolute lifetime cap
|
||||
# bound the exposure.
|
||||
access_token = _issue_access_token(grant.id, grant.user_id, grant.client_id or "")
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": _ACCESS_TOKEN_TTL_SECONDS,
|
||||
},
|
||||
)
|
||||
|
||||
new_refresh = _mint_refresh_token()
|
||||
rotated = device_grant_store.rotate_refresh_token(
|
||||
grant.id,
|
||||
expected_hash=presented_hash,
|
||||
new_hash=hash_secret(new_refresh, cookie_secret),
|
||||
now_epoch_seconds=int(time.time()),
|
||||
max_lifetime_seconds=_grant_max_lifetime,
|
||||
)
|
||||
if rotated is None:
|
||||
# Lost a concurrent rotation race, or the grant aged out between
|
||||
# the check above and here — reject without revoking (this is not
|
||||
# a reuse signal, so the grant must not be killed/oscillate).
|
||||
return _oauth_error("invalid_grant")
|
||||
if rotated.user_id is None:
|
||||
return _oauth_error("invalid_grant")
|
||||
access_token = _issue_access_token(
|
||||
rotated.id,
|
||||
rotated.user_id,
|
||||
rotated.client_id or "",
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"access_token": access_token,
|
||||
"refresh_token": new_refresh,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": _ACCESS_TOKEN_TTL_SECONDS,
|
||||
},
|
||||
)
|
||||
|
||||
# ── Revocation ────────────────────────────────────────────────
|
||||
|
||||
@router.post("/oauth/revoke", dependencies=[])
|
||||
async def revoke(request: Request) -> Response:
|
||||
"""Revoke a grant by refresh token or by the caller's access token.
|
||||
|
||||
Backs ``/omnigent logout``. Accepts a ``refresh_token`` form
|
||||
field; falls back to the ``grant_id`` on the caller's own
|
||||
delegated access token so a client with only its access token
|
||||
can still log out. Not behind the device client-secret gate: the
|
||||
presented refresh/access token IS the credential, and a CLI
|
||||
logging out its own login grant cannot carry that secret.
|
||||
"""
|
||||
form = await request.form()
|
||||
refresh_token = str(form.get("refresh_token") or "")
|
||||
grant = None
|
||||
if refresh_token:
|
||||
grant = device_grant_store.get_by_refresh_hash(
|
||||
hash_secret(refresh_token, cookie_secret)
|
||||
)
|
||||
if grant is None:
|
||||
grant_id = _grant_id_from_bearer(request)
|
||||
if grant_id is not None:
|
||||
grant = device_grant_store.get_by_id(grant_id)
|
||||
if grant is None:
|
||||
# Idempotent: nothing to revoke is still "revoked" from the
|
||||
# caller's perspective. Don't leak which tokens exist.
|
||||
return JSONResponse(status_code=200, content={"revoked": True})
|
||||
device_grant_store.revoke(grant.id)
|
||||
_logger.info("oauth/revoke: revoked grant %s", grant.id)
|
||||
return JSONResponse(status_code=200, content={"revoked": True})
|
||||
|
||||
def _grant_id_from_bearer(request: Request) -> str | None:
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if not auth_header.startswith("Bearer "):
|
||||
return None
|
||||
try:
|
||||
payload = jwt.decode(auth_header[7:], cookie_secret, algorithms=["HS256"])
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
grant_id = payload.get("grant_id")
|
||||
return grant_id if isinstance(grant_id, str) else None
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def create_device_auth_router(
|
||||
auth_provider: UnifiedAuthProvider,
|
||||
device_grant_store: DeviceGrantStore,
|
||||
@@ -278,28 +647,9 @@ def create_device_auth_router(
|
||||
base_url = cookie_config.base_url
|
||||
provider_name = auth_provider._source
|
||||
|
||||
# Read the optional client secret once at mount. When set, the
|
||||
# client-facing endpoints require a matching header; when unset they
|
||||
# stay public. Captured here (not per-request) so toggling it needs a
|
||||
# restart — consistent with the other auth env vars.
|
||||
client_secret = os.environ.get(_CLIENT_SECRET_ENV, "").strip() or None
|
||||
if client_secret is not None:
|
||||
_logger.info("device-auth: client-secret enforcement enabled")
|
||||
|
||||
def _client_secret_ok(request: Request) -> bool:
|
||||
"""Return True if the request may use the client-facing endpoints.
|
||||
|
||||
Open when no secret is configured; otherwise requires the presented
|
||||
header to match, compared in constant time to avoid leaking the
|
||||
secret through timing.
|
||||
"""
|
||||
if client_secret is None:
|
||||
return True
|
||||
# Compare on bytes: compare_digest raises TypeError on non-ASCII str
|
||||
# operands, and ASGI decodes header bytes as latin-1, so a crafted
|
||||
# non-ASCII header would otherwise 500 instead of cleanly failing.
|
||||
presented = request.headers.get(_CLIENT_SECRET_HEADER, "")
|
||||
return hmac.compare_digest(presented.encode("utf-8"), client_secret.encode("utf-8"))
|
||||
_client_secret_ok = _make_client_secret_gate()
|
||||
# Resolve grant max lifetime once at mount (not on every purge).
|
||||
_grant_max_lifetime = _grant_max_lifetime_seconds()
|
||||
|
||||
router = APIRouter()
|
||||
_rate_limiter = _SlidingWindowRateLimiter(
|
||||
@@ -347,7 +697,7 @@ def create_device_auth_router(
|
||||
_last_purge["at"] = now_wall
|
||||
try:
|
||||
device_grant_store.purge_expired(
|
||||
int(now_wall), max_lifetime_seconds=_GRANT_MAX_LIFETIME_SECONDS
|
||||
int(now_wall), max_lifetime_seconds=_grant_max_lifetime
|
||||
)
|
||||
except Exception: # housekeeping must never fail a request
|
||||
_logger.exception("device grant purge failed")
|
||||
@@ -359,6 +709,15 @@ def create_device_auth_router(
|
||||
if not isinstance(body, dict):
|
||||
body = {}
|
||||
client_id = _client_id(body)
|
||||
# LOGIN_GRANT_CLIENT_ID marks a first-party login grant, whose
|
||||
# refreshed tokens carry full session authority. It is reserved:
|
||||
# a device client must never be able to self-declare into that
|
||||
# class by naming it here.
|
||||
if _is_login_grant(client_id):
|
||||
_logger.warning(
|
||||
"device/authorize: refused reserved client_id %r", LOGIN_GRANT_CLIENT_ID
|
||||
)
|
||||
return _oauth_error("invalid_request")
|
||||
|
||||
device_code = secrets.token_urlsafe(32)
|
||||
grant_id = secrets.token_urlsafe(16)
|
||||
@@ -543,26 +902,10 @@ def create_device_auth_router(
|
||||
device_grant_store.deny(grant.id)
|
||||
return HTMLResponse(_consent_html(denied=True), status_code=200)
|
||||
|
||||
# ── Token endpoint (client polling + refresh) ─────────────────
|
||||
|
||||
@router.post("/oauth/token", dependencies=[])
|
||||
async def token(request: Request) -> Response:
|
||||
"""Exchange a device_code or refresh_token for an access token.
|
||||
|
||||
RFC 8628 / 6749 error shapes: ``authorization_pending``,
|
||||
``slow_down``, ``expired_token``, ``access_denied``,
|
||||
``invalid_grant``, ``unsupported_grant_type``.
|
||||
"""
|
||||
if not _client_secret_ok(request):
|
||||
return _oauth_error("invalid_client", status_code=401)
|
||||
form = await request.form()
|
||||
grant_type = str(form.get("grant_type") or "")
|
||||
|
||||
if grant_type == "urn:ietf:params:oauth:grant-type:device_code":
|
||||
return _handle_device_code_grant(str(form.get("device_code") or ""))
|
||||
if grant_type == "refresh_token":
|
||||
return _handle_refresh_grant(str(form.get("refresh_token") or ""))
|
||||
return _oauth_error("unsupported_grant_type")
|
||||
# ── Token + revocation endpoints ──────────────────────────────
|
||||
# Shared with the standalone login-grant mount: the device flow's
|
||||
# only addition is the device_code grant handler, injected here so
|
||||
# /oauth/token stays a single registration either way.
|
||||
|
||||
def _handle_device_code_grant(device_code: str) -> Response:
|
||||
if not device_code:
|
||||
@@ -613,103 +956,14 @@ def create_device_auth_router(
|
||||
},
|
||||
)
|
||||
|
||||
def _handle_refresh_grant(refresh_token: str) -> Response:
|
||||
if not refresh_token:
|
||||
return _oauth_error("invalid_request")
|
||||
presented_hash = hash_secret(refresh_token, cookie_secret)
|
||||
# A refresh token doesn't name its grant, so locate it by digest.
|
||||
# Only a live (redeemed, non-revoked) grant holds a matching hash.
|
||||
grant = device_grant_store.get_by_refresh_hash(presented_hash)
|
||||
if grant is None:
|
||||
# Not the current token. If it matches a grant's *previous*
|
||||
# token, a stale token was replayed — reuse/theft. Revoke the
|
||||
# whole grant so the attacker's freshly-rotated token dies too.
|
||||
stale = device_grant_store.get_by_prev_refresh_hash(presented_hash)
|
||||
if stale is not None:
|
||||
device_grant_store.revoke(stale.id)
|
||||
_logger.warning(
|
||||
"oauth/token: refresh reuse detected on grant %s — revoked", stale.id
|
||||
)
|
||||
return _oauth_error("invalid_grant")
|
||||
# Refuse to refresh a grant past its absolute lifetime — the user
|
||||
# must re-consent. Checked before rotating so an aged grant simply
|
||||
# stops working (it is NOT reuse, so it must not revoke/oscillate).
|
||||
if grant.approved_at is not None and (
|
||||
int(time.time()) - grant.approved_at >= _GRANT_MAX_LIFETIME_SECONDS
|
||||
):
|
||||
return _oauth_error("expired_token")
|
||||
new_refresh = _mint_refresh_token()
|
||||
rotated = device_grant_store.rotate_refresh_token(
|
||||
grant.id,
|
||||
expected_hash=presented_hash,
|
||||
new_hash=hash_secret(new_refresh, cookie_secret),
|
||||
now_epoch_seconds=int(time.time()),
|
||||
max_lifetime_seconds=_GRANT_MAX_LIFETIME_SECONDS,
|
||||
router.include_router(
|
||||
create_oauth_token_router(
|
||||
auth_provider,
|
||||
device_grant_store,
|
||||
handle_device_code=_handle_device_code_grant,
|
||||
client_secret_ok=_client_secret_ok,
|
||||
)
|
||||
if rotated is None:
|
||||
# Lost a concurrent rotation race, or the grant aged out between
|
||||
# the check above and here — reject without revoking (this is not
|
||||
# a reuse signal, so the grant must not be killed/oscillate).
|
||||
return _oauth_error("invalid_grant")
|
||||
if rotated.user_id is None:
|
||||
return _oauth_error("invalid_grant")
|
||||
access_token = _issue_access_token(
|
||||
rotated.id,
|
||||
rotated.user_id,
|
||||
rotated.client_id or "",
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"access_token": access_token,
|
||||
"refresh_token": new_refresh,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": _ACCESS_TOKEN_TTL_SECONDS,
|
||||
},
|
||||
)
|
||||
|
||||
# ── Revocation ────────────────────────────────────────────────
|
||||
|
||||
@router.post("/oauth/revoke", dependencies=[])
|
||||
async def revoke(request: Request) -> Response:
|
||||
"""Revoke a grant by refresh token or by the caller's access token.
|
||||
|
||||
Backs ``/omnigent logout``. Accepts a ``refresh_token`` form
|
||||
field; falls back to the ``grant_id`` on the caller's own
|
||||
delegated access token so a client with only its access token
|
||||
can still log out.
|
||||
"""
|
||||
if not _client_secret_ok(request):
|
||||
return _oauth_error("invalid_client", status_code=401)
|
||||
form = await request.form()
|
||||
refresh_token = str(form.get("refresh_token") or "")
|
||||
grant = None
|
||||
if refresh_token:
|
||||
grant = device_grant_store.get_by_refresh_hash(
|
||||
hash_secret(refresh_token, cookie_secret)
|
||||
)
|
||||
if grant is None:
|
||||
grant_id = _grant_id_from_bearer(request)
|
||||
if grant_id is not None:
|
||||
grant = device_grant_store.get_by_id(grant_id)
|
||||
if grant is None:
|
||||
# Idempotent: nothing to revoke is still "revoked" from the
|
||||
# caller's perspective. Don't leak which tokens exist.
|
||||
return JSONResponse(status_code=200, content={"revoked": True})
|
||||
device_grant_store.revoke(grant.id)
|
||||
_logger.info("oauth/revoke: revoked grant %s", grant.id)
|
||||
return JSONResponse(status_code=200, content={"revoked": True})
|
||||
|
||||
def _grant_id_from_bearer(request: Request) -> str | None:
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if not auth_header.startswith("Bearer "):
|
||||
return None
|
||||
try:
|
||||
payload = jwt.decode(auth_header[7:], cookie_secret, algorithms=["HS256"])
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
grant_id = payload.get("grant_id")
|
||||
return grant_id if isinstance(grant_id, str) else None
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
@@ -681,7 +681,7 @@ def create_hosts_router(
|
||||
request: Request,
|
||||
host_id: str,
|
||||
harness: str,
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
) -> dict[str, list[Any]]:
|
||||
"""Return pre-launch model choices resolved by the selected host.
|
||||
|
||||
A preview of the host's ambient default catalog, not a binding
|
||||
@@ -709,7 +709,22 @@ def create_hosts_router(
|
||||
detail=str(result.get("error") or "host model-options lookup failed"),
|
||||
)
|
||||
models = result.get("models")
|
||||
return {"models": models if isinstance(models, list) else []}
|
||||
routable = result.get("routable_models")
|
||||
payload: dict[str, Any] = {
|
||||
"models": models if isinstance(models, list) else [],
|
||||
# Every id the harness's endpoint routes: the picker names one
|
||||
# row per model, while a launch takes an exact id.
|
||||
"routable_models": (
|
||||
[m for m in routable if isinstance(m, str)] if isinstance(routable, list) else []
|
||||
),
|
||||
}
|
||||
# An honest empty answer carries the reason (e.g. "the codex model
|
||||
# probe failed — see the host log") so the picker can say WHY it is
|
||||
# empty instead of a generic "Models unavailable".
|
||||
error = result.get("error")
|
||||
if isinstance(error, str) and error:
|
||||
payload["error"] = error
|
||||
return payload
|
||||
|
||||
@router.post("/hosts/{host_id}/runners")
|
||||
async def launch_runner(
|
||||
|
||||
@@ -52,6 +52,7 @@ class CreateScheduledTaskRequest(BaseModel):
|
||||
timezone: str = "UTC"
|
||||
model_override: str | None = None
|
||||
reasoning_effort: str | None = None
|
||||
max_cost_usd: float | None = Field(default=None, gt=0)
|
||||
# Optional: no PINNED host/workspace. When both are unset the fire path
|
||||
# resolves the owner's online host at fire time and defaults the workspace to
|
||||
# that host's home directory (a failed run is recorded if none is online) —
|
||||
@@ -74,6 +75,7 @@ class UpdateScheduledTaskRequest(BaseModel):
|
||||
timezone: str | None = None
|
||||
model_override: str | None = None
|
||||
reasoning_effort: str | None = None
|
||||
max_cost_usd: float | None = Field(default=None, gt=0) # null clears the cap
|
||||
workspace: str | None = Field(default=None, min_length=1)
|
||||
host_id: str | None = Field(default=None, min_length=1)
|
||||
state: str | None = None
|
||||
@@ -121,6 +123,7 @@ def _to_response(
|
||||
"created_at": task.created_at,
|
||||
"model_override": task.model_override,
|
||||
"reasoning_effort": task.reasoning_effort,
|
||||
"max_cost_usd": task.max_cost_usd,
|
||||
"workspace": task.workspace,
|
||||
"host_id": task.host_id,
|
||||
"state": task.state,
|
||||
@@ -311,6 +314,7 @@ def create_scheduled_tasks_router(
|
||||
timezone=body.timezone,
|
||||
model_override=model_override,
|
||||
reasoning_effort=reasoning_effort,
|
||||
max_cost_usd=body.max_cost_usd,
|
||||
workspace=workspace,
|
||||
host_id=body.host_id,
|
||||
)
|
||||
|
||||
@@ -187,6 +187,8 @@ from omnigent.server.routes._sessions.common import (
|
||||
_CLAUDE_NATIVE_MESSAGE_TIMEOUT_S as _CLAUDE_NATIVE_MESSAGE_TIMEOUT_S,
|
||||
_CLAUDE_NATIVE_MODEL as _CLAUDE_NATIVE_MODEL,
|
||||
_CLAUDE_NATIVE_PERMISSION_HOOK_TIMEOUT_S as _CLAUDE_NATIVE_PERMISSION_HOOK_TIMEOUT_S,
|
||||
_CLAUDE_NATIVE_PERMISSION_MODES as _CLAUDE_NATIVE_PERMISSION_MODES,
|
||||
_CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY as _CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY,
|
||||
_CLAUDE_NATIVE_REMEMBER_INELIGIBLE_TOOLS as _CLAUDE_NATIVE_REMEMBER_INELIGIBLE_TOOLS,
|
||||
_CLAUDE_NATIVE_SUBAGENT_ID_LABEL_KEY as _CLAUDE_NATIVE_SUBAGENT_ID_LABEL_KEY,
|
||||
_CLAUDE_NATIVE_SUBAGENT_WRAPPER_LABEL_VALUE as _CLAUDE_NATIVE_SUBAGENT_WRAPPER_LABEL_VALUE,
|
||||
@@ -236,6 +238,7 @@ from omnigent.server.routes._sessions.common import (
|
||||
_EXTERNAL_SESSION_STATUS_TYPE as _EXTERNAL_SESSION_STATUS_TYPE,
|
||||
_EXTERNAL_SESSION_STATUS_VALUES as _EXTERNAL_SESSION_STATUS_VALUES,
|
||||
_EXTERNAL_SESSION_SUPERSEDED_TYPE as _EXTERNAL_SESSION_SUPERSEDED_TYPE,
|
||||
_EXTERNAL_SESSION_TITLE_TYPE as _EXTERNAL_SESSION_TITLE_TYPE,
|
||||
_EXTERNAL_SESSION_TODOS_TYPE as _EXTERNAL_SESSION_TODOS_TYPE,
|
||||
_EXTERNAL_SESSION_USAGE_TYPE as _EXTERNAL_SESSION_USAGE_TYPE,
|
||||
_EXTERNAL_STATUS_ASSISTANT_SCAN_LIMIT as _EXTERNAL_STATUS_ASSISTANT_SCAN_LIMIT,
|
||||
@@ -437,6 +440,7 @@ from omnigent.server.routes._sessions.helpers import (
|
||||
_persist_external_model_change as _persist_external_model_change,
|
||||
_persist_external_model_options as _persist_external_model_options,
|
||||
_persist_external_reasoning_effort_change as _persist_external_reasoning_effort_change,
|
||||
_persist_external_session_title as _persist_external_session_title,
|
||||
_persist_external_subagent_start as _persist_external_subagent_start,
|
||||
_persist_native_policy_notice as _persist_native_policy_notice,
|
||||
_persist_policy_deny_sentinel as _persist_policy_deny_sentinel,
|
||||
@@ -493,6 +497,7 @@ from omnigent.server.routes._sessions.helpers import (
|
||||
_require_declared_subagent as _require_declared_subagent,
|
||||
_require_external_status_forward as _require_external_status_forward,
|
||||
_require_host_conn_for_worktree as _require_host_conn_for_worktree,
|
||||
_require_permission_mode_forward as _require_permission_mode_forward,
|
||||
_reset_runner_resources_after_switch_impl as _reset_runner_resources_after_switch_impl,
|
||||
_resolve_llm_model as _resolve_llm_model,
|
||||
_resolve_skill_meta_text_via_runner as _resolve_skill_meta_text_via_runner,
|
||||
|
||||
@@ -94,9 +94,12 @@ from omnigent.server.routes._content_type import (
|
||||
from omnigent.server.routes._errors import session_not_found as _session_not_found
|
||||
from omnigent.server.routes._origin import require_trusted_origin
|
||||
from omnigent.server.routes._sessions.common import (
|
||||
_CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY,
|
||||
_CLAUDE_NATIVE_PERMISSION_MODES,
|
||||
_CLAUDE_NATIVE_UI_LABEL_KEY,
|
||||
_CLAUDE_NATIVE_UI_LABEL_VALUE,
|
||||
_CLAUDE_NATIVE_WRAPPER_LABEL_KEY,
|
||||
_CLAUDE_NATIVE_WRAPPER_LABEL_VALUE,
|
||||
_CODEX_NATIVE_COLLABORATION_MODE_LABEL_KEY,
|
||||
_CODEX_NATIVE_COLLABORATION_MODES,
|
||||
_CODEX_NATIVE_WRAPPER_LABEL_VALUE,
|
||||
@@ -125,12 +128,14 @@ from omnigent.server.routes._sessions.helpers import (
|
||||
_presentation_labels_for_agent,
|
||||
_prune_session_read_state,
|
||||
_publish_collaboration_mode,
|
||||
_publish_permission_mode,
|
||||
_publish_sandbox_status,
|
||||
_publish_terminal_pending,
|
||||
_reject_reserved_cost_control_label_seed,
|
||||
_reject_server_reserved_label_seed,
|
||||
_require_collaboration_mode_forward,
|
||||
_require_cost_control_label_authority,
|
||||
_require_permission_mode_forward,
|
||||
_reset_runner_resources_after_switch,
|
||||
_same_provider_family,
|
||||
_session_status_from_cache,
|
||||
@@ -648,7 +653,12 @@ def register_core_routes(
|
||||
by_name: dict[str, SessionProjectSummary] = {}
|
||||
if project_store is not None:
|
||||
for proj in project_store.list(user_id=user_id):
|
||||
by_name[proj.name] = SessionProjectSummary(id=proj.id, name=proj.name)
|
||||
icon = proj.config.get("icon")
|
||||
by_name[proj.name] = SessionProjectSummary(
|
||||
id=proj.id,
|
||||
name=proj.name,
|
||||
icon=icon if isinstance(icon, str) else None,
|
||||
)
|
||||
# Legacy path: label-derived projects (id=None unless already first-class).
|
||||
for name in conversation_store.list_projects(owned_by=user_id):
|
||||
by_name.setdefault(name, SessionProjectSummary(id=None, name=name))
|
||||
@@ -1593,6 +1603,34 @@ def register_core_routes(
|
||||
code=ErrorCode.INVALID_INPUT,
|
||||
)
|
||||
requested_codex_collaboration_mode = body.collaboration_mode
|
||||
permission_mode_requested = "permission_mode" in body.model_fields_set
|
||||
requested_claude_permission_mode: str | None = None
|
||||
if permission_mode_requested:
|
||||
if body.permission_mode is None:
|
||||
raise OmnigentError(
|
||||
"permission_mode must be a non-empty string",
|
||||
code=ErrorCode.INVALID_INPUT,
|
||||
)
|
||||
if body.permission_mode not in _CLAUDE_NATIVE_PERMISSION_MODES:
|
||||
raise OmnigentError(
|
||||
f"permission_mode must be one of {sorted(_CLAUDE_NATIVE_PERMISSION_MODES)}",
|
||||
code=ErrorCode.INVALID_INPUT,
|
||||
)
|
||||
conv_for_permission_mode = await asyncio.to_thread(
|
||||
conversation_store.get_conversation,
|
||||
session_id,
|
||||
)
|
||||
if conv_for_permission_mode is None:
|
||||
raise _session_not_found()
|
||||
if (
|
||||
conv_for_permission_mode.labels.get(_CLAUDE_NATIVE_WRAPPER_LABEL_KEY)
|
||||
!= _CLAUDE_NATIVE_WRAPPER_LABEL_VALUE
|
||||
):
|
||||
raise OmnigentError(
|
||||
"permission_mode is only supported for claude-native sessions",
|
||||
code=ErrorCode.INVALID_INPUT,
|
||||
)
|
||||
requested_claude_permission_mode = body.permission_mode
|
||||
labels_to_set = dict(body.labels or {})
|
||||
# Pins are per-user. The client writes the canonical ``omnigent.pinned``
|
||||
# key; rewrite it to the caller's per-user key so one user's pin doesn't
|
||||
@@ -1871,6 +1909,24 @@ def register_core_routes(
|
||||
_codex_plan_enabled,
|
||||
_runner_result,
|
||||
)
|
||||
if requested_claude_permission_mode is not None and live_forward:
|
||||
_mode_result = await _forward_session_change_to_runner(
|
||||
session_id,
|
||||
runner_router,
|
||||
{
|
||||
"type": "permission_mode_change",
|
||||
"permission_mode": requested_claude_permission_mode,
|
||||
},
|
||||
)
|
||||
# Raises unless the runner confirms the switch, so the label can
|
||||
# never claim a mode Claude isn't in. Stores the mode it reached.
|
||||
labels_to_set[_CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY] = (
|
||||
_require_permission_mode_forward(
|
||||
session_id,
|
||||
requested_claude_permission_mode,
|
||||
_mode_result,
|
||||
)
|
||||
)
|
||||
# Some labels are cleared by DELETE, not by upserting an empty value:
|
||||
# the project membership (empty = "remove from project") and the pinned
|
||||
# flag (empty = "unpin"). Split any empty-valued clear keys out before
|
||||
@@ -1884,6 +1940,13 @@ def register_core_routes(
|
||||
await asyncio.to_thread(conversation_store.delete_label, session_id, _clear_key)
|
||||
if labels_to_set:
|
||||
await asyncio.to_thread(conversation_store.set_labels, session_id, labels_to_set)
|
||||
# Only when the switch was forwarded: a silent PATCH writes no label,
|
||||
# and an unconfirmed mode must not reach the picker.
|
||||
if _CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY in labels_to_set:
|
||||
_publish_permission_mode(
|
||||
session_id,
|
||||
labels_to_set[_CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY],
|
||||
)
|
||||
# Archiving means "get this out of my way", which contradicts a pin
|
||||
# ("keep it at the top"), so drop the archiver's own pin — otherwise the
|
||||
# session lingers as a pinned row if later unarchived. Runs after the
|
||||
|
||||
@@ -93,11 +93,13 @@ from omnigent.server.routes._sessions.common import (
|
||||
_EXTERNAL_MODEL_OPTIONS_TYPE,
|
||||
_EXTERNAL_OUTPUT_REASONING_DELTA_TYPE,
|
||||
_EXTERNAL_OUTPUT_TEXT_DELTA_TYPE,
|
||||
_EXTERNAL_PERMISSION_MODE_CHANGE_TYPE,
|
||||
_EXTERNAL_REASONING_EFFORT_CHANGE_TYPE,
|
||||
_EXTERNAL_SESSION_INTERRUPTED_TYPE,
|
||||
_EXTERNAL_SESSION_STATUS_TYPE,
|
||||
_EXTERNAL_SESSION_STATUS_VALUES,
|
||||
_EXTERNAL_SESSION_SUPERSEDED_TYPE,
|
||||
_EXTERNAL_SESSION_TITLE_TYPE,
|
||||
_EXTERNAL_SESSION_TODOS_TYPE,
|
||||
_EXTERNAL_SESSION_USAGE_TYPE,
|
||||
_EXTERNAL_SUBAGENT_START_TYPE,
|
||||
@@ -139,7 +141,9 @@ from omnigent.server.routes._sessions.helpers import (
|
||||
_persist_external_codex_collaboration_mode_change,
|
||||
_persist_external_model_change,
|
||||
_persist_external_model_options,
|
||||
_persist_external_permission_mode_change,
|
||||
_persist_external_reasoning_effort_change,
|
||||
_persist_external_session_title,
|
||||
_persist_external_subagent_start,
|
||||
_persist_policy_deny_sentinel,
|
||||
_persist_session_status_error_labels,
|
||||
@@ -536,7 +540,9 @@ def register_events_routes(
|
||||
_EXTERNAL_MCP_STARTUP_TYPE,
|
||||
_EXTERNAL_MODEL_CHANGE_TYPE,
|
||||
_EXTERNAL_MODEL_OPTIONS_TYPE,
|
||||
_EXTERNAL_PERMISSION_MODE_CHANGE_TYPE,
|
||||
_EXTERNAL_REASONING_EFFORT_CHANGE_TYPE,
|
||||
_EXTERNAL_SESSION_TITLE_TYPE,
|
||||
_EXTERNAL_SESSION_TODOS_TYPE,
|
||||
_EXTERNAL_SUBAGENT_START_TYPE,
|
||||
_EXTERNAL_CODEX_SUBAGENT_START_TYPE,
|
||||
@@ -1183,6 +1189,22 @@ def register_events_routes(
|
||||
conversation_store,
|
||||
)
|
||||
return {"queued": False}
|
||||
if body.type == _EXTERNAL_PERMISSION_MODE_CHANGE_TYPE:
|
||||
await _persist_external_permission_mode_change(
|
||||
session_id,
|
||||
conv,
|
||||
body,
|
||||
conversation_store,
|
||||
)
|
||||
return {"queued": False}
|
||||
if body.type == _EXTERNAL_SESSION_TITLE_TYPE:
|
||||
await _persist_external_session_title(
|
||||
session_id,
|
||||
conv,
|
||||
body,
|
||||
conversation_store,
|
||||
)
|
||||
return {"queued": False}
|
||||
if body.type == _EXTERNAL_MODEL_OPTIONS_TYPE:
|
||||
_persist_external_model_options(session_id, conv, body)
|
||||
return {"queued": False}
|
||||
|
||||
@@ -32,10 +32,12 @@ to resolving the terminal in the local registry.
|
||||
Wire protocol on the WebSocket
|
||||
------------------------------
|
||||
|
||||
- **Server → client**: every PTY read is forwarded as a *binary*
|
||||
WebSocket frame. xterm.js's ``term.write()`` accepts ``Uint8Array``
|
||||
directly and runs it through its ANSI parser, so colors, cursor
|
||||
motion, alternate screen, mouse modes all work transparently.
|
||||
- **Server → client**: terminal output is forwarded as *binary* WebSocket
|
||||
frames. xterm.js's ``term.write()`` accepts ``Uint8Array`` directly and runs
|
||||
it through its ANSI parser, so colors, cursor motion, alternate screen, and
|
||||
mouse modes work transparently. Control-mode attaches may also send a text
|
||||
``clipboard-write`` JSON frame after tmux reports a copy-mode selection; PTY
|
||||
attaches advertise whether OSC 52 is safe before terminal output begins.
|
||||
- **Client → server**:
|
||||
- **Text frames** are JSON control messages:
|
||||
``{"type": "resize", "cols": N, "rows": M}``. Parsed and applied
|
||||
@@ -274,17 +276,21 @@ def create_terminal_attach_router(
|
||||
"terminal.transport": resolved_transport,
|
||||
},
|
||||
):
|
||||
bridge = (
|
||||
bridge_tmux_control_to_websocket
|
||||
if resolved_transport == TERMINAL_TRANSPORT_CONTROL
|
||||
else bridge_tmux_pty_to_websocket
|
||||
)
|
||||
await bridge(
|
||||
websocket,
|
||||
socket_path=str(entry.instance.socket_path),
|
||||
tmux_target=entry.instance.tmux_target,
|
||||
read_only=read_only,
|
||||
)
|
||||
if resolved_transport == TERMINAL_TRANSPORT_CONTROL:
|
||||
await bridge_tmux_control_to_websocket(
|
||||
websocket,
|
||||
socket_path=str(entry.instance.socket_path),
|
||||
tmux_target=entry.instance.tmux_target,
|
||||
read_only=read_only,
|
||||
)
|
||||
else:
|
||||
await bridge_tmux_pty_to_websocket(
|
||||
websocket,
|
||||
socket_path=str(entry.instance.socket_path),
|
||||
tmux_target=entry.instance.tmux_target,
|
||||
read_only=read_only,
|
||||
allow_osc52_clipboard=not entry.instance.tmux_allow_passthrough,
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from fastapi import APIRouter, Request
|
||||
|
||||
from omnigent._wrapper_labels import WRAPPER_LABEL_KEY
|
||||
from omnigent.entities import Conversation
|
||||
from omnigent.runtime.policies.builder import load_session_usage
|
||||
from omnigent.runtime.policies.builder import load_session_tree, load_session_usage
|
||||
from omnigent.server.auth import RESERVED_USER_LOCAL, AuthProvider
|
||||
from omnigent.server.feature_flags import Feature, FeatureFlags, resolve_feature_flags
|
||||
from omnigent.server.routes._auth_helpers import require_user
|
||||
@@ -65,6 +65,22 @@ def _session_models(usage: dict[str, Any]) -> dict[str, float]:
|
||||
return models
|
||||
|
||||
|
||||
def _collect_other_harnesses(
|
||||
primary: str | None,
|
||||
tree: list[Conversation],
|
||||
root_id: str,
|
||||
) -> list[str] | None:
|
||||
"""Distinct harnesses used by sub-agents, excluding the primary."""
|
||||
seen: set[str] = set()
|
||||
for conv in tree:
|
||||
if conv.id == root_id:
|
||||
continue
|
||||
h = _resolve_session_harness(conv)
|
||||
if h and h != primary:
|
||||
seen.add(h)
|
||||
return sorted(seen) if seen else None
|
||||
|
||||
|
||||
def _resolve_session_harness(conv: Conversation) -> str | None:
|
||||
"""
|
||||
Best-effort harness resolution for the usage report.
|
||||
@@ -152,6 +168,11 @@ def _build_usage_report(
|
||||
if conv.agent_id is None:
|
||||
continue
|
||||
usage = load_session_usage(conv.id, conversation_store)
|
||||
primary_harness = _resolve_session_harness(conv) if include_page_details else None
|
||||
other_harnesses = None
|
||||
if include_page_details:
|
||||
tree = load_session_tree(conv.id, conversation_store)
|
||||
other_harnesses = _collect_other_harnesses(primary_harness, tree, conv.id)
|
||||
sessions.append(
|
||||
SessionUsage(
|
||||
id=conv.id,
|
||||
@@ -160,7 +181,8 @@ def _build_usage_report(
|
||||
title=conv.title,
|
||||
cost_usd=_session_cost(usage),
|
||||
models=_session_models(usage),
|
||||
harness=_resolve_session_harness(conv) if include_page_details else None,
|
||||
harness=primary_harness,
|
||||
other_harnesses=other_harnesses,
|
||||
llm_model=(
|
||||
conv.model_override or _resolve_llm_model(conv)
|
||||
if include_page_details
|
||||
|
||||
@@ -120,6 +120,7 @@ class FireDeps:
|
||||
permission_store: Any | None
|
||||
host_store: Any | None
|
||||
host_registry: Any | None
|
||||
policy_store: Any | None = None
|
||||
agent_cache: Any | None = None
|
||||
runner_router: Any | None = None
|
||||
tunnel_registry: Any | None = None
|
||||
@@ -425,6 +426,8 @@ async def _run_fire_for_task(
|
||||
)
|
||||
return
|
||||
|
||||
await _attach_cost_budget(deps, task, conv.id)
|
||||
|
||||
try:
|
||||
await _grant_owner(deps, task, conv.id)
|
||||
except Exception:
|
||||
@@ -607,6 +610,38 @@ async def _create_session(deps: FireDeps, task: ScheduledTask) -> Conversation:
|
||||
return conv
|
||||
|
||||
|
||||
_COST_BUDGET_HANDLER = "omnigent.policies.builtins.cost.cost_budget"
|
||||
_COST_BUDGET_POLICY_NAME = "__scheduled_task_cost_budget"
|
||||
|
||||
|
||||
async def _attach_cost_budget(deps: FireDeps, task: ScheduledTask, conversation_id: str) -> None:
|
||||
"""Attach a cost_budget policy to a session spawned by a scheduled task.
|
||||
|
||||
Non-fatal: a failure logs a warning but does not fail the fire — an
|
||||
uncapped session is better than a dead run.
|
||||
"""
|
||||
if task.max_cost_usd is None or deps.policy_store is None:
|
||||
return
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
deps.policy_store.create,
|
||||
policy_id=_new_id(),
|
||||
session_id=conversation_id,
|
||||
name=_COST_BUDGET_POLICY_NAME,
|
||||
type="python",
|
||||
handler=_COST_BUDGET_HANDLER,
|
||||
factory_params={"max_cost_usd": task.max_cost_usd},
|
||||
enabled=True,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
_logger.warning(
|
||||
"scheduled fire: failed to attach cost budget for task %s (session %s)",
|
||||
task.id,
|
||||
conversation_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
async def _grant_owner(deps: FireDeps, task: ScheduledTask, conversation_id: str) -> None:
|
||||
"""Write the LEVEL_OWNER grant so the run is visible to its owner.
|
||||
|
||||
|
||||
+83
-17
@@ -1485,7 +1485,7 @@ class SessionCreateMetadata(BaseModel):
|
||||
:param reasoning_effort: Optional per-session reasoning-effort
|
||||
hint. Accepted metadata values are ``"none"``,
|
||||
``"minimal"``, ``"low"``, ``"medium"``, ``"high"``,
|
||||
``"xhigh"``, and ``"max"``. Provider-specific support is
|
||||
``"xhigh"``, ``"max"``, and ``"ultra"``. Provider-specific support is
|
||||
validated when a turn executes. ``None`` means use the agent
|
||||
default.
|
||||
:param host_id: Optional host to launch the runner on, e.g.
|
||||
@@ -1720,10 +1720,13 @@ class SessionResponse(BaseModel):
|
||||
permission level on this session: ``1`` = read, ``2`` =
|
||||
edit, ``3`` = manage. ``None`` when permissions are
|
||||
disabled (single-user mode without a permission store).
|
||||
:param llm_model: The LLM model identifier from the bound
|
||||
agent's spec, e.g. ``"anthropic/claude-sonnet-4-6"``.
|
||||
``None`` when the agent has no explicit ``llm:`` block or
|
||||
the agent cannot be looked up.
|
||||
:param llm_model: The model this session is actually on. When the
|
||||
harness has reported one (``reported_model``, written by
|
||||
``external_model_change``), that verbatim value serves here
|
||||
and is the only model value clients display; otherwise the
|
||||
bound agent spec's model, e.g.
|
||||
``"anthropic/claude-sonnet-4-6"``. ``None`` when neither
|
||||
exists.
|
||||
:param harness: The bound agent's canonical harness, e.g.
|
||||
``"claude-sdk"`` or ``"openai-agents"``. Lets the client
|
||||
render the active credential for the correct provider
|
||||
@@ -1977,6 +1980,15 @@ class UpdateSessionRequest(BaseModel):
|
||||
``"plan"`` enters Plan mode and ``"default"`` returns to Default
|
||||
mode for subsequent Codex turns. Only valid for sessions stamped
|
||||
with the codex-native wrapper label. Omitted leaves unchanged.
|
||||
:param permission_mode: Claude-native permission mode to switch a
|
||||
running session to, e.g. ``"auto"``. Only the modes Claude Code's
|
||||
shift+tab cycle can reach are accepted (``default``,
|
||||
``acceptEdits``, ``plan``, ``auto``) — ``dontAsk`` and
|
||||
``bypassPermissions`` are launch-only. Only valid for sessions
|
||||
stamped with the claude-native wrapper label. Unlike the other
|
||||
fields here the switch is applied by the live TUI, so a failure
|
||||
to reach the mode is surfaced as an error rather than persisted.
|
||||
Omitted leaves unchanged.
|
||||
:param cost_control_mode_override: Per-session cost-control
|
||||
switch: ``"on"`` activates the spec's configured cost-control
|
||||
mode, ``"off"`` disables cost control for this session.
|
||||
@@ -2034,6 +2046,7 @@ class UpdateSessionRequest(BaseModel):
|
||||
reasoning_effort: str | None = None
|
||||
model_override: str | None = None
|
||||
collaboration_mode: str | None = None
|
||||
permission_mode: str | None = None
|
||||
cost_control_mode_override: str | None = None
|
||||
subagent_routing_override: str | None = None
|
||||
external_session_id: str | None = None
|
||||
@@ -2440,6 +2453,7 @@ class SessionUsage(BaseModel):
|
||||
cost_usd: float = 0.0
|
||||
models: dict[str, float] = Field(default_factory=dict)
|
||||
harness: str | None = None
|
||||
other_harnesses: list[str] | None = None
|
||||
llm_model: str | None = None
|
||||
agent_name: str | None = None
|
||||
|
||||
@@ -2724,24 +2738,24 @@ class SessionUsageEvent(_SSEEventBase):
|
||||
|
||||
class SessionModelEvent(_SSEEventBase):
|
||||
"""
|
||||
Active-model update from a terminal-backed integration.
|
||||
Active-model report from a terminal-backed integration.
|
||||
|
||||
Emitted after an ``external_model_change`` POST from the
|
||||
``omnigent claude`` transcript forwarder when the model is
|
||||
switched inside the Claude Code terminal (a ``/model`` command or
|
||||
the in-TUI picker). Lets the web model picker reflect a TUI-side
|
||||
switch without a reload.
|
||||
Emitted after an ``external_model_change`` POST from a native
|
||||
forwarder — the launch's own model report, or a switch made inside
|
||||
the pane (a ``/model`` command or the in-TUI picker). Every surface
|
||||
re-renders its model display from this.
|
||||
|
||||
:param type: Always ``"session.model"``.
|
||||
:param conversation_id: Session identifier, e.g. ``"conv_abc123"``.
|
||||
:param model: Tier alias the session is now on, e.g. ``"opus"`` —
|
||||
Claude Code's version-agnostic alias, matching the picker's
|
||||
vocabulary (not a pinned ``"claude-opus-4-8"`` id).
|
||||
:param model: The model the harness reports the session is on,
|
||||
VERBATIM in the harness's own spelling, e.g.
|
||||
``"claude-opus-4-8[1m]"`` or ``"gpt-5.6-luna"`` — never
|
||||
collapsed to a picker alias.
|
||||
|
||||
Category: **transient** (SSE-only). The server also writes
|
||||
``model_override`` on the conversation, so on reconnect clients
|
||||
restore the selection from the snapshot's ``model_override`` rather
|
||||
than from a replayed event.
|
||||
``reported_model`` on the conversation (served on the snapshot's
|
||||
``llm_model``), so on reconnect clients restore the display from
|
||||
the snapshot rather than from a replayed event.
|
||||
"""
|
||||
|
||||
type: Literal["session.model"]
|
||||
@@ -2749,6 +2763,29 @@ class SessionModelEvent(_SSEEventBase):
|
||||
model: str
|
||||
|
||||
|
||||
class SessionTitleEvent(_SSEEventBase):
|
||||
"""
|
||||
Session-title update from a terminal-backed integration.
|
||||
|
||||
Emitted after an ``external_session_title`` POST from the
|
||||
``omnigent claude`` transcript forwarder when the operator renames
|
||||
the session inside the Claude Code pane (``/rename``). Lets the web
|
||||
session list show the new name without a reload.
|
||||
|
||||
:param type: Always ``"session.title"``.
|
||||
:param conversation_id: Session identifier, e.g. ``"conv_abc123"``.
|
||||
:param title: Title the session is now on, e.g. ``"auth-refactor"``.
|
||||
|
||||
Category: **transient** (SSE-only). The server also writes ``title``
|
||||
on the conversation, so on reconnect clients restore the name from
|
||||
the session snapshot rather than from a replayed event.
|
||||
"""
|
||||
|
||||
type: Literal["session.title"]
|
||||
conversation_id: str
|
||||
title: str
|
||||
|
||||
|
||||
class SessionReasoningEffortEvent(_SSEEventBase):
|
||||
"""
|
||||
Active reasoning-effort update from a terminal-backed integration.
|
||||
@@ -2797,6 +2834,29 @@ class SessionCollaborationModeEvent(_SSEEventBase):
|
||||
mode: str
|
||||
|
||||
|
||||
class SessionPermissionModeEvent(_SSEEventBase):
|
||||
"""
|
||||
Active permission-mode update from a claude-native session.
|
||||
|
||||
Emitted after the web UI switches the mode, and after the Claude forwarder
|
||||
observes a different mode in the pane footer — a shift+tab pressed inside
|
||||
the TUI, which Omnigent has no other way to see. Lets the composer's mode
|
||||
picker track the pane without a reload.
|
||||
|
||||
:param type: Always ``"session.permission_mode"``.
|
||||
:param conversation_id: Session identifier, e.g. ``"conv_abc123"``.
|
||||
:param permission_mode: The active mode, e.g. ``"auto"`` or ``"plan"``.
|
||||
|
||||
Category: **transient** (SSE-only). The server also writes
|
||||
``omnigent.claude_native.permission_mode`` on the conversation labels, so
|
||||
reconnecting clients restore the same state from the session snapshot.
|
||||
"""
|
||||
|
||||
type: Literal["session.permission_mode"]
|
||||
conversation_id: str
|
||||
permission_mode: str
|
||||
|
||||
|
||||
class SessionAgentChangedEvent(_SSEEventBase):
|
||||
"""
|
||||
Bound-agent change on a live session.
|
||||
@@ -4186,8 +4246,10 @@ ServerStreamEvent = Annotated[
|
||||
SessionStatusEvent
|
||||
| SessionUsageEvent
|
||||
| SessionModelEvent
|
||||
| SessionTitleEvent
|
||||
| SessionReasoningEffortEvent
|
||||
| SessionCollaborationModeEvent
|
||||
| SessionPermissionModeEvent
|
||||
| SessionAgentChangedEvent
|
||||
| SessionTodosEvent
|
||||
| SessionTerminalPendingEvent
|
||||
@@ -4374,10 +4436,14 @@ class SessionProjectSummary(BaseModel):
|
||||
:param id: First-class project id when one exists, or ``None`` for a
|
||||
label-only project not yet promoted to the ``projects`` table.
|
||||
:param name: Project name (the folder's display name and union key).
|
||||
:param icon: The project's chosen emoji icon (a unicode grapheme), read
|
||||
from its ``config``; ``None`` when unset or for a label-only folder,
|
||||
so the sidebar falls back to the default folder glyph.
|
||||
"""
|
||||
|
||||
id: str | None = None
|
||||
name: str
|
||||
icon: str | None = None
|
||||
|
||||
|
||||
class CreateProjectRequest(BaseModel):
|
||||
|
||||
@@ -1670,10 +1670,9 @@ def _translate_executor_from_def(
|
||||
of the supported set so an empty string fails
|
||||
hard there.
|
||||
:param raw_executor: Optional raw YAML ``executor:`` mapping.
|
||||
When present, ``use_responses`` (``bool | None``) is read
|
||||
from it and forwarded into ``executor.config["use_responses"]``
|
||||
so the openai-agents harness subprocess reads the correct
|
||||
API surface (chat/completions vs. responses). The omnigent
|
||||
When present, OpenAI Agents SDK wire settings are forwarded
|
||||
into ``executor.config`` so the harness subprocess reads the
|
||||
correct API surface and reasoning replay policy. The omnigent
|
||||
loader silently drops unknown fields on its own
|
||||
:class:`~omnigent.inner.datamodel.ExecutorSpec`, so we
|
||||
have to recover this field from the raw dict here.
|
||||
@@ -1749,9 +1748,8 @@ def _translate_executor_from_def(
|
||||
"harness": harness,
|
||||
"profile": profile,
|
||||
}
|
||||
# ``use_responses`` and ``acp_agent`` are not fields on the omnigent inner
|
||||
# ExecutorSpec (the loader drops unknown keys), so read them from the raw
|
||||
# YAML dict and carry them forward explicitly.
|
||||
# These are not fields on the omnigent inner ExecutorSpec, so read them
|
||||
# from the raw YAML dict and carry them forward explicitly.
|
||||
# The openai-agents harness spawn-env builder reads
|
||||
# ``spec.executor.config["use_responses"]`` to set
|
||||
# ``HARNESS_OPENAI_AGENTS_USE_RESPONSES``, which controls
|
||||
@@ -1761,6 +1759,8 @@ def _translate_executor_from_def(
|
||||
use_responses_raw = raw_executor.get("use_responses")
|
||||
if use_responses_raw is not None:
|
||||
config["use_responses"] = bool(use_responses_raw)
|
||||
if "reasoning_item_id_policy" in raw_executor:
|
||||
config["reasoning_item_id_policy"] = raw_executor["reasoning_item_id_policy"]
|
||||
if "acp_agent" in raw_executor:
|
||||
config["acp_agent"] = raw_executor["acp_agent"]
|
||||
# ``auth`` is now parsed by the loader into OmniExecutorSpec.auth;
|
||||
|
||||
@@ -776,6 +776,7 @@ class ConversationStore(ABC):
|
||||
_unset_harness_override: bool = False,
|
||||
terminal_launch_args: list[str] | None = None,
|
||||
archived: bool | None = None,
|
||||
reported_model: str | None = None,
|
||||
) -> Conversation | None:
|
||||
"""
|
||||
Update mutable fields on a conversation.
|
||||
@@ -785,7 +786,9 @@ class ConversationStore(ABC):
|
||||
and ``harness_override``,
|
||||
``None`` means "leave unchanged". To explicitly clear them
|
||||
back to ``None``, pass
|
||||
the matching ``_unset_*`` flag.
|
||||
the matching ``_unset_*`` flag. ``reported_model`` (the model
|
||||
the harness last reported, verbatim) has no ``_unset`` variant:
|
||||
reports only ever move forward.
|
||||
|
||||
:param conversation_id: Unique conversation identifier,
|
||||
e.g. ``"conv_abc123"``.
|
||||
|
||||
@@ -118,6 +118,7 @@ class _RowCountResult(Protocol):
|
||||
_SESSION_OVERRIDE_KEYS = (
|
||||
"reasoning_effort",
|
||||
"model_override",
|
||||
"reported_model",
|
||||
"cost_control_mode_override",
|
||||
"subagent_routing_override",
|
||||
"harness_override",
|
||||
@@ -209,6 +210,7 @@ def _to_conversation(
|
||||
session_usage=session_usage,
|
||||
reasoning_effort=overrides["reasoning_effort"],
|
||||
model_override=overrides["model_override"],
|
||||
reported_model=overrides["reported_model"],
|
||||
cost_control_mode_override=overrides["cost_control_mode_override"],
|
||||
subagent_routing_override=overrides["subagent_routing_override"],
|
||||
harness_override=overrides["harness_override"],
|
||||
@@ -2697,6 +2699,7 @@ class SqlAlchemyConversationStore(ConversationStore):
|
||||
_unset_harness_override: bool = False,
|
||||
terminal_launch_args: list[str] | None = None,
|
||||
archived: bool | None = None,
|
||||
reported_model: str | None = None,
|
||||
) -> Conversation | None:
|
||||
"""
|
||||
Update mutable fields on a conversation.
|
||||
@@ -2708,10 +2711,15 @@ class SqlAlchemyConversationStore(ConversationStore):
|
||||
e.g. ``"high"``. ``None`` leaves unchanged.
|
||||
:param _unset_reasoning_effort: When ``True``, clear
|
||||
``reasoning_effort`` to ``None``.
|
||||
:param model_override: Per-session LLM model override,
|
||||
e.g. ``"claude-opus-4-7"``. ``None`` leaves unchanged.
|
||||
:param model_override: Per-session LLM model override — the
|
||||
user's request, e.g. ``"claude-opus-4-7"``. ``None``
|
||||
leaves unchanged.
|
||||
:param _unset_model_override: When ``True``, clear
|
||||
``model_override`` to ``None``.
|
||||
:param reported_model: The model the harness last reported the
|
||||
session is actually on, verbatim, e.g.
|
||||
``"claude-opus-4-8[1m]"``. ``None`` leaves unchanged.
|
||||
No ``_unset`` variant — reports only ever move forward.
|
||||
:param cost_control_mode_override: Per-session cost-control
|
||||
switch, ``"on"`` or ``"off"``. ``None`` leaves unchanged.
|
||||
:param _unset_cost_control_mode_override: When ``True``, clear
|
||||
@@ -2764,6 +2772,9 @@ class SqlAlchemyConversationStore(ConversationStore):
|
||||
elif model_override is not None:
|
||||
overrides["model_override"] = model_override
|
||||
overrides_changed = True
|
||||
if reported_model is not None:
|
||||
overrides["reported_model"] = reported_model
|
||||
overrides_changed = True
|
||||
if _unset_cost_control_mode_override:
|
||||
overrides["cost_control_mode_override"] = None
|
||||
overrides_changed = True
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import cast
|
||||
|
||||
from sqlalchemy import delete, exists, literal, select, update
|
||||
@@ -26,6 +31,60 @@ from omnigent.stores.permission_store import PermissionStore
|
||||
# list is identical across auth modes.
|
||||
_HIDDEN_LIST_USERS = frozenset({RESERVED_USER_PUBLIC, RESERVED_USER_LOCAL})
|
||||
|
||||
# Short-lived cache of resolve_access() results. The per-event access-control
|
||||
# check on a busy session otherwise re-reads session_permissions + users on
|
||||
# every streamed event, for a session whose grants are stable across the turn.
|
||||
# Only a *positive* standing is cached — a no-access result is never stored, so
|
||||
# a freshly granted user is authorized on their next request, not after the TTL.
|
||||
# This store's own grant/revoke/reassign/set_admin writes evict, and a
|
||||
# generation counter stops an in-flight reader from re-storing a pre-commit
|
||||
# positive on top of that eviction, so once such a write returns this instance
|
||||
# serves no stale decision. Role changes made through the separate accounts
|
||||
# store (admin demote, user delete) are NOT evicted here and propagate within
|
||||
# the TTL. Across replicas there is no invalidation broadcast either, so a
|
||||
# revoke can be up to the TTL late elsewhere; that window is LEVEL_EDIT only —
|
||||
# the destructive stop/kill path re-gates LEVEL_OWNER separately, and a deleted
|
||||
# session still 404s on its uncached conversation read. Entries are keyed by
|
||||
# (conversation_id, user_id):
|
||||
# conversation ids are globally unique, so eviction needs no workspace context,
|
||||
# and the map is an LRU bounded by a hard entry cap so a long-lived replica
|
||||
# cannot grow it without limit (resolve_access is on the snapshot path too, not
|
||||
# just the hot event path). Set the TTL env to 0 to disable (zero overhead).
|
||||
_RESOLVE_ACCESS_CACHE_TTL_ENV = "OMNIGENT_ACL_RESOLVE_CACHE_TTL_S"
|
||||
_DEFAULT_RESOLVE_ACCESS_CACHE_TTL_S = 5.0
|
||||
_RESOLVE_ACCESS_CACHE_MAX_ENTRIES_ENV = "OMNIGENT_ACL_RESOLVE_CACHE_MAX_ENTRIES"
|
||||
_DEFAULT_RESOLVE_ACCESS_CACHE_MAX_ENTRIES = 50_000
|
||||
|
||||
|
||||
def _resolve_access_cache_ttl_s() -> float:
|
||||
"""Read the resolve_access cache TTL (seconds) from the environment.
|
||||
|
||||
Defaults to :data:`_DEFAULT_RESOLVE_ACCESS_CACHE_TTL_S`; a value <= 0
|
||||
disables the cache. An unparseable value falls back to the default.
|
||||
"""
|
||||
raw = os.environ.get(_RESOLVE_ACCESS_CACHE_TTL_ENV)
|
||||
if raw is None:
|
||||
return _DEFAULT_RESOLVE_ACCESS_CACHE_TTL_S
|
||||
try:
|
||||
return max(float(raw), 0.0)
|
||||
except ValueError:
|
||||
return _DEFAULT_RESOLVE_ACCESS_CACHE_TTL_S
|
||||
|
||||
|
||||
def _resolve_access_cache_max_entries() -> int:
|
||||
"""Read the resolve_access cache entry cap from the environment.
|
||||
|
||||
Defaults to :data:`_DEFAULT_RESOLVE_ACCESS_CACHE_MAX_ENTRIES`; ``0`` means
|
||||
unbounded. An unparseable value falls back to the default.
|
||||
"""
|
||||
raw = os.environ.get(_RESOLVE_ACCESS_CACHE_MAX_ENTRIES_ENV)
|
||||
if raw is None:
|
||||
return _DEFAULT_RESOLVE_ACCESS_CACHE_MAX_ENTRIES
|
||||
try:
|
||||
return max(int(raw), 0)
|
||||
except ValueError:
|
||||
return _DEFAULT_RESOLVE_ACCESS_CACHE_MAX_ENTRIES
|
||||
|
||||
|
||||
def _to_account(row: SqlUser) -> Account:
|
||||
"""Convert a :class:`SqlUser` ORM row to an :class:`Account` entity.
|
||||
@@ -78,6 +137,27 @@ class SqlAlchemyPermissionStore(PermissionStore):
|
||||
self._engine,
|
||||
query_name_prefix="omnigent.permission_store",
|
||||
)
|
||||
# resolve_access cache (see _RESOLVE_ACCESS_CACHE_TTL_ENV). An LRU keyed
|
||||
# (conversation_id, user_id) -> (expiry, access). conversation ids are
|
||||
# globally unique, so grant/revoke can drop a whole session's entries —
|
||||
# including the shared __public__ grant, which affects every user of
|
||||
# that session — without depending on the ambient workspace context.
|
||||
# The hard entry cap bounds memory on a long-lived replica. Per store
|
||||
# instance, so each replica caches independently and tests get a fresh
|
||||
# cache with each store. ``_resolve_cache_clock`` is injectable for
|
||||
# deterministic TTL tests.
|
||||
self._resolve_cache_ttl_s = _resolve_access_cache_ttl_s()
|
||||
self._resolve_cache_max_entries = _resolve_access_cache_max_entries()
|
||||
self._resolve_cache: collections.OrderedDict[
|
||||
tuple[str, str], tuple[float, ResolvedAccess]
|
||||
] = collections.OrderedDict()
|
||||
self._resolve_cache_lock = threading.Lock()
|
||||
self._resolve_cache_clock: Callable[[], float] = time.monotonic
|
||||
# Bumped by every invalidation. resolve_access samples it before its DB
|
||||
# read and refuses to store a result sampled under an older generation,
|
||||
# so a reader whose snapshot predates a concurrent grant/revoke commit
|
||||
# cannot re-poison the cache after that write already evicted.
|
||||
self._resolve_cache_generation = 0
|
||||
|
||||
def grant(
|
||||
self,
|
||||
@@ -120,11 +200,13 @@ class SqlAlchemyPermissionStore(PermissionStore):
|
||||
)
|
||||
session.execute(stmt)
|
||||
session.flush()
|
||||
return SessionPermission(
|
||||
user_id=user_id,
|
||||
conversation_id=conversation_id,
|
||||
level=level,
|
||||
)
|
||||
# Evict after commit: the grant changed this session's access picture.
|
||||
self._invalidate_resolve_cache_for_session(conversation_id)
|
||||
return SessionPermission(
|
||||
user_id=user_id,
|
||||
conversation_id=conversation_id,
|
||||
level=level,
|
||||
)
|
||||
|
||||
def revoke(self, user_id: str, conversation_id: str) -> bool:
|
||||
"""Remove a permission grant. See base class for contract."""
|
||||
@@ -139,7 +221,11 @@ class SqlAlchemyPermissionStore(PermissionStore):
|
||||
)
|
||||
),
|
||||
)
|
||||
return result.rowcount > 0
|
||||
deleted = result.rowcount > 0
|
||||
# Evict after commit: a revoke must not be served stale from this
|
||||
# instance's cache.
|
||||
self._invalidate_resolve_cache_for_session(conversation_id)
|
||||
return deleted
|
||||
|
||||
def get(self, user_id: str, conversation_id: str) -> SessionPermission | None:
|
||||
"""Look up a single grant. See base class for contract."""
|
||||
@@ -222,7 +308,10 @@ class SqlAlchemyPermissionStore(PermissionStore):
|
||||
.values(user_id=to_user_id)
|
||||
)
|
||||
moved = len(reassign_ids)
|
||||
return moved
|
||||
# Grants moved between users across sessions; drop this store's cache
|
||||
# (the no-rows path above returned early, having changed nothing).
|
||||
self._invalidate_resolve_cache_all()
|
||||
return moved
|
||||
|
||||
def list_for_session(
|
||||
self,
|
||||
@@ -352,6 +441,9 @@ class SqlAlchemyPermissionStore(PermissionStore):
|
||||
)
|
||||
.values(is_admin=is_admin)
|
||||
)
|
||||
# The admin flag flips access on every session for this user; drop
|
||||
# this store's cache.
|
||||
self._invalidate_resolve_cache_all()
|
||||
|
||||
def check_access(
|
||||
self,
|
||||
@@ -391,6 +483,66 @@ class SqlAlchemyPermissionStore(PermissionStore):
|
||||
return public_grant.level
|
||||
return None
|
||||
|
||||
def _resolve_cache_lookup(self, conversation_id: str, user_id: str) -> ResolvedAccess | None:
|
||||
"""Return a live cached resolve_access result, or ``None`` on miss/expiry."""
|
||||
now = self._resolve_cache_clock()
|
||||
key = (conversation_id, user_id)
|
||||
with self._resolve_cache_lock:
|
||||
entry = self._resolve_cache.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
expiry, access = entry
|
||||
if now >= expiry:
|
||||
del self._resolve_cache[key]
|
||||
return None
|
||||
self._resolve_cache.move_to_end(key) # LRU: mark most-recently used
|
||||
return access
|
||||
|
||||
def _resolve_cache_generation_now(self) -> int:
|
||||
"""Sample the invalidation generation before a read begins."""
|
||||
with self._resolve_cache_lock:
|
||||
return self._resolve_cache_generation
|
||||
|
||||
def _resolve_cache_store(
|
||||
self,
|
||||
conversation_id: str,
|
||||
user_id: str,
|
||||
access: ResolvedAccess,
|
||||
generation: int,
|
||||
) -> None:
|
||||
"""Cache one *granted* resolve_access result until now + TTL.
|
||||
|
||||
Dropped when *generation* is stale — an invalidation landed while this
|
||||
result was being read, so the value may predate that write and must not
|
||||
be stored on top of the eviction it already performed. Enforces the LRU
|
||||
entry cap so the cache cannot grow without bound on a long-lived replica.
|
||||
"""
|
||||
key = (conversation_id, user_id)
|
||||
expiry = self._resolve_cache_clock() + self._resolve_cache_ttl_s
|
||||
with self._resolve_cache_lock:
|
||||
if generation != self._resolve_cache_generation:
|
||||
return
|
||||
self._resolve_cache[key] = (expiry, access)
|
||||
self._resolve_cache.move_to_end(key)
|
||||
max_entries = self._resolve_cache_max_entries
|
||||
if max_entries > 0:
|
||||
while len(self._resolve_cache) > max_entries:
|
||||
self._resolve_cache.popitem(last=False) # drop least-recently used
|
||||
|
||||
def _invalidate_resolve_cache_for_session(self, conversation_id: str) -> None:
|
||||
"""Drop every cached decision for one session (all users + ``__public__``)."""
|
||||
with self._resolve_cache_lock:
|
||||
self._resolve_cache_generation += 1
|
||||
stale = [key for key in self._resolve_cache if key[0] == conversation_id]
|
||||
for key in stale:
|
||||
del self._resolve_cache[key]
|
||||
|
||||
def _invalidate_resolve_cache_all(self) -> None:
|
||||
"""Drop the whole cache — for admin-flag or bulk-grant changes."""
|
||||
with self._resolve_cache_lock:
|
||||
self._resolve_cache_generation += 1
|
||||
self._resolve_cache.clear()
|
||||
|
||||
def resolve_access(
|
||||
self,
|
||||
user_id: str | None,
|
||||
@@ -403,6 +555,17 @@ class SqlAlchemyPermissionStore(PermissionStore):
|
||||
user_grant_level=None,
|
||||
public_grant_level=None,
|
||||
)
|
||||
workspace_id = current_workspace_id()
|
||||
cache_enabled = self._resolve_cache_ttl_s > 0
|
||||
generation = 0
|
||||
if cache_enabled:
|
||||
cached = self._resolve_cache_lookup(conversation_id, user_id)
|
||||
if cached is not None:
|
||||
return cached
|
||||
# Sampled before the read: an invalidation landing while the rows
|
||||
# below are being fetched makes this result unstorable, so a write
|
||||
# committed mid-read can't be undone by a stale positive.
|
||||
generation = self._resolve_cache_generation_now()
|
||||
# One session = one connection checkout + transaction. Against a
|
||||
# remote DB (Lakebase) this is the round-trip that matters; the three
|
||||
# primary-key reads below pipeline on the same connection rather than
|
||||
@@ -410,19 +573,29 @@ class SqlAlchemyPermissionStore(PermissionStore):
|
||||
# calling is_admin + check_access + get_permission_level separately
|
||||
# did — see the GET /v1/sessions/{id} snapshot path).
|
||||
with self._session("resolve_access") as session:
|
||||
user_row = session.get(SqlUser, (current_workspace_id(), user_id))
|
||||
user_row = session.get(SqlUser, (workspace_id, user_id))
|
||||
user_grant = session.get(
|
||||
SqlSessionPermission, (current_workspace_id(), user_id, conversation_id)
|
||||
SqlSessionPermission, (workspace_id, user_id, conversation_id)
|
||||
)
|
||||
public_grant = session.get(
|
||||
SqlSessionPermission,
|
||||
(current_workspace_id(), RESERVED_USER_PUBLIC, conversation_id),
|
||||
(workspace_id, RESERVED_USER_PUBLIC, conversation_id),
|
||||
)
|
||||
return ResolvedAccess(
|
||||
access = ResolvedAccess(
|
||||
is_admin=user_row is not None and user_row.is_admin,
|
||||
user_grant_level=user_grant.level if user_grant is not None else None,
|
||||
public_grant_level=public_grant.level if public_grant is not None else None,
|
||||
)
|
||||
# Cache only a positive standing: a no-access result is left uncached so
|
||||
# a freshly granted user is authorized on their next request, not after
|
||||
# the TTL elapses.
|
||||
if cache_enabled and (
|
||||
access.is_admin
|
||||
or access.user_grant_level is not None
|
||||
or access.public_grant_level is not None
|
||||
):
|
||||
self._resolve_cache_store(conversation_id, user_id, access, generation)
|
||||
return access
|
||||
|
||||
def has_any_grants(self, conversation_id: str) -> bool:
|
||||
"""Check for any permission rows. See base class for contract."""
|
||||
|
||||
@@ -50,6 +50,7 @@ class ScheduledTaskStore(ABC):
|
||||
*,
|
||||
model_override: str | None = None,
|
||||
reasoning_effort: str | None = None,
|
||||
max_cost_usd: float | None = None,
|
||||
workspace: str | None = None,
|
||||
host_id: str | None = None,
|
||||
state: str = "active",
|
||||
@@ -68,6 +69,7 @@ class ScheduledTaskStore(ABC):
|
||||
:param timezone: IANA timezone the trigger is evaluated in.
|
||||
:param model_override: Optional LLM model override.
|
||||
:param reasoning_effort: Optional reasoning-effort hint.
|
||||
:param max_cost_usd: Optional per-firing cost budget in USD.
|
||||
:param workspace: Runner start path (source repo / working dir).
|
||||
:param host_id: The connected host to pin the run to.
|
||||
:param state: Lifecycle state — ``active``/``paused``/``deleted``.
|
||||
@@ -131,6 +133,7 @@ class ScheduledTaskStore(ABC):
|
||||
timezone: str | None = None,
|
||||
model_override: str | None = None,
|
||||
reasoning_effort: str | None = None,
|
||||
max_cost_usd: float | None = _UNSET,
|
||||
workspace: str | None = None,
|
||||
host_id: str | None = _UNSET,
|
||||
state: str | None = None,
|
||||
|
||||
@@ -53,6 +53,7 @@ def _to_entity(row: SqlScheduledTask) -> ScheduledTask:
|
||||
rrule=row.rrule,
|
||||
model_override=row.model_override,
|
||||
reasoning_effort=row.reasoning_effort,
|
||||
max_cost_usd=row.max_cost_usd,
|
||||
workspace=row.workspace,
|
||||
base_branch=row.base_branch,
|
||||
execution_target=decode_scheduled_task_execution_target(row.execution_target),
|
||||
@@ -127,6 +128,7 @@ class SqlAlchemyScheduledTaskStore(ScheduledTaskStore):
|
||||
*,
|
||||
model_override: str | None = None,
|
||||
reasoning_effort: str | None = None,
|
||||
max_cost_usd: float | None = None,
|
||||
workspace: str | None = None,
|
||||
host_id: str | None = None,
|
||||
state: str = "active",
|
||||
@@ -142,6 +144,7 @@ class SqlAlchemyScheduledTaskStore(ScheduledTaskStore):
|
||||
timezone=timezone,
|
||||
model_override=model_override,
|
||||
reasoning_effort=reasoning_effort,
|
||||
max_cost_usd=max_cost_usd,
|
||||
workspace=workspace,
|
||||
base_branch=None,
|
||||
execution_target=encode_scheduled_task_execution_target("connected_host"),
|
||||
@@ -246,6 +249,7 @@ class SqlAlchemyScheduledTaskStore(ScheduledTaskStore):
|
||||
timezone: str | None = None,
|
||||
model_override: str | None = None,
|
||||
reasoning_effort: str | None = None,
|
||||
max_cost_usd: float | None = _UNSET,
|
||||
workspace: str | None = None,
|
||||
host_id: str | None = _UNSET,
|
||||
state: str | None = None,
|
||||
@@ -254,11 +258,11 @@ class SqlAlchemyScheduledTaskStore(ScheduledTaskStore):
|
||||
) -> ScheduledTask | None:
|
||||
"""Update mutable fields.
|
||||
|
||||
``None`` leaves most fields unchanged. For ``host_id`` and
|
||||
``last_run_conversation_id``, the sentinel default means "not provided
|
||||
/ leave unchanged"; passing ``None`` explicitly sets the column to NULL.
|
||||
Passing ``rrule`` updates the recurring trigger; ``None``
|
||||
leaves it unchanged.
|
||||
``None`` leaves most fields unchanged. For ``host_id``,
|
||||
``max_cost_usd``, and ``last_run_conversation_id``, the sentinel
|
||||
default means "not provided / leave unchanged"; passing ``None``
|
||||
explicitly sets the column to NULL. Passing ``rrule`` updates the
|
||||
recurring trigger; ``None`` leaves it unchanged.
|
||||
"""
|
||||
with self._session("update_task") as session:
|
||||
row = session.get(SqlScheduledTask, (current_workspace_id(), scheduled_task_id))
|
||||
@@ -283,6 +287,9 @@ class SqlAlchemyScheduledTaskStore(ScheduledTaskStore):
|
||||
if reasoning_effort is not None and row.reasoning_effort != reasoning_effort:
|
||||
row.reasoning_effort = reasoning_effort
|
||||
changed = True
|
||||
if max_cost_usd is not _UNSET and row.max_cost_usd != max_cost_usd:
|
||||
row.max_cost_usd = max_cost_usd
|
||||
changed = True
|
||||
if workspace is not None and row.workspace != workspace:
|
||||
row.workspace = workspace
|
||||
changed = True
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Detect a system suspend/resume (laptop sleep) so live connections can
|
||||
reconnect promptly instead of waiting out the WebSocket keepalive.
|
||||
|
||||
When a laptop's lid closes the OS freezes the whole process and drops the
|
||||
network. Long-lived WebSockets (the host control channel in
|
||||
:mod:`omnigent.host.connect`, every runner tunnel in
|
||||
:mod:`omnigent.runner.transports.ws_tunnel.serve`) become half-open sockets
|
||||
the peer has already dropped. Nothing notices until the ``websockets``
|
||||
keepalive ping times out — up to ~120 s (``ping_interval`` 30 s +
|
||||
``ping_timeout`` 90 s) — during which the host and its sessions look offline
|
||||
to the server and the desktop app.
|
||||
|
||||
:func:`watch_for_resume` gives an event loop a cheap way to react the instant
|
||||
it wakes: it polls a short interval and compares how far the realtime (wall)
|
||||
clock advanced against the monotonic clock. The monotonic clock freezes while
|
||||
the machine is asleep (macOS ``mach_absolute_time`` and Linux
|
||||
``CLOCK_MONOTONIC`` both exclude sleep) while the realtime clock keeps
|
||||
counting, so a resume shows up as a large divergence between the two across a
|
||||
single poll. A merely-blocked event loop (a long synchronous call, a GC pause)
|
||||
advances *both* clocks equally, so the divergence stays ~0 — this never
|
||||
false-fires on CPU stalls, only on a real suspend.
|
||||
|
||||
Deliberately uses :func:`time.monotonic`, never the event loop's
|
||||
``loop.time()``: uvloop's ``loop.time()`` is backed by libuv's clock, which
|
||||
*includes* sleep on macOS (see the same trap documented in
|
||||
``omnigent/runtime/harnesses/process_manager.py``), which would zero out the
|
||||
divergence and silently disable detection. :func:`time.monotonic` is
|
||||
process-wide and loop-independent.
|
||||
|
||||
Windows note: ``time.monotonic()`` on Windows counts suspended time, so the
|
||||
divergence stays ~0 there and this watcher never fires — the connection falls
|
||||
back to the keepalive-timeout behavior it has today (no regression).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Poll cadence. Detection latency after a wake is at most one interval: the
|
||||
# in-flight sleep's deadline is on the frozen monotonic clock, so it elapses
|
||||
# shortly after the machine resumes. 5 s keeps wake reconnects snappy at
|
||||
# negligible cost — the host already runs a 2 s orphan-reaper and a 5 s
|
||||
# harness-readiness loop, so this adds no meaningful wakeups.
|
||||
SUSPEND_POLL_INTERVAL_S = 5.0
|
||||
|
||||
# Minimum wall-minus-monotonic divergence, in seconds, that counts as a resume.
|
||||
# Comfortably above scheduler jitter and clock-read skew. Because the signal is
|
||||
# the *divergence* (not raw wall time), a blocked event loop can never reach it
|
||||
# no matter how long it blocks — only real suspended time diverges the clocks.
|
||||
# A lid-close shorter than this won't fire, but such a short sleep rarely drops
|
||||
# the socket (the keepalive budget is 90 s) so the connection stays healthy.
|
||||
SUSPEND_GAP_THRESHOLD_S = 15.0
|
||||
|
||||
|
||||
async def watch_for_resume(
|
||||
on_resume: Callable[[float], None],
|
||||
*,
|
||||
interval_s: float = SUSPEND_POLL_INTERVAL_S,
|
||||
threshold_s: float = SUSPEND_GAP_THRESHOLD_S,
|
||||
wall_clock: Callable[[], float] = time.time,
|
||||
mono_clock: Callable[[], float] = time.monotonic,
|
||||
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
||||
) -> None:
|
||||
"""Call ``on_resume(gap_s)`` once each time the machine resumes from sleep.
|
||||
|
||||
Loops forever polling ``interval_s`` at a time; whenever the wall clock
|
||||
advanced more than ``threshold_s`` beyond the monotonic clock across one
|
||||
poll, the machine slept and just woke, and ``on_resume`` is invoked with
|
||||
the approximate sleep duration in seconds. Cancel the task to stop.
|
||||
|
||||
``on_resume`` runs on the event loop and must be sync, non-blocking, and
|
||||
must not raise — callers use it to abort a now-dead socket and flag a
|
||||
prompt reconnect. Any exception it raises is logged and swallowed so the
|
||||
watcher survives.
|
||||
|
||||
:param on_resume: Sync callback invoked once per detected resume with the
|
||||
approximate seconds spent asleep.
|
||||
:param interval_s: Poll cadence; also the worst-case detection latency
|
||||
after a wake.
|
||||
:param threshold_s: Minimum wall-minus-monotonic divergence that counts as
|
||||
a resume.
|
||||
:param wall_clock: Realtime clock reader (advances during sleep).
|
||||
Injectable for tests.
|
||||
:param mono_clock: Monotonic clock reader (freezes during sleep on
|
||||
macOS/Linux). Injectable for tests. Defaults to :func:`time.monotonic`
|
||||
— never ``loop.time()`` (see the module docstring).
|
||||
:param sleep: Awaitable sleeper; injectable so tests can drive the loop
|
||||
deterministically without patching :func:`asyncio.sleep` globally.
|
||||
:returns: Never returns normally; cancel the task to stop it.
|
||||
"""
|
||||
while True:
|
||||
wall_before = wall_clock()
|
||||
mono_before = mono_clock()
|
||||
await sleep(interval_s)
|
||||
# Re-sample per iteration (not against a fixed baseline): a resume must
|
||||
# fire exactly once, on the poll that spanned the sleep. The next poll
|
||||
# sees gap ~0 again.
|
||||
gap = (wall_clock() - wall_before) - (mono_clock() - mono_before)
|
||||
if gap >= threshold_s:
|
||||
_logger.info("Resumed from suspend (~%.0fs asleep); notifying", gap)
|
||||
try:
|
||||
on_resume(gap)
|
||||
except Exception:
|
||||
_logger.exception("suspend on_resume callback failed")
|
||||
@@ -28,10 +28,11 @@ Design notes learned from the protocol (see ``control_bridge`` spike):
|
||||
the client exits; the hex channel is byte-exact for ESC sequences, control
|
||||
chars, and UTF-8 multibyte alike.
|
||||
|
||||
The browser-facing wire protocol is identical to the PTY bridge (binary frames
|
||||
out = raw pane bytes; text frames in = JSON ``{"type":"resize",...}``; binary
|
||||
frames in = input bytes), so the two transports are interchangeable behind the
|
||||
same ``/attach`` WebSocket and a client cannot tell which one served it.
|
||||
The browser-facing terminal stream matches the PTY bridge (binary frames out =
|
||||
raw pane bytes; text frames in = JSON ``{"type":"resize",...}``; binary frames
|
||||
in = input bytes). Control mode additionally sends a typed text JSON frame when
|
||||
tmux reports a copied paste buffer, because its outer-client OSC 52 is not part
|
||||
of ``%output``. Both remain interchangeable behind the same ``/attach`` URL.
|
||||
|
||||
Known limitation vs the PTY bridge: tmux's own overlays (``display-popup``,
|
||||
copy-mode, status line) are NOT delivered to a control-mode client, so the
|
||||
@@ -47,6 +48,7 @@ attach transport, so they behave identically under either bridge.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
@@ -117,6 +119,21 @@ _CONTROL_STDOUT_BUFFER_LIMIT: Final[int] = 16 * 1024 * 1024
|
||||
# stuck-slow client can't hang the close; a normal drain completes well within.
|
||||
_FORWARD_DRAIN_TIMEOUT_S: Final[float] = 5.0
|
||||
|
||||
# tmux emits this control notification after copy-mode stores a selection in a
|
||||
# paste buffer. Only default-style, shell-safe names are accepted; copy-mode's
|
||||
# generated names (for example ``buffer0``) are covered without letting an
|
||||
# untrusted protocol line select an arbitrary command target.
|
||||
_CLIPBOARD_BUFFER_CHANGED_PREFIX: Final = b"%paste-buffer-changed "
|
||||
_CLIPBOARD_BUFFER_NAME_RE: Final = re.compile(rb"[A-Za-z0-9_.:-]{1,128}\Z")
|
||||
# Browser clipboard writes should stay text-sized. Bound the raw buffer before
|
||||
# base64/JSON expansion so a huge tmux buffer cannot become a websocket DoS.
|
||||
_CLIPBOARD_MAX_BYTES: Final[int] = 1024 * 1024
|
||||
_CLIPBOARD_READ_TIMEOUT_S: Final[float] = 2.0
|
||||
# A copy-mode commit follows the initiating key or mouse release immediately.
|
||||
# Correlating the notification with this client's recent input prevents one
|
||||
# attached browser from overwriting every other viewer's local clipboard.
|
||||
_CLIPBOARD_RECENT_INPUT_WINDOW_S: Final[float] = 5.0
|
||||
|
||||
|
||||
def unescape_control_output(value: bytes) -> bytes:
|
||||
"""Un-escape a ``%output`` value back to raw pane bytes.
|
||||
@@ -133,6 +150,79 @@ def unescape_control_output(value: bytes) -> bytes:
|
||||
return _OCTAL_ESCAPE_RE.sub(lambda m: bytes([int(m.group(1), 8)]), value)
|
||||
|
||||
|
||||
async def _read_tmux_buffer(
|
||||
tmux: str,
|
||||
socket_path: str,
|
||||
buffer_name: str,
|
||||
) -> bytes | None:
|
||||
"""Read one named tmux buffer exactly, rejecting failures and oversized data.
|
||||
|
||||
``save-buffer ... -`` writes the raw bytes without ``show-buffer``'s display
|
||||
formatting. ``readexactly(limit + 1)`` distinguishes an in-range buffer
|
||||
(EOF with a partial result) from an oversized one without first buffering
|
||||
an unbounded subprocess result in Python.
|
||||
|
||||
:param tmux: Absolute tmux executable path.
|
||||
:param socket_path: Private tmux server socket.
|
||||
:param buffer_name: Validated tmux buffer name, e.g. ``"buffer0"``.
|
||||
:returns: Raw buffer bytes, or ``None`` when unavailable/oversized.
|
||||
"""
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
tmux,
|
||||
"-S",
|
||||
socket_path,
|
||||
"save-buffer",
|
||||
"-b",
|
||||
buffer_name,
|
||||
"-",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
assert proc.stdout is not None
|
||||
try:
|
||||
try:
|
||||
data = await asyncio.wait_for(
|
||||
proc.stdout.readexactly(_CLIPBOARD_MAX_BYTES + 1),
|
||||
timeout=_CLIPBOARD_READ_TIMEOUT_S,
|
||||
)
|
||||
oversized = True
|
||||
except asyncio.IncompleteReadError as exc:
|
||||
data = exc.partial
|
||||
oversized = False
|
||||
if oversized:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
proc.kill()
|
||||
await asyncio.wait_for(proc.wait(), timeout=_CLIPBOARD_READ_TIMEOUT_S)
|
||||
except asyncio.CancelledError:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
proc.kill()
|
||||
with contextlib.suppress(Exception):
|
||||
await asyncio.shield(proc.wait())
|
||||
raise
|
||||
except (asyncio.TimeoutError, OSError):
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
proc.kill()
|
||||
with contextlib.suppress(Exception):
|
||||
await proc.wait()
|
||||
return None
|
||||
if oversized or proc.returncode != 0:
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
def _clipboard_buffer_name(line: bytes) -> str | None:
|
||||
"""Extract a safe buffer name from a tmux clipboard notification."""
|
||||
if not line.startswith(_CLIPBOARD_BUFFER_CHANGED_PREFIX):
|
||||
return None
|
||||
raw_name = line[len(_CLIPBOARD_BUFFER_CHANGED_PREFIX) :]
|
||||
if _CLIPBOARD_BUFFER_NAME_RE.fullmatch(raw_name) is None:
|
||||
return None
|
||||
return raw_name.decode("ascii")
|
||||
|
||||
|
||||
def _hex_send_keys_commands(target: str, data: bytes) -> list[bytes]:
|
||||
"""Build ``send-keys -H`` control-mode command line(s) for raw input bytes.
|
||||
|
||||
@@ -404,7 +494,8 @@ async def bridge_tmux_control_to_websocket(
|
||||
|
||||
Drop-in alternative to
|
||||
:func:`omnigent.terminals.ws_bridge.bridge_tmux_pty_to_websocket` with the
|
||||
same signature and browser wire protocol. Caller must have called
|
||||
same signature and terminal byte stream. Control mode additionally emits
|
||||
server-to-browser clipboard JSON frames. Caller must have called
|
||||
``websocket.accept()``. On exit (any branch) the control client is torn
|
||||
down and the websocket closed best-effort with the shared 4404/4405 codes.
|
||||
|
||||
@@ -476,9 +567,16 @@ async def bridge_tmux_control_to_websocket(
|
||||
# one bounded ``send_bytes``, so when the browser send lags tmux's firehose
|
||||
# a backlog of tiny per-line payloads collapses into a few large frames.
|
||||
output_chunks: asyncio.Queue[bytes | None] = asyncio.Queue()
|
||||
# Clipboard notifications are handled outside the raw output hot path: each
|
||||
# item names the immutable tmux buffer created by copy-mode; None is EOF.
|
||||
clipboard_buffers: asyncio.Queue[str | None] = asyncio.Queue()
|
||||
# Terminal bytes and clipboard JSON have separate producer tasks but one
|
||||
# websocket. Serialize sends so ASGI never sees concurrent send calls.
|
||||
ws_send_lock = asyncio.Lock()
|
||||
# Monotonic stamp of the last forwarded browser input; the forwarder reads
|
||||
# it to shrink the frame cap right after a keystroke (keeps the echo on
|
||||
# xterm's synchronous paint path — see the PTY bridge).
|
||||
# xterm's synchronous paint path — see the PTY bridge) and clipboard
|
||||
# forwarding uses it to identify which attached client initiated a copy.
|
||||
last_client_input_at: float | None = None
|
||||
|
||||
def _current_ws_coalesce_limit() -> int:
|
||||
@@ -512,6 +610,15 @@ async def bridge_tmux_control_to_websocket(
|
||||
if len(parts) == 3:
|
||||
output_chunks.put_nowait(unescape_control_output(parts[2]))
|
||||
return True
|
||||
buffer_name = _clipboard_buffer_name(line)
|
||||
if buffer_name is not None:
|
||||
if (
|
||||
not read_only
|
||||
and last_client_input_at is not None
|
||||
and _monotonic() - last_client_input_at <= _CLIPBOARD_RECENT_INPUT_WINDOW_S
|
||||
):
|
||||
clipboard_buffers.put_nowait(buffer_name)
|
||||
return True
|
||||
if line.startswith(b"%exit"):
|
||||
return False
|
||||
if line.startswith(b"%window-close"):
|
||||
@@ -549,9 +656,49 @@ async def bridge_tmux_control_to_websocket(
|
||||
return
|
||||
finally:
|
||||
output_chunks.put_nowait(None)
|
||||
clipboard_buffers.put_nowait(None)
|
||||
if reader_done is not None:
|
||||
reader_done.set()
|
||||
|
||||
async def _forward_clipboard_updates() -> None:
|
||||
"""Read copied tmux buffers and send bounded clipboard control frames."""
|
||||
while True:
|
||||
buffer_name = await clipboard_buffers.get()
|
||||
if buffer_name is None:
|
||||
return
|
||||
|
||||
# When several copies arrive before the subprocess starts, only the
|
||||
# newest clipboard value matters. Preserve an EOF sentinel so the
|
||||
# task exits after forwarding that final value.
|
||||
eof_seen = False
|
||||
while True:
|
||||
try:
|
||||
next_name = clipboard_buffers.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
if next_name is None:
|
||||
eof_seen = True
|
||||
break
|
||||
buffer_name = next_name
|
||||
|
||||
data = await _read_tmux_buffer(tmux, socket_path, buffer_name)
|
||||
if data is not None:
|
||||
message = json.dumps(
|
||||
{
|
||||
"type": "clipboard-write",
|
||||
"encoding": "base64",
|
||||
"data": base64.b64encode(data).decode("ascii"),
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
try:
|
||||
async with ws_send_lock:
|
||||
await websocket.send_text(message)
|
||||
except (RuntimeError, WebSocketDisconnect):
|
||||
return
|
||||
if eof_seen:
|
||||
return
|
||||
|
||||
async def _ws_to_control() -> None:
|
||||
"""Read browser frames; resize via refresh-client -C, input via -H hex."""
|
||||
nonlocal last_client_input_at
|
||||
@@ -600,10 +747,16 @@ async def bridge_tmux_control_to_websocket(
|
||||
read_task = asyncio.create_task(_read_control(), name="tmux-control-read")
|
||||
forward_task = asyncio.create_task(
|
||||
_forward_pty_to_ws(
|
||||
websocket, output_chunks, max_coalesce_bytes=_current_ws_coalesce_limit
|
||||
websocket,
|
||||
output_chunks,
|
||||
max_coalesce_bytes=_current_ws_coalesce_limit,
|
||||
send_lock=ws_send_lock,
|
||||
),
|
||||
name="tmux-control-forward",
|
||||
)
|
||||
clipboard_task = asyncio.create_task(
|
||||
_forward_clipboard_updates(), name="tmux-control-clipboard"
|
||||
)
|
||||
if forward_done is not None:
|
||||
forward_task.add_done_callback(lambda _task: forward_done.set())
|
||||
ws_task = asyncio.create_task(_ws_to_control(), name="tmux-ws-to-control")
|
||||
@@ -612,6 +765,9 @@ async def bridge_tmux_control_to_websocket(
|
||||
# finishing is downstream (it drains, then sees the EOF sentinel).
|
||||
control_ended_first = False
|
||||
try:
|
||||
# The clipboard task is intentionally not a FIRST_COMPLETED trigger: it
|
||||
# may finish after the reader's EOF sentinel, but the reader itself is
|
||||
# the authoritative control-side completion signal.
|
||||
done, pending = await asyncio.wait(
|
||||
{read_task, forward_task, ws_task}, return_when=asyncio.FIRST_COMPLETED
|
||||
)
|
||||
@@ -636,13 +792,18 @@ async def bridge_tmux_control_to_websocket(
|
||||
await asyncio.wait_for(
|
||||
asyncio.shield(forward_task), timeout=_FORWARD_DRAIN_TIMEOUT_S
|
||||
)
|
||||
for task in pending:
|
||||
if control_ended_first and not clipboard_task.done():
|
||||
with contextlib.suppress(Exception):
|
||||
await asyncio.wait_for(
|
||||
asyncio.shield(clipboard_task), timeout=_FORWARD_DRAIN_TIMEOUT_S
|
||||
)
|
||||
for task in {*pending, clipboard_task}:
|
||||
if task.done():
|
||||
continue
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
for task in {read_task, forward_task, ws_task}:
|
||||
for task in {read_task, forward_task, clipboard_task, ws_task}:
|
||||
if task.done() and not task.cancelled():
|
||||
exc = task.exception()
|
||||
if exc is not None:
|
||||
|
||||
@@ -16,7 +16,9 @@ Used by:
|
||||
|
||||
Wire protocol (same as the original server route):
|
||||
|
||||
- **Server → client**: every PTY read becomes a *binary* WS frame.
|
||||
- **Server → client**: every PTY read becomes a *binary* WS frame. Production
|
||||
attaches first send a text OSC 52 capability frame so the browser can reject
|
||||
clipboard escapes when tmux passthrough weakens the normal trust boundary.
|
||||
- **Client → server**:
|
||||
- **Text frames** are JSON control messages. Currently only
|
||||
``{"type": "resize", "cols": N, "rows": M}`` (applied via
|
||||
@@ -417,6 +419,7 @@ async def _forward_pty_to_ws(
|
||||
pty_chunks: asyncio.Queue[bytes | None],
|
||||
*,
|
||||
max_coalesce_bytes: int | Callable[[], int] = _WS_COALESCE_MAX_BYTES,
|
||||
send_lock: asyncio.Lock | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Forward queued PTY output to *websocket*, coalescing ready chunks.
|
||||
@@ -440,6 +443,9 @@ async def _forward_pty_to_ws(
|
||||
:data:`_WS_COALESCE_MAX_BYTES`; ``bridge_tmux_pty_to_websocket``
|
||||
supplies a callable so recently-typed redraws can use the smaller
|
||||
interactive cap while normal output keeps the larger flood cap.
|
||||
:param send_lock: Optional lock shared with another server-to-browser
|
||||
sender. Control mode uses it to serialize terminal bytes with clipboard
|
||||
control frames; PTY mode has only this sender and leaves it unset.
|
||||
:returns: None on EOF or websocket disconnect.
|
||||
"""
|
||||
pending = bytearray()
|
||||
@@ -468,7 +474,11 @@ async def _forward_pty_to_ws(
|
||||
frame = bytes(pending[:limit])
|
||||
del pending[:limit]
|
||||
try:
|
||||
await websocket.send_bytes(frame)
|
||||
if send_lock is None:
|
||||
await websocket.send_bytes(frame)
|
||||
else:
|
||||
async with send_lock:
|
||||
await websocket.send_bytes(frame)
|
||||
except (RuntimeError, WebSocketDisconnect):
|
||||
return
|
||||
if eof_seen:
|
||||
@@ -517,6 +527,7 @@ async def bridge_tmux_pty_to_websocket(
|
||||
tmux_target: str,
|
||||
read_only: bool,
|
||||
on_client_interaction: Callable[[], None] | None = None,
|
||||
allow_osc52_clipboard: bool | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Bridge a tmux attach PTY to an already-accepted *websocket*.
|
||||
@@ -545,12 +556,31 @@ async def bridge_tmux_pty_to_websocket(
|
||||
mis-reading them as agent activity. ``None`` (e.g. the
|
||||
server-direct attach path, which is out-of-process from the
|
||||
watcher) disables that attribution.
|
||||
:param allow_osc52_clipboard: Whether the browser may honor OSC 52 from this
|
||||
PTY. Production passes ``False`` when tmux passthrough is enabled, since
|
||||
pane output could otherwise bypass ``set-clipboard external``. ``None``
|
||||
omits the capability frame for low-level test compatibility.
|
||||
"""
|
||||
# Attaching is itself a client interaction: tmux resizes the window to
|
||||
# the new client, which reflows the pane. Stamp it before the bridge
|
||||
# starts so that reflow is discounted.
|
||||
if on_client_interaction is not None:
|
||||
on_client_interaction()
|
||||
|
||||
if allow_osc52_clipboard is not None:
|
||||
try:
|
||||
await websocket.send_text(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "osc52-clipboard-capability",
|
||||
"enabled": allow_osc52_clipboard and not read_only,
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
)
|
||||
except (RuntimeError, WebSocketDisconnect):
|
||||
return
|
||||
|
||||
argv = ["tmux", "-S", socket_path, "attach"]
|
||||
if read_only:
|
||||
argv.append("-r")
|
||||
|
||||
@@ -188,8 +188,8 @@ class SysAgentListTool(Tool):
|
||||
"""
|
||||
List launchable agents across three sources.
|
||||
|
||||
A **global read** that surfaces, in one call, every agent the caller
|
||||
could launch a session from:
|
||||
A **global read** that pages agents the caller could launch a session
|
||||
from across three sources:
|
||||
|
||||
- **built-ins**: template agents registered on the server
|
||||
(``GET /v1/agents``);
|
||||
@@ -230,7 +230,10 @@ class SysAgentListTool(Tool):
|
||||
"agent never needs its bundle downloaded or re-uploaded. "
|
||||
"Use sys_agent_get / sys_agent_download (with a "
|
||||
"session_agents row's session_id) only to inspect or fork "
|
||||
"an agent's config. Global read — no parameters."
|
||||
"an agent's config. Calls without pagination keep the complete "
|
||||
"result while it fits the tool-output budget; larger results "
|
||||
"return a page with has_more metadata and an opaque "
|
||||
"next_cursor. Pass that cursor to continue."
|
||||
)
|
||||
|
||||
def get_schema(self) -> dict[str, Any]:
|
||||
@@ -238,7 +241,7 @@ class SysAgentListTool(Tool):
|
||||
Return the OpenAI-format tool schema.
|
||||
|
||||
:returns: Dict with ``"type": "function"`` and a
|
||||
``"function"`` sub-dict; no parameters.
|
||||
``"function"`` sub-dict; optional pagination parameters.
|
||||
"""
|
||||
return {
|
||||
"type": "function",
|
||||
@@ -247,7 +250,25 @@ class SysAgentListTool(Tool):
|
||||
"description": SysAgentListTool.description(),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"properties": {
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
"description": (
|
||||
"Optional maximum rows returned from each source. "
|
||||
"Omit it to keep the complete result while it fits."
|
||||
),
|
||||
},
|
||||
"cursor": {
|
||||
"type": "string",
|
||||
"maxLength": 40000,
|
||||
"description": (
|
||||
"Opaque continuation cursor from a prior "
|
||||
"sys_agent_list result's page.next_cursor."
|
||||
),
|
||||
},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -93,6 +93,14 @@ class SysScheduledTaskCreateTool(Tool):
|
||||
"type": "string",
|
||||
"description": "Optional per-run reasoning-effort hint, e.g. 'high'.",
|
||||
},
|
||||
"max_cost_usd": {
|
||||
"type": "number",
|
||||
"description": (
|
||||
"Optional per-firing cost budget in USD. When set, each "
|
||||
"fired session is capped at this spend — all models are "
|
||||
"blocked once the limit is reached. Omit for no cap."
|
||||
),
|
||||
},
|
||||
"workspace": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
@@ -188,6 +196,12 @@ class SysScheduledTaskUpdateTool(Tool):
|
||||
"type": "string",
|
||||
"description": "New reasoning-effort hint.",
|
||||
},
|
||||
"max_cost_usd": {
|
||||
"type": "number",
|
||||
"description": (
|
||||
"New per-firing cost budget in USD. Null clears the cap."
|
||||
),
|
||||
},
|
||||
"workspace": {
|
||||
"type": "string",
|
||||
"description": "New existing absolute runner start path.",
|
||||
|
||||
@@ -499,7 +499,11 @@ class SysSessionListTool(Tool):
|
||||
"for orchestration (inspect via sys_agent_get / "
|
||||
"sys_session_get_info, or drive via sys_session_send by "
|
||||
"session_id). Pass agent_name to filter the global list to "
|
||||
"sessions running that agent."
|
||||
"sessions running that agent. Calls without pagination keep "
|
||||
"the complete result while it fits the tool-output budget; "
|
||||
"larger global session lists return a page with has_more "
|
||||
"metadata and an opaque next_cursor. Pass that cursor to continue; sub_agents stays "
|
||||
"complete."
|
||||
)
|
||||
|
||||
def get_schema(self) -> dict[str, Any]:
|
||||
@@ -507,7 +511,8 @@ class SysSessionListTool(Tool):
|
||||
Return the OpenAI-format tool schema.
|
||||
|
||||
:returns: Dict with ``"type": "function"`` and a
|
||||
``"function"`` sub-dict; an optional ``agent_name`` filter.
|
||||
``"function"`` sub-dict; an optional ``agent_name`` filter
|
||||
and pagination parameters.
|
||||
"""
|
||||
return {
|
||||
"type": "function",
|
||||
@@ -526,6 +531,23 @@ class SysSessionListTool(Tool):
|
||||
"affect the 'sub_agents' view."
|
||||
),
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
"description": (
|
||||
"Optional maximum rows returned from 'sessions'. "
|
||||
"Omit it to keep the complete result while it fits."
|
||||
),
|
||||
},
|
||||
"cursor": {
|
||||
"type": "string",
|
||||
"maxLength": 40000,
|
||||
"description": (
|
||||
"Opaque continuation cursor from a prior "
|
||||
"sys_session_list result's page.next_cursor."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
"additionalProperties": False,
|
||||
|
||||
+130
-7
@@ -3695,6 +3695,7 @@
|
||||
"session.mcp_startup": "#/components/schemas/SessionMcpStartupEvent",
|
||||
"session.model": "#/components/schemas/SessionModelEvent",
|
||||
"session.model_options": "#/components/schemas/SessionModelOptionsEvent",
|
||||
"session.permission_mode": "#/components/schemas/SessionPermissionModeEvent",
|
||||
"session.presence": "#/components/schemas/SessionPresenceEvent",
|
||||
"session.reasoning_effort": "#/components/schemas/SessionReasoningEffortEvent",
|
||||
"session.resource.created": "#/components/schemas/SessionResourceCreatedEvent",
|
||||
@@ -3705,6 +3706,7 @@
|
||||
"session.superseded": "#/components/schemas/SessionSupersededEvent",
|
||||
"session.terminal.activity": "#/components/schemas/SessionTerminalActivityEvent",
|
||||
"session.terminal_pending": "#/components/schemas/SessionTerminalPendingEvent",
|
||||
"session.title": "#/components/schemas/SessionTitleEvent",
|
||||
"session.todos": "#/components/schemas/SessionTodosEvent",
|
||||
"session.usage": "#/components/schemas/SessionUsageEvent",
|
||||
"turn.cancelled": "#/components/schemas/TurnCancelledEvent",
|
||||
@@ -3724,12 +3726,18 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionModelEvent"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionTitleEvent"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionReasoningEffortEvent"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionCollaborationModeEvent"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionPermissionModeEvent"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionAgentChangedEvent"
|
||||
},
|
||||
@@ -4771,7 +4779,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
"SessionModelEvent": {
|
||||
"description": "Active-model update from a terminal-backed integration.\n\nEmitted after an `external_model_change` POST from the\n`omnigent claude` transcript forwarder when the model is\nswitched inside the Claude Code terminal (a `/model` command or\nthe in-TUI picker). Lets the web model picker reflect a TUI-side\nswitch without a reload.",
|
||||
"description": "Active-model report from a terminal-backed integration.\n\nEmitted after an `external_model_change` POST from a native\nforwarder \u2014 the launch's own model report, or a switch made inside\nthe pane (a `/model` command or the in-TUI picker). Every surface\nre-renders its model display from this.",
|
||||
"properties": {
|
||||
"conversation_id": {
|
||||
"description": "Session identifier, e.g. `\"conv_abc123\"`.",
|
||||
@@ -4779,7 +4787,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"description": "Tier alias the session is now on, e.g. `\"opus\"` \u2014 Claude Code's version-agnostic alias, matching the picker's vocabulary (not a pinned `\"claude-opus-4-8\"` id). Category: **transient** (SSE-only). The server also writes `model_override` on the conversation, so on reconnect clients restore the selection from the snapshot's `model_override` rather than from a replayed event.",
|
||||
"description": "The model the harness reports the session is on, VERBATIM in the harness's own spelling, e.g. `\"claude-opus-4-8[1m]\"` or `\"gpt-5.6-luna\"` \u2014 never collapsed to a picker alias. Category: **transient** (SSE-only). The server also writes `reported_model` on the conversation (served on the snapshot's `llm_model`), so on reconnect clients restore the display from the snapshot rather than from a replayed event.",
|
||||
"title": "Model",
|
||||
"type": "string"
|
||||
},
|
||||
@@ -4844,6 +4852,46 @@
|
||||
"title": "SessionModelOptionsEvent",
|
||||
"type": "object"
|
||||
},
|
||||
"SessionPermissionModeEvent": {
|
||||
"description": "Active permission-mode update from a claude-native session.\n\nEmitted after the web UI switches the mode, and after the Claude forwarder\nobserves a different mode in the pane footer \u2014 a shift+tab pressed inside\nthe TUI, which Omnigent has no other way to see. Lets the composer's mode\npicker track the pane without a reload.",
|
||||
"properties": {
|
||||
"conversation_id": {
|
||||
"description": "Session identifier, e.g. `\"conv_abc123\"`.",
|
||||
"title": "Conversation Id",
|
||||
"type": "string"
|
||||
},
|
||||
"permission_mode": {
|
||||
"description": "The active mode, e.g. `\"auto\"` or `\"plan\"`. Category: **transient** (SSE-only). The server also writes `omnigent.claude_native.permission_mode` on the conversation labels, so reconnecting clients restore the same state from the session snapshot.",
|
||||
"title": "Permission Mode",
|
||||
"type": "string"
|
||||
},
|
||||
"sequence_number": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Sequence Number"
|
||||
},
|
||||
"type": {
|
||||
"const": "session.permission_mode",
|
||||
"description": "Always `\"session.permission_mode\"`.",
|
||||
"title": "Type",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"conversation_id",
|
||||
"permission_mode"
|
||||
],
|
||||
"title": "SessionPermissionModeEvent",
|
||||
"type": "object"
|
||||
},
|
||||
"SessionPresenceEvent": {
|
||||
"description": "The session's viewer list changed \u2014 full state, not a delta.\n\nEmitted on `GET /v1/sessions/{id}/stream` whenever a user\njoins, leaves (after the server-side grace window absorbs\nreconnect churn), or flips their idle aggregate, and once to\neach newly-connected stream as a snapshot-on-connect. Every\nevent carries the COMPLETE viewer list so clients replace their\nstate wholesale \u2014 missed events self-heal on the next event or\nreconnect. Viewers are scoped to the session *tree* (the root\nconversation and every sub-agent conversation under it), so a\nuser on a sub-agent page and a user on the root page appear in\neach other's lists. See `omnigent/server/presence.py` and\n`designs/UI/PRESENCE.md`.",
|
||||
"properties": {
|
||||
@@ -4890,6 +4938,18 @@
|
||||
"SessionProjectSummary": {
|
||||
"description": "One entry of `GET /v1/sessions/projects` \u2014 a sidebar project folder.\n\nDual-read union of first-class projects and legacy `omni_project`\nlabel-projects, keyed by name.",
|
||||
"properties": {
|
||||
"icon": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "The project's chosen emoji icon (a unicode grapheme), read from its `config`; `None` when unset or for a label-only folder, so the sidebar falls back to the default folder glyph.",
|
||||
"title": "Icon"
|
||||
},
|
||||
"id": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -5360,7 +5420,7 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "The LLM model identifier from the bound agent's spec, e.g. `\"anthropic/claude-sonnet-4-6\"`. `None` when the agent has no explicit `llm:` block or the agent cannot be looked up.",
|
||||
"description": "The model this session is actually on. When the harness has reported one (`reported_model`, written by `external_model_change`), that verbatim value serves here and is the only model value clients display; otherwise the bound agent spec's model, e.g. `\"anthropic/claude-sonnet-4-6\"`. `None` when neither exists.",
|
||||
"title": "Llm Model"
|
||||
},
|
||||
"mcp_startup": {
|
||||
@@ -5990,6 +6050,46 @@
|
||||
"title": "SessionTerminalPendingEvent",
|
||||
"type": "object"
|
||||
},
|
||||
"SessionTitleEvent": {
|
||||
"description": "Session-title update from a terminal-backed integration.\n\nEmitted after an `external_session_title` POST from the\n`omnigent claude` transcript forwarder when the operator renames\nthe session inside the Claude Code pane (`/rename`). Lets the web\nsession list show the new name without a reload.",
|
||||
"properties": {
|
||||
"conversation_id": {
|
||||
"description": "Session identifier, e.g. `\"conv_abc123\"`.",
|
||||
"title": "Conversation Id",
|
||||
"type": "string"
|
||||
},
|
||||
"sequence_number": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Sequence Number"
|
||||
},
|
||||
"title": {
|
||||
"description": "Title the session is now on, e.g. `\"auth-refactor\"`. Category: **transient** (SSE-only). The server also writes `title` on the conversation, so on reconnect clients restore the name from the session snapshot rather than from a replayed event.",
|
||||
"title": "Title",
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"const": "session.title",
|
||||
"description": "Always `\"session.title\"`.",
|
||||
"title": "Type",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"conversation_id",
|
||||
"title"
|
||||
],
|
||||
"title": "SessionTitleEvent",
|
||||
"type": "object"
|
||||
},
|
||||
"SessionTodosEvent": {
|
||||
"description": "Todo-list update from a Claude Code terminal-backed session.\n\nEmitted after an `external_session_todos` POST from the\n`omnigent claude` transcript forwarder, which captures todo\nupdates via `PostToolUse`/`TodoWrite` hook events from Claude\nCode and forwards them to the Omnigent server. Lets web render a\nlive todo panel in the right column without polling.",
|
||||
"properties": {
|
||||
@@ -6094,6 +6194,20 @@
|
||||
"title": "Models",
|
||||
"type": "object"
|
||||
},
|
||||
"other_harnesses": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Other Harnesses"
|
||||
},
|
||||
"title": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -6935,6 +7049,18 @@
|
||||
"description": "Per-session LLM model override, e.g. `\"claude-opus-4-7\"`. The value is forwarded as-is to the executor at turn start; the server does not enumerate valid models. Clear aliases such as `\"default\"`, `\"off\"`, or `\"reset\"` remove the override (matching the REPL's `/model` semantics). `None` leaves unchanged.",
|
||||
"title": "Model Override"
|
||||
},
|
||||
"permission_mode": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Claude-native permission mode to switch a running session to, e.g. `\"auto\"`. Only the modes Claude Code's shift+tab cycle can reach are accepted (`default`, `acceptEdits`, `plan`, `auto`) \u2014 `dontAsk` and `bypassPermissions` are launch-only. Only valid for sessions stamped with the claude-native wrapper label. Unlike the other fields here the switch is applied by the live TUI, so a failure to reach the mode is surfaced as an error rather than persisted. Omitted leaves unchanged.",
|
||||
"title": "Permission Mode"
|
||||
},
|
||||
"project_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -8137,10 +8263,7 @@
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"additionalProperties": {
|
||||
"items": {
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
"items": {},
|
||||
"type": "array"
|
||||
},
|
||||
"title": "Response Get Host Model Options V1 Hosts Host Id Harnesses Harness Model Options Get",
|
||||
|
||||
Generated
+10
@@ -112,6 +112,12 @@ importers:
|
||||
'@dnd-kit/core':
|
||||
specifier: ^6.3.1
|
||||
version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@emoji-mart/data':
|
||||
specifier: ^1.2.1
|
||||
version: 1.2.1
|
||||
'@emoji-mart/react':
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1(emoji-mart@5.6.0)(react@18.3.1)
|
||||
'@fontsource-variable/geist-mono':
|
||||
specifier: ^5.2.7
|
||||
version: 5.3.0
|
||||
@@ -235,6 +241,9 @@ importers:
|
||||
cmdk:
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
emoji-mart:
|
||||
specifier: ^5.6.0
|
||||
version: 5.6.0
|
||||
katex:
|
||||
specifier: ^0.16.47
|
||||
version: 0.16.47
|
||||
@@ -4122,6 +4131,7 @@ packages:
|
||||
'@xmldom/xmldom@0.8.13':
|
||||
resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
deprecated: this version has critical issues, please update to the latest version
|
||||
|
||||
'@xterm/addon-fit@0.11.0':
|
||||
resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==}
|
||||
|
||||
+1
-6
@@ -246,12 +246,6 @@ cursor = ["cursor-sdk>=0.1.7"]
|
||||
# _reflect). The tools import the client lazily, so only users who enable a
|
||||
# Hindsight memory tool need this extra.
|
||||
hindsight = ["hindsight-client>=0.4.0"]
|
||||
# Backwards-compatibility alias: this extra was renamed to `hindsight` (see
|
||||
# above) in #2605. Keep `memory` pulling the same client so existing
|
||||
# `omnigent[memory]` / `--extra memory` invocations keep working.
|
||||
# TODO(0.70): remove this `memory` alias extra — it exists only to keep the
|
||||
# pre-rename install command working during the deprecation window.
|
||||
memory = ["hindsight-client>=0.4.0"]
|
||||
# Nimble research runs (the `nimble_research` builtin). The tool imports the
|
||||
# nimble-python client lazily, so only users who enable nimble_research need
|
||||
# this extra. nimble_extract talks raw httpx and needs no extra.
|
||||
@@ -489,6 +483,7 @@ markers = [
|
||||
"mock_only: tests/integration test that only works in mock-LLM mode (no --llm-api-key). Skipped by tests/integration/conftest.py when a real --llm-api-key is supplied (the real-LLM Integration jobs). Use for tests whose mock LLM is scripted with a fixed tool-call sequence — a real LLM cannot reproduce the scripted markers.",
|
||||
"visual: UI diff visual-regression snapshot (pytest-playwright-visual-snapshot). Runs only in the pinned-runner gate (.github/workflows/ui-snapshot.yml); the main e2e_ui suite excludes it via -m 'not visual' since it runs on the unpinned ubuntu-latest.",
|
||||
"smart_routing: end-to-end Smart Routing CUJ (tests/e2e/routing). Opt-in via OMNIGENT_E2E_SMART_ROUTING=1: launches a real omnigent host plus real claude/codex TUIs against a gateway. The routing service itself is an in-test mock, so no AI-Gateway task_v1 deployment is needed.",
|
||||
"live_model_flows: end-to-end model-flow CUJs (tests/e2e/omnigent/test_model_flows_live.py). Opt-in via OMNIGENT_E2E_MODEL_FLOWS=1: boots a real omnigent server + host from a checkout (OMNIGENT_E2E_MODEL_FLOWS_REPO overrides which one, enabling the red-on-main matrix) with real claude/codex logins, drives the real SPA in a browser, and asserts pane truth via tmux. Never runs in CI by accident; each landing PR runs it locally per docs/model-flows test plan.",
|
||||
"posix_only: test relies on POSIX-only behaviour (fork, PTY, tmux, Unix sockets, signals); auto-skipped on Windows by tests/conftest.py.",
|
||||
"windows_only: test relies on Windows-only behaviour (Job Objects, cmd.exe); auto-skipped on POSIX by tests/conftest.py.",
|
||||
]
|
||||
|
||||
+93
-1
@@ -120,6 +120,39 @@ def _restore_logging_state() -> Iterator[None]:
|
||||
logger.propagate = propagate
|
||||
|
||||
|
||||
def test_global_profiling_writes_summary_and_timestamped_stats(tmp_path: Path) -> None:
|
||||
"""The real entry point profiles any command selected after the root flag."""
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
pythonpath = os.pathsep.join(
|
||||
part for part in (str(repo_root), os.environ.get("PYTHONPATH")) if part
|
||||
)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "omnigent", "--profiling", "version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
cwd=tmp_path,
|
||||
env={
|
||||
**os.environ,
|
||||
"PYTHONPATH": pythonpath,
|
||||
"OMNIGENT_DATA_DIR": str(tmp_path / "data"),
|
||||
},
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "CLI profile:" in result.stderr
|
||||
assert "Top Omnigent call paths" in result.stderr
|
||||
assert "Top Omnigent functions by self time" in result.stderr
|
||||
self_time_table = result.stderr.split("Top Omnigent functions by self time", 1)[1]
|
||||
assert "<built-in method" not in self_time_table
|
||||
assert "Full profile data:" in result.stderr
|
||||
[profile_path] = list((tmp_path / "data" / "profiles").glob("omnigent-cli-*.prof"))
|
||||
|
||||
import pstats
|
||||
|
||||
assert pstats.Stats(str(profile_path)).total_calls > 0
|
||||
|
||||
|
||||
def test_python_module_entrypoint_uses_unified_click_cli() -> None:
|
||||
"""
|
||||
``python -m omnigent`` must dispatch through the same click CLI
|
||||
@@ -191,6 +224,7 @@ def test_wrapper_guard_bypass_reaches_cli_end_to_end() -> None:
|
||||
(["run", "tests/resources/examples/hello_world.yaml"], False),
|
||||
(["attach", "tests/resources/examples/hello_world.yaml"], False),
|
||||
(["--help"], False),
|
||||
(["--profiling", "--help"], False),
|
||||
(["what does this repo do?"], True),
|
||||
(["--system-prompt", "You are terse"], True),
|
||||
# A single command-shaped word is an unknown subcommand, not
|
||||
@@ -1042,10 +1076,11 @@ def test_kiro_command_is_registered_in_click_help() -> None:
|
||||
|
||||
|
||||
def test_help_groups_harnesses_and_other_commands() -> None:
|
||||
"""``--help`` lists a ``Harnesses`` section separate from ``Commands``."""
|
||||
"""``--help`` lists global options and separates command categories."""
|
||||
result = CliRunner().invoke(cli, ["--help"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "--profiling" in result.output
|
||||
assert "Harnesses:" in result.output
|
||||
assert "Commands:" in result.output
|
||||
# A harness launcher lands under Harnesses; a management command
|
||||
@@ -3458,6 +3493,63 @@ def test_run_profile_sets_databricks_config_profile_env(
|
||||
assert seen["value"] == "my-sp"
|
||||
|
||||
|
||||
def test_bare_run_profile_shorthand_still_selects_databricks_profile(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The new root flag must not consume the historical bare-run spelling."""
|
||||
from omnigent.cli import main
|
||||
|
||||
monkeypatch.delenv("DATABRICKS_CONFIG_PROFILE", raising=False)
|
||||
seen = _capture_profile_env_at_dispatch(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
[
|
||||
"omnigent",
|
||||
"--profile",
|
||||
"my-sp",
|
||||
"--server",
|
||||
"https://example.com",
|
||||
"-p",
|
||||
"hi",
|
||||
],
|
||||
)
|
||||
|
||||
main()
|
||||
|
||||
assert seen["value"] == "my-sp"
|
||||
|
||||
|
||||
def test_global_profiling_coexists_with_run_databricks_profile(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Root profiling and ``run --profile NAME`` keep distinct semantics."""
|
||||
monkeypatch.setenv("OMNIGENT_DATA_DIR", str(tmp_path / "data"))
|
||||
monkeypatch.delenv("DATABRICKS_CONFIG_PROFILE", raising=False)
|
||||
seen = _capture_profile_env_at_dispatch(monkeypatch)
|
||||
|
||||
result = CliRunner().invoke(
|
||||
cli,
|
||||
[
|
||||
"--profiling",
|
||||
"run",
|
||||
"--server",
|
||||
"https://example.com",
|
||||
"--profile",
|
||||
"my-sp",
|
||||
"-p",
|
||||
"hi",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert seen["value"] == "my-sp"
|
||||
assert "Top Omnigent call paths" in result.stderr
|
||||
profiles = tmp_path / "data" / "profiles"
|
||||
assert len(list(profiles.glob("omnigent-cli-*.prof"))) == 1
|
||||
|
||||
|
||||
def test_run_profile_wins_over_preset_env(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user