Compare commits

..

No commits in common. "main" and "5.0.1" have entirely different histories.
main ... 5.0.1

460 changed files with 16974 additions and 30763 deletions

View file

@ -29,6 +29,3 @@ updates:
update-types:
- "version-update:semver-minor"
- "version-update:semver-major"
- dependency-name: "typescript"
update-types:
- "version-update:semver-major"

View file

@ -1,5 +0,0 @@
## Changes
- Persistent threads now reconnect cleanly after temporary Codex disconnects or app-server setting changes, while temporary side chats no longer resume incorrectly.
- Slash-command suggestions now immediately respect the restrictions for temporary side chats and subagent threads.
- Thread rename and archive results are now kept within the Codex context where the operation started, preventing stale updates after settings change.

View file

@ -1,3 +0,0 @@
## Changes
- Release assets now carry individually verifiable provenance attestations.

View file

@ -1,3 +0,0 @@
## Changes
- Restored availability of downloadable install assets while upstream attestation verification catches up with GitHub's API changes.

View file

@ -1,6 +0,0 @@
## Changes
- Added search to Codex Panel settings.
- Large web, thread, selection, and vault references are now bounded predictably, report truncation when needed, and keep their context details after reopening a thread.
- Paste and drop attachments now preserve their intended insertion point while files are being saved, with clearer composer editing state.
- Improved reliability when switching threads, leaving running subagents, or changing runtime and app-server settings, preventing stale results and lost updates.

View file

@ -1,6 +0,0 @@
## Changes
- Thread history now stays in sync across the chat toolbar, thread picker, and Threads view while loading older threads or refreshing.
- Thread search now uses fuzzy title matching across the complete active history instead of matching thread IDs.
- Running subagent summaries now show each agent's latest activity instead of only its initial status.
- Opening the thread picker again now closes the previous picker, and accepted MCP links are preserved in conversation history.

View file

@ -1,6 +0,0 @@
## Changes
- Thread rename and auto-name controls now prevent conflicting edits while work is in progress, and cancelling auto-name returns to the existing draft.
- Actions with known unmet prerequisites are now disabled before execution, including unavailable auto-name and selection rewrite actions.
- Thread operation errors no longer appear as entries in navigation lists.
- Resumed conversations now hide Codex Panel's internal context metadata while preserving their context attachments.

View file

@ -1,4 +0,0 @@
## Changes
- Thread references created with `/refer` now remain as readable, clickable links in conversation history and open the referenced thread in an available Codex Panel.
- Opening an existing thread in a new panel is now faster because resume no longer waits for background metadata and thread-list refreshes.

View file

@ -1,5 +0,0 @@
## Changes
- Updated compatibility for Codex CLI 0.145.0.
- Subagent conversations that support replies can now be continued directly from their panel.
- Fixed malformed links in exported thread archives.

View file

@ -21,29 +21,6 @@ jobs:
fetch-depth: 0
persist-credentials: false
- name: Detect compatibility changes
id: compatibility
env:
BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
run: |
if git diff --quiet "$BASE_SHA" "$HEAD_SHA" -- \
.github/workflows/check.yml \
README.md \
manifest.json \
package.json \
package-lock.json \
scripts/api-baseline.mjs \
scripts/app-server-compatibility.mjs \
scripts/generate-app-server-types.mjs \
src/app-server/connection/compatibility.json \
src/generated/app-server \
versions.json; then
echo "required=false" >> "$GITHUB_OUTPUT"
else
echo "required=true" >> "$GITHUB_OUTPUT"
fi
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
@ -54,15 +31,12 @@ jobs:
run: npm ci --ignore-scripts
- name: Install recorded Codex CLI
if: steps.compatibility.outputs.required == 'true'
run: npm install --global "@openai/codex@$(node scripts/app-server-compatibility.mjs --tested-cli-version)"
- name: Check API baselines
if: steps.compatibility.outputs.required == 'true'
run: npm run api:baseline
- name: Check generated app-server bindings
if: steps.compatibility.outputs.required == 'true'
run: npm run generate:app-server-types:check
- name: Check pull request commit messages

View file

@ -7,6 +7,8 @@ on:
permissions:
contents: write
id-token: write
attestations: write
jobs:
release:
@ -54,6 +56,14 @@ jobs:
- name: Run checks and build
run: npm run check
- name: Generate artifact attestations
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4
with:
subject-path: |
main.js
manifest.json
styles.css
- name: Create GitHub release
env:
GH_TOKEN: ${{ github.token }}

4
.gitignore vendored
View file

@ -1,6 +1,4 @@
node_modules/
.pnpm-store/
.stryker-tmp/
.jj/
.codex/
main.js
@ -9,6 +7,4 @@ data.json
*.log
.DS_Store
dist/
coverage/
reports/
tmp/

View file

@ -2,7 +2,7 @@ This repository contains the Codex Panel Obsidian plugin.
## Working Principles
- Start from evidence. Reproduce defects before fixing them; for product changes, reconstruct the current workflow, user need, alternatives, and losses before deciding what should change. Treat theoretical failure modes and reviewer agreement as hypotheses, not evidence of meaningful user or operational harm.
- Start from evidence. Reproduce defects before fixing them; for product changes, reconstruct the current workflow, user need, alternatives, and losses before deciding what should change.
- Use design documents as context, not as a substitute for current behavior and user expectations. Prefer coherent user-facing behavior and clear ownership, and remove needless abstraction or obsolete compatibility instead of preserving it by default.
- Treat implementation, tests, documentation, lint policy, and final history as one deliverable: inspect each for drift, but update only the durable contracts that changed. Documentation should record user-facing behavior, long-term design, or reusable workflow; do not add it merely to narrate an implementation change or duplicate tests and tooling.
- Parallelize only substantial concerns that can be implemented and tested independently with little shared-file contention. Keep small, tightly coupled, sequential, or coordination-heavy work together.

View file

@ -2,7 +2,7 @@
Codex Panel brings Codex into an Obsidian sidebar. It keeps Codex threads beside your notes, helps you add vault context to prompts, and lets you handle approvals and file changes without switching windows.
If the Codex CLI is already installed and authenticated, Codex Panel uses that local setup.
If the Codex CLI is already installed and authenticated, Codex Panel uses that local setup. The plugin handles the Obsidian side of the workflow, while models, credentials, sandboxing, tools, hooks, and thread state stay with Codex.
![Codex Panel](assets/screenshot.webp)
@ -35,46 +35,36 @@ You can also open the plugin page directly: <https://community.obsidian.md/plugi
## Quick start
1. Open the note or project you want to work on.
2. Run **Codex Panel: Open panel** from the command palette, or select the ribbon icon.
3. In the composer, reference what matters and describe the outcome you want. For example: `@active Summarize this note and suggest the next three actions.`
4. Follow the turn in the panel, respond to requests when needed, and review any file changes before continuing.
1. Open the command palette and run **Codex Panel: Open panel**, or select the ribbon icon.
2. If Obsidian cannot find `codex`, set **Settings -> Codex Panel -> Codex executable** to an absolute path such as `/opt/homebrew/bin/codex`.
3. Ask Codex to work on the vault or project from the composer.
4. Return to earlier work from **Codex Panel: Open thread...** or **Codex Panel: Open threads view**.
If Obsidian cannot find `codex`, set **Settings -> Codex Panel -> Codex executable** to an absolute path such as `/opt/homebrew/bin/codex`.
## Using Codex Panel
## Working with Codex Panel
Each panel can keep a separate conversation, so related work can stay open side by side, including temporary read-only side chats. Start fresh from the panel, reopen recent threads from the picker or panel history, or use the Threads view to see active work across the vault.
### Keep work separate or pick it up later
The composer treats Obsidian content as prompt context. It suggests vault files and recent notes for wikilinks, keeps links readable while attaching resolved files, and opens file links from Codex replies back in Obsidian. Use `@active` or `@selection` to include the active file or current Markdown selection without pasting it manually, or enable **Reference active file on send** to reference the current active file with each composer send. When Obsidian Daily Notes or the daily section of Periodic Notes is enabled, `@today`, `@tomorrow`, and `@yesterday` insert wikilinks using that daily-note folder and date format. Paste or drop files to save them in the attachment folder and reference them from the same prompt.
A panel is an independent working surface with its own active thread and draft. Open multiple panels when different tasks should stay visible and separate. Regular Codex threads remain available after a panel closes; return to them from **Codex Panel: Open thread...**, panel history, or the Threads view.
For context outside the current composer, use `/refer <thread> <message>` to send a message with recent turns from another Codex thread, or `/web <url> [message]` to fetch readable web content and attach it to the next turn without saving a note.
For a one-off question alongside the current thread, use `/btw` to open a temporary side chat. To carry context between regular threads without merging their histories, use `/refer <thread> <message>`. The submitted message keeps a normal link to the referenced thread; clicking it opens the thread in an available Codex Panel, creating one when needed.
Completions cover slash commands, `$skill-name` skills, Obsidian tags, recent threads, models, reasoning levels, and permission profiles. Use `/help` for the current command list.
### Bring in the right context
While a turn is running, the panel streams the conversation, reasoning, tool activity, file changes, plans, and agent activity. You can answer Codex questions, approve commands, respond to requests, steer or interrupt the turn, and manage active goals above the conversation.
The composer lets you point Codex to relevant material without pasting it into the prompt. Type wikilinks to reference vault files while keeping their names readable. Use `@active` for the active file or `@selection` for the current Markdown selection.
When Codex changes files, open an Obsidian diff view to review the changes and copy the patch. For a focused note edit, select Markdown text and run **Codex Panel: Rewrite selection** to ask Codex for a replacement and review a selection-scoped diff before applying it.
When Obsidian Daily Notes or the daily section of Periodic Notes is enabled, `@today`, `@tomorrow`, and `@yesterday` resolve through its folder and date format. Paste or drop files to save them in the configured attachment folder and reference them from the same prompt. For material outside the vault, `/web <url> [message]` fetches readable page content and attaches it to the next turn without creating a note.
Use the toolbar, composer status row, or slash commands to inspect usage, adjust turn settings, and check Codex connection details.
### Guide a running turn
As a turn runs, the panel keeps the conversation together with requests that need your attention. You can answer questions, approve or reject actions, steer the turn with another message, or interrupt it. Plans, goals, tool activity, and agent work remain available when you need to inspect how the task is progressing.
### Review and keep the result
When Codex changes files, open the Obsidian diff view to review the changes and copy the patch. For a focused edit, select Markdown text and run **Codex Panel: Rewrite selection** to request a replacement and review a selection-scoped diff before applying it.
Archive a finished thread with or without saving it as a Markdown note. Saved notes use the configured folder, filename template, and tags; archived threads can later be restored or permanently deleted from settings.
Use `/help` for the current slash command list.
Threads can be archived as Markdown notes with a configurable folder, filename template, and tags. You can also archive without saving, then restore or permanently delete archived threads from settings.
## Compatibility
| Key | Version | Policy |
| -------------------------------------- | --------- | --------------------------------------------------------------------------------------------------- |
| `manifest.minAppVersion` | `1.12.0` | Minimum Obsidian desktop version declared for plugin loading. |
| `obsidian` API types | `1.12.3` | TypeScript API package used for compile-time checks; kept in the same minor as `manifest` baseline. |
| `codexAppServer.testedCliVersion` | `0.145.0` | Exact CLI patch used to generate and verify bindings; compatibility is tracked by minor version. |
| Key | Version | Policy |
| ------------------------ | --------- | --------------------------------------------------------------------------------------------------- |
| `manifest.minAppVersion` | `1.12.0` | Minimum Obsidian desktop version declared for plugin loading. |
| `obsidian` API types | `1.12.3` | TypeScript API package used for compile-time checks; kept in the same minor as `manifest` baseline. |
| `codex.testedCliVersion` | `0.144.4` | Exact CLI patch used to generate and verify bindings; compatibility is tracked by minor version. |
Codex Panel depends on the experimental `codex app-server` API.

View file

@ -131,6 +131,32 @@
},
// Chat state and runtime ownership.
{
"path": "./scripts/grit/import-boundaries/no-preact-signal-imports.grit",
"includes": [
"**/src/**/*.{ts,tsx}",
"!**/src/features/chat/panel/shell-read-model.ts",
"!**/src/features/chat/panel/surface/**/*.{ts,tsx}"
]
},
{
"path": "./scripts/grit/import-boundaries/no-chat-shell-read-model-imports.grit",
"includes": [
"**/src/features/chat/**/*.{ts,tsx}",
"!**/src/features/chat/panel/shell-read-model.ts",
"!**/src/features/chat/panel/shell.dom.tsx",
"!**/src/features/chat/panel/composer-controller.ts",
"!**/src/features/chat/panel/surface/**/*.{ts,tsx}"
]
},
{
"path": "./scripts/grit/import-boundaries/no-chat-signal-type-references.grit",
"includes": [
"**/src/features/chat/**/*.{ts,tsx}",
"!**/src/features/chat/panel/shell-read-model.ts",
"!**/src/features/chat/panel/surface/**/*.{ts,tsx}"
]
},
{
"path": "./scripts/grit/runtime/no-state-module-side-effects.grit",
"includes": ["**/src/features/chat/application/state/**/*.ts"]

View file

@ -10,14 +10,8 @@ Keep the panel thin. Codex Panel owns the Obsidian experience around Codex: pane
Codex owns runtime behavior and thread state: models, credentials, sandboxing, approval policy, MCP servers, hooks, providers, agent-initiated network access, thread history, archived state, goals, and runtime settings. The panel should read and update that state through `codex app-server`, not redefine it.
The panel may provide a narrow management surface when direct configuration of Codex-owned state is impractical and app-server still owns the semantics. Hook controls serve this role: help users recognize and manage hooks they added or changed without presenting the panel as a code-audit surface.
The panel may acquire prompt context through an explicit user action. `/web` fetches the requested page through Obsidian and attaches the extracted content as untrusted turn context; it is not a search surface or agent network policy.
Panel-acquired context remains reference material rather than current user authorization. Send its bounded payload as untrusted app-server context without adding Panel-owned metadata envelopes to Codex history. Apply physical byte and part limits at the app-server boundary; keep source-specific selection and truncation policy with the context producer. Keep read-only compatibility for envelopes written by older Panel versions isolated at history projection boundaries.
Vault file references are prompt handoff data, not durable thread metadata. Keep their display state local; ordinary wikilinks remain represented by the user-authored message text. Thread references persist as ordinary, human-readable `codex://threads/` Markdown links, while their bounded transcript payload remains ephemeral turn context.
Panel settings should store only panel-specific preferences. Do not mirror Codex configuration in Obsidian settings just to display or inspect it.
## Sources of Truth
@ -30,8 +24,6 @@ Runtime controls should express visible user intent for the active thread rather
An app-server context is the pair of Codex executable and Vault root. Replacing it must invalidate context-bound connections and work before publishing the new context, keep events attributed to their source context, and discard old runtime metadata. Preserve last-known-good state only across transient failures within the same context.
Vault root is the Panel-owned workspace root for that context. Panel operations always use it as the thread working directory and do not project mutable protocol thread cwd values into panel state.
## Code Boundaries
Raw app-server protocol belongs at the app-server boundary. Boundary code should adapt protocol payloads into panel-owned domain models or small projections before those values reach features, workspace coordination, settings, or UI.
@ -52,31 +44,19 @@ Runtime UI composition is Preact-owned. Preact components should render the pane
Obsidian and app-server boundaries stay outside Preact components. External lifecycles, app-server connections, editor/workspace APIs, and rendering bridges belong in boundary modules.
Chat-visible state should have one authoritative owner. Components should consume narrow projections of that state rather than mirror it into another reactive store.
Chat-visible state belongs in the chat state store and named reducer actions. Signals and components may project that state, but they should not become parallel sources of truth.
TanStack Query is the single panel-side owner of cached app-server resources. Features may project authoritative event results into that state, but should not introduce parallel cache or synchronization mechanisms. Partial read models are reconciled at explicit lifecycle boundaries rather than kept globally and continuously consistent.
Each shared app-server resource should have one authoritative query record. Derived metadata may report status but must not duplicate its snapshot.
Thread lifecycle changes should be projected from completed operation facts into the shared read model. When one user action changes multiple visible projections, publish them coherently without delaying live operational state or coupling independent panels.
Preact Signals are a chat panel rendering adapter, not a second state system. Panel surfaces may read narrow signal-backed read models, but application workflows, domain code, presentation helpers, and pure UI components must not depend on broad reducer slices or reactive state primitives.
Reads with different completeness requirements have separate lifecycles. Complete operation-local reads must not replace bounded shared history, and transient activity should come from its owning live state rather than forcing history refreshes.
Imperative UI bridges are allowed only where the host platform requires them. They should remain narrow boundary adapters, not a second UI composition system inside Preact-owned surfaces.
Imperative DOM bridges are allowed when an external API, host lifecycle, hit-test, focus/selection operation, or measurement problem requires an `HTMLElement`. They should remain narrow boundary adapters, not a second UI composition system inside Preact-owned surfaces.
## Interaction Principles
Multiple panels are separate Obsidian leaves. Treat each panel as its own Codex working surface with independent connection, thread, turn state, composer, and pending requests.
Long-running actions must preserve the user intent that started them. Panel-local results may affect a panel only while that intent still owns its target; Codex facts completed in the current app-server context remain shared truth even if the initiating panel has moved on.
Coordinate conflicting work at the narrowest shared semantic owner. Independent panels should not block one another, and stale reads or writes must not overwrite newer shared state. Coalesce work only when replacement is part of the operation's meaning.
Foreground reveal and focus are the narrow workspace-wide exception: the latest user intent wins without serializing unrelated panel or app-server work. Cleanup obligations created by a committed state transition must survive the UI action that initiated them.
Do not use explanatory copy as the default remedy for an unclear or incoherent interface. Before adding instructions, warnings, status text, validation messages, confirmations, or diagnostics, determine why the interface does not communicate the necessary behavior through its structure and state. Prefer improving information hierarchy, labels, defaults, available choices, affordances, validation timing, progress indication, and feedback. Add text only when irreducible information remains and it materially helps the user decide, act, or recover; do not expose implementation concepts merely because they explain the current behavior.
Subagent threads opened from agent activity remain persistent and restorable but stay outside ordinary thread history. Use the app-server direct-input capability when available, falling back to read-only subagent panels for older servers, while preserving parent and agent provenance for other specialized behavior.
Mode-derived restrictions for the active panel thread must remain consistent across actions and UI. Keep connection state, turn busy state, and operations targeting another listed thread in their owning workflows.
Subagent threads opened from agent activity remain persistent and restorable but stay outside ordinary thread history. Treat their panels as read-only conversation surfaces while preserving their parent and agent provenance for future specialized behavior.
Thread history and other app-server resources should follow app-server semantics. Panel-side views are read models over app-server snapshots and lifecycle events; stale or partial refreshes must not overwrite newer state. Obsidian integrations such as archive note export are convenience views, not replacements for Codex history.
@ -96,6 +76,6 @@ Tests should protect user expectations, app-server/panel responsibility boundari
Prefer tests for visible behavior and state-transition invariants across panels, threads, requests, streams, and display fallbacks.
Avoid tests that freeze incidental implementation details unless those details directly protect a user-visible invariant.
Avoid tests that freeze incidental implementation details such as exact DOM nesting, render counts, node reuse, helper decomposition, or no-op array updates unless those details directly protect a user-visible invariant.
Panel tests may define how received structured values are displayed, retained, or normalized. They should not redefine Codex-owned runtime policy, model lists, sandbox behavior, approval policy, or thread history semantics.

View file

@ -8,18 +8,12 @@ Use this document for day-to-day implementation mechanics: commands, generated f
npm ci
npm run fix
npm run check
npm run test:coverage
npm run test:mutation
```
Use the Node.js version in `.node-version`.
Use `npm run fix` for mechanical cleanup, review its diff, and run `npm run check` before handoff.
Use `npm run test:coverage` to identify source modules and branches that lack exercised behavior. It reports every authored TypeScript source file, including files not imported by tests, while excluding generated app-server bindings. Open `coverage/index.html` to inspect line-level gaps. Coverage is diagnostic and has no pass/fail threshold; prioritize user-visible behavior and state-transition invariants rather than raising the aggregate percentage.
Use `npm run test:mutation` for an exploratory mutation test of domain-owned logic, app-server protocol normalization, thread-stream event mapping, and the chat root reducer. Review surviving mutants individually instead of treating the aggregate score as a quality gate: add tests for meaningful behavior gaps, simplify equivalent or redundant code, and leave mutants alone when neither change improves the durable contract. The run ignores expensive module-initialization mutants, is intentionally manual, and writes its ignored HTML report to `reports/mutation/mutation.html`.
## Commit Messages
Use [Conventional Commits 1.0.0](https://www.conventionalcommits.org/en/v1.0.0/) for new commits:

View file

@ -20,7 +20,7 @@ jj git push --remote origin --bookmark main
git push origin X.Y.Z
```
`release:prepare` updates version files and drafts `## Changes` from selected Conventional Commits since the previous version. Use those commits to locate candidate changes, then compare the resulting user-visible behavior with the previous release before choosing the version. Review the draft against the full diff for complete, user-facing notes; consolidate and reorder entries as needed, and replace an empty placeholder.
`release:prepare` updates version files and drafts `## Changes` from selected Conventional Commits since the previous version. Review the draft against the full diff for complete, user-facing notes; consolidate and reorder entries as needed, and replace an empty placeholder.
Run `release:preflight` after the release commit is on `main`; it validates the commit range and release readiness.

View file

@ -54,6 +54,8 @@ export default defineConfig([
},
],
"@typescript-eslint/require-await": "off",
// Declarative setting definitions require Obsidian 1.13; keep display() while the supported API baseline is 1.12.
"obsidianmd/settings-tab/prefer-setting-definitions": "off",
},
},
{

View file

@ -1,7 +1,7 @@
{
"id": "codex-panel",
"name": "Codex Panel",
"version": "5.2.1",
"version": "5.0.1",
"minAppVersion": "1.12.0",
"description": "Codex in your sidebar.",
"author": "murashit",

2570
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "codex-panel",
"version": "5.2.1",
"version": "5.0.1",
"description": "Codex in your Obsidian sidebar.",
"main": "main.js",
"author": "murashit",
@ -36,42 +36,33 @@
"release:preflight": "node scripts/release/preflight.mjs",
"release:prepare": "node scripts/release/prepare.mjs",
"test": "vitest run --pool threads --maxWorkers 6",
"test:coverage": "vitest run --pool threads --maxWorkers 6 --coverage",
"test:mutation": "stryker run",
"typecheck": "tsc -p tsconfig.json --noEmit --incremental --tsBuildInfoFile node_modules/.cache/typescript/tsconfig.tsbuildinfo"
},
"devDependencies": {
"@biomejs/biome": "^2.5.3",
"@biomejs/biome": "^2.5.2",
"@commitlint/cli": "^21.2.1",
"@commitlint/config-conventional": "^21.2.0",
"@stryker-mutator/core": "^9.6.1",
"@stryker-mutator/vitest-runner": "^9.6.1",
"@types/mdast": "^4.0.4",
"@types/node": "^26.1.1",
"@vitest/coverage-v8": "^4.1.10",
"concurrently": "^9.2.4",
"@types/node": "^26.1.0",
"concurrently": "^10.0.3",
"conventional-changelog-conventionalcommits": "^10.2.1",
"conventional-commits-parser": "^7.1.0",
"esbuild": "^0.28.0",
"eslint": "^10.6.0",
"eslint-plugin-obsidianmd": "^0.4.1",
"jsdom": "^29.1.1",
"knip": "^6.25.0",
"knip": "^6.24.0",
"moment": "^2.30.1",
"obsidian": "~1.12.3",
"typescript": "^6.0.3",
"typescript-eslint": "^8.64.0",
"typescript-eslint": "^8.63.0",
"vitest": "^4.1.10"
},
"dependencies": {
"@preact/signals": "^2.9.3",
"@tanstack/query-core": "^5.101.2",
"defuddle": "^0.19.1",
"diff": "^9.0.0",
"mdast-util-from-markdown": "^2.0.3",
"mdast-util-to-markdown": "^2.1.2",
"micromark": "^4.0.2",
"obsidian-daily-notes-interface": "^0.9.5",
"preact": "^10.29.7",
"unist-util-visit": "^5.1.0"
"preact": "^10.29.4"
}
}

View file

@ -86,7 +86,7 @@ export async function createApiBaselineReport(options = {}) {
const initializeRequestAttestationDisabled = initializeCapabilities.requestAttestation === false;
if (!codexReadmeSemver) {
fail("README.md Compatibility table must define `codexAppServer.testedCliVersion` as X.Y.Z.");
fail("README.md Compatibility table must define `codex.testedCliVersion` as X.Y.Z.");
}
if (!codexRecordedSemver || codexRecordedSemver.version !== codexRecordedVersion) {
fail("src/app-server/connection/compatibility.json must declare codexAppServer.testedCliVersion as X.Y.Z.");
@ -180,7 +180,7 @@ function readCompatibilityBaselines(readme) {
const section = markdownSection(readme, "Compatibility");
const table = readMarkdownTableValues(section);
return {
codexTestedCliVersion: table.get("codexAppServer.testedCliVersion") ?? null,
codexTestedCliVersion: table.get("codex.testedCliVersion") ?? null,
obsidianApiTypesVersion: table.get("obsidian") ?? null,
obsidianMinAppVersion: table.get("manifest.minAppVersion") ?? null,
};

View file

@ -21,7 +21,6 @@ export async function buildStyles() {
}
export async function renderStyles() {
const { version } = JSON.parse(await readFile("package.json", "utf8"));
const parts = [];
const sourceFiles = await readStyleOrder();
@ -30,7 +29,7 @@ export async function renderStyles() {
parts.push(content.trimEnd());
}
return `/* Codex Panel v${version} */\n\n${parts.join("\n\n")}\n`;
return `${parts.join("\n\n")}\n`;
}
async function readStyleOrder() {

View file

@ -0,0 +1,17 @@
language js
private pattern js_module_reference() {
or {
JsImport(),
JsImportCallExpression()
}
}
private pattern chat_shell_read_model_source() {
r"^[\"'](?:(?:\.\./)+(?:panel/)?shell-read-model|(?:\./)?shell-read-model|src/features/chat/panel/shell-read-model)(?:\.ts)?[\"']$"
}
js_module_reference() as $stmt where {
$stmt <: contains `$source` where { $source <: chat_shell_read_model_source() },
register_diagnostic(span=$stmt, message="Import chat panel signal read models only from the shell and panel surface rendering adapters.", severity="error")
}

View file

@ -0,0 +1,8 @@
language js
or {
`ReadonlySignal<$type>` as $stmt,
`Signal<$type>` as $stmt
} where {
register_diagnostic(span=$stmt, message="Keep Preact signal types inside the chat panel read-model and surface rendering adapter layer.", severity="error")
}

View file

@ -0,0 +1,13 @@
language js
private pattern js_module_reference() {
or {
JsImport(),
JsImportCallExpression()
}
}
js_module_reference() as $stmt where {
$stmt <: contains `$source` where { $source <: r"^[\"']@preact/signals[\"']$" },
register_diagnostic(span=$stmt, message="Do not import @preact/signals from this module.", severity="error")
}

View file

@ -30,6 +30,7 @@ import type { ThreadItemsListResponse } from "../../generated/app-server/v2/Thre
import type { ThreadListResponse } from "../../generated/app-server/v2/ThreadListResponse";
import type { ThreadReadResponse } from "../../generated/app-server/v2/ThreadReadResponse";
import type { ThreadResumeResponse } from "../../generated/app-server/v2/ThreadResumeResponse";
import type { ThreadRollbackResponse } from "../../generated/app-server/v2/ThreadRollbackResponse";
import type { ThreadSetNameResponse } from "../../generated/app-server/v2/ThreadSetNameResponse";
import type { ThreadSettingsUpdateResponse } from "../../generated/app-server/v2/ThreadSettingsUpdateResponse";
import type { ThreadStartResponse } from "../../generated/app-server/v2/ThreadStartResponse";
@ -86,6 +87,7 @@ export interface ClientResponseByMethod {
"thread/delete": ThreadDeleteResponse;
"thread/unsubscribe": ThreadUnsubscribeResponse;
"thread/unarchive": ThreadUnarchiveResponse;
"thread/rollback": ThreadRollbackResponse;
"thread/name/set": ThreadSetNameResponse;
"thread/settings/update": ThreadSettingsUpdateResponse;
"thread/turns/list": ThreadTurnsListResponse;

View file

@ -1,6 +1,6 @@
{
"codexAppServer": {
"testedCliVersion": "0.145.0",
"testedCliVersion": "0.144.4",
"typeGeneration": {
"arguments": ["app-server", "generate-ts", "--experimental"]
},

View file

@ -5,23 +5,30 @@ import type { ServerRequest } from "../../generated/app-server/ServerRequest";
import { AppServerClient, type AppServerClientHandlers, type AppServerServerRequestResponder } from "./client";
export interface ConnectionManagerHandlers {
onNotification: (notification: ServerNotification) => void;
onServerRequest: (request: ServerRequest, responder: AppServerServerRequestResponder) => void;
onLog: (message: string) => void;
onExit: () => void;
onNotification: (notification: ServerNotification, context: AppServerConnectionContext) => void;
onServerRequest: (request: ServerRequest, responder: AppServerServerRequestResponder, context: AppServerConnectionContext) => void;
onLog: (message: string, context: AppServerConnectionContext) => void;
onExit: (context: AppServerConnectionContext) => void;
}
type AppServerClientFactory = (codexPath: string, cwd: string, handlers: AppServerClientHandlers) => AppServerClient;
export type AppServerClientFactory = (codexPath: string, cwd: string, handlers: AppServerClientHandlers) => AppServerClient;
export interface AppServerConnectionContext {
readonly codexPath: string;
readonly cwd: string;
}
type ConnectionLifecycleState =
| { kind: "idle"; generation: number }
| {
kind: "connecting";
generation: number;
codexPath: string;
cwd: string;
client: AppServerClient;
promise: Promise<ServerInitialization>;
}
| { kind: "connected"; generation: number; client: AppServerClient }
| { kind: "connected"; generation: number; codexPath: string; cwd: string; client: AppServerClient }
| { kind: "disconnected"; generation: number };
export class StaleConnectionError extends Error {
@ -35,7 +42,7 @@ export class ConnectionManager {
private state: ConnectionLifecycleState = { kind: "idle", generation: 0 };
constructor(
private readonly codexPath: string,
private readonly codexPath: () => string,
private readonly cwd: string,
initializeParams: InitializeParams,
private readonly clientFactory: AppServerClientFactory = (codexPath, cwd, handlers) =>
@ -43,7 +50,16 @@ export class ConnectionManager {
) {}
currentClient(): AppServerClient | null {
return this.state.kind === "connected" && this.state.client.isConnected() ? this.state.client : null;
return this.state.kind === "connected" && this.state.client.isConnected() && this.stateMatchesCurrentContext(this.state)
? this.state.client
: null;
}
currentConnectionContext(): AppServerConnectionContext | null {
if (this.state.kind !== "connected" || !this.state.client.isConnected() || !this.stateMatchesCurrentContext(this.state)) {
return null;
}
return { codexPath: this.state.codexPath, cwd: this.state.cwd };
}
isConnected(): boolean {
@ -55,29 +71,30 @@ export class ConnectionManager {
if (currentClient) {
return serverInitializationFromResponse(currentClient.initializeResponse);
}
if (this.state.kind === "connecting") return this.state.promise;
if (this.state.kind === "connected") this.disconnect();
if (this.state.kind === "connecting" && this.stateMatchesCurrentContext(this.state)) return this.state.promise;
if (this.state.kind === "connecting" || this.state.kind === "connected") this.disconnect();
const generation = this.state.generation + 1;
const codexPath = this.codexPath;
const codexPath = this.codexPath();
const cwd = this.cwd;
const context = { codexPath, cwd };
const client = this.clientFactory(codexPath, cwd, {
onNotification: (notification) => {
if (this.isStale(generation)) return;
handlers.onNotification(notification);
handlers.onNotification(notification, context);
},
onServerRequest: (request, responder) => {
if (this.isStale(generation)) return;
handlers.onServerRequest(request, responder);
handlers.onServerRequest(request, responder, context);
},
onLog: (message) => {
if (this.isStale(generation)) return;
handlers.onLog(message);
handlers.onLog(message, context);
},
onExit: () => {
if (this.isStale(generation)) return;
this.state = { kind: "disconnected", generation };
handlers.onExit();
handlers.onExit(context);
},
});
const promise = client
@ -87,7 +104,7 @@ export class ConnectionManager {
client.disconnect();
throw new StaleConnectionError();
}
this.state = { kind: "connected", generation, client };
this.state = { kind: "connected", generation, codexPath, cwd, client };
return serverInitializationFromResponse(response);
})
.catch((error: unknown) => {
@ -101,10 +118,14 @@ export class ConnectionManager {
throw error;
});
this.state = { kind: "connecting", generation, client, promise };
this.state = { kind: "connecting", generation, codexPath, cwd, client, promise };
return promise;
}
resetConnection(): void {
this.disconnect();
}
disconnect(): void {
const previous = this.state;
this.state = { kind: "disconnected", generation: previous.generation + 1 };
@ -116,6 +137,10 @@ export class ConnectionManager {
private isStale(generation: number): boolean {
return generation !== this.state.generation;
}
private stateMatchesCurrentContext(state: Extract<ConnectionLifecycleState, { kind: "connecting" | "connected" }>): boolean {
return state.codexPath === this.codexPath() && state.cwd === this.cwd;
}
}
function serverInitializationFromResponse(response: ServerInitialization): ServerInitialization {

View file

@ -1,4 +0,0 @@
export interface AppServerExecutionContext {
readonly codexPath: string;
readonly vaultPath: string;
}

View file

@ -2,17 +2,11 @@ import { AppServerClient } from "./client";
import type { AppServerClientAccessOptions } from "./client-access";
import { codexPanelAppServerInitializeParams } from "./client-profile";
export interface ShortLivedAppServerClientLifetime {
created(client: AppServerClient): void;
disposed(client: AppServerClient): void;
}
export async function withShortLivedAppServerClient<T>(
codexPath: string,
cwd: string,
operation: (client: AppServerClient) => Promise<T>,
options: AppServerClientAccessOptions = {},
lifetime?: ShortLivedAppServerClientLifetime,
): Promise<T> {
const client = new AppServerClient({
codexPath,
@ -33,13 +27,11 @@ export async function withShortLivedAppServerClient<T>(
onExit: () => undefined,
},
});
lifetime?.created(client);
try {
await client.connect();
return await operation(client);
} finally {
client.disconnect();
lifetime?.disposed(client);
}
}

View file

@ -24,7 +24,10 @@ export interface AppServerTransportHandlers {
onError: (error: Error) => void;
}
function createAppServerSpawnSpec(codexPath: string, options: { platform?: NodeJS.Platform; comSpec?: string } = {}): AppServerSpawnSpec {
export function createAppServerSpawnSpec(
codexPath: string,
options: { platform?: NodeJS.Platform; comSpec?: string } = {},
): AppServerSpawnSpec {
const platform = options.platform ?? process.platform;
if (platform !== "win32" || !isWindowsCommandScript(codexPath)) {
return { command: codexPath, args: ["app-server"], killProcessTreeOnStop: false };

View file

@ -1,148 +0,0 @@
import type { VaultFileReference } from "../../domain/chat/input";
import type { ReferencedThreadMetadata } from "../../domain/threads/reference";
/*
* Compatibility boundary for user messages persisted by old Codex Panel
* versions. New request construction must not depend on this module. Once old
* thread history no longer needs recovery, delete this file and its explicit
* fallbacks in turn.ts.
*/
function legacyPanelReferencedThreadFromPrompt(text: string): { text: string; reference: ReferencedThreadMetadata } | null {
const envelope = referencedThreadEnvelopeFromPrompt(text);
return envelope ? { text: envelope.visibleText, reference: envelope.reference } : null;
}
interface LegacyPanelUserMessageContentItem {
type: string;
name?: string;
path?: string;
}
interface LegacyPanelUserMessageInput {
content: readonly LegacyPanelUserMessageContentItem[];
visibleText: string;
}
export interface LegacyPanelUserMessageProjection {
text: string;
referencedThread: ReferencedThreadMetadata | null;
fileReferences: VaultFileReference[];
mentionTextByContentIndex: ReadonlyMap<number, string>;
}
export function legacyPanelUserMessageProjection(input: LegacyPanelUserMessageInput): LegacyPanelUserMessageProjection {
const referencedThread = legacyPanelReferencedThreadFromPrompt(input.visibleText);
const fileReferences: VaultFileReference[] = [];
const mentionTextByContentIndex = new Map<number, string>();
for (const [index, item] of input.content.entries()) {
if (!isMention(item)) continue;
const reference = legacyPanelFileReference(item);
if (reference) fileReferences.push(reference);
if (!input.visibleText) {
mentionTextByContentIndex.set(index, reference ? `[file] ${reference.path}` : `[@${item.name}] ${item.path}`);
}
}
return {
text: referencedThread?.text ?? input.visibleText,
referencedThread: referencedThread?.reference ?? null,
fileReferences,
mentionTextByContentIndex,
};
}
function isMention(
input: LegacyPanelUserMessageInput["content"][number],
): input is LegacyPanelUserMessageContentItem & { type: "mention"; name: string; path: string } {
return input.type === "mention" && typeof input.name === "string" && typeof input.path === "string";
}
function legacyPanelFileReference(input: { name: string; path: string }): VaultFileReference | null {
if (!input.path || /^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(input.path)) return null;
return { name: input.name, path: input.path };
}
const REFERENCED_THREAD_ENVELOPE_START = "[Codex Panel referenced thread v1]";
const REFERENCED_THREAD_ENVELOPE_END = "[/Codex Panel referenced thread]";
interface ReferencedThreadEnvelope {
version: 1;
reference: ReferencedThreadMetadata;
visibleText: string;
}
interface ReferencedThreadEnvelopeMetadata {
version: 1;
threadId: string;
title: string;
includedTurns: number;
turnLimit: number;
}
function referencedThreadEnvelopeFromPrompt(text: string): ReferencedThreadEnvelope | null {
const headerStart = text.indexOf(REFERENCED_THREAD_ENVELOPE_START);
const requestBoundary = `\n${REFERENCED_THREAD_ENVELOPE_END}\n\nCurrent user request:\n`;
const boundaryStart = text.lastIndexOf(requestBoundary);
if (headerStart !== 0 || boundaryStart === -1) return null;
const metadataText = firstNonEmptyLine(text.slice(REFERENCED_THREAD_ENVELOPE_START.length, boundaryStart));
const metadata = referencedThreadEnvelopeMetadataFromJson(metadataText);
const visibleText = text.slice(boundaryStart + requestBoundary.length).trim();
if (!metadata || !visibleText) return null;
return {
version: 1,
visibleText,
reference: {
threadId: metadata.threadId,
title: metadata.title,
includedTurns: metadata.includedTurns,
turnLimit: metadata.turnLimit,
},
};
}
function firstNonEmptyLine(text: string): string | null {
return (
text
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0) ?? null
);
}
function referencedThreadEnvelopeMetadataFromJson(text: string | null): ReferencedThreadEnvelopeMetadata | null {
if (!text) return null;
let parsed: unknown;
try {
parsed = JSON.parse(text) as unknown;
} catch {
return null;
}
if (!parsed || typeof parsed !== "object") return null;
const value = parsed as Record<string, unknown>;
if (value["version"] !== 1) return null;
const threadId = stringValue(value["threadId"]);
const title = stringValue(value["title"]);
const includedTurns = finiteNonNegativeInteger(value["includedTurns"]);
const turnLimit = finitePositiveInteger(value["turnLimit"]);
if (!threadId || !title || includedTurns === null || turnLimit === null) return null;
return {
version: 1,
threadId,
title,
includedTurns,
turnLimit,
};
}
function stringValue(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
function finiteNonNegativeInteger(value: unknown): number | null {
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
}
function finitePositiveInteger(value: unknown): number | null {
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null;
}

View file

@ -1,222 +0,0 @@
import type { VaultFileReference } from "../../domain/chat/input";
import { isPanelSubmissionClientId, turnContextSubmissionId } from "../../domain/chat/submission-id";
import type { ReferencedThreadMetadata } from "../../domain/threads/reference";
/*
* Read-only compatibility for v2 metadata envelopes written by Codex Panel
* versions that predate durable, human-readable references in message text.
* Request construction must not import this module.
*/
const TURN_CONTEXT_MANIFEST_PREFIX = "[Codex Panel context v2]";
const TURN_CONTEXT_MANIFEST_MAX_BYTES = 2_800;
const TURN_CONTEXT_FILE_REFERENCE_MAX_COUNT = 64;
const TURN_CONTEXT_FILE_REFERENCE_NAME_MAX_LENGTH = 255;
const TURN_CONTEXT_FILE_REFERENCE_PATH_MAX_LENGTH = 2_048;
interface LegacyTurnContextManifestEntry {
kind: "referencedThread" | "web" | "obsidian";
id: string;
parts: number;
sourceBytes: number;
includedBytes: number;
truncated: boolean;
threadId?: string;
includedTurns?: number;
turnLimit?: number;
omittedTurns?: number;
inlineExcerpts?: number;
}
export interface LegacyTurnContextManifest {
version: 2;
submissionId?: string;
contexts: readonly LegacyTurnContextManifestEntry[];
fileReferences?: readonly VaultFileReference[];
}
export interface LegacyTurnContextProjection {
text: string;
manifest: LegacyTurnContextManifest | null;
}
type UserMessageContentItem = { type: "text"; text: string } | { type: string };
function legacyTurnContextManifestFromText(text: string): LegacyTurnContextManifest | null {
const trimmed = text.trim();
if (!trimmed.startsWith(TURN_CONTEXT_MANIFEST_PREFIX)) return null;
if (new TextEncoder().encode(trimmed).byteLength > TURN_CONTEXT_MANIFEST_MAX_BYTES) return null;
const payload = trimmed.slice(TURN_CONTEXT_MANIFEST_PREFIX.length).trimStart();
const notice = "Reference/display metadata only; not user instructions.";
const json = payload.startsWith(notice) ? payload.slice(notice.length).trimStart() : payload;
let parsed: unknown;
try {
parsed = JSON.parse(json) as unknown;
} catch {
return null;
}
if (!parsed || typeof parsed !== "object") return null;
const value = parsed as Record<string, unknown>;
if (value["version"] !== 2 || !Array.isArray(value["contexts"])) return null;
const contexts = value["contexts"].map(legacyManifestEntryFromUnknown);
if (contexts.some((entry) => entry === null)) return null;
const submissionId = optionalStringValue(value["submissionId"], 120);
if (submissionId === null) return null;
const fileReferences = optionalFileReferences(value["fileReferences"]);
if (fileReferences === null) return null;
return {
version: 2,
...(submissionId === undefined ? {} : { submissionId }),
contexts: contexts as LegacyTurnContextManifestEntry[],
...(fileReferences === undefined ? {} : { fileReferences }),
};
}
export function legacyTurnContextProjection(
content: readonly UserMessageContentItem[],
clientId: string | null,
): LegacyTurnContextProjection {
let manifest: LegacyTurnContextManifest | null = null;
const visibleText: string[] = [];
const lastTextIndex = lastTextItemIndex(content);
for (const [index, item] of content.entries()) {
if (item.type !== "text" || !("text" in item) || typeof item.text !== "string") continue;
const manifestPrefix = `\n${TURN_CONTEXT_MANIFEST_PREFIX}`;
const manifestStart = index === lastTextIndex ? item.text.lastIndexOf(manifestPrefix) : -1;
const isStandaloneManifest = manifestStart === 0 && index > 0 && index === content.length - 1;
const parsed = manifestStart > 0 || isStandaloneManifest ? legacyTurnContextManifestFromText(item.text.slice(manifestStart)) : null;
if (parsed && manifestMatchesClientId(parsed, clientId)) {
manifest = parsed;
if (manifestStart > 0) visibleText.push(item.text.slice(0, manifestStart));
continue;
}
visibleText.push(item.text);
}
return { text: visibleText.join("\n"), manifest };
}
function lastTextItemIndex(content: readonly UserMessageContentItem[]): number {
for (let index = content.length - 1; index >= 0; index -= 1) {
if (content[index]?.type === "text") return index;
}
return -1;
}
function manifestMatchesClientId(manifest: LegacyTurnContextManifest, clientId: string | null): boolean {
if (!isPanelSubmissionClientId(clientId)) return false;
const submissionId = turnContextSubmissionId(clientId);
if (manifest.submissionId !== undefined && manifest.submissionId !== submissionId) return false;
const contextIds = manifest.contexts.map((context) => context.id);
return (
(manifest.submissionId === submissionId || contextIds.length > 0) &&
new Set(contextIds).size === contextIds.length &&
contextIds.every((contextId) => new RegExp(`^${escapeRegExp(submissionId)}\\.\\d{2}$`).test(contextId))
);
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function referencedThreadFromLegacyManifest(manifest: LegacyTurnContextManifest | null): ReferencedThreadMetadata | null {
const context = manifest?.contexts.find((entry) => entry.kind === "referencedThread");
if (!context?.threadId || context.includedTurns === undefined || context.turnLimit === undefined || context.omittedTurns === undefined) {
return null;
}
return {
threadId: context.threadId,
title: context.threadId.slice(0, 8),
includedTurns: context.includedTurns,
turnLimit: context.turnLimit,
omittedTurns: context.omittedTurns,
truncated: context.truncated,
};
}
export function fileReferencesFromLegacyManifest(manifest: LegacyTurnContextManifest | null): VaultFileReference[] {
return manifest?.fileReferences ? [...manifest.fileReferences] : [];
}
function legacyManifestEntryFromUnknown(input: unknown): LegacyTurnContextManifestEntry | null {
if (!input || typeof input !== "object") return null;
const value = input as Record<string, unknown>;
const kind = contextKind(value["kind"]);
const id = stringValue(value["id"]);
const parts = nonNegativeInteger(value["parts"]);
const sourceBytes = nonNegativeInteger(value["sourceBytes"]);
const includedBytes = nonNegativeInteger(value["includedBytes"]);
const truncated = value["truncated"];
if (!kind || !id || parts === null || sourceBytes === null || includedBytes === null || typeof truncated !== "boolean") return null;
if (kind === "obsidian") {
const inlineExcerpts = optionalNonNegativeInteger(value["inlineExcerpts"]);
if (inlineExcerpts === null) return null;
return {
kind,
id,
parts,
sourceBytes,
includedBytes,
truncated,
...(inlineExcerpts === undefined ? {} : { inlineExcerpts }),
};
}
if (kind !== "referencedThread") return { kind, id, parts, sourceBytes, includedBytes, truncated };
const threadId = stringValue(value["threadId"]);
const includedTurns = nonNegativeInteger(value["includedTurns"]);
const turnLimit = positiveInteger(value["turnLimit"]);
const omittedTurns = nonNegativeInteger(value["omittedTurns"]);
if (!threadId || includedTurns === null || turnLimit === null || omittedTurns === null) return null;
return {
kind,
id,
parts,
sourceBytes,
includedBytes,
truncated,
threadId,
includedTurns,
turnLimit,
omittedTurns,
};
}
function contextKind(value: unknown): LegacyTurnContextManifestEntry["kind"] | null {
return value === "referencedThread" || value === "web" || value === "obsidian" ? value : null;
}
function stringValue(value: unknown): string | null {
return typeof value === "string" && value.length > 0 && value.length <= 160 ? value : null;
}
function optionalStringValue(value: unknown, maxLength: number): string | null | undefined {
if (value === undefined) return undefined;
return typeof value === "string" && value.length > 0 && value.length <= maxLength ? value : null;
}
function optionalFileReferences(value: unknown): VaultFileReference[] | null | undefined {
if (value === undefined) return undefined;
if (!Array.isArray(value) || value.length > TURN_CONTEXT_FILE_REFERENCE_MAX_COUNT) return null;
const references = value.map(manifestFileReference);
return references.some((reference) => reference === null) ? null : (references as VaultFileReference[]);
}
function manifestFileReference(input: unknown): VaultFileReference | null {
if (!input || typeof input !== "object") return null;
const reference = input as Record<string, unknown>;
const name = optionalStringValue(reference["name"], TURN_CONTEXT_FILE_REFERENCE_NAME_MAX_LENGTH);
const path = optionalStringValue(reference["path"], TURN_CONTEXT_FILE_REFERENCE_PATH_MAX_LENGTH);
return name && path ? { name, path } : null;
}
function nonNegativeInteger(value: unknown): number | null {
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
}
function optionalNonNegativeInteger(value: unknown): number | null | undefined {
return value === undefined ? undefined : nonNegativeInteger(value);
}
function positiveInteger(value: unknown): number | null {
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : null;
}

View file

@ -1,6 +1,4 @@
import { splitUtf8Context } from "../../domain/chat/context-budget";
import type { CodexInputItem } from "../../domain/chat/input";
import { turnContextSubmissionId } from "../../domain/chat/submission-id";
type AppServerUserInputImageDetail = "auto" | "low" | "high" | "original";
@ -8,75 +6,28 @@ type AppServerUserInput =
| { type: "text"; text: string; text_elements: [] }
| { type: "image"; detail?: AppServerUserInputImageDetail; url: string }
| { type: "localImage"; detail?: AppServerUserInputImageDetail; path: string }
| { type: "skill"; name: string; path: string };
| { type: "skill"; name: string; path: string }
| { type: "mention"; name: string; path: string };
interface AppServerAdditionalContextEntry {
value: string;
kind: "untrusted" | "application";
}
export interface AppServerTurnInput {
input: AppServerUserInput[];
additionalContext?: Record<string, AppServerAdditionalContextEntry>;
}
const ADDITIONAL_CONTEXT_PART_BODY_MAX_BYTES = 2_800;
const ADDITIONAL_CONTEXT_MAX_PARTS = 8;
export function toAppServerUserInput(input: readonly CodexInputItem[]): AppServerUserInput[] {
return input.flatMap((item) => appServerUserInputItemFromCodexInputItem(item));
}
export function appServerTurnInputFromCodexInput(input: readonly CodexInputItem[], submissionId: string): AppServerTurnInput {
export function additionalContextFromCodexInput(
input: readonly CodexInputItem[],
): Record<string, AppServerAdditionalContextEntry> | undefined {
const additionalContext: Record<string, AppServerAdditionalContextEntry> = {};
const contexts = input.filter(
(item): item is Extract<CodexInputItem, { type: "additionalContext" }> =>
item.type === "additionalContext" && Boolean(item.key) && Boolean(item.value),
);
if (contexts.length > ADDITIONAL_CONTEXT_MAX_PARTS) {
throw new Error(`Too many additional context sources (${String(contexts.length)}).`);
for (const item of input) {
if (item.type !== "additionalContext") continue;
if (!item.key || !item.value) continue;
additionalContext[item.key] = { value: item.value, kind: item.kind };
}
const partAllocations = allocatedPartCounts(contexts);
for (const [contextIndex, item] of contexts.entries()) {
const id = `${turnContextSubmissionId(submissionId)}.${String(contextIndex).padStart(2, "0")}`;
const split = splitUtf8Context(item.value, ADDITIONAL_CONTEXT_PART_BODY_MAX_BYTES, partAllocations[contextIndex] ?? 1);
const partCount = split.parts.length;
split.parts.forEach((part, partIndex) => {
const key = [
"codex_panel",
id,
safeKeyPart(item.key),
`part_${String(partIndex + 1).padStart(2, "0")}_of_${String(partCount).padStart(2, "0")}`,
].join(".");
additionalContext[key] = {
kind: item.kind,
value: [
`Codex Panel context part ${String(partIndex + 1)}/${String(partCount)}.`,
`Source: ${safeKeyPart(item.key)}`,
"",
part,
].join("\n"),
};
});
}
return {
input: toAppServerUserInput(input),
...(Object.keys(additionalContext).length > 0 ? { additionalContext } : {}),
};
}
function allocatedPartCounts(contexts: readonly Extract<CodexInputItem, { type: "additionalContext" }>[]): number[] {
const allocations = contexts.map(() => 1);
const desired = contexts.map(
(context) => splitUtf8Context(context.value, ADDITIONAL_CONTEXT_PART_BODY_MAX_BYTES, ADDITIONAL_CONTEXT_MAX_PARTS).parts.length,
);
let remaining = ADDITIONAL_CONTEXT_MAX_PARTS - contexts.length;
for (let index = 0; index < contexts.length && remaining > 0; index += 1) {
const extra = Math.min(Math.max((desired[index] ?? 1) - 1, 0), remaining);
allocations[index] = (allocations[index] ?? 1) + extra;
remaining -= extra;
}
return allocations;
return Object.keys(additionalContext).length > 0 ? additionalContext : undefined;
}
function appServerUserInputItemFromCodexInputItem(item: CodexInputItem): AppServerUserInput[] {
@ -89,18 +40,13 @@ function appServerUserInputItemFromCodexInputItem(item: CodexInputItem): AppServ
return [{ type: "localImage", path: item.path, ...appServerImageDetailProp(item.detail) }];
case "skill":
return [{ type: "skill", name: item.name, path: item.path }];
case "fileReference":
return [];
case "mention":
return [{ type: "mention", name: item.name, path: item.path }];
case "additionalContext":
return [];
}
}
function safeKeyPart(value: string): string {
const safe = value.replace(/[^A-Za-z0-9_.-]/g, "_").slice(0, 120);
return safe || "context";
}
function appServerImageDetailProp(detail: Extract<CodexInputItem, { type: "image" | "localImage" }>["detail"]): {
detail?: AppServerUserInputImageDetail;
} {

View file

@ -21,7 +21,6 @@ export function threadFromThreadRecord(thread: ThreadRecord, options: { archived
archived: options.archived ?? false,
createdAt: finiteTimestamp(thread.createdAt),
updatedAt: finiteTimestamp(thread.updatedAt),
canAcceptDirectInput: typeof thread.canAcceptDirectInput === "boolean" ? thread.canAcceptDirectInput : null,
provenance: threadProvenance(thread),
...(hasRecencyAt ? { recencyAt: typeof recencyAt === "number" && Number.isFinite(recencyAt) ? recencyAt : null } : {}),
};

View file

@ -1,5 +1,3 @@
import type { VaultFileReference } from "../../domain/chat/input";
import type { ReferencedThreadMetadata } from "../../domain/threads/reference";
import {
nonEmptyTurnTranscriptSummaries,
type ThreadTranscriptEntry,
@ -8,16 +6,13 @@ import {
} from "../../domain/threads/transcript";
import type { ThreadItem as GeneratedThreadItem } from "../../generated/app-server/v2/ThreadItem";
import type { Turn as GeneratedTurn } from "../../generated/app-server/v2/Turn";
import { legacyPanelUserMessageProjection } from "./legacy-panel-user-message";
import {
fileReferencesFromLegacyManifest,
legacyTurnContextProjection,
referencedThreadFromLegacyManifest,
} from "./legacy-turn-context-manifest";
export type TurnItem = GeneratedThreadItem;
export type TurnRecord = GeneratedTurn;
type AppServerUserInput = Extract<TurnItem, { type: "userMessage" }>["content"][number];
type AppServerTextUserInput = Extract<AppServerUserInput, { type: "text" }>;
function transcriptEntriesFromTurnRecord(turn: TurnRecord): ThreadTranscriptEntry[] {
return turn.items.flatMap((item) => transcriptEntriesFromTurnItem(item, turn));
}
@ -53,42 +48,8 @@ export function chronologicalTurnTranscriptSummariesFromTurnRecords(turns: reado
);
}
export interface TurnUserItemProjection {
text: string;
referencedThread: ReferencedThreadMetadata | null;
fileReferences: VaultFileReference[];
contexts: TurnUserContextMetadata[];
}
interface TurnUserContextMetadata {
kind: "web" | "obsidian";
truncated: boolean;
inlineExcerpts?: number;
}
export function turnUserItemProjection(item: Extract<TurnItem, { type: "userMessage" }>): TurnUserItemProjection {
const projected = legacyTurnContextProjection(item.content, item.clientId);
if (!projected.manifest) {
const legacy = legacyPanelUserMessageProjection({
content: item.content,
visibleText: projected.text,
});
const supplementalText = nonTextUserInputText(item.content, projected.text, legacy.mentionTextByContentIndex);
return {
text: [legacy.text, supplementalText].filter(Boolean).join("\n"),
referencedThread: legacy.referencedThread,
fileReferences: legacy.fileReferences,
contexts: [],
};
}
const supplementalText = nonTextUserInputText(item.content, projected.text);
const text = [projected.text, supplementalText].filter(Boolean).join("\n");
return {
text,
referencedThread: referencedThreadFromLegacyManifest(projected.manifest),
fileReferences: fileReferencesFromLegacyManifest(projected.manifest),
contexts: legacyContextMetadata(projected.manifest.contexts),
};
export function turnUserItemText(item: Extract<TurnItem, { type: "userMessage" }>): string {
return userInputText(item.content);
}
export function lastAgentMessageTextFromTurnRecord(turn: TurnRecord): string | null {
@ -104,20 +65,8 @@ export function lastAgentMessageTextFromTurnRecord(turn: TurnRecord): string | n
function transcriptEntriesFromTurnItem(item: TurnItem, turn: TurnRecord): ThreadTranscriptEntry[] {
if (item.type === "userMessage") {
const projection = turnUserItemProjection(item);
const text = projection.text.trim();
const contexts = projection.contexts.map((context) => ({ kind: context.kind, truncated: context.truncated }));
return text
? [
{
kind: "user",
text,
timestamp: turn.startedAt,
...(projection.referencedThread ? { referencedThread: projection.referencedThread } : {}),
...(contexts.length > 0 ? { contexts } : {}),
},
]
: [];
const text = turnUserItemText(item).trim();
return text ? [{ kind: "user", text, timestamp: turn.startedAt }] : [];
}
if (item.type === "agentMessage") {
const text = item.text.trim();
@ -130,36 +79,22 @@ function transcriptEntriesFromTurnItem(item: TurnItem, turn: TurnRecord): Thread
return [];
}
function legacyContextMetadata(
contexts: readonly { kind: "referencedThread" | "web" | "obsidian"; truncated: boolean; inlineExcerpts?: number }[],
): TurnUserContextMetadata[] {
return contexts.flatMap((context) => {
if (context.kind === "referencedThread") return [];
return [
{
kind: context.kind,
truncated: context.truncated,
...(context.inlineExcerpts === undefined ? {} : { inlineExcerpts: context.inlineExcerpts }),
},
];
});
}
function nonTextUserInputText(
content: Extract<TurnItem, { type: "userMessage" }>["content"],
visibleText: string,
mentionTextByContentIndex?: ReadonlyMap<number, string>,
): string {
const hasText = visibleText.length > 0;
const textIncludes = (value: string) => value.length > 0 && visibleText.includes(value);
function userInputText(content: readonly AppServerUserInput[]): string {
const textItems = content.filter(isTextUserInput);
const hasText = textItems.some((item) => item.text.length > 0);
const textIncludes = (value: string) => value.length > 0 && textItems.some((item) => item.text.includes(value));
return content
.map((item, index) => {
.map((item) => {
if (item.type === "text") return item.text;
if (item.type === "localImage") return hasText && textIncludes(item.path) ? "" : `[local image] ${item.path}`;
if (item.type === "image") return hasText && textIncludes(item.url) ? "" : `[image] ${item.url}`;
if (item.type === "mention") return mentionTextByContentIndex?.get(index) ?? "";
if (item.type === "skill") return hasText ? "" : `[$${item.name}] ${item.path}`;
return "";
if (item.type === "mention") return hasText ? "" : `[@${item.name}] ${item.path}`;
return hasText ? "" : `[$${item.name}] ${item.path}`;
})
.filter(Boolean)
.join("\n");
}
function isTextUserInput(item: AppServerUserInput): item is AppServerTextUserInput {
return item.type === "text";
}

View file

@ -1,65 +0,0 @@
import type { InfiniteData } from "@tanstack/query-core";
import { type Thread, threadRecencyAt } from "../../domain/threads/model";
import type { ThreadPage } from "../services/threads";
import type { ThreadListMutation } from "./thread-list-mutation";
export type ActiveThreadCursor = string | null;
export type ActiveThreadData = InfiniteData<ThreadPage, ActiveThreadCursor>;
export function activeThreadsFromData(data: ActiveThreadData | undefined): readonly Thread[] | null {
if (!data) return null;
return orderedUniqueThreads(data.pages.flatMap((page) => page.threads));
}
export function recentActiveThreadsFromData(data: ActiveThreadData | undefined): readonly Thread[] | null {
if (!data) return null;
const threads = activeThreadsFromData(data) ?? [];
const recentWindowCapacity = Math.max(data.pages[0]?.fetchedSize ?? 0, threads.length > 0 ? 1 : 0);
return threads.slice(0, recentWindowCapacity);
}
export function activeThreadDataHasMore(data: ActiveThreadData | undefined): boolean {
return data?.pages.at(-1)?.nextCursor != null;
}
export function applyActiveThreadMutation(data: ActiveThreadData | undefined, mutation: ThreadListMutation): ActiveThreadData | undefined {
if (!data || mutation.list !== "active" || mutation.kind === "refresh") return data;
const pages = data.pages.map((page) => ({ ...page, threads: [...page.threads] }));
switch (mutation.kind) {
case "upsert": {
const existing = pages.some((page) => page.threads.some((thread) => thread.id === mutation.thread.id));
if (existing) {
for (const page of pages) {
page.threads = page.threads.map((thread) => (thread.id === mutation.thread.id ? mutation.thread : thread));
}
} else if (pages[0]) {
pages[0].threads = [mutation.thread, ...pages[0].threads];
}
break;
}
case "remove":
for (const page of pages) page.threads = page.threads.filter((thread) => thread.id !== mutation.threadId);
break;
case "update":
for (const page of pages) {
page.threads = page.threads.map((thread) => (thread.id === mutation.threadId ? { ...thread, ...mutation.changes } : thread));
}
break;
}
return { pages, pageParams: [...data.pageParams] };
}
function orderedUniqueThreads(threads: readonly Thread[]): readonly Thread[] {
const seen = new Set<string>();
return threads
.filter((thread) => {
if (seen.has(thread.id)) return false;
seen.add(thread.id);
return true;
})
.map((thread, index) => ({ thread, index }))
.sort((left, right) => threadRecencyAt(right.thread) - threadRecencyAt(left.thread) || left.index - right.index)
.map(({ thread }) => thread);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,69 @@
export interface AppServerQueryContext {
codexPath: string;
vaultPath: string;
}
type AppServerQueryScope = readonly ["app-server", string, string];
export type AppServerActiveThreadsQueryKey = readonly [...AppServerQueryScope, "threads", "active"];
export type AppServerArchivedThreadsQueryKey = readonly [...AppServerQueryScope, "threads", "archived"];
export type AppServerModelsQueryKey = readonly [...AppServerQueryScope, "models"];
export type AppServerRuntimeConfigQueryKey = readonly [...AppServerQueryScope, "runtime-config"];
export type AppServerSkillsQueryKey = readonly [...AppServerQueryScope, "skills"];
export type AppServerPermissionProfilesQueryKey = readonly [...AppServerQueryScope, "permission-profiles"];
export type AppServerRateLimitsQueryKey = readonly [...AppServerQueryScope, "rate-limits"];
export function appServerQueryContextIsComplete(context: AppServerQueryContext): boolean {
return nonEmptyString(context.codexPath) && nonEmptyString(context.vaultPath);
}
export function cloneAppServerQueryContext(context: AppServerQueryContext): AppServerQueryContext {
return { ...context };
}
export function appServerQueryContextRawEquals(left: AppServerQueryContext, right: AppServerQueryContext): boolean {
return left.codexPath === right.codexPath && left.vaultPath === right.vaultPath;
}
export function appServerQueryContextMatches(left: AppServerQueryContext, right: AppServerQueryContext): boolean {
return appServerQueryContextIsComplete(left) && appServerQueryContextIsComplete(right) && appServerQueryContextRawEquals(left, right);
}
export function appServerQueryContextKey(context: AppServerQueryContext): string {
return `${context.codexPath}\u0000${context.vaultPath}`;
}
function appServerQueryScope(context: AppServerQueryContext): AppServerQueryScope {
return ["app-server", context.codexPath, context.vaultPath];
}
export function activeThreadsQueryKey(context: AppServerQueryContext): AppServerActiveThreadsQueryKey {
return [...appServerQueryScope(context), "threads", "active"];
}
export function archivedThreadsQueryKey(context: AppServerQueryContext): AppServerArchivedThreadsQueryKey {
return [...appServerQueryScope(context), "threads", "archived"];
}
export function appServerModelsQueryKey(context: AppServerQueryContext): AppServerModelsQueryKey {
return [...appServerQueryScope(context), "models"];
}
export function appServerRuntimeConfigQueryKey(context: AppServerQueryContext): AppServerRuntimeConfigQueryKey {
return [...appServerQueryScope(context), "runtime-config"];
}
export function appServerSkillsQueryKey(context: AppServerQueryContext): AppServerSkillsQueryKey {
return [...appServerQueryScope(context), "skills"];
}
export function appServerPermissionProfilesQueryKey(context: AppServerQueryContext): AppServerPermissionProfilesQueryKey {
return [...appServerQueryScope(context), "permission-profiles"];
}
export function appServerRateLimitsQueryKey(context: AppServerQueryContext): AppServerRateLimitsQueryKey {
return [...appServerQueryScope(context), "rate-limits"];
}
function nonEmptyString(value: string): boolean {
return value.trim().length > 0;
}

View file

@ -16,9 +16,16 @@ export type SkillMetadataProbeResult = MetadataProbeResult<SkillMetadata[], "ski
export type PermissionProfileMetadataProbeResult = MetadataProbeResult<RuntimePermissionProfileSummary[], "permissionProfiles">;
export type RateLimitMetadataProbeResult = MetadataProbeResult<RateLimitSnapshot | null, "rateLimits">;
export async function readSkillMetadataProbe(client: AppServerRequestClient, vaultPath: string): Promise<SkillMetadataProbeResult> {
export async function readSkillMetadataProbe(
client: AppServerRequestClient | null,
vaultPath: string,
forceReload = false,
): Promise<SkillMetadataProbeResult> {
if (!client) {
return { value: [], probe: diagnosticProbeError("skills", new Error("Codex app-server is not connected."), Date.now()) };
}
try {
const catalog = await listSkillCatalog(client, vaultPath);
const catalog = await listSkillCatalog(client, vaultPath, { forceReload });
return { value: catalog.skills, probe: diagnosticProbeOk("skills", `${String(catalog.totalCount)} skills`, Date.now()) };
} catch (error) {
return { value: [], probe: diagnosticProbeError("skills", error, Date.now()) };
@ -26,9 +33,15 @@ export async function readSkillMetadataProbe(client: AppServerRequestClient, vau
}
export async function readPermissionProfileMetadataProbe(
client: AppServerRequestClient,
client: AppServerRequestClient | null,
vaultPath: string,
): Promise<PermissionProfileMetadataProbeResult> {
if (!client) {
return {
value: [],
probe: diagnosticProbeError("permissionProfiles", new Error("Codex app-server is not connected."), Date.now()),
};
}
try {
const profiles = await listPermissionProfiles(client, vaultPath);
return { value: profiles, probe: diagnosticProbeOk("permissionProfiles", `${String(profiles.length)} profiles`, Date.now()) };
@ -37,7 +50,13 @@ export async function readPermissionProfileMetadataProbe(
}
}
export async function readRateLimitMetadataProbe(client: AppServerRequestClient): Promise<RateLimitMetadataProbeResult> {
export async function readRateLimitMetadataProbe(client: AppServerRequestClient | null): Promise<RateLimitMetadataProbeResult> {
if (!client) {
return {
value: null,
probe: diagnosticProbeError("rateLimits", new Error("Codex app-server is not connected."), Date.now()),
};
}
try {
const response = await readAccountRateLimits(client);
return {

View file

@ -6,13 +6,6 @@ export interface ObservedResult<T> {
export type ObservedResultListener<T> = (result: ObservedResult<T>) => void;
export interface ObservedPaginatedResult<T> extends ObservedResult<T> {
readonly hasMore: boolean;
readonly isFetchingNextPage: boolean;
}
export type ObservedPaginatedResultListener<T> = (result: ObservedPaginatedResult<T>) => void;
export function observedInitialLoading<T>(result: ObservedResult<T>, currentValue: T | null | undefined): boolean {
return currentValue == null && result.isFetching;
}

View file

@ -0,0 +1,205 @@
import type { ModelMetadata } from "../../domain/catalog/metadata";
import type { SharedServerMetadata } from "../../domain/server/metadata";
import type { Thread } from "../../domain/threads/model";
import type { AppServerQueryCache } from "./cache";
import {
type AppServerQueryContext,
appServerQueryContextKey,
appServerQueryContextMatches,
appServerQueryContextRawEquals,
cloneAppServerQueryContext,
} from "./keys";
import type { ObservedResultListener } from "./observed-result";
export interface AppServerSharedQueriesOptions {
cache: AppServerQueryCache;
context: () => AppServerQueryContext;
}
export class StaleAppServerSharedQueryContextError extends Error {
constructor() {
super("Codex app-server query context changed while loading shared queries.");
this.name = "StaleAppServerSharedQueryContextError";
}
}
export function isStaleAppServerSharedQueryContextError(error: unknown): error is StaleAppServerSharedQueryContextError {
return error instanceof StaleAppServerSharedQueryContextError;
}
export class AppServerSharedQueries {
private readonly contextChangeListeners = new Set<() => void>();
constructor(private readonly options: AppServerSharedQueriesOptions) {}
contextKey(): string {
return appServerQueryContextKey(this.context());
}
contextKeyFor(context: AppServerQueryContext): string {
return appServerQueryContextKey(context);
}
activeThreadsSnapshot(): readonly Thread[] | null {
return this.options.cache.activeThreadsSnapshot(this.context());
}
activeThreadsSnapshotFor(context: AppServerQueryContext): readonly Thread[] | null {
return this.options.cache.activeThreadsSnapshot(context);
}
archivedThreadsSnapshot(): readonly Thread[] | null {
return this.options.cache.archivedThreadsSnapshot(this.context());
}
archivedThreadsSnapshotFor(context: AppServerQueryContext): readonly Thread[] | null {
return this.options.cache.archivedThreadsSnapshot(context);
}
fetchAllActiveThreads(): Promise<readonly Thread[]> {
return this.runForCurrentContext((context) => this.options.cache.fetchAllActiveThreads(context));
}
hasMoreActiveThreads(): boolean {
return this.options.cache.hasMoreActiveThreads(this.context());
}
loadMoreActiveThreads(): Promise<readonly Thread[]> {
return this.runForCurrentContext((context) => this.options.cache.loadMoreActiveThreads(context));
}
refreshActiveThreads(): Promise<readonly Thread[]> {
return this.runForCurrentContext((context) => this.options.cache.refreshActiveThreads(context));
}
refreshActiveThreadsFor(context: AppServerQueryContext): Promise<readonly Thread[]> {
return this.options.cache.refreshActiveThreads(context);
}
refreshArchivedThreads(): Promise<readonly Thread[]> {
return this.runForCurrentContext((context) => this.options.cache.refreshArchivedThreads(context));
}
refreshArchivedThreadsFor(context: AppServerQueryContext): Promise<readonly Thread[]> {
return this.options.cache.refreshArchivedThreads(context);
}
observeActiveThreadsResult(listener: ObservedResultListener<readonly Thread[]>, options?: { emitCurrent?: boolean }): () => void {
return this.observeCurrentContext(
(context, contextListener, observeOptions) => this.options.cache.observeActiveThreadsResult(context, contextListener, observeOptions),
listener,
options,
);
}
observeArchivedThreadsResult(listener: ObservedResultListener<readonly Thread[]>, options?: { emitCurrent?: boolean }): () => void {
return this.observeCurrentContext(
(context, contextListener, observeOptions) =>
this.options.cache.observeArchivedThreadsResult(context, contextListener, observeOptions),
listener,
options,
);
}
appServerMetadataSnapshot(): SharedServerMetadata | null {
return this.options.cache.appServerMetadataSnapshot(this.context());
}
refreshAppServerMetadata(): Promise<SharedServerMetadata | null> {
return this.runForCurrentContext((context) => this.options.cache.refreshAppServerMetadata(context));
}
refreshSkills(): Promise<SharedServerMetadata | null> {
return this.runForCurrentContext((context) => this.options.cache.refreshSkills(context));
}
refreshRateLimits(): Promise<SharedServerMetadata | null> {
return this.runForCurrentContext((context) => this.options.cache.refreshRateLimits(context));
}
observeAppServerMetadataResult(listener: ObservedResultListener<SharedServerMetadata>, options?: { emitCurrent?: boolean }): () => void {
return this.observeCurrentContext(
(context, contextListener, observeOptions) =>
this.options.cache.observeAppServerMetadataResult(context, contextListener, observeOptions),
listener,
options,
);
}
modelsSnapshot(): readonly ModelMetadata[] | null {
return this.options.cache.modelsSnapshot(this.context());
}
fetchModels(): Promise<readonly ModelMetadata[]> {
return this.runForCurrentContext((context) => this.options.cache.fetchModels(context));
}
refreshModels(): Promise<readonly ModelMetadata[]> {
return this.runForCurrentContext((context) => this.options.cache.refreshModels(context));
}
observeModelsResult(listener: ObservedResultListener<readonly ModelMetadata[]>, options?: { emitCurrent?: boolean }): () => void {
return this.observeCurrentContext(
(context, contextListener, observeOptions) => this.options.cache.observeModelsResult(context, contextListener, observeOptions),
listener,
options,
);
}
notifyContextChanged(): void {
for (const listener of [...this.contextChangeListeners]) {
listener();
}
}
private context(): AppServerQueryContext {
return this.options.context();
}
private async runForCurrentContext<T>(operation: (context: AppServerQueryContext) => Promise<T>): Promise<T> {
const context = cloneAppServerQueryContext(this.context());
const result = await operation(context);
if (!appServerQueryContextRawEquals(this.context(), context)) {
throw new StaleAppServerSharedQueryContextError();
}
return result;
}
private observeCurrentContext<T>(
observe: (context: AppServerQueryContext, listener: (value: T) => void, options: { emitCurrent?: boolean }) => () => void,
listener: (value: T) => void,
options: { emitCurrent?: boolean } = {},
): () => void {
let observedContext: AppServerQueryContext | null = null;
let unsubscribeQuery: (() => void) | null = null;
let firstSubscribe = true;
const subscribe = (): void => {
const context = cloneAppServerQueryContext(this.context());
if (observedContext && appServerQueryContextMatches(observedContext, context)) return;
unsubscribeQuery?.();
observedContext = context;
const observeOptions: { emitCurrent?: boolean } = {};
if (firstSubscribe) {
if (options.emitCurrent !== undefined) observeOptions.emitCurrent = options.emitCurrent;
} else {
observeOptions.emitCurrent = true;
}
unsubscribeQuery = observe(
context,
(value) => {
if (appServerQueryContextMatches(this.context(), context)) listener(value);
},
observeOptions,
);
firstSubscribe = false;
};
subscribe();
this.contextChangeListeners.add(subscribe);
return () => {
this.contextChangeListeners.delete(subscribe);
unsubscribeQuery?.();
};
}
}

View file

@ -1,7 +1,7 @@
import type { ModelMetadata } from "../../domain/catalog/metadata";
import { cloneRuntimeConfigSnapshot } from "../../domain/runtime/config";
import type { RateLimitSnapshot } from "../../domain/runtime/metrics";
import type { SharedServerMetadata, SharedServerMetadataResource } from "../../domain/server/metadata";
import type { SharedServerMetadata } from "../../domain/server/metadata";
import { cloneToolInventorySnapshot } from "../../domain/server/tool-inventory";
import type { Thread } from "../../domain/threads/model";
@ -34,40 +34,6 @@ export function cloneSharedServerMetadata(metadata: SharedServerMetadata): Share
};
}
export function cloneSharedServerMetadataResource(resource: SharedServerMetadataResource): SharedServerMetadataResource {
switch (resource.id) {
case "runtimeConfig":
return {
id: resource.id,
value: resource.value ? cloneRuntimeConfigSnapshot(resource.value) : undefined,
};
case "models":
return {
id: resource.id,
value: resource.value ? cloneModelMetadata(resource.value) : undefined,
probe: { ...resource.probe },
};
case "skills":
return {
id: resource.id,
value: resource.value?.map((skill) => ({ ...skill })),
probe: { ...resource.probe },
};
case "permissionProfiles":
return {
id: resource.id,
value: resource.value?.map((profile) => ({ ...profile })),
probe: { ...resource.probe },
};
case "rateLimits":
return {
id: resource.id,
value: resource.value ? cloneRateLimitSnapshot(resource.value) : resource.value,
probe: { ...resource.probe },
};
}
}
function cloneRateLimitSnapshot(snapshot: RateLimitSnapshot): RateLimitSnapshot {
return {
...snapshot,

View file

@ -1,31 +0,0 @@
import type { Thread } from "../../domain/threads/model";
export type ThreadListKind = "active" | "archived";
export type ThreadListMutation =
| { readonly kind: "upsert"; readonly list: ThreadListKind; readonly thread: Thread }
| { readonly kind: "remove"; readonly list: ThreadListKind; readonly threadId: string }
| {
readonly kind: "update";
readonly list: ThreadListKind;
readonly threadId: string;
readonly changes: Partial<Pick<Thread, "name" | "recencyAt">>;
}
| { readonly kind: "refresh"; readonly list: ThreadListKind };
export function applyThreadListMutation(snapshot: readonly Thread[] | null, mutation: ThreadListMutation): readonly Thread[] | null {
switch (mutation.kind) {
case "refresh":
return snapshot;
case "upsert": {
if (!snapshot) return null;
const index = snapshot.findIndex((thread) => thread.id === mutation.thread.id);
if (index < 0) return [mutation.thread, ...snapshot];
return snapshot.map((thread) => (thread.id === mutation.thread.id ? mutation.thread : thread));
}
case "remove":
return snapshot?.filter((thread) => thread.id !== mutation.threadId) ?? null;
case "update":
return snapshot?.map((thread) => (thread.id === mutation.threadId ? { ...thread, ...mutation.changes } : thread)) ?? null;
}
}

View file

@ -33,31 +33,38 @@ export type ServerRequestRoute =
| { kind: "inactive"; request: ServerRequest };
type ServerRequestMethod = ServerRequest["method"];
type ServerRequestDescriptorByMethod = {
[Method in ServerRequestMethod]: {
routeKind: Exclude<ServerRequestRoute["kind"], "inactive" | "unknown">;
scope: (request: Extract<ServerRequest, { method: Method }>) => AppServerRouteScope;
};
type ServerRequestRouteKindByMethod = Record<ServerRequestMethod, Exclude<ServerRequestRoute["kind"], "inactive" | "unknown">>;
type ServerRequestScopeExtractors = {
[Method in ServerRequestMethod]: (request: Extract<ServerRequest, { method: Method }>) => AppServerRouteScope;
};
const SERVER_REQUEST_DESCRIPTORS = {
"item/commandExecution/requestApproval": { routeKind: "approval", scope: threadTurnRequestScope },
"item/fileChange/requestApproval": { routeKind: "approval", scope: threadTurnRequestScope },
"item/permissions/requestApproval": { routeKind: "approval", scope: threadTurnRequestScope },
"item/tool/requestUserInput": { routeKind: "userInput", scope: threadTurnRequestScope },
"mcpServer/elicitation/request": { routeKind: "mcpElicitation", scope: threadTurnRequestScope },
"item/tool/call": { routeKind: "unsupported", scope: threadTurnRequestScope },
"account/chatgptAuthTokens/refresh": { routeKind: "unsupported", scope: unscopedRequestScope },
"attestation/generate": { routeKind: "unsupported", scope: unscopedRequestScope },
"currentTime/read": { routeKind: "currentTime", scope: threadOnlyRequestScope },
applyPatchApproval: { routeKind: "unsupported", scope: unscopedRequestScope },
execCommandApproval: { routeKind: "unsupported", scope: unscopedRequestScope },
} satisfies ServerRequestDescriptorByMethod;
const SERVER_REQUEST_SCOPE_EXTRACTORS: ServerRequestScopeExtractors = {
"item/commandExecution/requestApproval": threadTurnRequestScope,
"item/fileChange/requestApproval": threadTurnRequestScope,
"item/tool/requestUserInput": threadTurnRequestScope,
"mcpServer/elicitation/request": threadTurnRequestScope,
"item/permissions/requestApproval": threadTurnRequestScope,
"item/tool/call": threadTurnRequestScope,
"account/chatgptAuthTokens/refresh": unscopedRequestScope,
"attestation/generate": unscopedRequestScope,
"currentTime/read": threadOnlyRequestScope,
applyPatchApproval: unscopedRequestScope,
execCommandApproval: unscopedRequestScope,
};
interface ServerRequestDescriptor {
readonly routeKind: Exclude<ServerRequestRoute["kind"], "inactive" | "unknown">;
readonly scope: (request: ServerRequest) => AppServerRouteScope;
}
const SERVER_REQUEST_ROUTE_KIND_BY_METHOD: ServerRequestRouteKindByMethod = {
"item/commandExecution/requestApproval": "approval",
"item/fileChange/requestApproval": "approval",
"item/permissions/requestApproval": "approval",
"item/tool/requestUserInput": "userInput",
"mcpServer/elicitation/request": "mcpElicitation",
"item/tool/call": "unsupported",
"account/chatgptAuthTokens/refresh": "unsupported",
"attestation/generate": "unsupported",
"currentTime/read": "currentTime",
applyPatchApproval: "unsupported",
execCommandApproval: "unsupported",
};
export function routeServerRequest(request: ServerRequest, scope: ActiveRouteScope): ServerRequestRoute {
const routeScope = serverRequestScope(request);
@ -69,7 +76,7 @@ export function routeServerRequest(request: ServerRequest, scope: ActiveRouteSco
if (!isAppServerRouteScopeInActiveRouteScope(routeScope, scope)) return { kind: "inactive", request };
if (isTurnScopedAppServerRouteForIdleActiveThread(routeScope, scope)) return { kind: "inactive", request };
switch (serverRequestDescriptor(request).routeKind) {
switch (SERVER_REQUEST_ROUTE_KIND_BY_METHOD[request.method]) {
case "approval": {
const approval = appServerApprovalRequest(request);
if (approval) return { kind: "approval", request, approval };
@ -113,15 +120,12 @@ export function serverRequestCurrentTimeResponse(currentTimeMs: number): unknown
function serverRequestScope(request: ServerRequest): AppServerRouteScope {
if (!isServerRequest(request)) return fallbackAppServerRouteScope(request);
return serverRequestDescriptor(request).scope(request);
const extractor = SERVER_REQUEST_SCOPE_EXTRACTORS[request.method] as (request: ServerRequest) => AppServerRouteScope;
return extractor(request);
}
function isServerRequest(request: ServerRequest): boolean {
return Object.hasOwn(SERVER_REQUEST_DESCRIPTORS, request.method);
}
function serverRequestDescriptor(request: ServerRequest): ServerRequestDescriptor {
return SERVER_REQUEST_DESCRIPTORS[request.method] as ServerRequestDescriptor;
return Object.hasOwn(SERVER_REQUEST_SCOPE_EXTRACTORS, request.method);
}
function threadTurnRequestScope(request: { params: { threadId: string; turnId: string | null } }): AppServerRouteScope {

View file

@ -6,7 +6,6 @@ import type { ServerNotification } from "../connection/rpc-messages";
import { lastAgentMessageTextFromTurnRecord, type TurnItem, type TurnRecord } from "../protocol/turn";
import type { ModelMetadataClient } from "./catalog";
import type { AppServerRequestClient } from "./request-client";
import { type RuntimeOverrideSettings, resolvedRuntimeOverrideForClient } from "./runtime-overrides";
import { deleteThread, startEphemeralThread } from "./threads";
import { type AppServerStartStructuredTurnOptions, startStructuredTurn } from "./turns";
@ -38,7 +37,7 @@ export interface EphemeralStructuredTurnClient {
type EphemeralStructuredTurnRuntimeCapableClient = EphemeralStructuredTurnClient & ModelMetadataClient;
type EphemeralStructuredTurnClientFactory = (
export type EphemeralStructuredTurnClientFactory = (
codexPath: string,
cwd: string,
handlers: AppServerClientHandlers,
@ -57,31 +56,17 @@ export interface RunEphemeralStructuredTurnOptions {
timedOutMessage: string;
abortMessage?: string;
runtime?: StructuredTurnRuntimeOverride | undefined;
runtimeSettings?: RuntimeOverrideSettings | undefined;
resolveRuntime?: ((client: ModelMetadataClient) => Promise<StructuredTurnRuntimeOverride>) | undefined;
signal?: AbortSignal | undefined;
onProgress?: (event: StructuredTurnProgressEvent) => void;
}
export type EphemeralStructuredTurnRunner = (options: RunEphemeralStructuredTurnOptions) => Promise<TurnRecord>;
export interface EphemeralStructuredTurnDependencies {
clientFactory?: EphemeralStructuredTurnClientFactory | undefined;
timers?: EphemeralStructuredTurnTimers | undefined;
clientLifecycle?:
| {
created(client: EphemeralStructuredTurnClient): void;
disposed(client: EphemeralStructuredTurnClient): void;
}
| undefined;
}
export async function runEphemeralStructuredTurn(
options: RunEphemeralStructuredTurnOptions,
dependencies: EphemeralStructuredTurnDependencies = {},
): Promise<TurnRecord> {
export async function runEphemeralStructuredTurn(options: RunEphemeralStructuredTurnOptions): Promise<TurnRecord> {
throwIfAborted(options.signal, options.abortMessage);
let state = createEphemeralStructuredTurnState();
const timers = dependencies.timers ?? DEFAULT_EPHEMERAL_STRUCTURED_TURN_TIMERS;
const timers = options.timers ?? DEFAULT_EPHEMERAL_STRUCTURED_TURN_TIMERS;
let handleNotification: (notification: ServerNotification) => void = () => undefined;
let operationAbortError: Error | null = null;
const operationAbort = new AbortController();
@ -113,7 +98,7 @@ export async function runEphemeralStructuredTurn(
});
const clientFactory =
dependencies.clientFactory ??
options.clientFactory ??
((codexPath, cwd, handlers) =>
new AppServerClient({
codexPath,
@ -137,13 +122,10 @@ export async function runEphemeralStructuredTurn(
abortOperation(new Error(options.exitedMessage));
},
});
dependencies.clientLifecycle?.created(client);
try {
await runAbortable(client.connect());
const runtime = options.runtimeSettings
? await runAbortable(resolvedRuntimeOverrideForClient(client, options.runtimeSettings))
: (options.runtime ?? {});
const runtime = options.resolveRuntime ? await runAbortable(options.resolveRuntime(client)) : (options.runtime ?? {});
const threadResponse = await runAbortable(
startEphemeralThread(client, {
cwd: options.cwd,
@ -182,20 +164,13 @@ export async function runEphemeralStructuredTurn(
} finally {
state = completeEphemeralStructuredTurnState(state);
timers.clearTimeout(timeout);
try {
await deleteEphemeralStructuredTurnThread(client, threadId);
} finally {
client.disconnect();
dependencies.clientLifecycle?.disposed(client);
}
await deleteEphemeralStructuredTurnThread(client, threadId);
client.disconnect();
}
}
export async function runEphemeralStructuredTurnForLastAgentText(
options: RunEphemeralStructuredTurnOptions,
runner: EphemeralStructuredTurnRunner = runEphemeralStructuredTurn,
): Promise<string | null> {
const turn = await runner(options);
export async function runEphemeralStructuredTurnForLastAgentText(options: RunEphemeralStructuredTurnOptions): Promise<string | null> {
const turn = await runEphemeralStructuredTurn(options);
return lastAgentMessageTextFromTurnRecord(turn);
}

View file

@ -7,10 +7,11 @@ import {
} from "../../domain/threads/title-generation-model";
import { turnTranscriptAssistantTextFromTurnRecord } from "../protocol/turn";
import {
type EphemeralStructuredTurnRunner,
type EphemeralStructuredTurnClientFactory,
runEphemeralStructuredTurn,
type StructuredTurnOutputSchema,
} from "./ephemeral-structured-turn";
import { resolvedRuntimeOverrideForClient } from "./runtime-overrides";
const THREAD_TITLE_SERVICE_NAME = "codex-panel-naming";
const THREAD_TITLE_TIMEOUT_MS = 60_000;
@ -40,20 +41,14 @@ export interface ThreadTitleRuntimeSettings {
threadNamingEffort: ReasoningEffort | null;
}
export interface GenerateThreadTitleWithCodexOptions {
runner?: EphemeralStructuredTurnRunner;
signal?: AbortSignal;
}
export async function generateThreadTitleWithCodex(
codexPath: string,
cwd: string,
context: ThreadTitleContext,
runtimeSettings: ThreadTitleRuntimeSettings,
options: GenerateThreadTitleWithCodexOptions = {},
clientFactory?: EphemeralStructuredTurnClientFactory,
): Promise<string | null> {
const runner = options.runner ?? runEphemeralStructuredTurn;
const turn = await runner({
const turn = await runEphemeralStructuredTurn({
codexPath,
cwd,
serviceName: THREAD_TITLE_SERVICE_NAME,
@ -64,9 +59,9 @@ export async function generateThreadTitleWithCodex(
serverRequests: { kind: "reject", message: "Thread title generation does not handle server requests." },
exitedMessage: "Codex title generation app-server exited.",
timedOutMessage: "Timed out while generating a Codex thread title.",
abortMessage: "Thread title generation cancelled.",
runtimeSettings: { model: runtimeSettings.threadNamingModel, effort: runtimeSettings.threadNamingEffort },
signal: options.signal,
resolveRuntime: (client) =>
resolvedRuntimeOverrideForClient(client, { model: runtimeSettings.threadNamingModel, effort: runtimeSettings.threadNamingEffort }),
clientFactory,
});
const response = turnTranscriptAssistantTextFromTurnRecord(turn);
return response ? threadTitleFromGeneratedText(response) : null;

View file

@ -6,6 +6,7 @@ import type { RuntimeServiceTierRequest, RuntimeSettingsPatch } from "../../doma
import type { ThreadActivationSnapshot } from "../../domain/threads/activation";
import type { ArchiveThreadInput } from "../../domain/threads/archive-markdown";
import type { ThreadGoal, ThreadGoalUpdate } from "../../domain/threads/goal";
import type { HistoricalTurn } from "../../domain/threads/history";
import type { Thread } from "../../domain/threads/model";
import { REFERENCED_THREAD_TURN_LIMIT } from "../../domain/threads/reference";
import type { TurnTranscriptSummary } from "../../domain/threads/transcript";
@ -22,6 +23,8 @@ import {
} from "../protocol/turn";
import type { AppServerRequestClient } from "./request-client";
const THREAD_LIST_PAGE_LIMIT = 100;
export type ThreadTurnSortDirection = "asc" | "desc";
interface TurnTranscriptSummaryPage {
@ -60,7 +63,6 @@ export interface AppServerThreadListOptions {
export interface ThreadPage {
readonly threads: readonly Thread[];
readonly nextCursor: string | null;
readonly fetchedSize: number;
}
export function startThread(
@ -105,21 +107,17 @@ export function resumeThread(
});
}
export async function listThreads(
client: AppServerRequestClient,
cwd: string,
options: { archived?: boolean; signal?: AbortSignal } = {},
): Promise<Thread[]> {
export async function listThreads(client: AppServerRequestClient, cwd: string, options: { archived?: boolean } = {}): Promise<Thread[]> {
const archived = options.archived ?? false;
const threads: Thread[] = [];
const seenCursors = new Set<string>();
let cursor: string | null = null;
for (;;) {
options.signal?.throwIfAborted();
const page = await readThreadPage(client, cwd, {
archived,
cursor,
limit: THREAD_LIST_PAGE_LIMIT,
});
threads.push(...page.threads);
@ -143,11 +141,11 @@ export async function readThreadPage(
const page = await readThreadRecordPage(client, cwd, {
...options,
archived,
limit: options.limit ?? THREAD_LIST_PAGE_LIMIT,
});
return {
threads: threadsFromThreadRecords(page.data, { archived }),
nextCursor: page.nextCursor ?? null,
fetchedSize: page.data.length,
};
}
@ -186,61 +184,47 @@ export async function readReferencedThreadTurnTranscriptSummaries(
return chronologicalTurnTranscriptSummariesFromTurnRecords(response.data);
}
type ThreadForkPosition =
| { readonly kind: "through-turn"; readonly turnId: string }
| { readonly kind: "before-turn"; readonly turnId: string };
export interface ThreadRollbackSnapshot {
thread: Thread;
cwd: string;
turns: readonly HistoricalTurn[];
}
interface AppServerForkThreadOptions {
readonly position?: ThreadForkPosition;
readonly deferGoalContinuation?: boolean;
readonly runtime?: {
readonly model?: string;
readonly reasoningEffort?: string | null;
readonly serviceTier?: ServiceTier | null;
readonly approvalPolicy?: RuntimePermissionState["approvalPolicy"];
readonly approvalsReviewer?: ApprovalsReviewer;
readonly permissions?: string;
readonly sandboxPolicy?: RuntimePermissionState["sandboxPolicy"];
interface ThreadRollbackResult {
readonly thread: ThreadRecord & {
readonly cwd: string;
readonly turns: readonly HistoricalTurn[];
};
}
function threadRollbackSnapshotFromAppServerResponse(response: ThreadRollbackResult): ThreadRollbackSnapshot {
return {
thread: threadFromThreadRecord(response.thread),
cwd: response.thread.cwd,
turns: response.thread.turns,
};
}
export async function rollbackThread(client: AppServerRequestClient, threadId: string, numTurns?: number): Promise<ThreadRollbackSnapshot> {
const response = await client.request("thread/rollback", { threadId, numTurns: numTurns ?? 1 });
return threadRollbackSnapshotFromAppServerResponse(response);
}
export async function forkThread(
client: AppServerRequestClient,
threadId: string,
cwd: string,
options: AppServerForkThreadOptions = {},
lastTurnId: string | null = null,
): Promise<Thread> {
const position = options.position;
const runtime = options.runtime;
const permissions = runtime?.permissions;
const sandbox = permissions === undefined ? appServerSandboxMode(runtime?.sandboxPolicy) : undefined;
const response = await client.request("thread/fork", {
threadId,
cwd,
excludeTurns: true,
...(position?.kind === "through-turn" ? { lastTurnId: position.turnId } : {}),
...(position?.kind === "before-turn" ? { beforeTurnId: position.turnId } : {}),
...(options.deferGoalContinuation ? { deferGoalContinuation: true } : {}),
...(runtime?.model !== undefined ? { model: runtime.model } : {}),
...(runtime?.serviceTier !== undefined ? { serviceTier: runtime.serviceTier } : {}),
...(runtime?.approvalPolicy !== undefined && runtime.approvalPolicy !== null ? { approvalPolicy: runtime.approvalPolicy } : {}),
...(runtime?.approvalsReviewer !== undefined ? { approvalsReviewer: runtime.approvalsReviewer } : {}),
...(permissions !== undefined ? { permissions } : {}),
...(sandbox !== undefined ? { sandbox } : {}),
...(runtime?.reasoningEffort !== undefined ? { config: { model_reasoning_effort: runtime.reasoningEffort } } : {}),
...(lastTurnId ? { lastTurnId } : {}),
});
return threadFromThreadRecord(response.thread);
}
function appServerSandboxMode(
policy: RuntimePermissionState["sandboxPolicy"] | undefined,
): "read-only" | "workspace-write" | "danger-full-access" | undefined {
if (!policy || policy.type === "externalSandbox") return undefined;
if (policy.type === "dangerFullAccess") return "danger-full-access";
if (policy.type === "readOnly") return "read-only";
return "workspace-write";
}
export interface EphemeralThreadForkSnapshot {
readonly activation: ThreadActivationSnapshot;
readonly sourceThreadId: string;
@ -261,12 +245,13 @@ export class EphemeralThreadCleanupRequiredError extends Error {
export async function forkEphemeralThread(
client: AppServerRequestClient,
sourceThreadId: string,
vaultPath: string,
cwd: string,
): Promise<EphemeralThreadForkSnapshot> {
const config = await client.request("config/read", { cwd: vaultPath });
const source = await client.request("thread/read", { threadId: sourceThreadId, includeTurns: false });
const config = await client.request("config/read", { cwd: source.thread.cwd });
const response = await client.request("thread/fork", {
threadId: sourceThreadId,
cwd: vaultPath,
cwd,
ephemeral: true,
sandbox: "read-only",
approvalPolicy: "never",
@ -325,6 +310,7 @@ export function threadActivationSnapshotFromAppServerResponse(response: ThreadAc
});
return {
thread: threadFromThreadRecord(response.thread),
cwd: response.cwd,
approvalPolicyKnown: "approvalPolicy" in response,
sandboxPolicyKnown: "sandbox" in response || "sandboxPolicy" in response,
permissionProfileKnown: "activePermissionProfile" in response,

View file

@ -1,7 +1,7 @@
import type { CodexInput } from "../../domain/chat/input";
import type { ClientResponseByMethod } from "../connection/client";
import type { ClientRequestParams } from "../connection/rpc-messages";
import { appServerTurnInputFromCodexInput, toAppServerUserInput } from "../protocol/request-input";
import { additionalContextFromCodexInput, toAppServerUserInput } from "../protocol/request-input";
import type { AppServerRequestClient } from "./request-client";
type AppServerTurnRuntimeOverrides = Partial<AppServerTurnRuntimeParams>;
@ -32,14 +32,14 @@ export function startTurn(
options: AppServerStartTurnOptions,
): Promise<ClientResponseByMethod["turn/start"]> {
const { threadId, cwd, input, clientUserMessageId, runtime } = options;
const prepared = toTurnInput(input, contextSubmissionId(clientUserMessageId, "user"));
const additionalContext = toAdditionalContext(input);
const params: ClientRequestParams<"turn/start"> = {
threadId,
cwd,
...(clientUserMessageId !== undefined ? { clientUserMessageId } : {}),
...(prepared.additionalContext !== undefined ? { additionalContext: prepared.additionalContext } : {}),
...(additionalContext !== undefined ? { additionalContext } : {}),
...appServerTurnRuntimeParams(runtime),
input: prepared.input,
input: toUserInput(input),
};
return client.request("turn/start", params);
}
@ -72,13 +72,13 @@ export function steerTurn(
input: string | CodexInput,
clientUserMessageId?: string | null,
): Promise<unknown> {
const prepared = toTurnInput(input, contextSubmissionId(clientUserMessageId, "steer"));
const additionalContext = toAdditionalContext(input);
return client.request("turn/steer", {
threadId,
expectedTurnId,
input: prepared.input,
input: toUserInput(input),
...(clientUserMessageId !== undefined ? { clientUserMessageId } : {}),
...(prepared.additionalContext !== undefined ? { additionalContext: prepared.additionalContext } : {}),
...(additionalContext !== undefined ? { additionalContext } : {}),
});
}
@ -86,9 +86,14 @@ export function interruptTurn(client: AppServerRequestClient, threadId: string,
return client.request("turn/interrupt", { threadId, turnId });
}
function toTurnInput(input: string | CodexInput, submissionId: string) {
if (typeof input !== "string") return appServerTurnInputFromCodexInput(input, submissionId);
return { input: toAppServerUserInput([{ type: "text", text: input }]) };
function toUserInput(input: string | CodexInput): ClientRequestParams<"turn/start">["input"] {
if (typeof input !== "string") return toAppServerUserInput(input);
return toAppServerUserInput([{ type: "text", text: input }]);
}
function toAdditionalContext(input: string | CodexInput): ClientRequestParams<"turn/start">["additionalContext"] | undefined {
if (typeof input === "string") return undefined;
return additionalContextFromCodexInput(input);
}
function appServerTurnRuntimeParams(runtime: AppServerTurnRuntimeOverrides | undefined): AppServerTurnRuntimeParams {
@ -100,11 +105,3 @@ function appServerTurnRuntimeParams(runtime: AppServerTurnRuntimeOverrides | und
if (runtime?.approvalsReviewer !== undefined) params.approvalsReviewer = runtime.approvalsReviewer;
return params;
}
let fallbackContextSubmissionSequence = 0;
function contextSubmissionId(clientUserMessageId: string | null | undefined, kind: "user" | "steer"): string {
if (clientUserMessageId) return clientUserMessageId;
fallbackContextSubmissionSequence += 1;
return `local-${kind}-${String(Date.now())}-fallback-${String(fallbackContextSubmissionSequence)}-1`;
}

View file

@ -1,53 +0,0 @@
const encoder = new TextEncoder();
export function utf8ByteLength(value: string): number {
return encoder.encode(value).byteLength;
}
export function splitUtf8Context(value: string, maxBytes: number, maxParts: number): { parts: string[]; includedBytes: number } {
const parts: string[] = [];
let rest = value;
while (rest && parts.length < maxParts) {
const prefix = utf8Prefix(rest, maxBytes);
if (!prefix) break;
const boundary = preferredBoundary(prefix, rest.length > prefix.length);
const part = prefix.slice(0, boundary);
parts.push(part);
rest = rest.slice(part.length);
}
return { parts, includedBytes: parts.reduce((total, part) => total + utf8ByteLength(part), 0) };
}
export function truncateUtf8(value: string, maxBytes: number): string {
return utf8Prefix(value, maxBytes);
}
function utf8Prefix(value: string, maxBytes: number): string {
if (maxBytes <= 0 || !value) return "";
if (utf8ByteLength(value) <= maxBytes) return value;
let low = 0;
let high = value.length;
while (low < high) {
const middle = Math.ceil((low + high) / 2);
const candidate = safeCodeUnitBoundary(value, middle);
if (utf8ByteLength(value.slice(0, candidate)) <= maxBytes) low = middle;
else high = middle - 1;
}
return value.slice(0, safeCodeUnitBoundary(value, low));
}
function safeCodeUnitBoundary(value: string, index: number): number {
if (index <= 0 || index >= value.length) return index;
const code = value.charCodeAt(index);
return code >= 0xdc00 && code <= 0xdfff ? index - 1 : index;
}
function preferredBoundary(prefix: string, hasRemainder: boolean): number {
if (!hasRemainder) return prefix.length;
const minimum = Math.floor(prefix.length / 2);
for (const marker of ["\n\n", "\n", " "]) {
const index = prefix.lastIndexOf(marker);
if (index >= minimum) return index + marker.length;
}
return prefix.length;
}

View file

@ -1,14 +1,9 @@
export interface VaultFileReference {
export interface RequestMention {
name: string;
path: string;
}
export interface SkillReference {
name: string;
path: string;
}
export const ACTIVE_FILE_REFERENCE_NAME = "<active>";
export const ACTIVE_FILE_MENTION_NAME = "<active>";
export interface RequestAdditionalContext {
key: string;
@ -21,13 +16,8 @@ export type CodexInputItem =
| { type: "image"; url: string; detail?: UserInputImageDetail }
| { type: "localImage"; path: string; detail?: UserInputImageDetail }
| { type: "skill"; name: string; path: string }
| { type: "fileReference"; name: string; path: string }
| {
type: "additionalContext";
key: string;
value: string;
kind: RequestAdditionalContext["kind"];
};
| { type: "mention"; name: string; path: string }
| { type: "additionalContext"; key: string; value: string; kind: RequestAdditionalContext["kind"] };
export type CodexInput = CodexInputItem[];
type UserInputImageDetail = "auto" | "low" | "high" | "original";
@ -36,15 +26,15 @@ export function codexTextInput(text: string): CodexInput {
return [{ type: "text", text }];
}
export function codexTextInputWithReferences(
export function codexTextInputWithMentions(
text: string,
fileReferences: readonly VaultFileReference[],
skills: readonly SkillReference[] = [],
mentions: readonly RequestMention[],
skills: readonly RequestMention[] = [],
additionalContext: readonly RequestAdditionalContext[] = [],
): CodexInput {
return [
...codexTextInput(text),
...fileReferences.map((reference) => ({ type: "fileReference" as const, name: reference.name, path: reference.path })),
...mentions.map((mention) => ({ type: "mention" as const, name: mention.name, path: mention.path })),
...skills.map((skill) => ({ type: "skill" as const, name: skill.name, path: skill.path })),
...additionalContext.map((context) => ({
type: "additionalContext" as const,

View file

@ -1,10 +0,0 @@
const PANEL_SUBMISSION_CLIENT_ID_PATTERN = /^local-(?:user|steer)-\d+-[A-Za-z0-9_-]+-[a-z0-9]+-[a-z0-9]+$/;
export function isPanelSubmissionClientId(value: string | null | undefined): value is string {
return typeof value === "string" && PANEL_SUBMISSION_CLIENT_ID_PATTERN.test(value);
}
export function turnContextSubmissionId(value: string): string {
const safe = value.replace(/[^A-Za-z0-9_.-]/g, "_").slice(0, 120);
return safe || "context";
}

View file

@ -1,19 +0,0 @@
import { fromMarkdown } from "mdast-util-from-markdown";
import { visit } from "unist-util-visit";
export type MarkdownCodeRange = readonly [start: number, end: number];
export function markdownCodeRanges(markdown: string): MarkdownCodeRange[] {
const ranges: MarkdownCodeRange[] = [];
visit(fromMarkdown(markdown), (node) => {
if (node.type !== "code" && node.type !== "inlineCode") return;
const start = node.position?.start.offset;
const end = node.position?.end.offset;
if (start !== undefined && end !== undefined) ranges.push([start, end]);
});
return ranges;
}
export function markdownCodeRangeContainsOffset(ranges: readonly MarkdownCodeRange[], offset: number): boolean {
return ranges.some(([start, end]) => offset >= start && offset < end);
}

View file

@ -0,0 +1,7 @@
export function yamlFrontmatterString(value: string): string {
return JSON.stringify(value);
}
export function yamlFrontmatterInlineList(values: readonly string[]): string {
return `[${values.map(yamlFrontmatterString).join(", ")}]`;
}

View file

@ -11,10 +11,24 @@ interface PendingRequestQueueSource {
readonly pendingMcpElicitations: readonly unknown[];
}
interface PendingRequestCountSource {
readonly pendingApprovals: number;
readonly pendingUserInputs: number;
readonly pendingMcpElicitations: number;
}
export function pendingRequestCountsFromQueues(source: PendingRequestQueueSource): PendingRequestCounts {
const approvals = source.approvals.length;
const userInputs = source.pendingUserInputs.length;
const mcpElicitations = source.pendingMcpElicitations.length;
return pendingRequestCounts({
pendingApprovals: source.approvals.length,
pendingUserInputs: source.pendingUserInputs.length,
pendingMcpElicitations: source.pendingMcpElicitations.length,
});
}
export function pendingRequestCounts(source: PendingRequestCountSource): PendingRequestCounts {
const approvals = source.pendingApprovals;
const userInputs = source.pendingUserInputs;
const mcpElicitations = source.pendingMcpElicitations;
return {
approvals,
userInputs,

View file

@ -19,7 +19,7 @@ export interface RuntimeConfigSnapshot {
readonly autoCompactTokenLimit: number | null;
}
function emptyRuntimeConfigSnapshot(): RuntimeConfigSnapshot {
export function emptyRuntimeConfigSnapshot(): RuntimeConfigSnapshot {
return {
profile: null,
model: null,

View file

@ -15,6 +15,7 @@ export interface CollaborationMode {
}
export interface RuntimeSettingsPatch {
cwd?: string | null;
approvalPolicy?: RuntimeApprovalPolicy | null;
approvalsReviewer?: ApprovalsReviewer | null;
permissions?: string | null;

View file

@ -125,6 +125,12 @@ export function upsertMcpServerDiagnostic(diagnostics: Diagnostics, server: McpS
};
}
export function upsertMcpServerStatusDiagnostics(diagnostics: Diagnostics, servers: readonly McpServerStatusSummary[]): Diagnostics {
let next = diagnostics;
for (const server of servers) next = upsertMcpServerDiagnostic(next, mcpServerDiagnosticFromStatus(server));
return next;
}
export function replaceMcpServerStatusDiagnostics(diagnostics: Diagnostics, servers: readonly McpServerStatusSummary[]): Diagnostics {
return {
...diagnostics,

View file

@ -1,8 +1,8 @@
import type { ModelMetadata, SkillMetadata } from "../catalog/metadata";
import type { SkillMetadata } from "../catalog/metadata";
import type { RuntimeConfigSnapshot } from "../runtime/config";
import type { RateLimitSnapshot } from "../runtime/metrics";
import type { RuntimePermissionProfileSummary } from "../runtime/permissions";
import type { DiagnosticProbeResult, Diagnostics } from "./diagnostics";
import type { Diagnostics } from "./diagnostics";
export interface SharedServerMetadata {
runtimeConfig: RuntimeConfigSnapshot | null;
@ -11,26 +11,3 @@ export interface SharedServerMetadata {
rateLimit: RateLimitSnapshot | null;
serverDiagnostics: Diagnostics;
}
export type SharedServerMetadataResource =
| { readonly id: "runtimeConfig"; readonly value: RuntimeConfigSnapshot | undefined }
| {
readonly id: "models";
readonly value: readonly ModelMetadata[] | undefined;
readonly probe: DiagnosticProbeResult;
}
| {
readonly id: "skills";
readonly value: readonly SkillMetadata[] | undefined;
readonly probe: DiagnosticProbeResult;
}
| {
readonly id: "permissionProfiles";
readonly value: readonly RuntimePermissionProfileSummary[] | undefined;
readonly probe: DiagnosticProbeResult;
}
| {
readonly id: "rateLimits";
readonly value: RateLimitSnapshot | null | undefined;
readonly probe: DiagnosticProbeResult;
};

View file

@ -5,6 +5,7 @@ import type { Thread } from "./model";
export interface ThreadActivationSnapshot extends RuntimePermissionState, RuntimePermissionKnownState {
thread: Thread;
cwd: string;
model: string | null;
serviceTier: ServiceTier | null;
approvalsReviewer: ApprovalsReviewer | null;

View file

@ -1,21 +1,21 @@
import type { Link, Nodes } from "mdast";
import { fromMarkdown } from "mdast-util-from-markdown";
import { toMarkdown } from "mdast-util-to-markdown";
import { visit } from "unist-util-visit";
import { parseFileHref } from "../vault/file-hrefs";
import { yamlFrontmatterInlineList, yamlFrontmatterString } from "../markdown/frontmatter";
import { isExternalFileHref, parseFileHref } from "../vault/file-hrefs";
import { isFilesystemAbsolutePath, isVaultConfigPath, normalizeFilePath, vaultRelativePath } from "../vault/paths";
import type { Thread } from "./model";
import { referencedThreadMetadataFromPrompt } from "./reference";
import { threadArchiveTitle } from "./title";
import type { ThreadTranscriptEntry } from "./transcript";
interface MarkdownSourceReplacement {
start: number;
interface ParsedMarkdownLink {
raw: string;
label: string;
href: string;
end: number;
value: string;
}
export interface ArchiveMarkdownOptions {
export interface ArchiveExportSettings {
archiveExportFolderTemplate: string;
archiveExportFilenameTemplate: string;
archiveExportTags?: string;
vaultPath?: string;
vaultConfigDir?: string;
@ -25,31 +25,56 @@ export interface ArchiveThreadInput extends Thread {
transcriptEntries: readonly ThreadTranscriptEntry[];
}
export function archivedThreadMarkdown(thread: ArchiveThreadInput, exportedAt = new Date(), settings: ArchiveMarkdownOptions = {}): string {
export function archivedThreadMarkdown(
thread: ArchiveThreadInput,
exportedAt = new Date(),
settings?: Partial<ArchiveExportSettings>,
): string {
const title = threadArchiveTitle(thread);
const tags = normalizedArchiveTags(settings.archiveExportTags ?? "");
const frontmatter = [
const tags = normalizedArchiveTags(settings?.archiveExportTags ?? "");
const lines = [
"---",
`title: ${yamlFrontmatterString(title)}`,
`thread_id: ${yamlFrontmatterString(thread.id)}`,
`created: ${yamlFrontmatterString(formatDate(exportedAt))}`,
...frontmatterTagsLines(tags),
"---",
].join("\n");
const body = `${trimTrailingBlankLines([`# ${title}`, "", ...transcriptMarkdownLines(thread.transcriptEntries)]).join("\n")}\n`;
const normalizedBody = settings.vaultPath ? normalizeExportedMarkdownLinks(body, settings.vaultPath, settings.vaultConfigDir) : body;
return `${frontmatter}\n\n${normalizedBody}`;
"",
`# ${title}`,
"",
...transcriptMarkdownLines(thread.transcriptEntries),
];
const markdown = `${trimTrailingBlankLines(lines).join("\n")}\n`;
return settings?.vaultPath ? normalizeExportedMarkdownLinks(markdown, settings.vaultPath, settings.vaultConfigDir) : markdown;
}
function normalizeExportedMarkdownLinks(markdown: string, vaultPath: string, vaultConfigDir: string | null | undefined): string {
const replacements: MarkdownSourceReplacement[] = [];
visit(fromMarkdown(markdown), "link", (link) => {
const replacement = normalizedExportedMarkdownLink(link, vaultPath, vaultConfigDir);
if (replacement) replacements.push(replacement);
});
let output = markdown;
for (const replacement of replacements.sort((left, right) => right.start - left.start)) {
output = `${output.slice(0, replacement.start)}${replacement.value}${output.slice(replacement.end)}`;
const lines = markdown.split("\n");
let inFence = false;
return lines
.map((line) => {
if (line.trimStart().startsWith("```")) {
inFence = !inFence;
return line;
}
return inFence ? line : normalizeExportedMarkdownLinksInLine(line, vaultPath, vaultConfigDir);
})
.join("\n");
}
function normalizeExportedMarkdownLinksInLine(line: string, vaultPath: string, vaultConfigDir: string | null | undefined): string {
let output = "";
let index = 0;
while (index < line.length) {
const parsed = parseMarkdownLinkAt(line, index);
if (!parsed || isInsideInlineCode(line, index)) {
output += line[index] ?? "";
index += 1;
continue;
}
output += normalizedExportedMarkdownLink(parsed, vaultPath, vaultConfigDir);
index = parsed.end;
}
return output;
}
@ -74,19 +99,18 @@ function markdownLinesFromTranscriptEntry(entry: ThreadTranscriptEntry): string[
switch (entry.kind) {
case "user": {
const heading = timestampedHeading("User", entry.timestamp);
return [
heading,
"",
entry.text,
"",
...(entry.referencedThread
? [
`> Referenced: ${entry.referencedThread.title} (${String(entry.referencedThread.includedTurns)}/${String(entry.referencedThread.turnLimit)} turns${entry.referencedThread.truncated ? ", truncated" : ""}, ${entry.referencedThread.threadId})`,
"",
]
: []),
...(entry.contexts ?? []).flatMap((context) => [`> Context: ${archiveContextLabel(context, entry.text)}`, ""]),
];
const referenced = referencedThreadMetadataFromPrompt(entry.text);
if (referenced) {
return [
heading,
"",
referenced.text,
"",
`> Referenced: ${referenced.reference.title} (${String(referenced.reference.includedTurns)}/${String(referenced.reference.turnLimit)} turns, ${referenced.reference.threadId})`,
"",
];
}
return [heading, "", entry.text, ""];
}
case "assistant":
return [timestampedHeading("Codex", entry.timestamp), "", entry.text, ""];
@ -95,23 +119,6 @@ function markdownLinesFromTranscriptEntry(entry: ThreadTranscriptEntry): string[
}
}
function archiveContextLabel(context: NonNullable<ThreadTranscriptEntry["contexts"]>[number], visibleText: string): string {
const truncated = context.truncated ? " (truncated)" : "";
if (context.kind === "obsidian") return `Obsidian context${truncated}`;
const url = visibleWebUrl(visibleText);
return `Web page${truncated}${url ? ` (${url})` : ""}`;
}
function visibleWebUrl(text: string): string | null {
const firstToken = text.trim().split(/\s+/, 1)[0] ?? "";
try {
const parsed = new URL(firstToken);
return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.toString() : null;
} catch {
return null;
}
}
function timestampedHeading(label: string, unixSeconds: number | null): string {
const timestamp = formatUnixTimestamp(unixSeconds);
return timestamp ? `## ${label} - ${timestamp}` : `## ${label}`;
@ -139,45 +146,67 @@ function stripMatchingQuotes(value: string): string {
return (first === `"` || first === `'`) && first === last ? value.slice(1, -1) : value;
}
function normalizedExportedMarkdownLink(
link: Link,
vaultPath: string,
vaultConfigDir: string | null | undefined,
): MarkdownSourceReplacement | null {
const start = link.position?.start.offset;
const end = link.position?.end.offset;
if (start === undefined || end === undefined || !link.url) return null;
function unwrappedMarkdownHref(value: string): string {
if (value.startsWith("<") && value.endsWith(">")) return value.slice(1, -1);
return value;
}
const parsed = parseFileHref(link.url);
if (!parsed) return null;
const vaultRelative = vaultRelativePath(vaultPath, parsed.path);
if (vaultRelative && !archiveExportShouldKeepAbsolute(vaultRelative, vaultConfigDir)) {
return {
start,
end,
value: markdownNodeSource({ ...link, url: `${vaultRelative}${parsed.subpath}` }),
};
}
function parseMarkdownLinkAt(line: string, start: number): ParsedMarkdownLink | null {
if (line[start] !== "[" || line[start - 1] === "!") return null;
if (!isFilesystemAbsolutePath(normalizeFilePath(parsed.path))) return null;
const labelEnd = line.indexOf("]", start + 1);
if (labelEnd === -1 || line[labelEnd + 1] !== "(") return null;
const hrefStart = labelEnd + 2;
const hrefEnd = line[hrefStart] === "<" ? line.indexOf(">)", hrefStart + 1) : line.indexOf(")", hrefStart);
if (hrefEnd === -1) return null;
const end = line[hrefStart] === "<" ? hrefEnd + 2 : hrefEnd + 1;
return {
start,
raw: line.slice(start, end),
label: line.slice(start + 1, labelEnd),
href: line.slice(hrefStart, hrefEnd + (line[hrefStart] === "<" ? 1 : 0)),
end,
value: markdownNodeSource({
type: "paragraph",
children: [...link.children, { type: "text", value: " (" }, { type: "inlineCode", value: link.url }, { type: "text", value: ")" }],
}),
};
}
function markdownNodeSource(node: Nodes): string {
return toMarkdown(node, { unsafe: [{ character: "|", inConstruct: ["label", "phrasing"] }] }).trimEnd();
function normalizedExportedMarkdownLink(link: ParsedMarkdownLink, vaultPath: string, vaultConfigDir: string | null | undefined): string {
const href = unwrappedMarkdownHref(link.href.trim());
if (!href || isExternalFileHref(href)) return link.raw;
const parsed = parseFileHref(href);
if (!parsed) return link.raw;
const vaultRelative = vaultRelativePath(vaultPath, parsed.path);
if (vaultRelative && !archiveExportShouldKeepAbsolute(vaultRelative, vaultConfigDir)) {
return `[${link.label}](${markdownHref(`${vaultRelative}${parsed.subpath}`)})`;
}
return isFilesystemAbsolutePath(normalizeFilePath(parsed.path)) ? `${link.label} (${markdownCodeSpan(href)})` : link.raw;
}
function archiveExportShouldKeepAbsolute(vaultRelativePath: string, vaultConfigDir: string | null | undefined): boolean {
return typeof vaultConfigDir === "string" && vaultConfigDir.length > 0 && isVaultConfigPath(vaultRelativePath, vaultConfigDir);
}
function markdownHref(value: string): string {
return /[\s()]/.test(value) ? `<${value}>` : value;
}
function markdownCodeSpan(value: string): string {
let fenceLength = 1;
for (const match of value.matchAll(/`+/g)) fenceLength = Math.max(fenceLength, match[0].length + 1);
const fence = "`".repeat(fenceLength);
return fenceLength === 1 ? `${fence}${value}${fence}` : `${fence} ${value} ${fence}`;
}
function isInsideInlineCode(line: string, offset: number): boolean {
let inCode = false;
for (let index = 0; index < offset; index += 1) {
if (line[index] === "`") inCode = !inCode;
}
return inCode;
}
function formatDate(date: Date): string {
return `${String(date.getFullYear())}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
}
@ -191,11 +220,3 @@ function trimTrailingBlankLines(lines: string[]): string[] {
while (result[result.length - 1] === "") result.pop();
return result;
}
function yamlFrontmatterString(value: string): string {
return JSON.stringify(value);
}
function yamlFrontmatterInlineList(values: readonly string[]): string {
return `[${values.map(yamlFrontmatterString).join(", ")}]`;
}

View file

@ -1,29 +0,0 @@
import type { Thread } from "./model";
import { threadCommandDisplayTitle } from "./title";
const CODEX_THREAD_LINK_PREFIX = "codex://threads/";
const MAX_THREAD_ID_LENGTH = 160;
export function codexThreadHref(threadId: string): string {
return `${CODEX_THREAD_LINK_PREFIX}${encodeURIComponent(threadId)}`;
}
export function codexThreadIdFromHref(href: string): string | null {
if (!href.startsWith(CODEX_THREAD_LINK_PREFIX)) return null;
const encoded = href.slice(CODEX_THREAD_LINK_PREFIX.length);
if (!encoded || encoded.includes("/") || encoded.includes("?") || encoded.includes("#")) return null;
try {
const threadId = decodeURIComponent(encoded);
return threadId.length > 0 && threadId.length <= MAX_THREAD_ID_LENGTH ? threadId : null;
} catch {
return null;
}
}
export function threadReferenceMarkdown(thread: Thread): string {
return `[${markdownLinkLabel(threadCommandDisplayTitle(thread))}](${codexThreadHref(thread.id)})`;
}
function markdownLinkLabel(value: string): string {
return value.replace(/[\\[\]]/g, "\\$&");
}

View file

@ -6,11 +6,6 @@ export interface Thread {
readonly createdAt: number;
readonly updatedAt: number;
readonly recencyAt?: number | null;
/**
* Whether the loaded app-server thread accepts direct turn input.
* `null` means the capability is unavailable, so panel mode policy decides.
*/
readonly canAcceptDirectInput?: boolean | null;
readonly provenance: ThreadProvenance;
}

View file

@ -1,5 +1,5 @@
import { truncateUtf8, utf8ByteLength } from "../chat/context-budget";
import type { Thread } from "./model";
import { threadDisplayTitle } from "./title";
import type { TurnTranscriptSummary } from "./transcript";
export const REFERENCED_THREAD_TURN_LIMIT = 20;
@ -9,70 +9,162 @@ export interface ReferencedThreadMetadata {
title: string;
includedTurns: number;
turnLimit: number;
omittedTurns?: number;
truncated?: boolean;
}
const REFERENCED_THREAD_CONTEXT_MAX_BYTES = 18_000;
interface ReferencedThreadEnvelope {
version: 1;
reference: ReferencedThreadMetadata;
visibleText: string;
}
export interface ReferencedThreadPromptBundle {
prompt: string;
referencedThread: ReferencedThreadMetadata;
}
function referencedThreadPrompt(thread: Thread, turns: readonly TurnTranscriptSummary[], userRequest: string): string {
const reference = referencedThreadMetadata(thread, turns.length);
const envelope = referencedThreadEnvelope(reference, userRequest);
export function referencedThreadContext(thread: Thread, turns: readonly TurnTranscriptSummary[]): string {
const rendered = turns.map((turn, index) => renderedReferenceTurn(turn, index + 1));
const included: string[] = [];
let bytes = 0;
for (let index = rendered.length - 1; index >= 0; index -= 1) {
const value = rendered[index];
if (value === undefined) continue;
const nextBytes = utf8ByteLength(value);
if (bytes + nextBytes > REFERENCED_THREAD_CONTEXT_MAX_BYTES) {
if (included.length === 0) {
const turn = turns[index];
if (turn) included.unshift(truncatedReferenceTurn(turn, index + 1, REFERENCED_THREAD_CONTEXT_MAX_BYTES));
}
break;
}
included.unshift(value);
bytes += nextBytes;
}
const omittedTurns = turns.length - included.length;
return [
"Referenced thread context for the current user input:",
`Thread: ${thread.id}`,
`Included turns: ${String(included.length)}`,
`Omitted turns: ${String(omittedTurns)}`,
REFERENCED_THREAD_ENVELOPE_START,
JSON.stringify(envelopeMetadata(envelope)),
"",
...included,
"Reference thread history:",
...turns.flatMap((turn, index) => {
const lines = [`Turn ${String(index + 1)}:`];
if (turn.userText) lines.push(`User:\n${turn.userText}`);
if (turn.assistantText) lines.push(`Codex:\n${turn.assistantText}`);
return ["", ...lines];
}),
"",
REFERENCED_THREAD_ENVELOPE_END,
"",
"Current user request:",
envelope.visibleText,
].join("\n");
}
function renderedReferenceTurn(turn: TurnTranscriptSummary, index: number): string {
const lines = [`Turn ${String(index)}:`];
if (turn.userText) lines.push(`User:\n${turn.userText}`);
if (turn.assistantText) lines.push(`Codex:\n${turn.assistantText}`);
return `${lines.join("\n")}\n\n`;
function referencedThreadMetadata(thread: Thread, count: number): ReferencedThreadMetadata {
return {
threadId: thread.id,
title: threadDisplayTitle(thread),
includedTurns: count,
turnLimit: REFERENCED_THREAD_TURN_LIMIT,
};
}
function truncatedReferenceTurn(turn: TurnTranscriptSummary, index: number, maxBytes: number): string {
const user = turn.userText ?? "";
const assistant = turn.assistantText ?? "";
const prefix = `Turn ${String(index)}:\n`;
const userLabel = user ? "User:\n" : "";
const assistantLabel = assistant ? "Codex:\n" : "";
const separator = user && assistant ? "\n" : "";
const suffix = "\n[Turn fields truncated]\n\n";
const fixedBytes = utf8ByteLength(`${prefix}${userLabel}${separator}${assistantLabel}${suffix}`);
const available = Math.max(maxBytes - fixedBytes, 0);
const userBytes = utf8ByteLength(user);
const assistantBytes = utf8ByteLength(assistant);
let assistantBudget = assistant ? Math.min(assistantBytes, Math.floor(available / 2)) : 0;
let userBudget = user ? Math.min(userBytes, available - assistantBudget) : 0;
const remaining = available - userBudget - assistantBudget;
assistantBudget += Math.min(Math.max(assistantBytes - assistantBudget, 0), remaining);
userBudget += Math.min(Math.max(userBytes - userBudget, 0), available - userBudget - assistantBudget);
return [
prefix,
user ? `${userLabel}${truncateUtf8(user, userBudget)}` : "",
separator,
assistant ? `${assistantLabel}${truncateUtf8(assistant, assistantBudget)}` : "",
suffix,
].join("");
export function referencedThreadPromptBundle(
thread: Thread,
turns: readonly TurnTranscriptSummary[],
userRequest: string,
): ReferencedThreadPromptBundle {
const prompt = referencedThreadPrompt(thread, [...turns], userRequest);
return {
prompt,
referencedThread: referencedThreadMetadata(thread, turns.length),
};
}
export function referencedThreadMetadataFromPrompt(text: string): { text: string; reference: ReferencedThreadMetadata } | null {
const envelope = referencedThreadEnvelopeFromPrompt(text);
return envelope ? { text: envelope.visibleText, reference: envelope.reference } : null;
}
const REFERENCED_THREAD_ENVELOPE_START = "[Codex Panel referenced thread v1]";
const REFERENCED_THREAD_ENVELOPE_END = "[/Codex Panel referenced thread]";
interface ReferencedThreadEnvelopeMetadata {
version: 1;
threadId: string;
title: string;
includedTurns: number;
turnLimit: number;
}
function referencedThreadEnvelope(reference: ReferencedThreadMetadata, visibleText: string): ReferencedThreadEnvelope {
return {
version: 1,
reference,
visibleText,
};
}
function envelopeMetadata(envelope: ReferencedThreadEnvelope): ReferencedThreadEnvelopeMetadata {
return {
version: envelope.version,
threadId: envelope.reference.threadId,
title: envelope.reference.title,
includedTurns: envelope.reference.includedTurns,
turnLimit: envelope.reference.turnLimit,
};
}
function referencedThreadEnvelopeFromPrompt(text: string): ReferencedThreadEnvelope | null {
const headerStart = text.indexOf(REFERENCED_THREAD_ENVELOPE_START);
const headerEnd = text.indexOf(REFERENCED_THREAD_ENVELOPE_END);
const requestMarker = "\nCurrent user request:\n";
const requestStart = text.indexOf(requestMarker);
if (headerStart !== 0 || headerEnd === -1 || requestStart === -1 || requestStart < headerEnd) return null;
const metadataText = firstNonEmptyLine(text.slice(REFERENCED_THREAD_ENVELOPE_START.length, headerEnd));
const metadata = referencedThreadEnvelopeMetadataFromJson(metadataText);
const visibleText = text.slice(requestStart + requestMarker.length).trim();
if (!metadata || !visibleText) return null;
return {
version: 1,
visibleText,
reference: {
threadId: metadata.threadId,
title: metadata.title,
includedTurns: metadata.includedTurns,
turnLimit: metadata.turnLimit,
},
};
}
function firstNonEmptyLine(text: string): string | null {
return (
text
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0) ?? null
);
}
function referencedThreadEnvelopeMetadataFromJson(text: string | null): ReferencedThreadEnvelopeMetadata | null {
if (!text) return null;
let parsed: unknown;
try {
parsed = JSON.parse(text) as unknown;
} catch {
return null;
}
if (!parsed || typeof parsed !== "object") return null;
const value = parsed as Record<string, unknown>;
if (value["version"] !== 1) return null;
const threadId = stringValue(value["threadId"]);
const title = stringValue(value["title"]);
const includedTurns = finiteNonNegativeInteger(value["includedTurns"]);
const turnLimit = finitePositiveInteger(value["turnLimit"]);
if (!threadId || !title || includedTurns === null || turnLimit === null) return null;
return {
version: 1,
threadId,
title,
includedTurns,
turnLimit,
};
}
function stringValue(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
function finiteNonNegativeInteger(value: unknown): number | null {
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
}
function finitePositiveInteger(value: unknown): number | null {
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null;
}

View file

@ -1,30 +1,28 @@
import type { ThreadTitleContext } from "./title-generation-model";
type ThreadRenameAutoNameState = { kind: "checking" } | { kind: "unavailable" } | { kind: "ready"; context: ThreadTitleContext };
export type ThreadRenameLifecycleState =
| { kind: "idle" }
| { kind: "editing"; draft: string; autoName: ThreadRenameAutoNameState }
| { kind: "saving"; draft: string; autoName: ThreadRenameAutoNameState; saveToken: number }
| { kind: "generating"; draft: string; autoName: Extract<ThreadRenameAutoNameState, { kind: "ready" }>; generationToken: number };
| { kind: "editing"; draft: string }
| { kind: "generating"; draft: string; originalDraft: string; generationToken: number };
export type ThreadRenameActiveState = Exclude<ThreadRenameLifecycleState, { kind: "idle" }>;
type ThreadRenameGeneratingState = Extract<ThreadRenameLifecycleState, { kind: "generating" }>;
type ThreadRenameSavingState = Extract<ThreadRenameLifecycleState, { kind: "saving" }>;
export type ThreadRenameGeneratingState = Extract<ThreadRenameLifecycleState, { kind: "generating" }>;
export type ThreadRenameLifecycleEvent =
| { type: "started"; draft: string }
| { type: "draft-updated"; draft: string }
| { type: "auto-name-context-resolved"; context: ThreadTitleContext | null }
| { type: "cancelled" }
| { type: "save-started"; saveToken: number }
| { type: "save-failed"; saveToken: number }
| { type: "save-succeeded"; saveToken: number }
| { type: "generation-started"; generationToken: number }
| { type: "generation-succeeded"; generationToken: number; draft: string }
| { type: "generation-finished"; generationToken: number }
| { type: "generation-succeeded"; generatingState: ThreadRenameGeneratingState; draft: string }
| { type: "generation-finished"; generatingState: ThreadRenameGeneratingState }
| { type: "cleared" };
type ThreadRenameLifecycleKind = ThreadRenameLifecycleState["kind"];
type ThreadRenameLifecycleEventType = ThreadRenameLifecycleEvent["type"];
type ThreadRenameLifecycleTransition = (state: ThreadRenameLifecycleState, event: ThreadRenameLifecycleEvent) => ThreadRenameLifecycleState;
type ThreadRenameLifecycleTransitionTable = Record<
ThreadRenameLifecycleKind,
Record<ThreadRenameLifecycleEventType, ThreadRenameLifecycleTransition>
>;
export function initialThreadRenameLifecycleState(): ThreadRenameLifecycleState {
return { kind: "idle" };
}
@ -33,60 +31,96 @@ export function transitionThreadRenameLifecycleState(
state: ThreadRenameLifecycleState,
event: ThreadRenameLifecycleEvent,
): ThreadRenameLifecycleState {
switch (event.type) {
case "started":
if (state.kind === "saving") return state;
return { kind: "editing", draft: event.draft, autoName: { kind: "checking" } };
case "draft-updated":
return state.kind === "editing" ? { ...state, draft: event.draft } : state;
case "auto-name-context-resolved":
if (state.kind !== "editing" && state.kind !== "saving") return state;
return {
...state,
autoName: event.context ? { kind: "ready", context: event.context } : { kind: "unavailable" },
};
case "cancelled":
return state.kind === "saving" || state.kind === "idle" ? state : initialThreadRenameLifecycleState();
case "save-started":
return state.kind === "editing" ? { ...state, kind: "saving", saveToken: event.saveToken } : state;
case "save-failed":
return threadRenameSaveStillActive(state, event.saveToken)
? { kind: "editing", draft: state.draft, autoName: state.autoName }
: state;
case "save-succeeded":
return threadRenameSaveStillActive(state, event.saveToken) ? initialThreadRenameLifecycleState() : state;
case "generation-started":
if (state.kind !== "editing" || state.autoName.kind !== "ready") return state;
return {
kind: "generating",
draft: state.draft,
autoName: state.autoName,
generationToken: event.generationToken,
};
case "generation-succeeded":
if (!threadRenameGenerationStillActive(state, event.generationToken)) return state;
return { ...state, draft: event.draft };
case "generation-finished":
if (!threadRenameGenerationStillActive(state, event.generationToken)) return state;
return { kind: "editing", draft: state.draft, autoName: state.autoName };
case "cleared":
return state.kind === "idle" ? state : initialThreadRenameLifecycleState();
default:
return unhandledThreadRenameLifecycleEvent(event);
}
return threadRenameLifecycleTransitions[state.kind][event.type](state, event);
}
export function threadRenameGenerationStillActive(
state: ThreadRenameLifecycleState,
generationToken: number,
generatingState: ThreadRenameGeneratingState,
): state is ThreadRenameGeneratingState {
return state.kind === "generating" && state.generationToken === generationToken;
return (
state.kind === "generating" &&
state.originalDraft === generatingState.originalDraft &&
state.generationToken === generatingState.generationToken
);
}
export function threadRenameSaveStillActive(state: ThreadRenameLifecycleState, saveToken: number): state is ThreadRenameSavingState {
return state.kind === "saving" && state.saveToken === saveToken;
const keepThreadRenameState: ThreadRenameLifecycleTransition = (state) => state;
const startThreadRenameTransition: ThreadRenameLifecycleTransition = (_state, event) => ({
kind: "editing",
draft: requireRenameDraft(event),
});
const updateThreadRenameDraftTransition: ThreadRenameLifecycleTransition = (state, event) => {
if (state.kind === "idle") return state;
return { ...state, draft: requireRenameDraft(event) };
};
const cancelThreadRenameTransition: ThreadRenameLifecycleTransition = () => initialThreadRenameLifecycleState();
const startThreadRenameGenerationTransition: ThreadRenameLifecycleTransition = (state, event) => {
if (state.kind !== "editing") return state;
return {
kind: "generating",
draft: state.draft,
originalDraft: state.draft,
generationToken: requireRenameGenerationToken(event),
};
};
const succeedThreadRenameGenerationTransition: ThreadRenameLifecycleTransition = (state, event) => {
const generatingState = requireRenameGeneratingState(event);
if (!threadRenameGenerationStillActive(state, generatingState) || state.draft !== state.originalDraft) return state;
return { ...state, draft: requireRenameDraft(event) };
};
const finishThreadRenameGenerationTransition: ThreadRenameLifecycleTransition = (state, event) => {
if (!threadRenameGenerationStillActive(state, requireRenameGeneratingState(event))) return state;
return { kind: "editing", draft: state.draft };
};
const clearThreadRenameTransition: ThreadRenameLifecycleTransition = (state) =>
state.kind === "idle" ? state : initialThreadRenameLifecycleState();
const threadRenameActiveTransitions = {
started: startThreadRenameTransition,
"draft-updated": updateThreadRenameDraftTransition,
cancelled: cancelThreadRenameTransition,
"generation-started": startThreadRenameGenerationTransition,
"generation-succeeded": succeedThreadRenameGenerationTransition,
"generation-finished": finishThreadRenameGenerationTransition,
cleared: clearThreadRenameTransition,
} satisfies Record<ThreadRenameLifecycleEventType, ThreadRenameLifecycleTransition>;
const threadRenameLifecycleTransitions: ThreadRenameLifecycleTransitionTable = {
idle: {
...threadRenameActiveTransitions,
"draft-updated": keepThreadRenameState,
cancelled: keepThreadRenameState,
"generation-started": keepThreadRenameState,
"generation-succeeded": keepThreadRenameState,
"generation-finished": keepThreadRenameState,
cleared: clearThreadRenameTransition,
},
editing: threadRenameActiveTransitions,
generating: {
...threadRenameActiveTransitions,
"generation-started": keepThreadRenameState,
},
};
function requireRenameDraft(event: ThreadRenameLifecycleEvent): string {
if ("draft" in event) return event.draft;
throw new Error(`Thread rename lifecycle event ${event.type} does not include a draft.`);
}
function unhandledThreadRenameLifecycleEvent(event: never): never {
throw new Error(`Unhandled thread rename lifecycle event: ${String(event)}`);
function requireRenameGenerationToken(event: ThreadRenameLifecycleEvent): number {
if ("generationToken" in event) return event.generationToken;
throw new Error(`Thread rename lifecycle event ${event.type} does not include a generation token.`);
}
function requireRenameGeneratingState(event: ThreadRenameLifecycleEvent): ThreadRenameGeneratingState {
if ("generatingState" in event) return event.generatingState;
throw new Error(`Thread rename lifecycle event ${event.type} does not include generating state.`);
}

View file

@ -1,3 +1,4 @@
import { shortThreadId } from "./id";
import { type Thread, threadRecencyAt } from "./model";
import { threadDisplayTitle } from "./title";
@ -15,18 +16,14 @@ export type ThreadSearchResolution =
| { kind: "none"; query: string };
const NO_THREAD_SEARCH_MATCH = -1;
const TITLE_EXACT_SCORE = 0;
const TITLE_PREFIX_SCORE = 1;
const TITLE_SUBSTRING_SCORE = 2;
const TITLE_FUZZY_SCORE = 3;
const EMPTY_THREAD_SEARCH_SCORE = 4;
const EMPTY_THREAD_SEARCH_SCORE = 5;
export function threadSearchMatches(threads: readonly Thread[], queryText: string): ThreadSearchMatch[] {
const query = normalizedThreadSearchQuery(queryText);
return threads
.map((thread, index) => {
const title = threadDisplayTitle(thread);
const score = threadSearchScore(title, query);
const score = threadSearchScore(thread, title, query);
return { thread, title, score, recencyAt: threadRecencyAt(thread), index };
})
.filter((match) => match.score !== NO_THREAD_SEARCH_MATCH)
@ -52,21 +49,16 @@ function normalizedThreadSearchQuery(queryText: string): string {
return queryText.trim().toLowerCase();
}
function threadSearchScore(title: string, query: string): number {
function threadSearchScore(thread: Thread, title: string, query: string): number {
if (!query) return EMPTY_THREAD_SEARCH_SCORE;
const id = thread.id.toLowerCase();
const normalizedTitle = title.toLowerCase();
if (normalizedTitle === query) return TITLE_EXACT_SCORE;
if (normalizedTitle.startsWith(query)) return TITLE_PREFIX_SCORE;
if (normalizedTitle.includes(query)) return TITLE_SUBSTRING_SCORE;
return fuzzySubsequenceMatches(normalizedTitle, query) ? TITLE_FUZZY_SCORE : NO_THREAD_SEARCH_MATCH;
}
function fuzzySubsequenceMatches(title: string, query: string): boolean {
let queryIndex = 0;
for (const character of title) {
if (character === query[queryIndex]) queryIndex += 1;
if (queryIndex === query.length) return true;
}
return false;
const shortId = shortThreadId(thread.id).toLowerCase();
if (id === query || shortId === query) return 0;
if (normalizedTitle.startsWith(query)) return 1;
if (id.startsWith(query) || shortId.startsWith(query)) return 2;
if (normalizedTitle.includes(query)) return 3;
if (id.includes(query)) return 4;
return NO_THREAD_SEARCH_MATCH;
}

View file

@ -6,6 +6,9 @@ const DEFAULT_CONTEXT_PAGE_LIMIT = 20;
const DEFAULT_CONTEXT_MAX_PAGES = 5;
export const THREAD_TITLE_MAX_CHARS = 40;
export const THREAD_TITLE_CONTEXT_UNAVAILABLE_MESSAGE =
"Auto-name needs completed history or visible resumed history with both user and assistant text.";
export interface ThreadTitleContext {
userRequest: string;
assistantResponse: string;

View file

@ -2,10 +2,8 @@ import { shortThreadId } from "./id";
import type { Thread } from "./model";
const MAX_ARCHIVED_THREAD_DISPLAY_TITLE_LENGTH = 96;
const MAX_THREAD_COMMAND_DISPLAY_TITLE_LENGTH = 96;
const UNTITLED_THREAD_TITLE = "Untitled thread";
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
export function threadMeaningfulTitle(thread: Thread): string | null {
for (const value of [thread.name, thread.preview]) {
@ -19,10 +17,6 @@ export function threadDisplayTitle(thread: Thread): string {
return threadMeaningfulTitle(thread) ?? UNTITLED_THREAD_TITLE;
}
export function threadCommandDisplayTitle(thread: Thread): string {
return truncateThreadDisplayTitle(threadDisplayTitle(thread), MAX_THREAD_COMMAND_DISPLAY_TITLE_LENGTH);
}
export function threadRenameDraftTitle(thread: Thread): string {
return threadMeaningfulTitle(thread) ?? "";
}
@ -32,7 +26,9 @@ export function threadArchiveTitle(thread: Thread): string {
}
export function threadArchiveDisplayTitle(thread: Thread): string {
return truncateThreadDisplayTitle(threadArchiveTitle(thread), MAX_ARCHIVED_THREAD_DISPLAY_TITLE_LENGTH);
const title = threadArchiveTitle(thread);
if (title.length <= MAX_ARCHIVED_THREAD_DISPLAY_TITLE_LENGTH) return title;
return `${title.slice(0, MAX_ARCHIVED_THREAD_DISPLAY_TITLE_LENGTH - 3).trimEnd()}...`;
}
export function threadWindowTitle(activeThreadId: string | null, threads: readonly Thread[], fallbackTitle?: string | null): string {
@ -51,12 +47,3 @@ export function threadWindowTitle(activeThreadId: string | null, threads: readon
function normalizeThreadTitleText(value: string | null | undefined): string {
return typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
}
function truncateThreadDisplayTitle(title: string, maxLength: number): string {
const graphemes = Array.from(GRAPHEME_SEGMENTER.segment(title), ({ segment }) => segment);
if (graphemes.length <= maxLength) return title;
return `${graphemes
.slice(0, maxLength - 3)
.join("")
.trimEnd()}...`;
}

View file

@ -4,13 +4,6 @@ export interface ThreadTranscriptEntry {
kind: ThreadTranscriptEntryKind;
text: string;
timestamp: number | null;
referencedThread?: ReferencedThreadMetadata;
contexts?: readonly ThreadTranscriptContext[];
}
interface ThreadTranscriptContext {
kind: "web" | "obsidian";
truncated: boolean;
}
export interface TurnTranscriptSummary {
@ -53,5 +46,3 @@ function lastTranscriptText(
}
return null;
}
import type { ReferencedThreadMetadata } from "./reference";

View file

@ -1,387 +0,0 @@
import type { App } from "obsidian";
import type { AppServerClient } from "./app-server/connection/client";
import type { AppServerClientAccess, AppServerClientAccessOptions } from "./app-server/connection/client-access";
import type { AppServerExecutionContext } from "./app-server/connection/execution-context";
import { withShortLivedAppServerClient } from "./app-server/connection/short-lived-client";
import { AppServerQueryCache } from "./app-server/query/cache";
import {
type EphemeralStructuredTurnClient,
type EphemeralStructuredTurnRunner,
runEphemeralStructuredTurn,
} from "./app-server/services/ephemeral-structured-turn";
import { createThreadGoalOperationCoordinator } from "./features/chat/application/threads/goal-actions";
import type { ChatPanelSettingsAccess, ChatRuntimeView, CodexChatHost, WorkspacePanels } from "./features/chat/host/contracts";
import { createAppServerSelectionRewriteTransport } from "./features/selection-rewrite/app-server-transport";
import type { SelectionRewriteTransport } from "./features/selection-rewrite/transport";
import { openThreadPicker, type ThreadPickerController } from "./features/thread-picker/modal.obsidian";
import { createThreadOperationsTransport, createThreadTitleTransport } from "./features/threads/app-server/workflow-transports";
import type { ThreadCatalog } from "./features/threads/catalog/thread-catalog";
import {
createThreadOperationCoordinator,
type ThreadOperationCoordinator,
} from "./features/threads/workflows/thread-operation-coordinator";
import type { ThreadLifecycleEvent } from "./features/threads/workflows/thread-operation-event";
import { projectThreadListChanges } from "./features/threads/workflows/thread-read-model-projection";
import type { ThreadsViewHost, ThreadsViewSettingsAccess } from "./features/threads-view/session";
import type { ThreadsViewPanelActivity } from "./features/threads-view/state";
import type { ThreadsRuntimeView } from "./features/threads-view/view.obsidian";
import { createSettingsAppServerDynamicData } from "./settings/app-server-dynamic-data";
import type { SettingsDynamicDataAccess } from "./settings/dynamic-data";
import type { CodexPanelSettings } from "./settings/model";
import { StaleExecutionRuntimeError } from "./shared/runtime/execution-runtime-lifetime";
import { createKeyedOperationQueue } from "./shared/runtime/keyed-operation-queue";
export interface CodexExecutionRuntimeOptions {
app: App;
context: AppServerExecutionContext;
settings: () => CodexPanelSettings;
workspace: WorkspacePanels;
onThreadLifecycleEvents(events: readonly ThreadLifecycleEvent[]): void;
openNewPanel(): Promise<unknown>;
openThreadInCurrentView(threadId: string): Promise<void>;
openThreadInAvailableView(threadId: string): Promise<void>;
openPanelActivities(): readonly ThreadsViewPanelActivity[];
}
export interface ExecutionRuntimeViews {
readonly chat: readonly ChatRuntimeView[];
readonly threads: readonly ThreadsRuntimeView[];
}
export class CodexExecutionRuntime implements AppServerClientAccess {
private readonly context: Readonly<AppServerExecutionContext>;
private readonly appServerQueries: AppServerQueryCache;
private readonly threadCatalog: ThreadCatalog;
private readonly threadOperationCoordinator: ThreadOperationCoordinator;
readonly settingsDynamicData: SettingsDynamicDataAccess;
private readonly threadNameMutations = createKeyedOperationQueue<string>();
private readonly threadGoalOperations = createThreadGoalOperationCoordinator();
private readonly runtimeSettingsCommitQueue = createKeyedOperationQueue<string>();
private readonly shortLivedClients = new Set<AppServerClient>();
private readonly structuredTurnClients = new Set<EphemeralStructuredTurnClient>();
private readonly structuredTurnOperations = new Set<AbortController>();
private activeThreadPicker: ThreadPickerController | null = null;
private readonly chatViews = new Set<ChatRuntimeView>();
private readonly threadsViews = new Set<ThreadsRuntimeView>();
private disposed = false;
constructor(private readonly options: CodexExecutionRuntimeOptions) {
this.context = Object.freeze({ ...options.context });
this.appServerQueries = new AppServerQueryCache(this.context, this);
this.threadCatalog = this.appServerQueries;
this.threadOperationCoordinator = createThreadOperationCoordinator((events) => {
this.threadCatalog.applyThreadListMutations(projectThreadListChanges(this.threadCatalog, events));
options.onThreadLifecycleEvents(events);
});
this.settingsDynamicData = createSettingsAppServerDynamicData({
vaultPath: this.context.vaultPath,
clientAccess: this,
appServerQueries: this.appServerQueries,
threadCatalog: this.threadCatalog,
threadEvents: this.threadOperationCoordinator,
});
}
private chatHost(): CodexChatHost {
this.assertActive();
return {
appServerClientAccess: this,
appServerContext: this.context,
settings: this.chatSettings(),
workspace: this.options.workspace,
appServerQueries: this.appServerQueries,
threadCatalog: this.threadCatalog,
threadOperationCoordinator: this.threadOperationCoordinator,
threadNameMutations: this.threadNameMutations,
threadTitleTransport: this.threadTitleTransport(),
threadGoalOperations: this.threadGoalOperations,
runtimeSettingsCommitQueue: this.runtimeSettingsCommitQueue,
};
}
private threadsHost(): ThreadsViewHost {
this.assertActive();
return {
settings: this.threadsSettings(),
vaultPath: this.context.vaultPath,
threadCatalog: this.threadCatalog,
threadEvents: this.threadOperationCoordinator,
threadNameMutations: this.threadNameMutations,
threadOperationsTransport: createThreadOperationsTransport(this),
threadTitleTransport: this.threadTitleTransport(),
openNewPanel: () => this.options.openNewPanel(),
openThreadInAvailableView: (threadId) => this.options.openThreadInAvailableView(threadId),
openPanelActivities: () => this.options.openPanelActivities(),
};
}
withClient<T>(operation: (client: AppServerClient) => Promise<T>, options: AppServerClientAccessOptions = {}): Promise<T> {
return this.runWithAppServerClient(operation, options);
}
attachChatView(view: ChatRuntimeView): void {
this.assertActive();
if (this.chatViews.has(view)) return;
this.chatViews.add(view);
view.attachRuntime(this.chatHost());
view.activateRuntime();
}
detachChatView(view: ChatRuntimeView): void {
if (!this.chatViews.delete(view)) return;
view.detachRuntime();
}
attachThreadsView(view: ThreadsRuntimeView): void {
this.assertActive();
if (this.threadsViews.has(view)) return;
this.threadsViews.add(view);
view.attachRuntime(this.threadsHost());
view.activateRuntime();
}
detachThreadsView(view: ThreadsRuntimeView): void {
if (!this.threadsViews.delete(view)) return;
view.detachRuntime();
}
adoptViews(views: ExecutionRuntimeViews): void {
this.assertActive();
for (const view of views.chat) {
if (
this.tryCleanup(() => {
view.attachRuntime(this.chatHost());
})
)
this.chatViews.add(view);
else
this.tryCleanup(() => {
view.detachRuntime();
});
}
for (const view of views.threads) {
if (
this.tryCleanup(() => {
view.attachRuntime(this.threadsHost());
})
)
this.threadsViews.add(view);
else
this.tryCleanup(() => {
view.detachRuntime();
});
}
for (const view of [...this.chatViews]) {
if (
this.tryCleanup(() => {
view.activateRuntime();
})
)
continue;
this.chatViews.delete(view);
this.tryCleanup(() => {
view.detachRuntime();
});
}
for (const view of [...this.threadsViews]) {
if (
this.tryCleanup(() => {
view.activateRuntime();
})
)
continue;
this.threadsViews.delete(view);
this.tryCleanup(() => {
view.detachRuntime();
});
}
}
selectionRewriteTransport(): SelectionRewriteTransport {
return createAppServerSelectionRewriteTransport({
codexPath: this.context.codexPath,
cwd: this.context.vaultPath,
runner: this.structuredTurnRunner(),
});
}
openThreadPicker(): void {
this.activeThreadPicker?.close();
const picker = openThreadPicker(
{
app: this.options.app,
threadCatalog: this.threadCatalog,
openThreadInCurrentView: (threadId) => this.options.openThreadInCurrentView(threadId),
openThreadInAvailableView: (threadId) => this.options.openThreadInAvailableView(threadId),
},
() => {
if (this.activeThreadPicker === picker) this.activeThreadPicker = null;
},
);
this.activeThreadPicker = picker;
}
dispose(): ExecutionRuntimeViews {
if (this.disposed) return { chat: [], threads: [] };
this.disposed = true;
const views: { chat: ChatRuntimeView[]; threads: ThreadsRuntimeView[] } = { chat: [], threads: [] };
for (const view of this.chatViews) {
if (
this.tryCleanup(() => {
view.detachRuntime();
})
)
views.chat.push(view);
}
for (const view of this.threadsViews) {
if (
this.tryCleanup(() => {
view.detachRuntime();
})
)
views.threads.push(view);
}
this.chatViews.clear();
this.threadsViews.clear();
this.tryCleanup(() => {
this.activeThreadPicker?.close();
});
this.activeThreadPicker = null;
for (const operation of this.structuredTurnOperations)
this.tryCleanup(() => {
operation.abort();
});
this.structuredTurnOperations.clear();
for (const client of this.structuredTurnClients)
this.tryCleanup(() => {
client.disconnect();
});
this.structuredTurnClients.clear();
for (const client of this.shortLivedClients)
this.tryCleanup(() => {
client.disconnect();
});
this.shortLivedClients.clear();
this.tryCleanup(() => {
this.appServerQueries.dispose();
});
return views;
}
private async runWithAppServerClient<T>(
operation: (client: AppServerClient) => Promise<T>,
options: AppServerClientAccessOptions = {},
): Promise<T> {
this.assertActive();
const guardedOperation = (client: AppServerClient): Promise<T> => {
this.assertActive();
return operation(client);
};
try {
const result = await withShortLivedAppServerClient(this.context.codexPath, this.context.vaultPath, guardedOperation, options, {
created: (client) => {
if (this.disposed) {
client.disconnect();
throw new StaleExecutionRuntimeError();
}
this.shortLivedClients.add(client);
},
disposed: (client) => {
this.shortLivedClients.delete(client);
},
});
this.assertActive();
return result;
} catch (error) {
if (this.disposed) throw new StaleExecutionRuntimeError();
throw error;
}
}
private structuredTurnRunner(): EphemeralStructuredTurnRunner {
return async (options) => {
this.assertActive();
const operation = new AbortController();
const abort = (): void => {
operation.abort(options.signal?.reason);
};
if (options.signal?.aborted) abort();
else options.signal?.addEventListener("abort", abort, { once: true });
this.structuredTurnOperations.add(operation);
try {
return await runEphemeralStructuredTurn(
{ ...options, signal: operation.signal },
{
clientLifecycle: {
created: (client) => {
if (this.disposed) {
client.disconnect();
throw new StaleExecutionRuntimeError();
}
this.structuredTurnClients.add(client);
},
disposed: (client) => {
this.structuredTurnClients.delete(client);
},
},
},
);
} finally {
options.signal?.removeEventListener("abort", abort);
this.structuredTurnOperations.delete(operation);
}
};
}
private threadTitleTransport() {
return createThreadTitleTransport({
clientAccess: this,
codexPath: this.context.codexPath,
vaultPath: this.context.vaultPath,
threadNamingModel: () => this.options.settings().threadNamingModel,
threadNamingEffort: () => this.options.settings().threadNamingEffort,
runner: this.structuredTurnRunner(),
});
}
private assertActive(): void {
if (this.disposed) throw new StaleExecutionRuntimeError();
}
private tryCleanup(operation: () => void): boolean {
try {
operation();
return true;
} catch {
return false;
}
}
private chatSettings(): ChatPanelSettingsAccess {
return {
referenceActiveNoteOnSend: () => this.options.settings().referenceActiveNoteOnSend,
attachmentFolder: () => this.options.settings().attachmentFolder,
archiveExportEnabled: () => this.options.settings().archiveExportEnabled,
archiveExportSettings: () => ({
archiveExportFolderTemplate: this.options.settings().archiveExportFolderTemplate,
archiveExportFilenameTemplate: this.options.settings().archiveExportFilenameTemplate,
archiveExportTags: this.options.settings().archiveExportTags,
}),
scrollThreadFromComposerEdges: () => this.options.settings().scrollThreadFromComposerEdges,
sendShortcut: () => this.options.settings().sendShortcut,
showToolbar: () => this.options.settings().showToolbar,
};
}
private threadsSettings(): ThreadsViewSettingsAccess {
return {
archiveExportEnabled: () => this.options.settings().archiveExportEnabled,
archiveExportSettings: () => ({
archiveExportFolderTemplate: this.options.settings().archiveExportFolderTemplate,
archiveExportFilenameTemplate: this.options.settings().archiveExportFilenameTemplate,
archiveExportTags: this.options.settings().archiveExportTags,
}),
};
}
}

View file

@ -1,4 +1,5 @@
import type { RequestId, ServerNotification, ServerRequest } from "../../../../app-server/connection/rpc-messages";
import type { AppServerQueryContext } from "../../../../app-server/query/keys";
import {
routeServerRequest,
serverRequestApprovalResponse,
@ -14,10 +15,10 @@ import {
type PendingUserInput,
} from "../../../../domain/pending-requests/model";
import type { TurnTranscriptSummary } from "../../../../domain/threads/transcript";
import type { ThreadOperationEvent } from "../../../threads/workflows/thread-operation-event";
import type { ThreadCatalogEvent } from "../../../threads/catalog/thread-catalog";
import type { AppServerResourceEvent } from "../../application/connection/server-metadata-actions";
import type { LocalIdSource } from "../../application/local-id-source";
import { activeThreadId, type ChatAction, type ChatState } from "../../application/state/root-reducer";
import type { ChatAction, ChatState } from "../../application/state/root-reducer";
import type { ChatStateStore } from "../../application/state/store";
import { activeTurnId } from "../../application/turns/turn-state";
import {
@ -31,16 +32,17 @@ import { classifyAppServerLog } from "./app-server-logs";
import { type ChatNotificationEffect, planChatNotification } from "./notification-plan";
export interface ChatInboundHandlerActions {
refreshActiveThreads: () => void;
refreshServerDiagnostics: (options?: { forceResourceProbes?: boolean }) => void;
applyAppServerResourceEvent: (event: AppServerResourceEvent) => void;
maybeNameThread: (threadId: string, turnId: string, completedTurnTranscriptSummary: TurnTranscriptSummary | null) => void;
applyThreadOperationEvent: (event: ThreadOperationEvent) => void;
applyThreadCatalogEvent: (event: ThreadCatalogEvent, sourceContext: AppServerQueryContext) => void;
respondToServerRequest: (requestId: RequestId, result: unknown) => boolean;
rejectServerRequest: (requestId: RequestId, code: number, message: string) => boolean;
}
export interface ChatInboundHandler {
handleNotification(notification: ServerNotification): void;
handleNotification(notification: ServerNotification, sourceContext: AppServerQueryContext): void;
handleServerRequest(request: ServerRequest): void;
handleAppServerLog(message: string): void;
resolveApproval(requestId: PendingRequestId, action: ApprovalAction): void;
@ -65,8 +67,8 @@ export function createChatInboundHandler(
): ChatInboundHandler {
const context: ChatInboundHandlerContext = { store, actions, localItemIds };
return {
handleNotification: (notification) => {
handleNotification(context, notification);
handleNotification: (notification, sourceContext) => {
handleNotification(context, notification, sourceContext);
},
handleServerRequest: (request) => {
handleServerRequest(context, request);
@ -106,15 +108,19 @@ function dispatch(context: ChatInboundHandlerContext, action: ChatAction): void
context.store.dispatch(action);
}
function handleNotification(context: ChatInboundHandlerContext, notification: ServerNotification): void {
function handleNotification(
context: ChatInboundHandlerContext,
notification: ServerNotification,
sourceContext: AppServerQueryContext,
): void {
const plan = planChatNotification(state(context), notification, (prefix) => localItemId(context, prefix));
for (const action of plan.actions) dispatch(context, action);
for (const effect of plan.effects) runNotificationEffect(context, effect);
for (const effect of plan.effects) runNotificationEffect(context, effect, sourceContext);
}
function handleServerRequest(context: ChatInboundHandlerContext, request: ServerRequest): void {
const current = state(context);
const route = routeServerRequest(request, { activeThreadId: activeThreadId(current), activeTurnId: activeTurnId(current) });
const route = routeServerRequest(request, { activeThreadId: current.activeThread.id, activeTurnId: activeTurnId(current) });
switch (route.kind) {
case "approval":
dispatch(context, { type: "request/approval-queued", approval: route.approval });
@ -237,8 +243,15 @@ function localItemId(context: ChatInboundHandlerContext, prefix: string): string
return context.localItemIds.next(prefix);
}
function runNotificationEffect(context: ChatInboundHandlerContext, effect: ChatNotificationEffect): void {
function runNotificationEffect(
context: ChatInboundHandlerContext,
effect: ChatNotificationEffect,
sourceContext: AppServerQueryContext,
): void {
switch (effect.type) {
case "refresh-threads":
context.actions.refreshActiveThreads();
return;
case "refresh-server-diagnostics":
context.actions.refreshServerDiagnostics({ forceResourceProbes: effect.forceResourceProbes === true });
return;
@ -248,8 +261,8 @@ function runNotificationEffect(context: ChatInboundHandlerContext, effect: ChatN
case "maybe-name-thread":
context.actions.maybeNameThread(effect.threadId, effect.turnId, effect.completedTurnTranscriptSummary);
return;
case "apply-thread-operation-event":
context.actions.applyThreadOperationEvent(effect.event);
case "apply-thread-catalog-event":
context.actions.applyThreadCatalogEvent(effect.event, sourceContext);
return;
}
}

View file

@ -2,18 +2,17 @@ import type { ServerNotification } from "../../../../app-server/connection/rpc-m
import { threadFromAppServerRecord } from "../../../../app-server/services/threads";
import { threadTokenUsageFromRuntimeUsage } from "../../../../domain/runtime/metrics";
import { normalizeExplicitThreadName } from "../../../../domain/threads/model";
import type { ThreadOperationEvent } from "../../../threads/workflows/thread-operation-event";
import type { ThreadCatalogEvent } from "../../../threads/catalog/thread-catalog";
import type { AppServerResourceEvent } from "../../application/connection/server-metadata-actions";
import { activeThreadSettingsAppliedAction } from "../../application/state/actions";
import { activeThreadId, activeThreadState, type ChatAction, type ChatState } from "../../application/state/root-reducer";
import type { SubagentActivityAction } from "../../application/state/subagent-activity";
import type { ChatAction, ChatState } from "../../application/state/root-reducer";
import { planTurnRuntimeEvents, type TurnRuntimeOutcome } from "../../application/turns/runtime-event-plan";
import type { TurnRuntimeEvent } from "../../application/turns/runtime-events";
import { goalChangeItem } from "../../domain/thread-stream/factories/goal-items";
import { type DiagnosticStatusNotification, routeServerNotification, type ThreadLifecycleNotification } from "./notification-routing";
import { turnRuntimeEventsFromNotification } from "./runtime-events";
export type ChatNotificationEffect =
| { type: "refresh-threads" }
| {
type: "maybe-name-thread";
threadId: string;
@ -22,9 +21,9 @@ export type ChatNotificationEffect =
}
| { type: "refresh-server-diagnostics"; forceResourceProbes?: boolean }
| { type: "apply-app-server-resource-event"; event: AppServerResourceEvent }
| { type: "apply-thread-operation-event"; event: ThreadOperationEvent };
| { type: "apply-thread-catalog-event"; event: ThreadCatalogEvent };
type TurnRuntimeCompletedTurnTranscriptSummary = TurnRuntimeOutcome["completedTurnTranscriptSummary"];
type TurnRuntimeCompletedTurnTranscriptSummary = Extract<TurnRuntimeOutcome, { type: "turn-completed" }>["completedTurnTranscriptSummary"];
export interface ChatNotificationPlan {
actions: readonly ChatAction[];
@ -41,12 +40,11 @@ export function planChatNotification(
localItemId: LocalItemIdProvider,
): ChatNotificationPlan {
const route = routeServerNotification(notification, {
activeThreadId: activeThreadId(state),
activeThreadId: state.activeThread.id,
activeTurnId: activeTurnIdForState(state),
});
switch (route.kind) {
case "inactive":
return planTrackedSubagentNotification(state, route.scope.threadId, route.notification, localItemId);
case "ignored":
case "unhandled":
return EMPTY_PLAN;
@ -67,188 +65,34 @@ function runtimeEventsPlan(
notification: Parameters<typeof turnRuntimeEventsFromNotification>[0],
localItemId: LocalItemIdProvider,
): ChatNotificationPlan {
const events = turnRuntimeEventsFromNotification(notification, localItemId);
const plan = planTurnRuntimeEvents(state, events);
const plan = planTurnRuntimeEvents(state, turnRuntimeEventsFromNotification(notification, localItemId));
return {
actions: [
...plan.actions,
...subagentTrackingActionsFromParentEvents(state, events),
...subagentTrackingActionsFromActivityItem(state, notification),
],
actions: plan.actions,
effects: plan.outcomes.flatMap((outcome) => chatNotificationEffectsFromTurnRuntimeOutcome(state, outcome)),
};
}
function subagentTrackingActionsFromActivityItem(
state: ChatState,
notification: Parameters<typeof turnRuntimeEventsFromNotification>[0],
): SubagentActivityAction[] {
if (notification.method !== "item/started" && notification.method !== "item/completed") return [];
const item = notification.params.item;
if (item.type !== "subAgentActivity") return [];
const parentTurnId = activeTurnIdForState(state);
if (!parentTurnId || notification.params.turnId !== parentTurnId) return [];
const tracked: SubagentActivityAction = {
type: "subagent-activity/tracked",
threadId: item.agentThreadId,
parentTurnId,
};
return item.kind === "interrupted"
? [tracked, { type: "subagent-activity/execution-state-changed", threadId: item.agentThreadId, executionState: "failed" }]
: [tracked];
}
function planTrackedSubagentNotification(
state: ChatState,
threadId: string | null,
notification: ServerNotification,
localItemId: LocalItemIdProvider,
): ChatNotificationPlan {
if (!threadId || !state.subagentActivity.byThreadId.has(threadId)) return EMPTY_PLAN;
const events = subagentRuntimeEvents(notification, localItemId);
if (!events) return EMPTY_PLAN;
const actions = events.flatMap((event) => subagentActivityActionsFromRuntimeEvent(threadId, notification.method, event));
return actions.length > 0 ? { actions, effects: [] } : EMPTY_PLAN;
}
function subagentRuntimeEvents(notification: ServerNotification, localItemId: LocalItemIdProvider): readonly TurnRuntimeEvent[] | null {
switch (notification.method) {
case "item/agentMessage/delta":
case "item/plan/delta":
case "turn/plan/updated":
case "item/reasoning/summaryTextDelta":
case "item/reasoning/textDelta":
case "item/reasoning/summaryPartAdded":
case "item/started":
case "item/completed":
case "item/commandExecution/outputDelta":
case "item/fileChange/patchUpdated":
case "turn/diff/updated":
case "hook/started":
case "hook/completed":
case "item/mcpToolCall/progress":
case "item/autoApprovalReview/started":
case "item/autoApprovalReview/completed":
case "guardianWarning":
case "turn/started":
case "turn/completed":
return turnRuntimeEventsFromNotification(notification, localItemId);
default:
return null;
}
}
function subagentActivityActionsFromRuntimeEvent(
threadId: string,
notificationMethod: ServerNotification["method"],
event: TurnRuntimeEvent,
): SubagentActivityAction[] {
switch (event.type) {
case "turnStarted":
return [{ type: "subagent-activity/turn-started", threadId, childTurnId: event.turnId }];
case "turnCompleted":
return [
{
type: "subagent-activity/turn-completed",
threadId,
childTurnId: event.turnId,
items: event.completedItems,
executionState: event.status === "completed" ? "completed" : "failed",
},
];
case "itemUpserted":
return [
{
type: "subagent-activity/item-observed",
threadId,
item: event.item,
advance: notificationMethod === "item/started" || notificationMethod === "turn/plan/updated",
},
];
case "itemCompleted":
return [{ type: "subagent-activity/item-observed", threadId, item: event.item, advance: false }];
case "assistantDelta":
return [
{
type: "subagent-activity/assistant-delta-appended",
threadId,
childTurnId: event.turnId,
itemId: event.itemId,
delta: event.delta,
},
];
case "planDelta":
return [
{
type: "subagent-activity/plan-delta-appended",
threadId,
childTurnId: event.turnId,
itemId: event.itemId,
delta: event.delta,
},
];
case "textDelta":
if (notificationMethod === "item/reasoning/textDelta") return [];
return [
{
type: "subagent-activity/text-delta-appended",
threadId,
childTurnId: event.turnId,
itemId: event.itemId,
label: event.label,
delta: event.delta,
kind: event.kind,
},
];
case "toolOutputDelta":
return [
{
type: "subagent-activity/tool-output-appended",
threadId,
childTurnId: event.turnId,
itemId: event.itemId,
delta: event.delta,
fallbackLabel: event.fallbackLabel,
},
];
case "hookRunObserved":
return event.turnId
? [{ type: "subagent-activity/item-observed", threadId, item: { ...event.item, turnId: event.turnId }, advance: true }]
: [];
case "autoReviewUpdated":
case "reviewWarning":
return [{ type: "subagent-activity/item-observed", threadId, item: event.item, advance: true }];
case "itemOutputDelta":
case "turnDiffUpdated":
case "requestResolved":
case "systemNotice":
return [];
}
}
function subagentTrackingActionsFromParentEvents(state: ChatState, events: readonly TurnRuntimeEvent[]): SubagentActivityAction[] {
const parentTurnId = activeTurnIdForState(state);
if (!parentTurnId) return [];
const threadIds = new Set<string>();
for (const event of events) {
if (event.type !== "itemUpserted" && event.type !== "itemCompleted") continue;
if (event.item.kind !== "agent" || event.item.turnId !== parentTurnId) continue;
for (const threadId of event.item.receiverThreadIds) threadIds.add(threadId);
for (const agent of event.item.agents) threadIds.add(agent.threadId);
}
return [...threadIds].map((threadId) => ({ type: "subagent-activity/tracked", threadId, parentTurnId }));
}
function chatNotificationEffectsFromTurnRuntimeOutcome(state: ChatState, outcome: TurnRuntimeOutcome): readonly ChatNotificationEffect[] {
if (activeThreadState(state)?.lifetime?.kind === "ephemeral") return [];
return [
{
type: "maybe-name-thread",
threadId: outcome.threadId,
turnId: outcome.turnId,
completedTurnTranscriptSummary: outcome.completedTurnTranscriptSummary,
},
];
if (state.activeThread.lifetime?.kind === "ephemeral") return [];
switch (outcome.type) {
case "turn-started":
return [
{
type: "apply-thread-catalog-event",
event: { type: "thread-touched", threadId: outcome.threadId, recencyAt: outcome.recencyAt },
},
];
case "turn-completed":
return [
{
type: "maybe-name-thread",
threadId: outcome.threadId,
turnId: outcome.turnId,
completedTurnTranscriptSummary: outcome.completedTurnTranscriptSummary,
},
{ type: "refresh-threads" },
];
}
}
function planDiagnosticStatus(notification: DiagnosticStatusNotification): ChatNotificationPlan {
@ -291,23 +135,17 @@ function planThreadLifecycle(
case "thread/started":
return threadStartedPlan(state, notification);
case "thread/archived":
return effectPlan({
type: "apply-thread-operation-event",
event: { type: "thread-archived", threadId: notification.params.threadId },
});
return effectPlan({ type: "apply-thread-catalog-event", event: { type: "thread-archived", threadId: notification.params.threadId } });
case "thread/deleted":
return effectPlan({
type: "apply-thread-operation-event",
event: { type: "thread-deleted", threadId: notification.params.threadId },
});
return effectPlan({ type: "apply-thread-catalog-event", event: { type: "thread-deleted", threadId: notification.params.threadId } });
case "thread/unarchived":
return effectPlan({
type: "apply-thread-operation-event",
type: "apply-thread-catalog-event",
event: { type: "thread-unarchived", threadId: notification.params.threadId },
});
case "thread/name/updated":
return effectPlan({
type: "apply-thread-operation-event",
type: "apply-thread-catalog-event",
event: {
type: "thread-renamed",
threadId: notification.params.threadId,
@ -315,7 +153,7 @@ function planThreadLifecycle(
},
});
case "thread/settings/updated":
if (activeThreadId(state) !== notification.params.threadId) return EMPTY_PLAN;
if (state.activeThread.id !== notification.params.threadId) return EMPTY_PLAN;
return actionPlan(activeThreadSettingsAppliedAction(notification.params.threadSettings));
case "thread/goal/updated":
return threadGoalPlan(state, notification.params.threadId, notification.params.goal, localItemId);
@ -329,25 +167,19 @@ function threadStartedPlan(
notification: Extract<ThreadLifecycleNotification, { method: "thread/started" }>,
): ChatNotificationPlan {
const thread = threadFromAppServerRecord(notification.params.thread);
const activeParentTurnId = activeTurnIdForState(state);
const trackAction: SubagentActivityAction[] =
thread.provenance.kind === "subagent" && thread.provenance.parentThreadId === activeThreadId(state) && activeParentTurnId
? [{ type: "subagent-activity/tracked", threadId: thread.id, parentTurnId: activeParentTurnId }]
: [];
const effects: ChatNotificationEffect[] =
notification.params.thread.ephemeral || thread.provenance.kind === "subagent"
? []
: [
{
type: "apply-thread-operation-event",
event: {
type: "thread-upserted",
thread,
forkedFromThreadId: notification.params.thread.forkedFromId,
},
type: "apply-thread-catalog-event",
event: { type: "thread-started", thread },
},
];
return { actions: trackAction, effects };
if (!state.activeThread.id || state.activeThread.id === notification.params.thread.id) {
return { actions: [{ type: "active-thread/cwd-set", cwd: notification.params.thread.cwd }], effects };
}
return { actions: [], effects };
}
function threadGoalPlan(
@ -356,10 +188,9 @@ function threadGoalPlan(
goal: Extract<ThreadLifecycleNotification, { method: "thread/goal/updated" }>["params"]["goal"] | null,
localItemId: LocalItemIdProvider,
): ChatNotificationPlan {
const activeThread = activeThreadState(state);
if (!activeThread || activeThread.id !== threadId) return EMPTY_PLAN;
if (state.activeThread.id !== threadId) return EMPTY_PLAN;
const actions: ChatAction[] = [{ type: "active-thread/goal-set", goal }];
const item = goalChangeItem(localItemId("goal"), activeThread.goal, goal);
const item = goalChangeItem(localItemId("goal"), state.activeThread.goal, goal);
if (item) actions.push({ type: "thread-stream/item-upserted", item });
return { actions, effects: [] };
}

View file

@ -25,7 +25,7 @@ export type ServerNotificationRoute =
| { kind: "userVisibleNotice"; notification: UserVisibleNoticeNotification }
| { kind: "ignored"; notification: ServerNotification }
| { kind: "unhandled"; notification: ServerNotification }
| { kind: "inactive"; notification: ServerNotification; scope: AppServerRouteScope };
| { kind: "inactive"; notification: ServerNotification };
type ServerNotificationScopeExtractors = Partial<{
[Method in ServerNotificationMethod]: (notification: Extract<ServerNotification, { method: Method }>) => AppServerRouteScope;
@ -105,9 +105,6 @@ const IGNORED_SERVER_NOTIFICATION_METHODS = [
"thread/status/changed",
"thread/closed",
"rawResponseItem/completed",
"rawResponse/completed",
"thread/environment/connected",
"thread/environment/disconnected",
"command/exec/outputDelta",
"process/outputDelta",
"process/exited",
@ -160,7 +157,6 @@ const SERVER_NOTIFICATION_SCOPE_EXTRACTORS: ServerNotificationScopeExtractors =
"item/autoApprovalReview/completed": threadTurnNotificationScope,
"item/completed": threadTurnNotificationScope,
"rawResponseItem/completed": threadTurnNotificationScope,
"rawResponse/completed": threadTurnNotificationScope,
"item/agentMessage/delta": threadTurnNotificationScope,
"item/plan/delta": threadTurnNotificationScope,
"command/exec/outputDelta": unscopedNotificationScope,
@ -204,15 +200,13 @@ const SERVER_NOTIFICATION_SCOPE_EXTRACTORS: ServerNotificationScopeExtractors =
"windows/worldWritableWarning": unscopedNotificationScope,
"windowsSandbox/setupCompleted": unscopedNotificationScope,
"account/login/completed": unscopedNotificationScope,
"thread/environment/connected": threadOnlyNotificationScope,
"thread/environment/disconnected": threadOnlyNotificationScope,
};
export function routeServerNotification(notification: ServerNotification, scope: ActiveRouteScope): ServerNotificationRoute {
if (isThreadCatalogNotification(notification)) return { kind: "threadLifecycle", notification };
const routeScope = serverNotificationScope(notification);
if (!isAppServerRouteScopeInActiveRouteScope(routeScope, scope)) return { kind: "inactive", notification, scope: routeScope };
if (isIdleThreadStreamUpdate(notification, routeScope, scope)) return { kind: "inactive", notification, scope: routeScope };
if (!isAppServerRouteScopeInActiveRouteScope(routeScope, scope)) return { kind: "inactive", notification };
if (isIdleThreadStreamUpdate(notification, routeScope, scope)) return { kind: "inactive", notification };
if (isStreamUpdateNotification(notification)) return { kind: "streamUpdate", notification };
if (isTurnLifecycleNotification(notification)) return { kind: "turnLifecycle", notification };

View file

@ -126,6 +126,7 @@ export function turnRuntimeEventsFromNotification(
type: "turnStarted",
threadId: notification.params.threadId,
turnId: notification.params.turn.id,
recencyAt: notification.params.turn.startedAt,
},
];
case "turn/completed":

View file

@ -1,4 +1,5 @@
import { type ExecutionState, RUNNING_EXECUTION_STATE, type ThreadStreamExecutionState } from "../../../domain/thread-stream/items";
import { RUNNING_EXECUTION_STATE, type ThreadStreamExecutionState } from "../../../domain/thread-stream/execution-state";
import type { ExecutionState } from "../../../domain/thread-stream/items";
export { RUNNING_EXECUTION_STATE };

View file

@ -2,13 +2,13 @@ import {
completedTurnTranscriptSummaryFromTurnRecord,
type TurnItem,
type TurnRecord,
turnUserItemProjection,
turnUserItemText,
} from "../../../../../app-server/protocol/turn";
import { jsonPreview } from "../../../../../domain/display/json-preview";
import type { HistoricalTurn } from "../../../../../domain/threads/history";
import { referencedThreadMetadataFromPrompt } from "../../../../../domain/threads/reference";
import type { TurnTranscriptSummary } from "../../../../../domain/threads/transcript";
import { contextAttachmentsFromHistoryContexts } from "../../../domain/thread-stream/format/context-attachments";
import { threadStreamFileReferences } from "../../../domain/thread-stream/format/file-references";
import { fileMentionsFromInput } from "../../../domain/thread-stream/format/file-mentions";
import { normalizeProposedPlanMarkdown } from "../../../domain/thread-stream/format/proposed-plan";
import { userMessageDisplayText } from "../../../domain/thread-stream/format/user-message-text";
import type { CommandThreadStreamTarget, ThreadStreamDiagnosticSection, ThreadStreamItem } from "../../../domain/thread-stream/items";
@ -130,23 +130,20 @@ function turnItemSourceFields(item: { id: string }, turnId?: string): TurnItemSo
}
function userThreadStreamItem(item: UserMessageItem, turnId?: string): ThreadStreamItem {
const projection = turnUserItemProjection(item);
const text = projection.text;
const referencedThread = projection.referencedThread;
const referencedFiles = threadStreamFileReferences(projection.fileReferences);
const contextAttachments = contextAttachmentsFromHistoryContexts(projection.contexts, text);
const text = turnUserItemText(item);
const referencedThread = referencedThreadMetadataFromPrompt(text);
const mentionedFiles = fileMentionsFromInput(item.content);
if (referencedThread) {
return {
...turnItemSourceFields(item, turnId),
kind: "dialogue",
dialogueKind: "user",
role: "user",
text: userMessageDisplayText(text, item.content),
copyText: text,
referencedThread,
text: userMessageDisplayText(referencedThread.text, item.content),
copyText: referencedThread.text,
referencedThread: referencedThread.reference,
...definedProp("clientId", item.clientId),
...(referencedFiles.length > 0 ? { referencedFiles } : {}),
...(contextAttachments.length > 0 ? { contextAttachments } : {}),
...(mentionedFiles.length > 0 ? { mentionedFiles } : {}),
};
}
return {
@ -157,8 +154,7 @@ function userThreadStreamItem(item: UserMessageItem, turnId?: string): ThreadStr
text: userMessageDisplayText(text, item.content),
copyText: text,
...definedProp("clientId", item.clientId),
...(referencedFiles.length > 0 ? { referencedFiles } : {}),
...(contextAttachments.length > 0 ? { contextAttachments } : {}),
...(mentionedFiles.length > 0 ? { mentionedFiles } : {}),
};
}

View file

@ -1,23 +1,14 @@
import type { AppServerClient } from "../../../app-server/connection/client";
import type { AppServerClientAccess, AppServerClientAccessOptions } from "../../../app-server/connection/client-access";
import { withShortLivedAppServerClient } from "../../../app-server/connection/short-lived-client";
import type { CodexInput } from "../../../domain/chat/input";
import type { ComposerInputSnapshot } from "../application/composer/input-snapshot";
import { createThreadReferenceResolver, type ThreadReferenceResolver } from "./thread-reference-resolver";
import { type ChatMetadataTransports, createChatMetadataTransports } from "./transports/metadata-transports";
import {
type ChatConnectedSessionTransports,
type ChatCurrentSessionTransports,
createChatConnectedSessionTransports,
createChatCurrentSessionTransports,
} from "./transports/session-transports";
import { type ChatSessionTransports, createChatSessionTransports } from "./transports/session-transports";
export interface ChatCurrentAppServerGatewayHost {
fallbackClientAccess: AppServerClientAccess;
vaultPath: string;
currentClient(): AppServerClient | null;
}
export interface ChatConnectedAppServerGatewayHost {
export interface ChatAppServerGatewayHost {
codexPath(): string;
vaultPath: string;
currentClient(): AppServerClient | null;
connectedClient(): Promise<AppServerClient | null>;
@ -29,19 +20,17 @@ interface ChatThreadReferenceResolverOptions {
setStatus(status: string): void;
}
export interface ChatCurrentAppServerGateway extends ChatCurrentSessionTransports, ChatMetadataTransports {
export interface ChatAppServerGateway extends ChatSessionTransports, ChatMetadataTransports {
clientAccess: AppServerClientAccess;
connectionAvailable(): boolean;
readFileBase64(path: string, options?: { timeoutMs?: number }): Promise<string | null>;
threadReferences(options: ChatThreadReferenceResolverOptions): ThreadReferenceResolver;
}
export interface ChatAppServerGateway extends ChatCurrentAppServerGateway, ChatConnectedSessionTransports {}
export function createChatCurrentAppServerGateway(host: ChatCurrentAppServerGatewayHost): ChatCurrentAppServerGateway {
export function createChatAppServerGateway(host: ChatAppServerGatewayHost): ChatAppServerGateway {
return {
clientAccess: createCurrentClientAccess(host),
...createChatCurrentSessionTransports(host),
...createChatSessionTransports(host),
...createChatMetadataTransports(host),
connectionAvailable: () => host.currentClient() !== null,
readFileBase64: (path, options) => readCurrentClientFileBase64(host, path, options),
@ -59,21 +48,11 @@ export function createChatCurrentAppServerGateway(host: ChatCurrentAppServerGate
};
}
export function createChatAppServerGateway(
currentGateway: ChatCurrentAppServerGateway,
host: ChatConnectedAppServerGatewayHost,
): ChatAppServerGateway {
return {
...currentGateway,
...createChatConnectedSessionTransports(host),
};
}
function createCurrentClientAccess(host: ChatCurrentAppServerGatewayHost): AppServerClientAccess {
function createCurrentClientAccess(host: ChatAppServerGatewayHost): AppServerClientAccess {
return {
withClient: async (operation, options: AppServerClientAccessOptions = {}) => {
if (options.serverRequests?.kind === "reject") {
return host.fallbackClientAccess.withClient(operation, options);
return withShortLivedAppServerClient(host.codexPath(), host.vaultPath, operation, options);
}
const client = host.currentClient();
@ -88,7 +67,7 @@ function createCurrentClientAccess(host: ChatCurrentAppServerGatewayHost): AppSe
}
async function readCurrentClientFileBase64(
host: ChatCurrentAppServerGatewayHost,
host: ChatAppServerGatewayHost,
path: string,
options: { timeoutMs?: number } = {},
): Promise<string | null> {

View file

@ -1,10 +1,9 @@
import type { AppServerRequestClient } from "../../../app-server/services/request-client";
import { readReferencedThreadTurnTranscriptSummaries } from "../../../app-server/services/threads";
import { type CodexInput, codexTextInputWithAttachments } from "../../../domain/chat/input";
import { threadReferenceMarkdown } from "../../../domain/threads/deep-link";
import { shortThreadId } from "../../../domain/threads/id";
import type { Thread } from "../../../domain/threads/model";
import { REFERENCED_THREAD_TURN_LIMIT, referencedThreadContext } from "../../../domain/threads/reference";
import { REFERENCED_THREAD_TURN_LIMIT, referencedThreadPromptBundle } from "../../../domain/threads/reference";
import type { ComposerInputSnapshot } from "../application/composer/input-snapshot";
import type { ThreadReferenceInput } from "../application/turns/slash-command-execution";
@ -41,20 +40,12 @@ async function referencedThreadInput(
return null;
}
const messageInput = host.prepareInput(message, snapshot);
const reference = referencedThreadContext(thread, turns);
const visibleText = `${threadReferenceMarkdown(thread)}\n\n${messageInput.text}`;
const reference = referencedThreadPromptBundle(thread, turns, messageInput.text);
host.setStatus(`Referencing ${shortThreadId(thread.id)} (${String(turns.length)}/${String(REFERENCED_THREAD_TURN_LIMIT)} turns).`);
return {
text: visibleText,
input: codexTextInputWithAttachments(visibleText, [
{
type: "additionalContext",
key: "codex_panel_referenced_thread",
kind: "untrusted",
value: reference,
},
...messageInput.input,
]),
text: messageInput.text,
input: codexTextInputWithAttachments(reference.prompt, messageInput.input),
referencedThread: reference.referencedThread,
};
} catch (error) {
if (host.currentClient() !== client) return null;

View file

@ -10,6 +10,7 @@ import {
readThreadGoal,
recordThreadGoalUserMessage,
resumeThread,
rollbackThread,
setThreadGoal,
startThread,
threadActivationSnapshotFromAppServerResponse,
@ -19,9 +20,8 @@ import {
import { interruptTurn, startTurn, steerTurn } from "../../../../app-server/services/turns";
import type { RuntimeSettingsPatch } from "../../../../domain/runtime/thread-settings";
import type { ThreadTurnsPage } from "../../../../domain/threads/history";
import type { EffectOutcome } from "../../application/effect-outcome";
import type { RuntimeSettingsTransport } from "../../application/runtime/settings-transport";
import type { EphemeralThreadForkResult, EphemeralThreadTransport } from "../../application/threads/ephemeral-thread-transport";
import type { EphemeralThreadTransport } from "../../application/threads/ephemeral-thread-transport";
import type { ThreadGoalReadTransport, ThreadGoalTransport } from "../../application/threads/goal-transport";
import type {
ThreadHistoryPage,
@ -29,9 +29,8 @@ import type {
ThreadResumeSnapshot,
ThreadResumeTransport,
} from "../../application/threads/thread-loading-transport";
import type { ThreadMutationTransport } from "../../application/threads/thread-mutation-transport";
import type { ThreadMutationTransport, ThreadRollbackSnapshot } from "../../application/threads/thread-mutation-transport";
import type { ThreadStartTransport } from "../../application/threads/thread-start-transport";
import type { ThreadSubscriptionTransport } from "../../application/threads/thread-subscription-transport";
import type { ChatTurnTransport } from "../../application/turns/turn-transport";
import { threadStreamItemsFromTurns } from "../mappers/thread-stream/turn-items";
@ -43,57 +42,45 @@ interface ConnectedChatAppServerClientHost extends CurrentChatAppServerClientHos
connectedClient(): Promise<AppServerClient | null>;
}
interface ChatCurrentAppServerTransportHost extends CurrentChatAppServerClientHost {
interface ChatAppServerTransportHost extends ConnectedChatAppServerClientHost {
vaultPath: string;
}
interface ChatAppServerTransportHost extends ConnectedChatAppServerClientHost, ChatCurrentAppServerTransportHost {}
interface AppServerThreadTurnsPage {
readonly data: ThreadTurnsPage["turns"];
readonly nextCursor: string | null;
}
export interface ChatCurrentSessionTransports {
export interface ChatSessionTransports {
readonly turn: ChatTurnTransport;
readonly runtimeSettings: RuntimeSettingsTransport;
readonly threadStart: ThreadStartTransport;
readonly threadHistory: ThreadHistoryTransport;
readonly threadGoalRead: ThreadGoalReadTransport;
}
export interface ChatConnectedSessionTransports {
readonly turn: ChatTurnTransport;
readonly threadResume: ThreadResumeTransport;
readonly threadMutation: ThreadMutationTransport;
readonly threadEphemeral: EphemeralThreadTransport;
readonly threadSubscription: ThreadSubscriptionTransport;
readonly threadGoalRead: ThreadGoalReadTransport;
readonly threadGoal: ThreadGoalTransport;
}
export function createChatCurrentSessionTransports(host: ChatCurrentAppServerTransportHost): ChatCurrentSessionTransports {
export function createChatSessionTransports(host: ChatAppServerTransportHost): ChatSessionTransports {
return {
turn: createChatTurnTransport(host),
runtimeSettings: createChatRuntimeSettingsTransport(host),
threadStart: createChatThreadStartTransport(host),
threadHistory: createChatThreadHistoryTransport(host),
threadGoalRead: createChatThreadGoalReadTransport(host),
};
}
export function createChatConnectedSessionTransports(host: ChatAppServerTransportHost): ChatConnectedSessionTransports {
return {
turn: createChatTurnTransport(host),
threadResume: createChatThreadResumeTransport(host),
threadMutation: createChatThreadMutationTransport(host),
threadEphemeral: createChatEphemeralThreadTransport(host),
threadSubscription: createChatThreadSubscriptionTransport(host),
threadGoalRead: createChatThreadGoalReadTransport(host),
threadGoal: createChatThreadGoalTransport(host),
};
}
function createChatThreadStartTransport(host: ChatCurrentAppServerTransportHost): ThreadStartTransport {
function createChatThreadStartTransport(host: ChatAppServerTransportHost): ThreadStartTransport {
return {
startThread: (request) =>
runCurrentChatAppServerEffect(host, async (client) => {
withCurrentChatAppServerClient(host, async (client) => {
const response = await startThread(client, {
cwd: host.vaultPath,
serviceTier: request.serviceTier,
@ -108,7 +95,7 @@ function createChatTurnTransport(host: ChatAppServerTransportHost): ChatTurnTran
return {
ensureConnected: async () => (await host.connectedClient()) !== null,
startTurn: (request) =>
runCurrentChatAppServerEffect(host, async (client) => {
withCurrentChatAppServerClient(host, async (client) => {
const response = await startTurn(client, {
threadId: request.threadId,
cwd: host.vaultPath,
@ -117,10 +104,13 @@ function createChatTurnTransport(host: ChatAppServerTransportHost): ChatTurnTran
});
return { turnId: response.turn.id };
}),
steerTurn: (request) =>
runCurrentChatAppServerEffect(host, async (client) => {
steerTurn: async (request) => {
const steered = await withCurrentChatAppServerClient(host, async (client) => {
await steerTurn(client, request.threadId, request.turnId, request.input, request.clientUserMessageId);
}),
return true;
});
return steered ?? false;
},
interruptTurn: async (threadId, turnId) => {
const interrupted = await withCurrentChatAppServerClient(host, async (client) => {
await interruptTurn(client, threadId, turnId);
@ -152,39 +142,48 @@ function createChatThreadHistoryTransport(host: CurrentChatAppServerClientHost):
function createChatThreadResumeTransport(host: ChatAppServerTransportHost): ThreadResumeTransport {
return {
ensureConnected: async () => (await host.connectedClient()) !== null,
resumeThread: (threadId): Promise<EffectOutcome<ThreadResumeSnapshot>> =>
runCurrentChatAppServerEffect(host, (client) => resumeChatThread(client, threadId, host.vaultPath)),
resumeThread: (threadId): Promise<ThreadResumeSnapshot | null> =>
withConnectedChatAppServerClient(host, (client) => resumeChatThread(client, threadId, host.vaultPath)),
};
}
function createChatThreadMutationTransport(host: ChatAppServerTransportHost): ThreadMutationTransport {
return {
ensureConnected: async () => (await host.connectedClient()) !== null,
compactThread: (threadId) =>
runCurrentChatAppServerEffect(host, async (client) => {
compactThread: async (threadId) => {
const result = await withConnectedChatAppServerClient(host, async (client) => {
await compactThread(client, threadId);
return true;
});
return result ?? false;
},
forkThread: (threadId, lastTurnId = null) =>
withConnectedChatAppServerClient(host, (client) => forkThread(client, threadId, host.vaultPath, lastTurnId)),
rollbackThread: (threadId) =>
withConnectedChatAppServerClient(host, async (client): Promise<ThreadRollbackSnapshot> => {
const snapshot = await rollbackThread(client, threadId);
return {
thread: snapshot.thread,
cwd: snapshot.cwd,
items: threadStreamItemsFromTurns(snapshot.turns),
};
}),
forkThread: (threadId, options) =>
runCurrentChatAppServerEffect(host, (client) => forkThread(client, threadId, host.vaultPath, options)),
};
}
function createChatEphemeralThreadTransport(host: ChatAppServerTransportHost): EphemeralThreadTransport {
return {
forkEphemeralThread: async (sourceThreadId) => {
const client = host.currentClient();
if (!client) return { kind: "not-started" };
const value = await forkEphemeralThreadResult(client, sourceThreadId, host.vaultPath);
if (!chatAppServerClientIsStale(host, client)) return { kind: "completed-current", value };
const threadId = value.kind === "ready" ? value.activation.thread.id : value.threadId;
try {
await unsubscribeThread(client, threadId, { timeoutMs: 5_000 });
} catch {
// The superseded connection remains the only valid cleanup context.
}
return { kind: "completed-stale", value };
},
forkEphemeralThread: (sourceThreadId) =>
withConnectedChatAppServerClient(host, async (client) => {
try {
const snapshot = await forkEphemeralThread(client, sourceThreadId, host.vaultPath);
return { kind: "ready" as const, ...snapshot };
} catch (error) {
if (error instanceof EphemeralThreadCleanupRequiredError) {
return { kind: "cleanup-required" as const, threadId: error.threadId };
}
throw error;
}
}),
unsubscribeEphemeralThread: async (threadId) => {
const result = await withCurrentChatAppServerClient(host, async (client) => {
await unsubscribeThread(client, threadId, { timeoutMs: 5_000 });
@ -195,34 +194,6 @@ function createChatEphemeralThreadTransport(host: ChatAppServerTransportHost): E
};
}
async function forkEphemeralThreadResult(
client: AppServerRequestClient,
sourceThreadId: string,
vaultPath: string,
): Promise<EphemeralThreadForkResult> {
try {
const snapshot = await forkEphemeralThread(client, sourceThreadId, vaultPath);
return { kind: "ready", ...snapshot };
} catch (error) {
if (error instanceof EphemeralThreadCleanupRequiredError) {
return { kind: "cleanup-required", threadId: error.threadId };
}
throw error;
}
}
function createChatThreadSubscriptionTransport(host: ChatAppServerTransportHost): ThreadSubscriptionTransport {
return {
unsubscribeThread: async (threadId) => {
const result = await withCurrentChatAppServerClient(host, async (client) => {
await unsubscribeThread(client, threadId, { timeoutMs: 5_000 });
return true;
});
return result ?? false;
},
};
}
function createChatThreadGoalReadTransport(host: CurrentChatAppServerClientHost): ThreadGoalReadTransport {
return {
readThreadGoal: (threadId) => readThreadGoalFromCurrentClient(host, threadId),
@ -234,12 +205,18 @@ function createChatThreadGoalTransport(host: ConnectedChatAppServerClientHost):
ensureConnected: async () => (await host.connectedClient()) !== null,
readThreadGoal: (threadId) => readThreadGoalFromCurrentClient(host, threadId),
setThreadGoal: async (threadId, params) => {
return runCurrentChatAppServerEffect(host, (client) => setThreadGoal(client, threadId, params));
const client = await host.connectedClient();
if (!client) return undefined;
const goal = await setThreadGoal(client, threadId, params);
return chatAppServerClientIsStale(host, client) ? undefined : goal;
},
clearThreadGoal: (threadId) =>
runCurrentChatAppServerEffect(host, async (client) => {
clearThreadGoal: async (threadId) => {
const result = await withConnectedChatAppServerClient(host, async (client) => {
await clearThreadGoal(client, threadId);
}),
return true;
});
return result ?? false;
},
recordThreadGoalUserMessage: async (threadId, objective) => {
const result = await withCurrentChatAppServerClient(host, async (client) => {
await recordThreadGoalUserMessage(client, threadId, objective);
@ -254,16 +231,14 @@ function chatAppServerClientIsStale(host: CurrentChatAppServerClientHost, client
return host.currentClient() !== client;
}
function runCurrentChatAppServerEffect<T>(
host: CurrentChatAppServerClientHost,
async function withConnectedChatAppServerClient<T>(
host: ConnectedChatAppServerClientHost,
operation: (client: AppServerClient) => Promise<T>,
): Promise<EffectOutcome<T>> {
const client = host.currentClient();
if (!client) return Promise.resolve({ kind: "not-started" });
const effect = operation(client);
return effect.then((value) =>
chatAppServerClientIsStale(host, client) ? { kind: "completed-stale", value } : { kind: "completed-current", value },
);
): Promise<T | null> {
const client = await host.connectedClient();
if (!client) return null;
const result = await operation(client);
return chatAppServerClientIsStale(host, client) ? null : result;
}
async function withCurrentChatAppServerClient<T>(

View file

@ -1,9 +1,4 @@
import {
type CodexInput,
type CodexInputItem,
codexTextInputWithAttachments,
type VaultFileReference,
} from "../../../../domain/chat/input";
import { type CodexInput, type CodexInputItem, codexTextInputWithAttachments, type RequestMention } from "../../../../domain/chat/input";
type ComposerAttachmentKind = "image" | "file";
@ -26,14 +21,14 @@ export function codexInputWithComposerAttachments(text: string, input: CodexInpu
}
function inputItemsForAttachments(input: readonly CodexInputItem[], attachments: readonly ComposerAttachment[]): CodexInputItem[] {
const seenFileReferencePaths = new Set(input.flatMap((item) => (item.type === "fileReference" ? [item.path] : [])));
const seenMentionPaths = new Set(input.flatMap((item) => (item.type === "mention" ? [item.path] : [])));
const seenLocalImagePaths = new Set(input.flatMap((item) => (item.type === "localImage" ? [item.path] : [])));
const items: CodexInputItem[] = [];
for (const attachment of attachments) {
if (!seenFileReferencePaths.has(attachment.path)) {
seenFileReferencePaths.add(attachment.path);
items.push(fileReferenceInputItem(attachment));
if (!seenMentionPaths.has(attachment.path)) {
seenMentionPaths.add(attachment.path);
items.push(mentionInputItem(attachment));
}
if (attachment.kind === "image" && !seenLocalImagePaths.has(attachment.path)) {
seenLocalImagePaths.add(attachment.path);
@ -43,9 +38,9 @@ function inputItemsForAttachments(input: readonly CodexInputItem[], attachments:
return items;
}
function fileReferenceInputItem(attachment: ComposerAttachment): VaultFileReference & { type: "fileReference" } {
function mentionInputItem(attachment: ComposerAttachment): RequestMention & { type: "mention" } {
return {
type: "fileReference",
type: "mention",
name: attachment.name,
path: attachment.path,
};

View file

@ -34,6 +34,10 @@ export interface ComposerContextReferenceProvider {
dispose(): void;
}
export function emptyComposerContextReferences(): ComposerContextReferences {
return { activeNote: null, selection: null, activeNoteSnapshots: [], selectionSnapshots: [] };
}
export function formatComposerContextRange(range: ComposerContextRange): string {
return `${formatComposerContextPosition(range.from)}-${formatComposerContextPosition(range.to)}`;
}

View file

@ -0,0 +1,7 @@
export interface DailyNoteReferenceCandidate {
keyword: "today" | "tomorrow" | "yesterday";
display: string;
name: string;
path: string;
linktext: string;
}

View file

@ -1,7 +1,6 @@
import type { SkillMetadata } from "../../../../domain/catalog/metadata";
import type { ComposerAttachment } from "./attachments";
import type { ActiveNoteContextReference, ComposerContextReferences, SelectionContextReference } from "./context-references";
import type { ThreadCommandTarget } from "./thread-title-argument";
export interface ComposerInputSnapshot {
readonly sourcePath: string;
@ -11,5 +10,4 @@ export interface ComposerInputSnapshot {
readonly activeNoteSnapshots: readonly ActiveNoteContextReference[];
readonly selectionSnapshots: readonly SelectionContextReference[];
readonly attachments: readonly ComposerAttachment[];
readonly threadCommandTarget?: ThreadCommandTarget;
}

View file

@ -1,33 +1,15 @@
import type { VaultFileReference } from "../../../../domain/chat/input";
import type { DailyNoteReferenceCandidate } from "./daily-note-references";
import type { NoteCandidate } from "./suggestions";
export interface DailyNoteReferenceCandidate {
keyword: "today" | "tomorrow" | "yesterday";
display: string;
export interface WikiLinkMention {
name: string;
path: string;
linktext: string;
}
export interface NoteHeadingCandidate {
heading: string;
linkHeading: string;
level: number;
}
export interface NoteCandidate {
basename: string;
displayName: string;
path: string;
mtime: number;
linktext: string;
headings: NoteHeadingCandidate[];
recentIndex: number | null;
}
export interface NoteCandidateProvider {
candidates(sourcePath: string): readonly NoteCandidate[];
dailyNoteReferences(sourcePath: string): readonly DailyNoteReferenceCandidate[];
tags(): readonly string[];
resolveFileReference(target: string, sourcePath: string): VaultFileReference | null;
resolveMention(target: string, sourcePath: string): WikiLinkMention | null;
dispose(): void;
}

View file

@ -1,5 +1,3 @@
import type { ActivePanelOperation } from "../panel-operation-policy";
type SlashCommandArgsKind =
| "none"
| "optionalThread"
@ -74,20 +72,8 @@ export const SLASH_COMMANDS = [
surface: "composition",
detail: "Fetch readable web content as context and send it with an optional message.",
},
{
command: "/fork",
usage: "/fork",
argsKind: "none",
surface: "panelAction",
detail: "Fork the active Codex thread.",
},
{
command: "/btw",
usage: "/btw",
argsKind: "none",
surface: "panelAction",
detail: "Open a temporary side chat.",
},
{ command: "/fork", usage: "/fork", argsKind: "none", surface: "panelAction", detail: "Fork the active Codex thread." },
{ command: "/btw", usage: "/btw", argsKind: "none", surface: "panelAction", detail: "Open a temporary side chat." },
{
command: "/rollback",
usage: "/rollback",
@ -217,55 +203,6 @@ export function slashCommandRequiresConnection(command: SlashCommandName): boole
return !CONNECTION_INDEPENDENT_SLASH_COMMANDS.has(command);
}
/** Maps a parsed command to the active-panel operation it performs, if any. */
export function activePanelOperationForSlashCommand(command: SlashCommandName, args: string): ActivePanelOperation | null {
switch (command) {
case "goal":
return args.trim() ? "goal-mutation" : "goal-read";
case "compact":
return "compact";
case "fork":
return "fork";
case "rollback":
return "rollback";
case "btw":
return "start-side-chat";
case "fast":
case "auto-review":
case "plan":
return "thread-settings";
case "permissions":
return args ? "permission-settings" : null;
case "model":
case "reasoning":
return args ? "thread-settings" : null;
case "status":
case "doctor":
case "tools":
case "help":
return null;
default:
return "submit";
}
}
/** Maps a slash-completion to its mode-derived operation without assuming arguments. */
export function activePanelOperationForSlashCommandSuggestion(command: SlashCommandName): ActivePanelOperation | null {
switch (command) {
case "goal":
case "compact":
case "fork":
case "rollback":
case "btw":
case "fast":
case "auto-review":
case "plan":
return activePanelOperationForSlashCommand(command, "");
default:
return null;
}
}
export function slashCommandDefinition(command: SlashCommandName): SlashCommandDefinition {
const definition = SLASH_COMMANDS.find((item) => item.command === `/${command}`);
if (!definition) throw new Error(`Unknown slash command: ${command}`);

View file

@ -10,7 +10,6 @@ import type { RuntimePermissionProfileSummary } from "../../../../domain/runtime
import { shortThreadId } from "../../../../domain/threads/id";
import type { Thread } from "../../../../domain/threads/model";
import { compareThreadSearchMatches, threadSearchMatches } from "../../../../domain/threads/search";
import { threadCommandDisplayTitle } from "../../../../domain/threads/title";
import {
type ActiveNoteContextReference,
activeNoteContextReferenceMarker,
@ -19,15 +18,8 @@ import {
type SelectionContextReference,
selectionContextReferenceMarker,
} from "./context-references";
import type { DailyNoteReferenceCandidate, NoteCandidate, NoteHeadingCandidate } from "./note-context";
import type { DailyNoteReferenceCandidate } from "./daily-note-references";
import { isSlashCommandName, SLASH_COMMANDS, type SlashCommandName, slashCommandSubcommands } from "./slash-commands";
import {
partialThreadTitleQuery,
quotedThreadTitleArgument,
THREAD_TITLE_COMMANDS,
type ThreadCommandTarget,
type ThreadTitleCommand,
} from "./thread-title-argument";
export interface ComposerSuggestion {
display: string;
@ -39,18 +31,34 @@ export interface ComposerSuggestion {
suffixOnInsert?: string;
activeNoteContext?: ActiveNoteContextReference;
selectionContext?: SelectionContextReference;
threadCommandTarget?: ThreadCommandTarget;
}
export interface ComposerSuggestionOptions {
activeThreadId?: string | null;
slashCommandAvailable?: (command: SlashCommandName) => boolean;
activeThreadEphemeral?: boolean;
activeThreadSubagent?: boolean;
contextReferences?: ComposerContextReferences;
dailyNoteReferences?: readonly DailyNoteReferenceCandidate[] | (() => readonly DailyNoteReferenceCandidate[]);
permissionProfiles?: readonly RuntimePermissionProfileSummary[];
tagCandidates?: readonly string[] | (() => readonly string[]);
}
export interface NoteCandidate {
basename: string;
displayName: string;
path: string;
mtime: number;
linktext: string;
headings: NoteHeadingCandidate[];
recentIndex: number | null;
}
interface NoteHeadingCandidate {
heading: string;
linkHeading: string;
level: number;
}
interface NoteCandidateMatch {
file: NoteCandidate;
match: SearchResult;
@ -59,12 +67,16 @@ interface NoteCandidateMatch {
path: string;
}
const THREAD_SUGGESTION_COMMANDS = ["resume", "refer", "archive", "rename"] as const;
type ThreadSuggestionCommand = (typeof THREAD_SUGGESTION_COMMANDS)[number];
interface ThreadCommandSuggestionPolicy {
excludeActiveThread: boolean;
prioritizeActiveThreadForEmptyQuery: boolean;
}
const THREAD_COMMAND_SUGGESTION_POLICIES: Record<ThreadTitleCommand, ThreadCommandSuggestionPolicy> = {
const THREAD_COMMAND_SUGGESTION_POLICIES: Record<ThreadSuggestionCommand, ThreadCommandSuggestionPolicy> = {
resume: {
excludeActiveThread: true,
prioritizeActiveThreadForEmptyQuery: false,
@ -83,7 +95,7 @@ const THREAD_COMMAND_SUGGESTION_POLICIES: Record<ThreadTitleCommand, ThreadComma
},
};
const THREAD_SUGGESTION_COMMAND_PATTERN = new RegExp(`^/(${THREAD_TITLE_COMMANDS.join("|")})\\s+([^\\n]{0,120})$`);
const THREAD_SUGGESTION_COMMAND_PATTERN = new RegExp(`^/(${THREAD_SUGGESTION_COMMANDS.join("|")})\\s+([^\\s\\n]{0,120})$`);
const SELECTION_SUGGESTION_PREVIEW_LIMIT = 500;
export function parseSlashCommand(text: string): { command: SlashCommandName; args: string } | null {
@ -103,16 +115,14 @@ export function activeComposerSuggestions(
currentModel: string | null = null,
options: ComposerSuggestionOptions = {},
): ComposerSuggestion[] {
const slashCommandAvailable = options.slashCommandAvailable ?? (() => true);
const slashSuggestions = slashSuggestionsForActivePanel(
beforeCursor,
threads,
models,
currentModel,
options.activeThreadId ?? null,
options.permissionProfiles ?? [],
slashCommandAvailable,
);
const slashSuggestions = options.activeThreadSubagent
? null
: (activeSlashSubcommandSuggestions(beforeCursor) ??
activeThreadCommandSuggestions(beforeCursor, threads, options.activeThreadId ?? null) ??
modelOverrideSuggestions(beforeCursor, models) ??
reasoningEffortOverrideSuggestions(beforeCursor, models, currentModel) ??
permissionProfileOverrideSuggestions(beforeCursor, options.permissionProfiles ?? []) ??
activeSlashCommandSuggestions(beforeCursor, options.activeThreadEphemeral ?? false, false));
return (
activeWikiLinkSuggestions(beforeCursor, notes) ??
activeContextReferenceSuggestions(beforeCursor, options.contextReferences, options.dailyNoteReferences) ??
@ -123,27 +133,6 @@ export function activeComposerSuggestions(
);
}
function slashSuggestionsForActivePanel(
beforeCursor: string,
threads: readonly Thread[],
models: readonly ModelMetadata[],
currentModel: string | null,
activeThreadId: string | null,
permissionProfiles: readonly RuntimePermissionProfileSummary[],
slashCommandAvailable: (command: SlashCommandName) => boolean,
): ComposerSuggestion[] | null {
const command = /^\/([A-Za-z-]+)/.exec(beforeCursor)?.[1];
if (command && isSlashCommandName(command) && !slashCommandAvailable(command)) return [];
return (
activeSlashSubcommandSuggestions(beforeCursor, slashCommandAvailable) ??
activeThreadCommandSuggestions(beforeCursor, threads, activeThreadId) ??
modelOverrideSuggestions(beforeCursor, models) ??
reasoningEffortOverrideSuggestions(beforeCursor, models, currentModel) ??
permissionProfileOverrideSuggestions(beforeCursor, permissionProfiles) ??
activeSlashCommandSuggestions(beforeCursor, slashCommandAvailable)
);
}
function activeContextReferenceSuggestions(
beforeCursor: string,
references: ComposerContextReferences | undefined,
@ -408,9 +397,12 @@ function compareWikiLinkSuggestionTiebreakers(a: NoteCandidateMatch, b: NoteCand
return b.mtime - a.mtime || a.basename.localeCompare(b.basename) || a.path.localeCompare(b.path);
}
const SIDE_CHAT_UNAVAILABLE_SLASH_COMMANDS = new Set(["/btw", "/fork", "/rollback"]);
function activeSlashCommandSuggestions(
beforeCursor: string,
slashCommandAvailable: (command: SlashCommandName) => boolean,
activeThreadEphemeral: boolean,
activeThreadSubagent: boolean,
): ComposerSuggestion[] | null {
const match = /^(\/[A-Za-z-]*)$/.exec(beforeCursor);
if (match?.index === undefined) return null;
@ -418,10 +410,12 @@ function activeSlashCommandSuggestions(
const rawQuery = match[1];
if (rawQuery === undefined) return null;
const query = rawQuery.toLowerCase();
if (activeThreadSubagent) return [];
if (SLASH_COMMANDS.some((item) => item.command.toLowerCase() === query)) return null;
const start = match.index + match[0].lastIndexOf("/");
return SLASH_COMMANDS.filter(
(item) => item.command.toLowerCase().startsWith(query) && slashCommandAvailable(item.command.slice(1) as SlashCommandName),
(item) =>
item.command.toLowerCase().startsWith(query) && (!activeThreadEphemeral || !SIDE_CHAT_UNAVAILABLE_SLASH_COMMANDS.has(item.command)),
)
.slice(0, 8)
.map((item) => ({
@ -433,17 +427,13 @@ function activeSlashCommandSuggestions(
}));
}
function activeSlashSubcommandSuggestions(
beforeCursor: string,
slashCommandAvailable: (command: SlashCommandName) => boolean,
): ComposerSuggestion[] | null {
function activeSlashSubcommandSuggestions(beforeCursor: string): ComposerSuggestion[] | null {
const match = /^\/([A-Za-z-]+)\s+([A-Za-z-]{0,120})$/.exec(beforeCursor);
if (!match) return null;
const command = match[1];
const rawQuery = match[2];
if (!command || !isSlashCommandName(command) || rawQuery === undefined) return null;
if (!slashCommandAvailable(command)) return [];
const query = rawQuery.toLowerCase();
const subcommands = slashCommandSubcommands(command);
@ -472,6 +462,7 @@ function activeThreadCommandSuggestions(
const { command, query, start } = completion;
const policy = THREAD_COMMAND_SUGGESTION_POLICIES[command];
if (threads.some((thread) => thread.id.toLowerCase() === query)) return null;
const candidateThreads = threads.filter((thread) => !shouldExcludeActiveThreadSuggestion(policy, thread.id, activeThreadId));
return threadSearchMatches(candidateThreads, query)
@ -480,17 +471,13 @@ function activeThreadCommandSuggestions(
return { ...match, activePriority };
})
.sort((a, b) => a.activePriority - b.activePriority || compareThreadSearchMatches(a, b))
.map(({ thread }) => {
const title = threadCommandDisplayTitle(thread);
return {
display: title,
detail: shortThreadId(thread.id),
replacement: quotedThreadTitleArgument(title),
start,
appendSpaceOnInsert: true,
threadCommandTarget: { command, threadId: thread.id, title },
};
});
.map(({ thread, title }) => ({
display: title,
detail: shortThreadId(thread.id),
replacement: thread.id,
start,
appendSpaceOnInsert: true,
}));
}
function shouldExcludeActiveThreadSuggestion(
@ -510,20 +497,22 @@ function activeThreadSuggestionPriority(
return policy.prioritizeActiveThreadForEmptyQuery && query.length === 0 && activeThreadId !== null && threadId === activeThreadId ? 0 : 1;
}
function activeThreadCommandCompletionQuery(beforeCursor: string): { command: ThreadTitleCommand; query: string; start: number } | null {
function activeThreadCommandCompletionQuery(
beforeCursor: string,
): { command: ThreadSuggestionCommand; query: string; start: number } | null {
const match = THREAD_SUGGESTION_COMMAND_PATTERN.exec(beforeCursor);
if (!match) return null;
const command = threadSuggestionCommand(match[1]);
const rawQuery = match[2];
if (!command || rawQuery === undefined) return null;
const query = partialThreadTitleQuery(rawQuery);
if (query === null) return null;
const query = rawQuery.trim().toLowerCase();
if (query.length > 0 && /\s$/.test(rawQuery)) return null;
return { command, query, start: beforeCursor.length - rawQuery.length };
}
function threadSuggestionCommand(value: string | undefined): ThreadTitleCommand | null {
return THREAD_TITLE_COMMANDS.find((command) => command === value) ?? null;
function threadSuggestionCommand(value: string | undefined): ThreadSuggestionCommand | null {
return THREAD_SUGGESTION_COMMANDS.find((command) => command === value) ?? null;
}
function modelOverrideSuggestions(beforeCursor: string, models: readonly ModelMetadata[]): ComposerSuggestion[] | null {

View file

@ -1,77 +0,0 @@
export const THREAD_TITLE_COMMANDS = ["resume", "refer", "archive", "rename"] as const;
export type ThreadTitleCommand = (typeof THREAD_TITLE_COMMANDS)[number];
export interface ThreadCommandTarget {
readonly command: ThreadTitleCommand;
readonly threadId: string;
readonly title: string;
}
export interface ParsedThreadTitleArgument {
readonly title: string;
readonly rest: string;
}
export function threadCommandTargetForDraft(draft: string, target: ThreadCommandTarget | null): ThreadCommandTarget | null {
if (!target) return null;
const match = /^\/([A-Za-z-]+)(?:\s+([\s\S]*))?$/.exec(draft.trim());
if (match?.[1] !== target.command) return null;
const parsed = parseThreadTitleArgument(match[2] ?? "");
return parsed?.title === target.title ? target : null;
}
export function quotedThreadTitleArgument(title: string): string {
return `"${title.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
}
export function parseThreadTitleArgument(input: string): ParsedThreadTitleArgument | null {
const source = input.trimStart();
if (!source) return null;
if (!source.startsWith('"')) {
const match = /^(\S+)([\s\S]*)$/.exec(source);
const title = match?.[1];
const rest = match?.[2];
return title === undefined || rest === undefined ? null : { title, rest: rest.trimStart() };
}
let title = "";
for (let index = 1; index < source.length; index += 1) {
const character = source.charAt(index);
if (character === "\\") {
const escaped = source.charAt(index + 1);
if (!escaped) return null;
if (escaped !== "\\" && escaped !== '"') title += "\\";
title += escaped;
index += 1;
continue;
}
if (character === '"') {
const rest = source.slice(index + 1);
if (rest && !/^\s/.test(rest)) return null;
return { title, rest: rest.trimStart() };
}
title += character;
}
return null;
}
export function partialThreadTitleQuery(input: string): string | null {
if (!input.startsWith('"')) return /\s/.test(input) ? null : input;
let query = "";
for (let index = 1; index < input.length; index += 1) {
const character = input.charAt(index);
if (character === "\\") {
const escaped = input.charAt(index + 1);
if (!escaped) return query;
if (escaped !== "\\" && escaped !== '"') query += "\\";
query += escaped;
index += 1;
continue;
}
if (character === '"') return null;
query += character;
}
return query;
}

View file

@ -1,17 +1,16 @@
import { parseLinktext } from "obsidian";
import type { SkillMetadata } from "../../../../domain/catalog/metadata";
import {
ACTIVE_FILE_REFERENCE_NAME,
ACTIVE_FILE_MENTION_NAME,
type CodexInput,
codexTextInputWithReferences,
codexTextInputWithMentions,
type RequestAdditionalContext,
type SkillReference,
type VaultFileReference,
type RequestMention,
} from "../../../../domain/chat/input";
import { type MarkdownCodeRange, markdownCodeRangeContainsOffset, markdownCodeRanges } from "../../../../domain/markdown/code-ranges";
import {
type ActiveNoteContextReference,
type ComposerContextReferences,
formatComposerContextRange,
type SelectionContextReference,
selectionContextReferenceMarker,
} from "./context-references";
@ -23,13 +22,7 @@ interface ParsedWikiLink {
display: string;
}
interface ObsidianReference {
marker: string;
path: string;
excerpt?: string;
}
export type WikiLinkFileReferenceResolver = (target: string) => VaultFileReference | null;
export type WikiLinkMentionResolver = (target: string) => { name: string; path: string } | null;
const OBSIDIAN_CONTEXT_ADDITIONAL_CONTEXT_KEY = "codex_panel_obsidian_context";
const WIKILINK_PATTERN = /\[\[([^\]\n]+?)\]\]/g;
@ -44,11 +37,10 @@ interface PreparedComposerInputOptions {
referenceActiveNoteOnSend: boolean;
}
function parsedWikiLinks(text: string, codeRanges: readonly MarkdownCodeRange[]): ParsedWikiLink[] {
function parsedWikiLinks(text: string): ParsedWikiLink[] {
const links: ParsedWikiLink[] = [];
const seen = new Set<string>();
for (const match of text.matchAll(WIKILINK_PATTERN)) {
if (markdownCodeRangeContainsOffset(codeRanges, match.index)) continue;
const rawValue = match[1];
if (rawValue === undefined) continue;
const raw = rawValue.trim();
@ -62,42 +54,41 @@ function parsedWikiLinks(text: string, codeRanges: readonly MarkdownCodeRange[])
return links;
}
export function preparedUserInputWithWikiLinkReferencesSkillsAndContext(
export function preparedUserInputWithWikiLinkMentionsSkillsAndContext(
text: string,
resolveFileReference: WikiLinkFileReferenceResolver,
resolveMention: WikiLinkMentionResolver,
skills: readonly SkillMetadata[],
contextReferences: ComposerContextReferences,
options: PreparedComposerInputOptions,
): PreparedComposerInput {
const codeRanges = markdownReferenceCodeRanges(text);
const contextReplacement = textWithContextReferences(text, contextReferences, codeRanges);
const contextReplacement = textWithContextReferences(text, contextReferences);
const resolvedText = contextReplacement.text;
const fileReferences: VaultFileReference[] = [];
const wikilinkReferences: ObsidianReference[] = [];
const mentions: RequestMention[] = [];
const wikilinkMappings: string[] = [];
const seenPaths = new Set<string>();
const activeNoteSnapshots = contextReferences.activeNoteSnapshots ?? [];
for (const link of parsedWikiLinks(resolvedText, codeRanges)) {
const fileReference = activeNoteFileReferenceForLink(link, activeNoteSnapshots) ?? resolveFileReference(link.target);
if (!fileReference || seenPaths.has(fileReference.path)) continue;
seenPaths.add(fileReference.path);
fileReferences.push(fileReference);
wikilinkReferences.push({ marker: `[[${link.raw}]]`, path: fileReference.path });
for (const link of parsedWikiLinks(resolvedText)) {
const mention = activeNoteMentionForLink(link, activeNoteSnapshots) ?? resolveMention(link.target);
if (!mention || seenPaths.has(mention.path)) continue;
seenPaths.add(mention.path);
mentions.push(mention);
wikilinkMappings.push(`- [[${link.raw}]] -> ${mention.path}`);
}
for (const selection of contextReplacement.selections) {
if (seenPaths.has(selection.path)) continue;
seenPaths.add(selection.path);
fileReferences.push({ name: selection.name, path: selection.path });
mentions.push({ name: selection.name, path: selection.path });
}
const attachedActiveNote = options.referenceActiveNoteOnSend ? contextReferences.activeNote : null;
if (attachedActiveNote) fileReferences.push({ name: ACTIVE_FILE_REFERENCE_NAME, path: attachedActiveNote.path });
if (attachedActiveNote) mentions.push({ name: ACTIVE_FILE_MENTION_NAME, path: attachedActiveNote.path });
const skillByName = firstEnabledSkillByName(skills);
const resolvedSkills: SkillReference[] = [];
const resolvedSkills: RequestMention[] = [];
const seenSkillPaths = new Set<string>();
for (const reference of parsedSkillReferences(resolvedText, codeRanges)) {
for (const reference of parsedSkillReferences(resolvedText)) {
const skill = skillByName.get(reference.toLowerCase());
if (!skill || seenSkillPaths.has(skill.path)) continue;
seenSkillPaths.add(skill.path);
@ -106,16 +97,16 @@ export function preparedUserInputWithWikiLinkReferencesSkillsAndContext(
return {
text: resolvedText,
input: codexTextInputWithReferences(
input: codexTextInputWithMentions(
resolvedText,
fileReferences,
mentions,
resolvedSkills,
additionalContext(wikilinkReferences, contextReplacement.selections, attachedActiveNote),
additionalContext(wikilinkMappings, contextReplacement.selections, attachedActiveNote),
),
};
}
function activeNoteFileReferenceForLink(link: ParsedWikiLink, snapshots: readonly ActiveNoteContextReference[]): VaultFileReference | null {
function activeNoteMentionForLink(link: ParsedWikiLink, snapshots: readonly ActiveNoteContextReference[]): RequestMention | null {
const snapshot = snapshots.find((item) => link.raw.trim() === item.linktext && link.target === item.linktext);
return snapshot ? { name: snapshot.name, path: snapshot.path } : null;
}
@ -123,24 +114,19 @@ function activeNoteFileReferenceForLink(link: ParsedWikiLink, snapshots: readonl
function textWithContextReferences(
text: string,
contextReferences: ComposerContextReferences,
codeRanges: readonly MarkdownCodeRange[],
): { text: string; selections: SelectionContextReference[] } {
return {
text,
selections: selectionsReferencedByText(text, contextReferences.selectionSnapshots ?? [], codeRanges),
selections: selectionsReferencedByText(text, contextReferences.selectionSnapshots ?? []),
};
}
function selectionsReferencedByText(
text: string,
snapshots: readonly SelectionContextReference[],
codeRanges: readonly MarkdownCodeRange[],
): SelectionContextReference[] {
function selectionsReferencedByText(text: string, snapshots: readonly SelectionContextReference[]): SelectionContextReference[] {
const selections: SelectionContextReference[] = [];
const seen = new Set<string>();
for (const snapshot of snapshots) {
const marker = selectionContextReferenceMarker(snapshot);
if (!includesOutsideMarkdownCode(text, marker, codeRanges) || seen.has(marker)) continue;
if (!text.includes(marker) || seen.has(marker)) continue;
seen.add(marker);
selections.push(snapshot);
}
@ -148,72 +134,67 @@ function selectionsReferencedByText(
}
function additionalContext(
wikilinkReferences: readonly ObsidianReference[],
wikilinkMappings: readonly string[],
selections: readonly SelectionContextReference[],
activeNote: ActiveNoteContextReference | null,
): RequestAdditionalContext[] {
return obsidianContextAdditionalContext(obsidianReferences(wikilinkReferences, selections, activeNote));
return obsidianContextAdditionalContext(wikilinkMappings, selections, activeNote);
}
function obsidianReferences(
wikilinkReferences: readonly ObsidianReference[],
function obsidianContextAdditionalContext(
wikilinkMappings: readonly string[],
selections: readonly SelectionContextReference[],
activeNote: ActiveNoteContextReference | null,
): ObsidianReference[] {
const selectionReferences = selections.map((selection) => ({
marker: selectionContextReferenceMarker(selection),
path: selection.path,
excerpt: selection.text,
}));
const selectedWikilinks = new Set(selections.map((selection) => referenceKey(`[[${selection.linktext}]]`, selection.path)));
return [
...wikilinkReferences.filter((reference) => !selectedWikilinks.has(referenceKey(reference.marker, reference.path))),
...selectionReferences,
...(activeNote ? [{ marker: ACTIVE_FILE_REFERENCE_NAME, path: activeNote.path }] : []),
];
}
function referenceKey(marker: string, path: string): string {
return `${marker}\u0000${path}`;
}
function obsidianContextAdditionalContext(references: readonly ObsidianReference[]): RequestAdditionalContext[] {
if (references.length === 0) return [];
): RequestAdditionalContext[] {
if (wikilinkMappings.length === 0 && selections.length === 0 && !activeNote) return [];
return [
{
key: OBSIDIAN_CONTEXT_ADDITIONAL_CONTEXT_KEY,
kind: "untrusted",
value: obsidianContextValue(references),
value: obsidianContextValue(wikilinkMappings, selections, activeNote),
},
];
}
function obsidianContextValue(references: readonly ObsidianReference[]): string {
const excerpts = references.filter((reference): reference is ObsidianReference & { excerpt: string } => reference.excerpt !== undefined);
function obsidianContextValue(
wikilinkMappings: readonly string[],
selections: readonly SelectionContextReference[],
activeNote: ActiveNoteContextReference | null,
): string {
const sections: string[][] = [];
if (wikilinkMappings.length > 0) sections.push(["Resolved wikilinks:", ...wikilinkMappings]);
if (selections.length > 0) {
sections.push(["Referenced selections:", ...selectionReferenceLines(selections), "", ...selectionBodyLines(selections)]);
}
if (activeNote) sections.push(activeNoteContextLines(activeNote));
return [
"Obsidian references for the current user input:",
...references.map(referenceLine),
...(excerpts.length > 0 ? ["", "Inline excerpts:", ...excerptLines(excerpts)] : []),
"Obsidian context for the current user input:",
...sections.flatMap((section, index) => (index === 0 ? section : ["", ...section])),
].join("\n");
}
function referenceLine(reference: ObsidianReference): string {
return `- ${reference.marker} -> ${reference.path}${reference.excerpt === undefined ? "" : " (inline excerpt below)"}`;
}
function excerptLines(references: readonly (ObsidianReference & { excerpt: string })[]): string[] {
return references.flatMap((reference, index) => {
const prefix = index === 0 ? [] : [""];
return [...prefix, `${reference.marker}:`, reference.excerpt];
function selectionReferenceLines(selections: readonly SelectionContextReference[]): string[] {
return selections.map((selection) => {
const location = `${selection.path} ${formatComposerContextRange(selection.range)}`;
return `- ${selectionContextReferenceMarker(selection)} -> ${location}`;
});
}
function parsedSkillReferences(text: string, codeRanges: readonly MarkdownCodeRange[]): string[] {
function selectionBodyLines(selections: readonly SelectionContextReference[]): string[] {
return selections.flatMap((selection, index) => {
const prefix = index === 0 ? [] : [""];
return [...prefix, `${selectionContextReferenceMarker(selection)}:`, selection.text];
});
}
function activeNoteContextLines(activeNote: ActiveNoteContextReference): string[] {
return ["Referenced active file:", `- ${ACTIVE_FILE_MENTION_NAME} -> ${activeNote.path}`];
}
function parsedSkillReferences(text: string): string[] {
const references: string[] = [];
const seen = new Set<string>();
for (const match of text.matchAll(SKILL_REFERENCE_PATTERN)) {
const prefix = match[1] ?? "";
if (markdownCodeRangeContainsOffset(codeRanges, match.index + prefix.length)) continue;
const name = match[2];
if (name === undefined) continue;
const key = name.toLowerCase();
@ -224,19 +205,6 @@ function parsedSkillReferences(text: string, codeRanges: readonly MarkdownCodeRa
return references;
}
function markdownReferenceCodeRanges(text: string): MarkdownCodeRange[] {
return text.includes("[[") || text.includes("$") ? markdownCodeRanges(text) : [];
}
function includesOutsideMarkdownCode(text: string, value: string, codeRanges: readonly MarkdownCodeRange[]): boolean {
let index = text.indexOf(value);
while (index >= 0) {
if (!markdownCodeRangeContainsOffset(codeRanges, index)) return true;
index = text.indexOf(value, index + value.length);
}
return false;
}
function parseWikiLink(raw: string): ParsedWikiLink | null {
const trimmed = raw.trim();
if (!trimmed) return null;

View file

@ -7,23 +7,22 @@ const STATUS_CONNECTION_STARTING = "Starting Codex app-server...";
const STATUS_CONNECTED = "Connected.";
const STATUS_CONNECTION_FAILED = "Connection failed.";
interface ChatConnectionAdapter {
export interface ChatConnectionAdapter {
connect(): Promise<ServerInitialization>;
isConnected(): boolean;
}
interface ChatConnectionMetadataActions {
export interface ChatConnectionMetadataActions {
refreshAppServerMetadata: () => Promise<unknown>;
}
interface ChatConnectionDiagnosticsActions {
export interface ChatConnectionDiagnosticsActions {
refreshServerDiagnostics: (options?: { appServerMetadataSnapshot?: boolean; forceResourceProbes?: boolean }) => Promise<void>;
}
export interface ChatConnectionActionsHost {
stateStore: ChatStateStore;
connection: ChatConnectionAdapter;
canConnect: () => boolean;
metadata: ChatConnectionMetadataActions;
diagnostics: ChatConnectionDiagnosticsActions;
invalidateThreadWork: () => void;
@ -35,14 +34,15 @@ export interface ChatConnectionActionsHost {
setStatus: (statusText: string, phase?: ChatConnectionPhase) => void;
addSystemMessage: (text: string) => void;
configuredCommand: () => string;
refreshLiveState: () => void;
isStaleConnectionError: (error: unknown) => boolean;
isStaleRuntimeError: (error: unknown) => boolean;
isStaleSharedQueryError: (error: unknown) => boolean;
notifyConnectionFailed: () => void;
}
type ChatConnectionExitHost = Pick<
ChatConnectionActionsHost,
"invalidateThreadWork" | "setStatus" | "stateStore" | "resetThreadTurnPresence"
"invalidateThreadWork" | "setStatus" | "stateStore" | "resetThreadTurnPresence" | "refreshLiveState"
>;
function handleChatConnectionExit(host: ChatConnectionExitHost): void {
@ -50,10 +50,10 @@ function handleChatConnectionExit(host: ChatConnectionExitHost): void {
host.setStatus(STATUS_CONNECTION_STOPPED, { kind: "disconnected", message: STATUS_CONNECTION_STOPPED });
host.stateStore.dispatch({ type: "connection/scoped-cleared" });
host.resetThreadTurnPresence(false);
host.refreshLiveState();
}
export interface ChatConnectionActions {
ensureInitialized(): Promise<void>;
ensureConnected(): Promise<void>;
invalidate(): void;
handleExit(): void;
@ -64,40 +64,28 @@ export interface ChatConnectionActions {
export function createChatConnectionActions(host: ChatConnectionActionsHost): ChatConnectionActions {
let generation = 0;
let activeConnection: { generation: number; initialization: Promise<void>; hydration: Promise<void> } | null = null;
let activeConnection: { generation: number; promise: Promise<void> } | null = null;
const invalidate = (): void => {
generation += 1;
activeConnection = null;
};
const isStale = (candidateGeneration: number): boolean => candidateGeneration !== generation;
const startConnection = (): NonNullable<typeof activeConnection> => {
const connectionGeneration = generation;
const connectionIsStale = (): boolean => isStale(connectionGeneration);
const initialization = initializeConnection(host, connectionIsStale);
const hydration = initialization.then(async () => {
if (connectionIsStale() || !host.connection.isConnected()) return;
await hydrateConnectedResources(host, connectionIsStale);
});
const active = { generation: connectionGeneration, initialization, hydration };
activeConnection = active;
const clear = (): void => {
if (activeConnection === active) activeConnection = null;
};
void hydration.then(clear, clear);
return active;
};
const actions: ChatConnectionActions = {
ensureInitialized: async () => {
if (!host.canConnect()) return;
if (activeConnection) return activeConnection.initialization;
if (host.connection.isConnected()) return;
await startConnection().initialization;
},
ensureConnected: async () => {
if (!host.canConnect()) return;
if (activeConnection) return activeConnection.hydration;
if (activeConnection) return activeConnection.promise;
if (host.connection.isConnected()) return;
await startConnection().hydration;
const connectionGeneration = generation;
const promise = initializeConnection(host, () => isStale(connectionGeneration));
const active = { generation: connectionGeneration, promise };
activeConnection = active;
try {
await promise;
} finally {
if (activeConnection === active) {
activeConnection = null;
}
}
},
invalidate,
handleExit: () => {
@ -117,7 +105,7 @@ async function refreshActiveThreads(host: ChatConnectionActionsHost): Promise<vo
await host.refreshSharedThreads();
host.refreshTabHeader();
} catch (error) {
if (host.isStaleRuntimeError(error)) return;
if (host.isStaleSharedQueryError(error)) return;
host.addSystemMessage(error instanceof Error ? error.message : String(error));
}
}
@ -155,20 +143,22 @@ async function initializeConnection(host: ChatConnectionActionsHost, isStale: ()
} catch (error) {
if (isStale()) return;
if (host.isStaleConnectionError(error)) return;
if (host.isStaleRuntimeError(error)) return;
if (host.isStaleSharedQueryError(error)) return;
const message = connectionErrorMessage(error, host.configuredCommand());
host.setStatus(STATUS_CONNECTION_FAILED, { kind: "failed", message });
host.addSystemMessage(message);
host.notifyConnectionFailed();
return;
}
await hydrateConnectedResources(host, isStale);
}
async function hydrateConnectedResources(host: ChatConnectionActionsHost, isStale: () => boolean): Promise<void> {
try {
await host.metadata.refreshAppServerMetadata();
} catch (error) {
if (isStale() || host.isStaleRuntimeError(error)) return;
if (isStale() || host.isStaleSharedQueryError(error)) return;
host.addSystemMessage(`Could not refresh Codex metadata: ${errorMessage(error)}`);
}
if (isStale()) return;
@ -176,7 +166,7 @@ async function hydrateConnectedResources(host: ChatConnectionActionsHost, isStal
try {
await host.refreshSharedThreads();
} catch (error) {
if (isStale() || host.isStaleRuntimeError(error)) return;
if (isStale() || host.isStaleSharedQueryError(error)) return;
host.addSystemMessage(`Could not refresh Codex threads: ${errorMessage(error)}`);
}
if (isStale()) return;

View file

@ -1,49 +1,42 @@
import { capturePanelTargetLease, panelTargetLeaseIsCurrent } from "../state/panel-target";
import { activeThreadId, activeThreadState, type ChatConnectionPhase, panelThreadId } from "../state/root-reducer";
import type { ChatConnectionPhase } from "../state/root-reducer";
import type { ChatStateStore } from "../state/store";
const STATUS_RECONNECTING = "Reconnecting...";
export interface ChatReconnectActionsHost {
stateStore: ChatStateStore;
resetConnectionScope: () => void;
invalidateConnectionWork: () => void;
invalidateThreadWork: () => void;
clearDeferredDiagnostics: () => void;
resetConnection: () => void;
setStatus: (statusText: string, phase?: ChatConnectionPhase) => void;
ensureConnected: () => Promise<void>;
isConnected: () => boolean;
resumeThread: (threadId: string) => Promise<boolean>;
resumeThread: (threadId: string) => Promise<void>;
addSystemMessage: (text: string) => void;
}
export function createReconnectPanelAction(host: ChatReconnectActionsHost): () => Promise<boolean> {
let activeReconnect: Promise<boolean> | null = null;
return async () => {
if (activeReconnect) return activeReconnect;
const operation = reconnectPanel(host);
activeReconnect = operation;
try {
return await operation;
} finally {
if (activeReconnect === operation) activeReconnect = null;
}
};
}
async function reconnectPanel(host: ChatReconnectActionsHost): Promise<boolean> {
const currentState = host.stateStore.getState();
const panelTarget = capturePanelTargetLease(currentState);
const threadId = activeThreadState(currentState)?.lifetime?.kind === "ephemeral" ? null : panelThreadId(currentState);
export async function reconnectPanel(
host: ChatReconnectActionsHost,
target: { resumeThreadId: string | null; isCurrent?: () => boolean } | null = null,
): Promise<boolean> {
const threadId = target ? target.resumeThreadId : host.stateStore.getState().activeThread.id;
const isCurrent = target?.isCurrent ?? (() => true);
host.stateStore.dispatch({ type: "ui/panel-set", panel: null });
host.resetConnectionScope();
host.invalidateConnectionWork();
host.invalidateThreadWork();
host.clearDeferredDiagnostics();
host.resetConnection();
host.stateStore.dispatch({ type: "connection/scoped-cleared" });
host.setStatus(STATUS_RECONNECTING, { kind: "connecting" });
await host.ensureConnected();
if (!host.isConnected() || !panelTargetLeaseIsCurrent(host.stateStore.getState(), panelTarget)) return false;
if (!isCurrent() || !host.isConnected()) return false;
if (!threadId) return true;
try {
if (!(await host.resumeThread(threadId))) return false;
if (!panelTargetLeaseIsCurrent(host.stateStore.getState(), panelTarget)) return false;
return activeThreadId(host.stateStore.getState()) === threadId;
await host.resumeThread(threadId);
if (!isCurrent()) return false;
return host.stateStore.getState().activeThread.id === threadId;
} catch (error) {
host.addSystemMessage(error instanceof Error ? error.message : String(error));
return false;

View file

@ -6,7 +6,6 @@ import {
replaceMcpServerStatusDiagnostics,
} from "../../../../domain/server/diagnostics";
import type { SharedServerMetadata } from "../../../../domain/server/metadata";
import { activeThreadId } from "../state/root-reducer";
import type { ChatStateStore } from "../state/store";
import type { ServerDiagnosticsTransport } from "./metadata-transport";
@ -46,7 +45,7 @@ async function refreshServerDiagnostics(
): Promise<boolean> {
const initialDiagnostics = currentPanelDiagnostics(host);
const state = host.stateStore.getState();
const threadId = activeThreadId(state);
const activeThreadId = state.activeThread.id;
const metadataSnapshot = host.appServerMetadataSnapshot();
const cachedSkills =
options.forceResourceProbes === true ? undefined : (metadataSnapshot?.availableSkills ?? state.connection.availableSkills);
@ -55,7 +54,7 @@ async function refreshServerDiagnostics(
? undefined
: (metadataSnapshot?.serverDiagnostics.probes.skills ?? state.connection.serverDiagnostics.probes.skills);
const request = {
threadId,
threadId: activeThreadId,
initialDiagnostics,
forceResourceProbes: options.forceResourceProbes === true,
appServerMetadataSnapshot: options.appServerMetadataSnapshot === true,
@ -63,7 +62,7 @@ async function refreshServerDiagnostics(
...(cachedSkillsProbe !== undefined ? { cachedSkillsProbe } : {}),
};
const snapshot = await host.diagnosticsTransport.readServerDiagnostics(request);
if (!snapshot || !isCurrent() || activeThreadId(host.stateStore.getState()) !== threadId) return false;
if (!snapshot || !isCurrent() || host.stateStore.getState().activeThread.id !== activeThreadId) return false;
let diagnostics = currentPanelDiagnostics(host);
for (const probe of snapshot.resourceProbes) {

View file

@ -1,11 +1,10 @@
import {
cloneServerDiagnostics,
diagnosticsWithMetadataResourceProbes,
diagnosticsWithProbe,
upsertMcpServerDiagnostic,
} from "../../../../domain/server/diagnostics";
import type { McpServerStartupStatus } from "../../../../domain/server/mcp-status";
import type { SharedServerMetadata, SharedServerMetadataResource } from "../../../../domain/server/metadata";
import type { SharedServerMetadata } from "../../../../domain/server/metadata";
import type { ChatStateStore } from "../state/store";
export type AppServerResourceEvent =
@ -16,22 +15,22 @@ export type AppServerResourceEvent =
export interface ServerMetadataActionsHost {
stateStore: ChatStateStore;
appServerMetadataSnapshot: () => SharedServerMetadata | null;
refreshAppServerMetadata: () => Promise<void>;
refreshSkills: () => Promise<void>;
refreshRateLimits: () => Promise<void>;
isStaleRuntimeError: (error: unknown) => boolean;
refreshAppServerMetadata: () => Promise<SharedServerMetadata | null>;
refreshSkills: () => Promise<SharedServerMetadata | null>;
refreshRateLimits: () => Promise<SharedServerMetadata | null>;
isStaleSharedQueryError: (error: unknown) => boolean;
}
export interface ServerMetadataActions {
applyAppServerMetadataResource: (resource: SharedServerMetadataResource) => void;
refreshAppServerMetadata: () => Promise<void>;
applyAppServerMetadata: (metadata: SharedServerMetadata) => void;
refreshAppServerMetadata: () => Promise<SharedServerMetadata | null>;
applyAppServerResourceEvent: (event: AppServerResourceEvent) => Promise<void>;
}
export function createServerMetadataActions(host: ServerMetadataActionsHost): ServerMetadataActions {
return {
applyAppServerMetadataResource: (resource) => {
applyAppServerMetadataResource(host, resource);
applyAppServerMetadata: (metadata) => {
applyAppServerMetadata(host, metadata);
},
refreshAppServerMetadata: () => refreshAppServerMetadata(host),
applyAppServerResourceEvent: async (event) => {
@ -57,65 +56,53 @@ async function applyAppServerResourceEvent(host: ServerMetadataActionsHost, even
if (event.name.length > 0) {
applyMcpStartupStatusEvent(host, event.name, event.status, event.message);
}
applyCurrentAppServerMetadataSnapshot(host);
return;
}
}
function applyAppServerMetadataResource(host: ServerMetadataActionsHost, resource: SharedServerMetadataResource): void {
if (resource.id === "runtimeConfig") {
if (resource.value) host.stateStore.dispatch({ type: "connection/metadata-applied", runtimeConfig: resource.value });
return;
}
const serverDiagnostics = diagnosticsWithProbe(
function applyAppServerMetadata(host: ServerMetadataActionsHost, metadata: SharedServerMetadata): void {
const serverDiagnostics = diagnosticsWithMetadataResourceProbes(
cloneServerDiagnostics(host.stateStore.getState().connection.serverDiagnostics),
resource.probe,
metadata.serverDiagnostics,
);
switch (resource.id) {
case "models":
host.stateStore.dispatch({
type: "connection/metadata-applied",
...(resource.value ? { availableModels: resource.value } : {}),
serverDiagnostics,
});
return;
case "skills":
host.stateStore.dispatch({
type: "connection/metadata-applied",
...(resource.value ? { availableSkills: resource.value } : {}),
serverDiagnostics,
});
return;
case "permissionProfiles":
host.stateStore.dispatch({
type: "connection/metadata-applied",
...(resource.value ? { availablePermissionProfiles: resource.value } : {}),
serverDiagnostics,
});
return;
case "rateLimits":
host.stateStore.dispatch({
type: "connection/metadata-applied",
...(resource.value !== undefined ? { rateLimit: resource.value } : {}),
serverDiagnostics,
});
return;
}
host.stateStore.dispatch({
type: "connection/metadata-applied",
runtimeConfig: metadata.runtimeConfig,
availableSkills: metadata.availableSkills,
availablePermissionProfiles: metadata.availablePermissionProfiles,
rateLimit: metadata.rateLimit,
serverDiagnostics,
});
}
async function refreshAppServerMetadata(host: ServerMetadataActionsHost): Promise<void> {
async function refreshAppServerMetadata(host: ServerMetadataActionsHost): Promise<SharedServerMetadata | null> {
let metadata: SharedServerMetadata | null;
try {
await host.refreshAppServerMetadata();
metadata = await host.refreshAppServerMetadata();
} catch (error) {
if (host.isStaleRuntimeError(error)) return;
if (host.isStaleSharedQueryError(error)) return null;
throw error;
}
if (!metadata) return null;
applyAppServerMetadata(host, metadata);
return metadata;
}
async function refreshMetadataResource(host: ServerMetadataActionsHost, refresh: () => Promise<void>): Promise<void> {
function applyCurrentAppServerMetadataSnapshot(host: ServerMetadataActionsHost): void {
const metadata = host.appServerMetadataSnapshot();
if (metadata) applyAppServerMetadata(host, metadata);
}
async function refreshMetadataResource(
host: ServerMetadataActionsHost,
refresh: () => Promise<SharedServerMetadata | null>,
): Promise<void> {
try {
await refresh();
const metadata = await refresh();
if (metadata) applyAppServerMetadata(host, metadata);
} catch (error) {
if (!host.isStaleRuntimeError(error)) throw error;
if (!host.isStaleSharedQueryError(error)) throw error;
}
}

View file

@ -1,14 +0,0 @@
export type EffectOutcome<T> =
| { readonly kind: "not-started" }
| { readonly kind: "completed-current"; readonly value: T }
| { readonly kind: "completed-stale"; readonly value: T };
export function effectCompleted<T>(outcome: EffectOutcome<T>): outcome is Exclude<EffectOutcome<T>, { kind: "not-started" }> {
return outcome.kind !== "not-started";
}
export function effectCompletedInCurrentContext<T>(
outcome: EffectOutcome<T>,
): outcome is Extract<EffectOutcome<T>, { kind: "completed-current" }> {
return outcome.kind === "completed-current";
}

View file

@ -1,135 +0,0 @@
import type { ChatState } from "./state/root-reducer";
/**
* Operations whose availability derives from the panel's current thread mode.
*
* This intentionally excludes connection, turn-busy, and operations aimed at a
* different listed thread. Those are workflow concerns rather than properties
* of the active panel thread.
*/
export type ActivePanelOperation =
| "submit"
| "start-side-chat"
| "compact"
| "fork"
| "rollback"
| "thread-settings"
| "permission-settings"
| "goal-read"
| "goal-mutation"
| "implement-plan";
export type ActivePanelOperationDecision =
| { readonly kind: "allowed" }
| { readonly kind: "blocked"; readonly message: string }
| { readonly kind: "resume-required" };
type ActivePanelThreadFacts =
| { readonly phase: "empty" }
| { readonly phase: "awaiting-resume"; readonly provenance: "interactive" | "subagent" | null }
| {
readonly phase: "active";
readonly lifetime: "persistent" | "ephemeral";
readonly canAcceptDirectInput: boolean | null;
readonly provenance: "interactive" | "subagent" | null;
};
const ALLOWED: ActivePanelOperationDecision = { kind: "allowed" };
export function activePanelOperationDecision(state: ChatState, operation: ActivePanelOperation): ActivePanelOperationDecision {
return activePanelOperationDecisionForFacts(activePanelThreadFacts(state), operation);
}
function activePanelOperationDecisionForFacts(
facts: ActivePanelThreadFacts,
operation: ActivePanelOperation,
): ActivePanelOperationDecision {
if (facts.phase === "awaiting-resume") {
if (facts.provenance === "subagent") return agentThreadBlocked(operation);
return operation === "goal-mutation" ? { kind: "resume-required" } : ALLOWED;
}
if (facts.phase === "empty") return ALLOWED;
if (operation === "submit") {
if (facts.canAcceptDirectInput !== null) return facts.canAcceptDirectInput ? ALLOWED : directInputBlocked();
if (facts.provenance === "subagent") return agentThreadBlocked(operation);
if (facts.lifetime === "ephemeral") return sideChatDecision(operation);
return ALLOWED;
}
// Keep the stricter interpretation if a malformed or future state contains
// both mode facts. This makes mode restrictions monotonically safe.
if (facts.provenance === "subagent" && facts.lifetime === "ephemeral") {
return operation === "goal-read" ? sideChatDecision(operation) : agentThreadBlocked(operation);
}
if (facts.provenance === "subagent") return agentThreadBlocked(operation);
if (facts.lifetime === "ephemeral") return sideChatDecision(operation);
return ALLOWED;
}
function activePanelThreadFacts(state: ChatState): ActivePanelThreadFacts {
const panelThread = state.panelThread;
if (panelThread.kind === "empty") return { phase: "empty" };
if (panelThread.kind === "awaiting-resume") {
return { phase: "awaiting-resume", provenance: panelThread.provenance?.kind ?? null };
}
return {
phase: "active",
lifetime: panelThread.thread.lifetime?.kind ?? "persistent",
canAcceptDirectInput: panelThread.thread.canAcceptDirectInput,
provenance: panelThread.thread.provenance?.kind ?? null,
};
}
function directInputBlocked(): ActivePanelOperationDecision {
return { kind: "blocked", message: "This thread cannot accept messages." };
}
function agentThreadBlocked(operation: ActivePanelOperation): ActivePanelOperationDecision {
switch (operation) {
case "submit":
return { kind: "blocked", message: "Messages are unavailable in agent threads. Start a new chat to continue." };
case "goal-mutation":
return { kind: "blocked", message: "Goals are read-only in agent threads." };
case "goal-read":
return ALLOWED;
case "implement-plan":
return { kind: "blocked", message: "Plans cannot be implemented from agent threads." };
case "compact":
return { kind: "blocked", message: "Agent threads cannot be compacted." };
case "fork":
return { kind: "blocked", message: "Agent threads cannot be forked." };
case "rollback":
return { kind: "blocked", message: "Agent threads cannot be rolled back." };
case "start-side-chat":
return { kind: "blocked", message: "Side chats cannot be started from agent threads." };
case "thread-settings":
case "permission-settings":
return { kind: "blocked", message: "Thread settings are unavailable in agent threads." };
}
}
function sideChatDecision(operation: ActivePanelOperation): ActivePanelOperationDecision {
switch (operation) {
case "goal-mutation":
return { kind: "blocked", message: "Goals are unavailable in side chats." };
case "goal-read":
return { kind: "blocked", message: "Goals are unavailable in side chats." };
case "implement-plan":
return { kind: "blocked", message: "Plans cannot be implemented from side chats." };
case "compact":
return ALLOWED;
case "fork":
return { kind: "blocked", message: "Side chats cannot be forked." };
case "rollback":
return { kind: "blocked", message: "Side chats cannot be rolled back." };
case "start-side-chat":
return { kind: "blocked", message: "Side chats cannot be started from another side chat." };
case "permission-settings":
return { kind: "blocked", message: "Permission changes are unavailable in side chats." };
case "submit":
return ALLOWED;
case "thread-settings":
return { kind: "blocked", message: "Thread settings are unavailable in side chats." };
}
}

View file

@ -22,6 +22,7 @@ export interface PendingRequestActionsHost {
responder: PendingRequestResponder;
composerHasFocus: () => boolean;
focusComposer: () => void;
refreshLiveState: () => void;
}
export interface PendingRequestActions {
@ -120,5 +121,6 @@ function pendingUserInput(host: PendingRequestActionsHost, requestId: PendingReq
}
function commitRequestAction(host: PendingRequestActionsHost): void {
host.refreshLiveState();
host.focusComposer();
}

View file

@ -1,7 +1,6 @@
import type { ReasoningEffort } from "../../../../domain/catalog/metadata";
import { type RuntimeConfigSnapshot, runtimeConfigOrDefault } from "../../../../domain/runtime/config";
import type { RuntimeSettingsPatch } from "../../../../domain/runtime/thread-settings";
import { createKeyedOperationQueue, type KeyedOperationQueue } from "../../../../shared/runtime/keyed-operation-queue";
import { type CollaborationModeSelection, nextCollaborationMode, type RequestedFastMode } from "../../domain/runtime/intent";
import { modelOverrideMessage, reasoningEffortOverrideMessage } from "../../domain/runtime/labels";
import { resolveRuntimeControls } from "../../domain/runtime/resolution";
@ -10,9 +9,7 @@ import {
pendingRuntimeSettingsPatch as buildPendingRuntimeSettingsPatch,
type PendingRuntimeSettingsPatch,
} from "../../domain/runtime/thread-settings-patch";
import { type ActivePanelOperation, activePanelOperationDecision } from "../panel-operation-policy";
import { capturePanelTargetLease, panelTargetLeaseIsCurrent } from "../state/panel-target";
import { activeThreadId, type ChatAction, type ChatState } from "../state/root-reducer";
import type { ChatAction, ChatState } from "../state/root-reducer";
import type { ChatStateStore } from "../state/store";
import type { RuntimeSettingsTransport } from "./settings-transport";
@ -32,15 +29,6 @@ export interface RuntimeSettingsActionsHost {
addSystemMessage: (text: string) => void;
}
interface RuntimeSettingsActionsContext extends RuntimeSettingsActionsHost {
threadCommits: KeyedOperationQueue<string>;
}
interface RuntimeSettingsCommandScope {
readonly threadId: string;
readonly panelTargetRevision: number;
}
export interface ChatRuntimeSettingsActions {
applyPendingThreadSettings: () => Promise<boolean>;
requestModel: (model: string) => Promise<boolean>;
@ -63,215 +51,152 @@ export interface ChatRuntimeSettingsActions {
toggleAutoReview: () => Promise<void>;
}
export function createChatRuntimeSettingsActions(
host: RuntimeSettingsActionsHost,
threadCommits: KeyedOperationQueue<string> = createKeyedOperationQueue(),
): ChatRuntimeSettingsActions {
const context: RuntimeSettingsActionsContext = { ...host, threadCommits };
export function createChatRuntimeSettingsActions(host: RuntimeSettingsActionsHost): ChatRuntimeSettingsActions {
return {
applyPendingThreadSettings: () => applyPendingThreadSettings(context),
requestModel: (model) => requestModel(context, model),
resetModelToConfig: () => resetModelToConfig(context),
requestModelFromUi: (model) => requestModelFromUi(context, model),
requestReasoningEffort: (effort) => requestReasoningEffort(context, effort),
resetReasoningEffortToConfig: () => resetReasoningEffortToConfig(context),
requestReasoningEffortFromUi: (effort) => requestReasoningEffortFromUi(context, effort),
resetReasoningEffortToConfigFromUi: () => resetReasoningEffortToConfigFromUi(context),
requestPermissionProfile: (permissionProfile) => requestPermissionProfile(context, permissionProfile),
resetPermissionProfileToConfig: () => resetPermissionProfileToConfig(context),
enableFastMode: () => setFastMode(context, "enabled"),
disableFastMode: () => setFastMode(context, "disabled"),
toggleFastMode: () => toggleFastMode(context),
toggleCollaborationMode: () => toggleCollaborationMode(context),
setCollaborationMode: (collaborationMode) => setCollaborationMode(context, collaborationMode),
applyPendingThreadSettings: () => applyPendingThreadSettings(host),
requestModel: (model) => requestModel(host, model),
resetModelToConfig: () => resetModelToConfig(host),
requestModelFromUi: (model) => requestModelFromUi(host, model),
requestReasoningEffort: (effort) => requestReasoningEffort(host, effort),
resetReasoningEffortToConfig: () => resetReasoningEffortToConfig(host),
requestReasoningEffortFromUi: (effort) => requestReasoningEffortFromUi(host, effort),
resetReasoningEffortToConfigFromUi: () => resetReasoningEffortToConfigFromUi(host),
requestPermissionProfile: (permissionProfile) => requestPermissionProfile(host, permissionProfile),
resetPermissionProfileToConfig: () => resetPermissionProfileToConfig(host),
enableFastMode: () => setFastMode(host, "enabled"),
disableFastMode: () => setFastMode(host, "disabled"),
toggleFastMode: () => toggleFastMode(host),
toggleCollaborationMode: () => toggleCollaborationMode(host),
setCollaborationMode: (collaborationMode) => setCollaborationMode(host, collaborationMode),
requestDefaultCollaborationModeForNextTurn: () => {
requestDefaultCollaborationModeForNextTurn(context);
requestDefaultCollaborationModeForNextTurn(host);
},
enableAutoReview: () => setAutoReview(context, "enabled"),
disableAutoReview: () => setAutoReview(context, "disabled"),
toggleAutoReview: () => toggleAutoReview(context),
enableAutoReview: () => setAutoReview(host, "enabled"),
disableAutoReview: () => setAutoReview(host, "disabled"),
toggleAutoReview: () => toggleAutoReview(host),
};
}
async function applyPendingThreadSettings(
host: RuntimeSettingsActionsContext,
fields?: readonly (keyof RuntimeSettingsPatch)[],
): Promise<boolean> {
return (await commitPendingThreadSettings(host, fields)).ok;
async function applyPendingThreadSettings(host: RuntimeSettingsActionsHost): Promise<boolean> {
return (await commitPendingThreadSettings(host)).ok;
}
async function commitPendingThreadSettings(
host: RuntimeSettingsActionsContext,
fields?: readonly (keyof RuntimeSettingsPatch)[],
): Promise<RuntimeSettingsCommitResult> {
const threadId = activeThreadId(state(host));
async function commitPendingThreadSettings(host: RuntimeSettingsActionsHost): Promise<RuntimeSettingsCommitResult> {
const threadId = state(host).activeThread.id;
if (!threadId) return { ok: true, collaborationModeApplied: true };
const panelTarget = capturePanelTargetLease(state(host));
const { update, collaborationModeWarning } = pendingRuntimeSettingsPatch(host);
if (collaborationModeWarning) reportCollaborationModeWarning(host, collaborationModeWarning);
const collaborationModeApplied = !collaborationModeWarning && "collaborationMode" in update;
if (Object.keys(update).length === 0) return { ok: true, collaborationModeApplied };
if (activePanelOperationBlocked(host, "thread-settings")) return { ok: false, collaborationModeApplied: false };
const scope = { threadId, panelTargetRevision: panelTarget.revision };
const ok = fields
? await commitRuntimeSettingsFields(host, scope, pickRuntimeSettingsFields(update, fields))
: await settleRuntimeSettings(host, scope);
return { ok, collaborationModeApplied: ok && collaborationModeApplied };
}
function commitRuntimeSettingsFields(
host: RuntimeSettingsActionsContext,
scope: RuntimeSettingsCommandScope,
update: RuntimeSettingsPatch,
): Promise<boolean> {
if (patchEmpty(update)) return Promise.resolve(true);
const command = { ...update };
return host.threadCommits.run(scope.threadId, async () => {
if (!runtimeSettingsScopeIsCurrent(host, scope)) return false;
if (!patchEqual(matchingPendingPatch(currentPendingRuntimeSettingsPatch(host), command), command)) return false;
const updated = await updateRuntimeSettings(host, scope, command);
if (!updated || !runtimeSettingsScopeIsCurrent(host, scope)) return false;
const committed = matchingPendingPatch(currentPendingRuntimeSettingsPatch(host), command);
if (!patchEmpty(committed)) commitRuntimeSettingsPatch(host, committed);
return patchEqual(committed, command);
});
}
async function settleRuntimeSettings(host: RuntimeSettingsActionsContext, scope: RuntimeSettingsCommandScope): Promise<boolean> {
while (runtimeSettingsScopeIsCurrent(host, scope)) {
const result = await host.threadCommits.run(scope.threadId, async (): Promise<"continue" | "failed" | "settled"> => {
if (!runtimeSettingsScopeIsCurrent(host, scope)) return "failed";
const update = currentPendingRuntimeSettingsPatch(host);
if (patchEmpty(update)) return "settled";
if (!(await updateRuntimeSettings(host, scope, update)) || !runtimeSettingsScopeIsCurrent(host, scope)) return "failed";
const committed = matchingPendingPatch(currentPendingRuntimeSettingsPatch(host), update);
if (!patchEmpty(committed)) commitRuntimeSettingsPatch(host, committed);
return patchEmpty(currentPendingRuntimeSettingsPatch(host)) ? "settled" : "continue";
});
if (result !== "continue") return result === "settled";
}
return false;
}
async function updateRuntimeSettings(
host: RuntimeSettingsActionsContext,
scope: RuntimeSettingsCommandScope,
update: RuntimeSettingsPatch,
): Promise<boolean> {
try {
return await host.runtimeTransport.updateThreadSettings(scope.threadId, update);
} catch (error) {
if (runtimeSettingsScopeIsCurrent(host, scope)) {
host.addSystemMessage(error instanceof Error ? error.message : String(error));
if (!(await host.runtimeTransport.updateThreadSettings(threadId, update))) {
return { ok: false, collaborationModeApplied: false };
}
return false;
if (state(host).activeThread.id !== threadId) return { ok: false, collaborationModeApplied: false };
if (!runtimeSettingsPatchStillPending(currentPendingRuntimeSettingsPatch(host), update)) {
return { ok: false, collaborationModeApplied: false };
}
dispatch(host, { type: "runtime/pending-thread-settings-committed", update });
return { ok: true, collaborationModeApplied };
} catch (error) {
if (state(host).activeThread.id !== threadId) return { ok: false, collaborationModeApplied: false };
host.addSystemMessage(error instanceof Error ? error.message : String(error));
return { ok: false, collaborationModeApplied: false };
}
}
function runtimeSettingsScopeIsCurrent(host: RuntimeSettingsActionsHost, scope: RuntimeSettingsCommandScope): boolean {
const currentState = state(host);
return (
activeThreadId(currentState) === scope.threadId &&
panelTargetLeaseIsCurrent(currentState, {
revision: scope.panelTargetRevision,
target: { kind: "thread", threadId: scope.threadId },
})
);
}
function commitRuntimeSettingsPatch(host: RuntimeSettingsActionsHost, update: RuntimeSettingsPatch): void {
dispatch(host, { type: "runtime/pending-thread-settings-committed", update });
}
async function requestModel(host: RuntimeSettingsActionsContext, model: string): Promise<boolean> {
if (activePanelOperationBlocked(host, "thread-settings")) return false;
async function requestModel(host: RuntimeSettingsActionsHost, model: string): Promise<boolean> {
if (agentThreadSettingsBlocked(host)) return false;
dispatch(host, { type: "runtime/model-requested", model });
return applyPendingThreadSettings(host, ["model"]);
return applyPendingThreadSettings(host);
}
async function resetModelToConfig(host: RuntimeSettingsActionsContext): Promise<boolean> {
if (activePanelOperationBlocked(host, "thread-settings")) return false;
async function resetModelToConfig(host: RuntimeSettingsActionsHost): Promise<boolean> {
if (agentThreadSettingsBlocked(host)) return false;
dispatch(host, { type: "runtime/model-reset-to-config" });
return applyPendingThreadSettings(host, ["model"]);
return applyPendingThreadSettings(host);
}
async function requestModelFromUi(host: RuntimeSettingsActionsContext, model: string): Promise<void> {
async function requestModelFromUi(host: RuntimeSettingsActionsHost, model: string): Promise<void> {
await runRuntimeUiCommand(host, () => requestModel(host, model), modelOverrideMessage(model));
}
async function requestReasoningEffort(host: RuntimeSettingsActionsContext, effort: ReasoningEffort): Promise<boolean> {
if (activePanelOperationBlocked(host, "thread-settings")) return false;
async function requestReasoningEffort(host: RuntimeSettingsActionsHost, effort: ReasoningEffort): Promise<boolean> {
if (agentThreadSettingsBlocked(host)) return false;
dispatch(host, { type: "runtime/reasoning-effort-requested", effort });
return applyPendingThreadSettings(host, ["effort"]);
return applyPendingThreadSettings(host);
}
async function resetReasoningEffortToConfig(host: RuntimeSettingsActionsContext): Promise<boolean> {
if (activePanelOperationBlocked(host, "thread-settings")) return false;
async function resetReasoningEffortToConfig(host: RuntimeSettingsActionsHost): Promise<boolean> {
if (agentThreadSettingsBlocked(host)) return false;
dispatch(host, { type: "runtime/reasoning-effort-reset-to-config" });
return applyPendingThreadSettings(host, ["effort"]);
return applyPendingThreadSettings(host);
}
async function requestReasoningEffortFromUi(host: RuntimeSettingsActionsContext, effort: ReasoningEffort): Promise<void> {
async function requestReasoningEffortFromUi(host: RuntimeSettingsActionsHost, effort: ReasoningEffort): Promise<void> {
await runRuntimeUiCommand(host, () => requestReasoningEffort(host, effort), reasoningEffortOverrideMessage(effort));
}
async function resetReasoningEffortToConfigFromUi(host: RuntimeSettingsActionsContext): Promise<void> {
async function resetReasoningEffortToConfigFromUi(host: RuntimeSettingsActionsHost): Promise<void> {
await runRuntimeUiCommand(host, () => resetReasoningEffortToConfig(host), reasoningEffortOverrideMessage(null));
}
async function requestPermissionProfile(host: RuntimeSettingsActionsContext, permissionProfile: string): Promise<boolean> {
if (activePanelOperationBlocked(host, "permission-settings")) return false;
async function requestPermissionProfile(host: RuntimeSettingsActionsHost, permissionProfile: string): Promise<boolean> {
if (agentThreadSettingsBlocked(host)) return false;
if (permissionChangesBlocked(host)) return false;
dispatch(host, { type: "runtime/permission-profile-requested", permissionProfile });
return applyPendingThreadSettings(host, ["permissions"]);
return applyPendingThreadSettings(host);
}
async function resetPermissionProfileToConfig(host: RuntimeSettingsActionsContext): Promise<boolean> {
if (activePanelOperationBlocked(host, "permission-settings")) return false;
async function resetPermissionProfileToConfig(host: RuntimeSettingsActionsHost): Promise<boolean> {
if (agentThreadSettingsBlocked(host)) return false;
if (permissionChangesBlocked(host)) return false;
dispatch(host, { type: "runtime/permission-profile-reset-to-config" });
return applyPendingThreadSettings(host, ["permissions"]);
return applyPendingThreadSettings(host);
}
function activePanelOperationBlocked(host: RuntimeSettingsActionsHost, operation: ActivePanelOperation): boolean {
const decision = activePanelOperationDecision(state(host), operation);
if (decision.kind !== "blocked") return false;
host.addSystemMessage(decision.message);
function permissionChangesBlocked(host: RuntimeSettingsActionsHost): boolean {
if (state(host).activeThread.lifetime?.kind !== "ephemeral") return false;
host.addSystemMessage("Permission changes are unavailable in side chats.");
return true;
}
async function toggleFastMode(host: RuntimeSettingsActionsContext): Promise<void> {
function agentThreadSettingsBlocked(host: RuntimeSettingsActionsHost): boolean {
if (state(host).activeThread.provenance?.kind !== "subagent") return false;
host.addSystemMessage("Thread settings are unavailable in agent threads.");
return true;
}
async function toggleFastMode(host: RuntimeSettingsActionsHost): Promise<void> {
const { snapshot, config } = runtimeProjection(host);
await setFastMode(host, resolveRuntimeControls(snapshot, config).fastMode.active ? "disabled" : "enabled");
}
async function setFastMode(host: RuntimeSettingsActionsContext, mode: FastModeState): Promise<void> {
if (activePanelOperationBlocked(host, "thread-settings")) return;
async function setFastMode(host: RuntimeSettingsActionsHost, mode: FastModeState): Promise<void> {
if (agentThreadSettingsBlocked(host)) return;
const fastMode: RequestedFastMode = mode;
await runRuntimeUiCommand(
host,
async () => {
dispatch(host, { type: "runtime/fast-mode-requested", fastMode });
return applyPendingThreadSettings(host, ["serviceTier"]);
return applyPendingThreadSettings(host);
},
mode === "enabled" ? "Fast mode on for subsequent turns." : "Fast mode off for subsequent turns.",
);
}
async function toggleCollaborationMode(host: RuntimeSettingsActionsContext): Promise<void> {
async function toggleCollaborationMode(host: RuntimeSettingsActionsHost): Promise<void> {
const { snapshot, config } = runtimeProjection(host);
const next = nextCollaborationMode(resolveRuntimeControls(snapshot, config).collaborationMode.effective);
await setCollaborationMode(host, next);
}
async function setCollaborationMode(host: RuntimeSettingsActionsContext, collaborationMode: CollaborationModeSelection): Promise<boolean> {
if (activePanelOperationBlocked(host, "thread-settings")) return false;
async function setCollaborationMode(host: RuntimeSettingsActionsHost, collaborationMode: CollaborationModeSelection): Promise<boolean> {
if (agentThreadSettingsBlocked(host)) return false;
dispatch(host, { type: "runtime/requested-collaboration-mode-set", collaborationMode });
const result = await commitPendingThreadSettings(host, ["collaborationMode"]);
const result = await commitPendingThreadSettings(host);
if (result.ok) closeRuntimePanel(host);
if (result.ok && result.collaborationModeApplied) {
host.addSystemMessage(collaborationMode === "plan" ? "Plan mode on for subsequent turns." : "Plan mode off for subsequent turns.");
@ -280,23 +205,23 @@ async function setCollaborationMode(host: RuntimeSettingsActionsContext, collabo
}
function requestDefaultCollaborationModeForNextTurn(host: RuntimeSettingsActionsHost): void {
if (activePanelOperationBlocked(host, "thread-settings")) return;
if (agentThreadSettingsBlocked(host)) return;
dispatch(host, { type: "runtime/requested-collaboration-mode-set", collaborationMode: "default" });
}
async function toggleAutoReview(host: RuntimeSettingsActionsContext): Promise<void> {
async function toggleAutoReview(host: RuntimeSettingsActionsHost): Promise<void> {
const { snapshot, config } = runtimeProjection(host);
const nextState = resolveRuntimeControls(snapshot, config).autoReview.active ? "disabled" : "enabled";
await setAutoReview(host, nextState);
}
async function setAutoReview(host: RuntimeSettingsActionsContext, mode: AutoReviewState): Promise<void> {
if (activePanelOperationBlocked(host, "thread-settings")) return;
async function setAutoReview(host: RuntimeSettingsActionsHost, mode: AutoReviewState): Promise<void> {
if (agentThreadSettingsBlocked(host)) return;
await runRuntimeUiCommand(
host,
async () => {
dispatch(host, { type: "runtime/approvals-reviewer-requested", approvalsReviewer: mode === "enabled" ? "auto_review" : "user" });
return applyPendingThreadSettings(host, ["approvalsReviewer"]);
return applyPendingThreadSettings(host);
},
mode === "enabled" ? "Auto-review on for subsequent turns." : "Auto-review off for subsequent turns.",
);
@ -336,36 +261,10 @@ function currentPendingRuntimeSettingsPatch(host: RuntimeSettingsActionsHost): R
return buildPendingRuntimeSettingsPatch(snapshot, config).update;
}
function pickRuntimeSettingsFields(update: RuntimeSettingsPatch, fields: readonly (keyof RuntimeSettingsPatch)[]): RuntimeSettingsPatch {
const targetedUpdate: RuntimeSettingsPatch = {};
for (const key of fields) {
if (key in update) Object.assign(targetedUpdate, { [key]: update[key] });
}
return targetedUpdate;
}
function matchingPendingPatch(current: RuntimeSettingsPatch, sent: RuntimeSettingsPatch): RuntimeSettingsPatch {
const matching: RuntimeSettingsPatch = {};
for (const key of patchKeys(sent)) {
if (key in current && threadSettingsValueEqual(current[key], sent[key])) {
Object.assign(matching, { [key]: sent[key] });
}
}
return matching;
}
function patchEqual(left: RuntimeSettingsPatch, right: RuntimeSettingsPatch): boolean {
const leftKeys = patchKeys(left);
const rightKeys = patchKeys(right);
return leftKeys.length === rightKeys.length && leftKeys.every((key) => key in right && threadSettingsValueEqual(left[key], right[key]));
}
function patchEmpty(patch: RuntimeSettingsPatch): boolean {
return patchKeys(patch).length === 0;
}
function patchKeys(patch: RuntimeSettingsPatch): (keyof RuntimeSettingsPatch)[] {
return Object.keys(patch) as (keyof RuntimeSettingsPatch)[];
function runtimeSettingsPatchStillPending(current: RuntimeSettingsPatch, applied: RuntimeSettingsPatch): boolean {
return (Object.keys(applied) as (keyof RuntimeSettingsPatch)[]).every((key) => {
return key in current && threadSettingsValueEqual(current[key], applied[key]);
});
}
function threadSettingsValueEqual(left: unknown, right: unknown): boolean {

View file

@ -1,13 +1,12 @@
import type { ThreadTokenUsage } from "../../../../domain/runtime/metrics";
import type { RuntimeSnapshot } from "../../domain/runtime/snapshot";
import { activeThreadRuntimeState, pendingRuntimeIntentState } from "../../domain/runtime/state";
import type { ThreadStreamItem } from "../../domain/thread-stream/items";
import { activeThreadState, type ChatState } from "../state/root-reducer";
import type { ChatState } from "../state/root-reducer";
import { threadStreamItems } from "../state/thread-stream";
interface RuntimeSnapshotInput {
runtimeConfig: ChatState["connection"]["runtimeConfig"];
activeThread: { id: string | null; tokenUsage: ThreadTokenUsage | null };
activeThread: Pick<ChatState["activeThread"], "id" | "tokenUsage">;
runtime: ChatState["runtime"];
rateLimit: ChatState["connection"]["rateLimit"];
hasThreadTurns: boolean;
@ -34,10 +33,9 @@ export function runtimeSnapshotForChatSlices(input: RuntimeSnapshotInput): Runti
}
export function runtimeSnapshotForChatState(state: ChatState): RuntimeSnapshot {
const activeThread = activeThreadState(state);
return runtimeSnapshotForChatSlices({
runtimeConfig: state.connection.runtimeConfig,
activeThread: { id: activeThread?.id ?? null, tokenUsage: activeThread?.tokenUsage ?? null },
activeThread: state.activeThread,
runtime: state.runtime,
rateLimit: state.connection.rateLimit,
hasThreadTurns: threadStreamItemsHaveThreadTurns(threadStreamItems(state.threadStream)),

View file

@ -20,11 +20,11 @@ interface ResumedThreadActionParams {
preserveRequestedRuntimeSettings?: boolean;
serviceTierKnown?: boolean;
preservePendingSubmissionId?: string;
expectedPanelTargetRevision?: number;
}
interface ResumedThreadFromActiveRuntimeParams {
thread: Thread;
cwd: string;
runtime: Pick<
ActiveThreadRuntimeState,
| "model"
@ -41,12 +41,12 @@ interface ResumedThreadFromActiveRuntimeParams {
>;
listedThreads?: readonly Thread[];
items?: readonly ThreadStreamItem[];
expectedPanelTargetRevision?: number;
}
export interface ActiveThreadResumedAction extends RuntimePermissionState, RuntimePermissionKnownState {
type: "active-thread/resumed";
thread: Thread;
cwd: string;
model: string | null;
reasoningEffort: ReasoningEffort | null;
serviceTier: ServiceTier | null;
@ -57,7 +57,6 @@ export interface ActiveThreadResumedAction extends RuntimePermissionState, Runti
listedThreads?: readonly Thread[];
preserveRequestedRuntimeSettings?: boolean;
preservePendingSubmissionId?: string;
expectedPanelTargetRevision?: number;
lifetime?:
| { readonly kind: "persistent" }
| { readonly kind: "ephemeral"; readonly sourceThreadId: string; readonly sourceThreadTitle: string | null };
@ -65,6 +64,7 @@ export interface ActiveThreadResumedAction extends RuntimePermissionState, Runti
export interface ActiveThreadSettingsAppliedAction extends RuntimePermissionState, RuntimePermissionKnownState {
type: "active-thread/settings-applied";
cwd: string;
model: string | null;
reasoningEffort: ReasoningEffort | null;
collaborationMode: CollaborationModeSelection;
@ -73,6 +73,7 @@ export interface ActiveThreadSettingsAppliedAction extends RuntimePermissionStat
}
export interface ActiveThreadSettingsAppliedActionSettings extends RuntimePermissionState {
cwd: string;
model: string | null;
effort: string | null;
collaborationMode: { mode: CollaborationModeSelection };
@ -99,16 +100,12 @@ export interface ClearLocalTurnAction {
export interface ClearActiveThreadAction {
type: "active-thread/cleared";
expectedPanelTargetRevision?: number;
}
export interface ThreadListAppliedAction {
type: "thread-list/applied";
threads: readonly Thread[];
hasMore?: boolean;
isFetching?: boolean;
isFetchingNextPage?: boolean;
error?: string | null;
threadsLoaded?: boolean;
}
export interface DisclosureSetAction {
@ -140,6 +137,7 @@ export function resumedThreadActionFromActiveRuntime(params: ResumedThreadFromAc
return resumedThreadAction({
response: {
thread: params.thread,
cwd: params.cwd,
approvalPolicyKnown: params.runtime.approvalPolicyKnown,
sandboxPolicyKnown: params.runtime.sandboxPolicyKnown,
permissionProfileKnown: params.runtime.permissionProfileKnown,
@ -154,7 +152,6 @@ export function resumedThreadActionFromActiveRuntime(params: ResumedThreadFromAc
serviceTierKnown: params.runtime.serviceTierKnown,
...(params.listedThreads ? { listedThreads: params.listedThreads } : {}),
...(params.items ? { items: params.items } : {}),
...(params.expectedPanelTargetRevision === undefined ? {} : { expectedPanelTargetRevision: params.expectedPanelTargetRevision }),
});
}
@ -164,6 +161,7 @@ export function resumedThreadAction(params: ResumedThreadActionParams): ActiveTh
return {
type: "active-thread/resumed",
thread: response.thread,
cwd: response.cwd,
model: response.model,
reasoningEffort: response.reasoningEffort,
serviceTier: response.serviceTier,
@ -180,7 +178,6 @@ export function resumedThreadAction(params: ResumedThreadActionParams): ActiveTh
: {}),
...(params.preserveRequestedRuntimeSettings ? { preserveRequestedRuntimeSettings: true } : {}),
...(params.preservePendingSubmissionId ? { preservePendingSubmissionId: params.preservePendingSubmissionId } : {}),
...(params.expectedPanelTargetRevision === undefined ? {} : { expectedPanelTargetRevision: params.expectedPanelTargetRevision }),
};
}
@ -207,6 +204,7 @@ export function activeThreadSettingsAppliedAction(settings: ActiveThreadSettings
const permissions = runtimePermissionStateOrDefault(settings);
return {
type: "active-thread/settings-applied",
cwd: settings.cwd,
model: settings.model,
reasoningEffort: normalizeReasoningEffort(settings.effort),
collaborationMode: settings.collaborationMode.mode,

Some files were not shown because too many files have changed in this diff Show more