mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Compare commits
271 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dc4d82c26 | ||
|
|
811a4b7158 | ||
|
|
05ea87643a | ||
|
|
bb673b2a75 | ||
|
|
01a20dcf70 | ||
|
|
73be2beea7 | ||
|
|
14adac592d | ||
|
|
f4616cf945 | ||
|
|
67289765c6 | ||
|
|
ab0a9047f4 | ||
|
|
488344a74d | ||
|
|
6bdd9e5972 | ||
|
|
5b6fce1a4d | ||
|
|
05f9740e8e | ||
|
|
6b2a20cf0d | ||
|
|
d0965d19b1 | ||
|
|
381e2a5467 | ||
|
|
03305a47ef | ||
|
|
d74747fbf0 | ||
|
|
f707da0274 | ||
|
|
2fb7d29841 | ||
|
|
8a798a5bca | ||
|
|
689368fe8d | ||
|
|
5cd106973c | ||
|
|
f3d987ee03 | ||
|
|
bbe311c4de | ||
|
|
4f3ebcaa31 | ||
|
|
96b88c1715 | ||
|
|
193b680f06 | ||
|
|
2d9f98f434 | ||
|
|
ffcc814ba5 | ||
|
|
1f5aa50862 | ||
|
|
a2c896d0ae | ||
|
|
205893cc1f | ||
|
|
b00f6292c4 | ||
|
|
8d952fcfbf | ||
|
|
54dcbd8759 | ||
|
|
381646ebfb | ||
|
|
aaa04507e0 | ||
|
|
a92f3b031d | ||
|
|
567c744624 | ||
|
|
11c233b8fe | ||
|
|
aadcf179bc | ||
|
|
52c350093a | ||
|
|
a35c9f4263 | ||
|
|
dc467695f4 | ||
|
|
1ef344b02a | ||
|
|
f93570848f | ||
|
|
cf0548a0d0 | ||
|
|
ea665af455 | ||
|
|
7ec4acf2b1 | ||
|
|
44128caf2c | ||
|
|
e7efd773b3 | ||
|
|
9c71b0eb88 | ||
|
|
d3b426d500 | ||
|
|
d114c3e473 | ||
|
|
eecf73dc77 | ||
|
|
358cdd7b7c | ||
|
|
5cd898fabe | ||
|
|
8351156589 | ||
|
|
ef5d1d803d | ||
|
|
760421613d | ||
|
|
46ecf3addd | ||
|
|
93c4ba50a1 | ||
|
|
d568f9abb1 | ||
|
|
d1337a6c85 | ||
|
|
eb6d4a3c2d | ||
|
|
4820fd5821 | ||
|
|
f588719440 | ||
|
|
6524409313 | ||
|
|
d8f4f8a899 | ||
|
|
9b2585b2bd | ||
|
|
4581ee4c5f | ||
|
|
56a7662b2e | ||
|
|
93d0021391 | ||
|
|
dfeeb0e4b2 | ||
|
|
ffb11490ce | ||
|
|
51994c6777 | ||
|
|
5e2007c899 | ||
|
|
4ddb17a27b | ||
|
|
48bd261f64 | ||
|
|
58f8805328 | ||
|
|
6800bcc339 | ||
|
|
562513099f | ||
|
|
e056fee378 | ||
|
|
2de242819e | ||
|
|
9ad8ade044 | ||
|
|
1eb074b361 | ||
|
|
716d6a2db5 | ||
|
|
4b0218169c | ||
|
|
ec3bcd0312 | ||
|
|
7dfc023360 | ||
|
|
900d5cd53c | ||
|
|
01f5de79b7 | ||
|
|
a7fbb2bddc | ||
|
|
eff82d1166 | ||
|
|
3dd4c8b9a3 | ||
|
|
60882f68ea | ||
|
|
dfff322541 | ||
|
|
40153a1da5 | ||
|
|
ed942d1c9b | ||
|
|
86fc06998b | ||
|
|
c37832d0a3 | ||
|
|
760bdf3016 | ||
|
|
d525d97f9e | ||
|
|
bf8c87046b | ||
|
|
edf2b70902 | ||
|
|
cb6e89b1ae | ||
|
|
f7efd475a4 | ||
|
|
48401b6201 | ||
|
|
e4e69ca060 | ||
|
|
a97f80e25e | ||
|
|
359bf1c21e | ||
|
|
cb172edfb7 | ||
|
|
d202849313 | ||
|
|
a099e1fffc | ||
|
|
f7e5319c3e | ||
|
|
98a17574e2 | ||
|
|
779d12f401 | ||
|
|
a7317fd518 | ||
|
|
5e3eaf9b9f | ||
|
|
4faa986e17 | ||
|
|
805e7bfdf4 | ||
|
|
8ae6aab1fb | ||
|
|
bbd981f255 | ||
|
|
c23c453597 | ||
|
|
7d6b81d367 | ||
|
|
d010e2ac47 | ||
|
|
04341f7258 | ||
|
|
e7ad2ca4c4 | ||
|
|
2e97525d4f | ||
|
|
ca1a71eaac | ||
|
|
9624f19a95 | ||
|
|
635353cb99 | ||
|
|
10da7f273e | ||
|
|
09e2eb05ce | ||
|
|
788d5cc949 | ||
|
|
f8efaf1806 | ||
|
|
41ad68a419 | ||
|
|
88694b2a6a | ||
|
|
8fc051ebd4 | ||
|
|
84b920d0da | ||
|
|
00402e0d56 | ||
|
|
2c254339cf | ||
|
|
10c69cec3f | ||
|
|
34aca42dc0 | ||
|
|
3db509a98a | ||
|
|
08cd3f624e | ||
|
|
bd74d47ec1 | ||
|
|
4d138a3f9b | ||
|
|
57cab74ecf | ||
|
|
c053888fcd | ||
|
|
b92b6d5fd3 | ||
|
|
b30b913c04 | ||
|
|
9d2b750d77 | ||
|
|
8cd3bafaff | ||
|
|
ce427a9a6c | ||
|
|
acd6c418d8 | ||
|
|
859ccb2ac1 | ||
|
|
2364664c2e | ||
|
|
eb56414cd7 | ||
|
|
26adfa4d46 | ||
|
|
a1ad965dcc | ||
|
|
160c344008 | ||
|
|
697b748aaf | ||
|
|
d174f3273c | ||
|
|
bcb9759a91 | ||
|
|
c039e8be7a | ||
|
|
45f1a47848 | ||
|
|
17835a8b7d | ||
|
|
e1e3786531 | ||
|
|
0c587f72cd | ||
|
|
dec80ff6cf | ||
|
|
3845da80e0 | ||
|
|
305a257ace | ||
|
|
ad50c56090 | ||
|
|
78a8432bed | ||
|
|
e2f5b88f2e | ||
|
|
d21922f814 | ||
|
|
1ff3aa8c14 | ||
|
|
919f9f14b3 | ||
|
|
58bbaf6a5f | ||
|
|
164c307bd1 | ||
|
|
5f22d21dc7 | ||
|
|
d9024ba983 | ||
|
|
0cb39fb6dc | ||
|
|
ee0cfa9891 | ||
|
|
2a0fb96ce6 | ||
|
|
7f09418f7c | ||
|
|
5e362add5b | ||
|
|
b6f6d04147 | ||
|
|
c0e4fc590c | ||
|
|
e9ca2b0448 | ||
|
|
c375f110a2 | ||
|
|
dccd63b75b | ||
|
|
03a026512e | ||
|
|
9616fe588d | ||
|
|
d52841240a | ||
|
|
d417157162 | ||
|
|
75bf1a0fb8 | ||
|
|
d28895b214 | ||
|
|
2ca7dc42d2 | ||
|
|
ca62b40929 | ||
|
|
5a2dff36c5 | ||
|
|
37051838a8 | ||
|
|
b3a770d1b1 | ||
|
|
34e85b9588 | ||
|
|
e44f95e368 | ||
|
|
a297859ec1 | ||
|
|
085af148e6 | ||
|
|
2debad388b | ||
|
|
682a23d712 | ||
|
|
d544791b3a | ||
|
|
35af82b5ed | ||
|
|
5c840a277f | ||
|
|
4202748cc6 | ||
|
|
36ed7fceb3 | ||
|
|
d81044c17b | ||
|
|
3f2c4fd970 | ||
|
|
3060834fda | ||
|
|
2353f725d1 | ||
|
|
0ce5ceedd5 | ||
|
|
19d35f7e33 | ||
|
|
e83dc59521 | ||
|
|
48c0b80f39 | ||
|
|
df9585499c | ||
|
|
d6fc86bb2e | ||
|
|
84367e795b | ||
|
|
3b42c1dbd4 | ||
|
|
a330f181b5 | ||
|
|
22be754296 | ||
|
|
a2fd30850b | ||
|
|
dffca699f0 | ||
|
|
42af40c299 | ||
|
|
a9e68e159d | ||
|
|
1946ecffe0 | ||
|
|
08ccb61737 | ||
|
|
4f6732da5d | ||
|
|
eeece5e14d | ||
|
|
c610bc377e | ||
|
|
3c684d239b | ||
|
|
95496f356f | ||
|
|
eeefedfc8f | ||
|
|
be63c6171d | ||
|
|
943ddd6b26 | ||
|
|
947430abbc | ||
|
|
b622c9e427 | ||
|
|
99257950d7 | ||
|
|
fe1ec64355 | ||
|
|
4dd222ab2c | ||
|
|
c0a954d4ab | ||
|
|
b045f74a86 | ||
|
|
1e3e34dd84 | ||
|
|
ee93b0c810 | ||
|
|
a3d3809e8b | ||
|
|
982760f401 | ||
|
|
9f8fb2c5c8 | ||
|
|
17b1dc8574 | ||
|
|
1d90eaf602 | ||
|
|
ba5e292487 | ||
|
|
504dae0886 | ||
|
|
ceb85bd251 | ||
|
|
b4e4f6251e | ||
|
|
210a4632f2 | ||
|
|
36f15b10ef | ||
|
|
394a900023 | ||
|
|
a35d0108d4 | ||
|
|
ee3efb7c6c | ||
|
|
d2cb61e097 | ||
|
|
60dba267ba | ||
|
|
d5390d9752 |
683 changed files with 50985 additions and 27978 deletions
|
|
@ -5,33 +5,24 @@ description: Use when updating Codex Panel for a new Codex CLI or app-server API
|
||||||
|
|
||||||
# Codex Panel App-Server Update
|
# Codex Panel App-Server Update
|
||||||
|
|
||||||
Use this skill when Codex Panel needs to follow Codex CLI or experimental `codex app-server` API changes.
|
|
||||||
|
|
||||||
## Ground Rules
|
## Ground Rules
|
||||||
|
|
||||||
- Treat `src/generated/app-server/` as generated output. Do not hand-edit generated bindings.
|
- Treat `src/generated/app-server/` as generated output. Do not hand-edit generated bindings.
|
||||||
- Use `docs/design.md` for app-server boundary and source-of-truth decisions before adding compatibility layers or product behavior.
|
- Use `docs/design.md` for app-server boundary and source-of-truth decisions before adding compatibility layers or product behavior.
|
||||||
- Prefer current Codex CLI behavior over broad backward compatibility unless a concrete user need requires compatibility code.
|
- Prefer current Codex CLI behavior over broad backward compatibility unless a concrete user need requires compatibility code.
|
||||||
- Separate required compatibility fixes from optional cleanup or product improvements.
|
- Separate required compatibility fixes from optional cleanup or product improvements.
|
||||||
- Do not rely on local Git hooks. Run required generation and verification commands explicitly.
|
|
||||||
|
|
||||||
## Procedure
|
## Procedure
|
||||||
|
|
||||||
1. Read the README Compatibility section, `docs/design.md`, `docs/development.md`, `package.json`, and app-server-related source around requests, notifications, threads, approvals, runtime settings, hooks, and model listing.
|
1. Read the README Compatibility section and the relevant design and development guidance, then trace the affected app-server paths.
|
||||||
2. Compare the README Compatibility table's `codex.testedCliVersion` with the target `codex --version`.
|
2. Confirm the recorded, installed, and target Codex CLI versions. Check official release information when behavior is uncertain.
|
||||||
3. Check official Codex CLI or app-server release information when the target version is newer or behavior is uncertain.
|
3. Regenerate bindings with `npm run generate:app-server-types`.
|
||||||
4. Regenerate bindings with `npm run generate:app-server-types`.
|
4. Review generated diffs for protocol changes that affect runtime behavior. If mechanical normalization is needed, update the normalization code in `scripts/generate-app-server-types.mjs` and regenerate instead of patching generated files by hand.
|
||||||
5. Review generated diffs for protocol changes that affect runtime behavior. If mechanical normalization is needed, update the normalization code in `scripts/generate-app-server-types.mjs` and regenerate instead of patching generated files by hand.
|
5. Implement only the required compatibility changes and record the target version after validating it.
|
||||||
6. Implement only the compatibility changes needed for the target Codex CLI version.
|
6. Report optional cleanup or product follow-ups separately; do not implement them unless requested.
|
||||||
7. Update the README Compatibility table's `codex.testedCliVersion` only after validating against that version.
|
|
||||||
8. Identify optional follow-ups separately from required compatibility work:
|
|
||||||
- API changes that allow removing compatibility shims, fallback code, or local workarounds.
|
|
||||||
- New routes, notifications, fields, or lifecycle signals that could simplify existing implementation.
|
|
||||||
- New app-server capabilities that could enable Codex Panel product improvements.
|
|
||||||
9. Report optional follow-ups as proposals only, grouped separately from implemented required fixes unless the user explicitly asks to implement them.
|
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
- Run `npm run check` after regenerated bindings or compatibility changes.
|
- Run `npm run check` after regenerated bindings or compatibility changes.
|
||||||
- If the change must be reflected in Obsidian, run `npm run build` and verify the plugin reload path separately.
|
- Use the live Obsidian workflow when runtime behavior needs verification.
|
||||||
- Report the tested Codex CLI version, the generation command used, any app-server compatibility behavior that changed, and any optional simplification or feature proposals found.
|
- Report the tested Codex CLI version, changed compatibility behavior, and optional follow-ups.
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,11 @@ description: Use when validating Codex Panel inside a live Obsidian app with the
|
||||||
|
|
||||||
# Codex Panel Obsidian Dev
|
# Codex Panel Obsidian Dev
|
||||||
|
|
||||||
Use this skill for live Obsidian checks after building Codex Panel or when investigating behavior that unit tests cannot observe.
|
|
||||||
|
|
||||||
## Ground Rules
|
## Ground Rules
|
||||||
|
|
||||||
- Work from the Codex Panel repository root.
|
|
||||||
- Use `docs/development.md` for the build and generated-asset expectations before live Obsidian validation.
|
- Use `docs/development.md` for the build and generated-asset expectations before live Obsidian validation.
|
||||||
- Treat `obsidian plugin:reload id=codex-panel`, `obsidian reload`, `obsidian restart`, `obsidian devtools`, `obsidian eval`, `obsidian dev:cdp`, and `obsidian dev:mobile` as state-changing or intrusive. Ask the user before running them.
|
- Ask before altering or reloading the live Obsidian session, attaching a debugger, or running intrusive evaluation or input commands.
|
||||||
- Prefer read-only inspection first: `plugin`, `commands`, `dev:errors`, `dev:dom`, `dev:css`, `dev:console`, `dev:screenshot`, `version`.
|
- Prefer read-only inspection before state-changing commands.
|
||||||
- If `dev:console` reports that the debugger is not attached, ask before running `obsidian dev:debug on`.
|
|
||||||
- Do not clear console or error buffers unless the user approves; clearing can destroy useful failure context.
|
- Do not clear console or error buffers unless the user approves; clearing can destroy useful failure context.
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
@ -24,13 +20,13 @@ Use this skill for live Obsidian checks after building Codex Panel or when inves
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Before reloading the live plugin, ask the user whether it is OK to reload their current Obsidian session. If approved:
|
2. If approved, reload the plugin when the live session needs the new build:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
obsidian plugin:reload id=codex-panel
|
obsidian plugin:reload id=codex-panel
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Open the relevant Codex Panel surface when needed:
|
3. Open the relevant surface when needed:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
obsidian command id=codex-panel:open-panel
|
obsidian command id=codex-panel:open-panel
|
||||||
|
|
@ -47,54 +43,12 @@ Use this skill for live Obsidian checks after building Codex Panel or when inves
|
||||||
obsidian dev:screenshot path=<scratch-path>/codex-panel.png
|
obsidian dev:screenshot path=<scratch-path>/codex-panel.png
|
||||||
```
|
```
|
||||||
|
|
||||||
5. Use focused selectors for the behavior under test. Prefer stable Codex Panel classes such as `.codex-panel`, `.codex-panel__composer`, `.codex-panel-threads`, and `.codex-panel-chat-turn-diff`.
|
5. Use focused Codex Panel-owned selectors found in the relevant implementation or styles.
|
||||||
|
|
||||||
## Dynamic UI Investigations
|
## Dynamic UI Investigations
|
||||||
|
|
||||||
When checking behavior that depends on browser layout, asynchronous rendering, or virtualized DOM state, measure the live UI rather than inferring from unit tests alone.
|
When behavior depends on layout, asynchronous rendering, or virtualized DOM state, capture the visible state and relevant DOM metrics before and after the real input path, then again after rendering settles. Compare framework or data state with the DOM where possible. Remove temporary probes and instrumentation before final validation and re-check runtime errors.
|
||||||
|
|
||||||
- Capture both the user-visible state and the underlying DOM metrics. Useful fields include `scrollTop`, `scrollHeight`, `clientHeight`, element bounding rects, rendered element counts, inline styles, and relevant computed styles.
|
|
||||||
- For virtualized or asynchronously rendered regions, compare framework state against DOM state where possible, such as virtualizer total size versus actual `scrollHeight`, rendered item count versus data count, or pre-event versus post-frame measurements.
|
|
||||||
- Prefer real input paths for input-sensitive bugs. Use `obsidian dev:cdp method=Input.dispatchMouseEvent ...` or focused keyboard events when a synthetic `dispatchEvent()` may skip browser/Electron behavior.
|
|
||||||
- Record measurements before the action, immediately after the action, after at least one animation frame, and after a short timeout when layout, markdown rendering, resize observers, or virtualizer settle loops may run later.
|
|
||||||
- If injecting temporary DOM probes with `obsidian eval`, remove them before finishing and re-check `dev:errors`. Avoid leaving long-running eval promises; use bounded `setTimeout`-based scripts that resolve.
|
|
||||||
- Treat console instrumentation as temporary. Remove debug logging before final validation, and search the touched files for probe markers or conflict markers before reporting.
|
|
||||||
|
|
||||||
## Common Checks
|
|
||||||
|
|
||||||
- Confirm the loaded version and enabled state:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
obsidian plugin id=codex-panel
|
|
||||||
```
|
|
||||||
|
|
||||||
- List available Codex Panel commands:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
obsidian commands filter=codex-panel
|
|
||||||
```
|
|
||||||
|
|
||||||
- Count rendered elements:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
obsidian dev:dom selector=.codex-panel__composer total
|
|
||||||
```
|
|
||||||
|
|
||||||
- Inspect text, attributes, or computed styles:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
obsidian dev:dom selector=.codex-panel__composer text
|
|
||||||
obsidian dev:dom selector=.codex-panel__composer attr=aria-label
|
|
||||||
obsidian dev:dom selector=.codex-panel__composer css=display
|
|
||||||
obsidian dev:css selector=.codex-panel__composer
|
|
||||||
```
|
|
||||||
|
|
||||||
- Capture a screenshot for visual review:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
obsidian dev:screenshot path=<scratch-path>/codex-panel.png
|
|
||||||
```
|
|
||||||
|
|
||||||
## Reporting
|
## Reporting
|
||||||
|
|
||||||
Report the commands run, whether Obsidian was reloaded, key `dev:errors` or `dev:console` findings, and any screenshot path produced. If a command was skipped because it required user approval, state that plainly.
|
Report the observed behavior, runtime errors, intrusive actions taken, and evidence paths. State when an approval-gated check was skipped.
|
||||||
|
|
|
||||||
|
|
@ -5,28 +5,23 @@ description: Use when preparing, checking, committing, tagging, pushing, or repa
|
||||||
|
|
||||||
# Codex Panel Release
|
# Codex Panel Release
|
||||||
|
|
||||||
Use this skill when delegating Codex Panel release work to an agent. `docs/release.md` is the public procedure and source of truth for the release command sequence. This skill adds agent-facing gates, review duties, and failure handling.
|
`docs/release.md` is the public procedure and source of truth for the release command sequence. This skill adds agent-facing gates, review duties, and failure handling.
|
||||||
|
|
||||||
## Ground Rules
|
## Ground Rules
|
||||||
|
|
||||||
- Read and follow `docs/release.md`; do not maintain a parallel release command sequence here.
|
- Read and follow `docs/release.md`; do not maintain a parallel release command sequence here.
|
||||||
- Do not assume local Git hooks exist or ran. Run required verification commands explicitly.
|
|
||||||
- Do not create GitHub Releases locally with `gh release create`; the tag-triggered GitHub Actions workflow owns release creation and asset attachment.
|
- Do not create GitHub Releases locally with `gh release create`; the tag-triggered GitHub Actions workflow owns release creation and asset attachment.
|
||||||
- Keep internal validation notes, procedural details, and implementation reasoning out of release notes.
|
- Treat generated release notes as a draft. Review the full release diff and keep one short, public-facing `## Changes` section without internal or procedural detail.
|
||||||
- Release notes must be short, public-facing bullets under the single `## Changes` section required by `docs/release.md`.
|
|
||||||
|
|
||||||
## Delegation Procedure
|
## Delegation Procedure
|
||||||
|
|
||||||
1. Read `docs/release.md`, `package.json`, `manifest.json`, `versions.json`, and existing `.github/release-notes/` files.
|
1. Read `docs/release.md` and inspect the current version metadata and recent release notes.
|
||||||
2. Identify the target release version and the commit range since the previous released tag.
|
2. Identify the target release version and the commit range since the previous released tag.
|
||||||
3. Follow the preparation step in `docs/release.md`.
|
3. Inspect the full release diff, run preparation as documented, and edit the draft for completeness, accuracy, and user-facing wording.
|
||||||
4. Draft `.github/release-notes/X.Y.Z.md` from the full diff since the previous released tag, not only the latest commit.
|
4. Before committing, ask the user to approve the release version, reviewed release-note bullets, and included commit range.
|
||||||
5. Before committing, ask the user to approve the release version, release-note bullets, and included commit range.
|
5. After approval, follow `docs/release.md` through preflight, tag, and push. Let GitHub Actions create or update the GitHub Release.
|
||||||
6. After approval, follow `docs/release.md` for the commit, preflight, tag, and push sequence.
|
|
||||||
7. After pushing, let GitHub Actions create or update the GitHub Release.
|
|
||||||
|
|
||||||
## Failure Handling
|
## Failure Handling
|
||||||
|
|
||||||
- If a release script or preflight fails, fix the cause and rerun the explicit failed command.
|
|
||||||
- If the tag-triggered workflow fails before GitHub Release creation, use the repair procedure in `docs/release.md`.
|
- If the tag-triggered workflow fails before GitHub Release creation, use the repair procedure in `docs/release.md`.
|
||||||
- If a GitHub Release already exists or was partially created, inspect the state before taking action; do not assume local asset upload is the right recovery path.
|
- If a GitHub Release already exists or was partially created, inspect the state before taking action; do not assume local asset upload is the right recovery path.
|
||||||
|
|
|
||||||
8
.github/dependabot.yml
vendored
8
.github/dependabot.yml
vendored
|
|
@ -4,6 +4,8 @@ updates:
|
||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
|
commit-message:
|
||||||
|
prefix: "ci(deps)"
|
||||||
cooldown:
|
cooldown:
|
||||||
default-days: 7
|
default-days: 7
|
||||||
|
|
||||||
|
|
@ -11,6 +13,9 @@ updates:
|
||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
|
commit-message:
|
||||||
|
prefix: "chore(deps)"
|
||||||
|
prefix-development: "chore(deps)"
|
||||||
cooldown:
|
cooldown:
|
||||||
default-days: 7
|
default-days: 7
|
||||||
open-pull-requests-limit: 5
|
open-pull-requests-limit: 5
|
||||||
|
|
@ -24,3 +29,6 @@ updates:
|
||||||
update-types:
|
update-types:
|
||||||
- "version-update:semver-minor"
|
- "version-update:semver-minor"
|
||||||
- "version-update:semver-major"
|
- "version-update:semver-major"
|
||||||
|
- dependency-name: "typescript"
|
||||||
|
update-types:
|
||||||
|
- "version-update:semver-major"
|
||||||
|
|
|
||||||
4
.github/release-notes/4.1.2.md
vendored
Normal file
4
.github/release-notes/4.1.2.md
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Let `/permissions` set the permission profile used for subsequent Codex turns, with composer completions for available profiles.
|
||||||
|
- Updated app-server compatibility for Codex CLI 0.142.5.
|
||||||
4
.github/release-notes/4.2.0.md
vendored
Normal file
4
.github/release-notes/4.2.0.md
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Added an opt-in **Reference active note on send** setting that references the current Obsidian note when sending from the composer without changing the prompt text.
|
||||||
|
- Renamed sent-message context metadata to `Context · n items`, with implicit active-note context shown as `Active note · path/to/note.md`.
|
||||||
4
.github/release-notes/4.3.0.md
vendored
Normal file
4
.github/release-notes/4.3.0.md
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Added `/clip <url> [message]` to save readable web page clippings as Markdown notes and send wikilink references from the composer.
|
||||||
|
- Added web clipping settings for the saved note folder, filename template, and tags.
|
||||||
5
.github/release-notes/4.3.1.md
vendored
Normal file
5
.github/release-notes/4.3.1.md
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Renamed active-note context to `@active` and updated related settings copy to refer to the active file consistently.
|
||||||
|
- Improved thread matching for composer thread commands and thread search so title and ID matches rank more predictably.
|
||||||
|
- Made saved vault paths for attachments, web clippings, and archived thread notes more consistent with their configured defaults.
|
||||||
4
.github/release-notes/4.4.0.md
vendored
Normal file
4
.github/release-notes/4.4.0.md
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Add Obsidian tag completions in the composer after `#`.
|
||||||
|
- Open Obsidian Search when clicking rendered tags in Codex Panel messages.
|
||||||
5
.github/release-notes/4.4.1.md
vendored
Normal file
5
.github/release-notes/4.4.1.md
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Match Obsidian's tag index and global search behavior for tag completions and rendered tag clicks.
|
||||||
|
- Show composer context as 0% before a thread starts instead of leaving it blank.
|
||||||
|
- Harden saved Markdown output for web clips and archived threads with safer YAML frontmatter handling.
|
||||||
6
.github/release-notes/4.5.0.md
vendored
Normal file
6
.github/release-notes/4.5.0.md
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Add an action to open subagent threads directly from agent summary messages.
|
||||||
|
- Show reasoning effort descriptions in composer suggestions.
|
||||||
|
- Update app-server compatibility for Codex CLI 0.143.0.
|
||||||
|
- Polish toolbar and slash command wording, and fix composer suggestion matching.
|
||||||
3
.github/release-notes/4.5.1.md
vendored
Normal file
3
.github/release-notes/4.5.1.md
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Polish chat panel wording around conversations, context, and composer guidance.
|
||||||
6
.github/release-notes/4.5.2.md
vendored
Normal file
6
.github/release-notes/4.5.2.md
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Update app-server compatibility for Codex CLI 0.144.0 and improve reconnection, metadata, and MCP request handling.
|
||||||
|
- Load thread history incrementally in Threads View while retaining complete thread-picker search results.
|
||||||
|
- Fix web clips to save Defuddle's Markdown output and refresh collapsed-content controls after layout changes.
|
||||||
|
- Prevent stale asynchronous UI updates after panels, settings, or the plugin close.
|
||||||
4
.github/release-notes/4.6.0.md
vendored
Normal file
4
.github/release-notes/4.6.0.md
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Add `@today`, `@tomorrow`, and `@yesterday` composer completions that resolve through the configured Daily Notes or Periodic Notes folder and date format.
|
||||||
|
- Improve `@active` and `@selection` suggestions with clearer note, range, and selected-text previews.
|
||||||
3
.github/release-notes/4.6.1.md
vendored
Normal file
3
.github/release-notes/4.6.1.md
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Align DOM creation and daily-note date handling with Obsidian's bundled APIs for Community plugin review compatibility.
|
||||||
5
.github/release-notes/4.7.0.md
vendored
Normal file
5
.github/release-notes/4.7.0.md
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Add `/btw` side chats that open an ephemeral conversation in a new panel without interrupting the source thread.
|
||||||
|
- Open subagent threads as read-only panels and hide conversation actions that do not apply to them.
|
||||||
|
- Improve reliability around Vault file destinations, web clipping, app-server connections, and asynchronous panel cleanup.
|
||||||
4
.github/release-notes/4.7.1.md
vendored
Normal file
4
.github/release-notes/4.7.1.md
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Improve chat and side-chat stability during concurrent actions, panel restoration, and cleanup.
|
||||||
|
- Prevent stale app-server data and rapid settings changes from overwriting newer state.
|
||||||
5
.github/release-notes/5.0.0.md
vendored
Normal file
5
.github/release-notes/5.0.0.md
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Replace `/clip` with `/web <url> [message]` for temporary web context without saving a note. For durable archives—especially rendered or signed-in pages—use Obsidian Web Clipper and reference the saved note.
|
||||||
|
- Improve hook controls so trusting a hook no longer enables it automatically, and only trusted unmanaged hooks can be enabled or disabled.
|
||||||
|
- Prevent an in-flight automatic thread title from overwriting a newer manual rename.
|
||||||
5
.github/release-notes/5.0.1.md
vendored
Normal file
5
.github/release-notes/5.0.1.md
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- You can now cancel an in-progress `/web` import and keep the original prompt to edit or resend it.
|
||||||
|
- Changing the Codex executable in settings now reconnects open panels cleanly.
|
||||||
|
- File links containing backticks are now preserved when exporting archived threads.
|
||||||
5
.github/release-notes/5.0.2.md
vendored
Normal file
5
.github/release-notes/5.0.2.md
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
## 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.
|
||||||
3
.github/release-notes/5.0.3.md
vendored
Normal file
3
.github/release-notes/5.0.3.md
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Release assets now carry individually verifiable provenance attestations.
|
||||||
3
.github/release-notes/5.0.4.md
vendored
Normal file
3
.github/release-notes/5.0.4.md
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Restored availability of downloadable install assets while upstream attestation verification catches up with GitHub's API changes.
|
||||||
6
.github/release-notes/5.1.0.md
vendored
Normal file
6
.github/release-notes/5.1.0.md
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
## 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.
|
||||||
6
.github/release-notes/5.1.1.md
vendored
Normal file
6
.github/release-notes/5.1.1.md
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
## 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.
|
||||||
6
.github/release-notes/5.1.2.md
vendored
Normal file
6
.github/release-notes/5.1.2.md
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
## 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.
|
||||||
4
.github/release-notes/5.2.0.md
vendored
Normal file
4
.github/release-notes/5.2.0.md
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
## 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.
|
||||||
5
.github/release-notes/5.2.1.md
vendored
Normal file
5
.github/release-notes/5.2.1.md
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
## 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.
|
||||||
55
.github/workflows/check.yml
vendored
55
.github/workflows/check.yml
vendored
|
|
@ -18,16 +18,69 @@ jobs:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
persist-credentials: false
|
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
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 26
|
||||||
cache: npm
|
cache: npm
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci --ignore-scripts
|
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
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
env:
|
||||||
|
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||||
|
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||||
|
run: npm run commitlint -- --from "$BASE_SHA" --to "$HEAD_SHA" --verbose
|
||||||
|
|
||||||
|
- name: Check pushed commit messages
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
env:
|
||||||
|
BASE_SHA: ${{ github.event.before }}
|
||||||
|
HEAD_SHA: ${{ github.sha }}
|
||||||
|
run: npm run commitlint -- --from "$BASE_SHA" --to "$HEAD_SHA" --verbose
|
||||||
|
|
||||||
|
- name: Check release metadata
|
||||||
|
run: npm run release:check
|
||||||
|
|
||||||
- name: Check
|
- name: Check
|
||||||
run: npm run check
|
run: npm run check
|
||||||
|
|
|
||||||
30
.github/workflows/release.yml
vendored
30
.github/workflows/release.yml
vendored
|
|
@ -7,8 +7,6 @@ on:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
id-token: write
|
|
||||||
attestations: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
|
|
@ -19,33 +17,43 @@ jobs:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 26
|
||||||
package-manager-cache: false
|
package-manager-cache: false
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci --ignore-scripts
|
run: npm ci --ignore-scripts
|
||||||
|
|
||||||
|
- name: Install recorded Codex CLI
|
||||||
|
run: npm install --global "@openai/codex@$(node scripts/app-server-compatibility.mjs --tested-cli-version)"
|
||||||
|
|
||||||
|
- name: Check API baselines
|
||||||
|
run: npm run api:baseline
|
||||||
|
|
||||||
|
- name: Check generated app-server bindings
|
||||||
|
run: npm run generate:app-server-types:check
|
||||||
|
|
||||||
- name: Check release version
|
- name: Check release version
|
||||||
run: npm run release:check
|
run: npm run release:check
|
||||||
env:
|
env:
|
||||||
RELEASE_VERSION: ${{ github.ref_name }}
|
RELEASE_VERSION: ${{ github.ref_name }}
|
||||||
|
|
||||||
|
- name: Check release commit messages
|
||||||
|
env:
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
PREVIOUS_TAG="$(node -p 'Object.keys(require("./versions.json")).at(-2)')"
|
||||||
|
git rev-parse --verify "refs/tags/$PREVIOUS_TAG"
|
||||||
|
npm run commitlint -- --from "$PREVIOUS_TAG" --to "$TAG" --verbose
|
||||||
|
|
||||||
- name: Run checks and build
|
- name: Run checks and build
|
||||||
run: npm run check
|
run: npm run check
|
||||||
|
|
||||||
- name: Generate artifact attestations
|
|
||||||
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4
|
|
||||||
with:
|
|
||||||
subject-path: |
|
|
||||||
main.js
|
|
||||||
manifest.json
|
|
||||||
styles.css
|
|
||||||
|
|
||||||
- name: Create GitHub release
|
- name: Create GitHub release
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
|
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -1,4 +1,6 @@
|
||||||
node_modules/
|
node_modules/
|
||||||
|
.pnpm-store/
|
||||||
|
.stryker-tmp/
|
||||||
.jj/
|
.jj/
|
||||||
.codex/
|
.codex/
|
||||||
main.js
|
main.js
|
||||||
|
|
@ -7,4 +9,6 @@ data.json
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
dist/
|
dist/
|
||||||
|
coverage/
|
||||||
|
reports/
|
||||||
tmp/
|
tmp/
|
||||||
|
|
|
||||||
1
.node-version
Normal file
1
.node-version
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
26
|
||||||
13
AGENTS.md
13
AGENTS.md
|
|
@ -1,6 +1,11 @@
|
||||||
This repository contains the Codex Panel Obsidian plugin.
|
This repository contains the Codex Panel Obsidian plugin.
|
||||||
|
|
||||||
Jujutsu is the recommended local change-management workflow when available.
|
## 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.
|
||||||
|
- 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.
|
||||||
|
|
||||||
## What To Read
|
## What To Read
|
||||||
|
|
||||||
|
|
@ -10,4 +15,8 @@ Jujutsu is the recommended local change-management workflow when available.
|
||||||
- Read `docs/release.md` for release preparation, release notes, preflight, tagging, pushing, and release repair.
|
- Read `docs/release.md` for release preparation, release notes, preflight, tagging, pushing, and release repair.
|
||||||
- Use the repo-local skills in `.agents/skills/` when a task matches a more specific workflow.
|
- Use the repo-local skills in `.agents/skills/` when a task matches a more specific workflow.
|
||||||
|
|
||||||
When implementation, design, or workflow changes create drift from docs or lint policy, update the affected docs or lint settings in the same change and report that update.
|
## Changes And Validation
|
||||||
|
|
||||||
|
- Jujutsu is the recommended local change-management workflow when available. Make each final change a coherent review unit with an honest description.
|
||||||
|
- Before publishing, inspect and reorganize the graph when needed rather than preserving implementation chronology: normally fold corrective follow-ups into the concern they complete, split mixed changes, and keep a follow-up separate only when it remains meaningful on its own.
|
||||||
|
- Use Conventional Commits for new commits and follow `docs/development.md` for repository rules and validation. Re-run relevant validation after history edits and before handoff or publication.
|
||||||
|
|
|
||||||
48
README.md
48
README.md
|
|
@ -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.
|
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. The plugin handles the Obsidian side of the workflow, while models, credentials, sandboxing, tools, hooks, and thread state stay with Codex.
|
If the Codex CLI is already installed and authenticated, Codex Panel uses that local setup.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|
@ -35,34 +35,46 @@ You can also open the plugin page directly: <https://community.obsidian.md/plugi
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
1. Open the command palette and run **Codex Panel: Open panel**, or select the ribbon icon.
|
1. Open the note or project you want to work on.
|
||||||
2. If Obsidian cannot find `codex`, set **Settings -> Codex Panel -> Codex executable** to an absolute path such as `/opt/homebrew/bin/codex`.
|
2. Run **Codex Panel: Open panel** from the command palette, or select the ribbon icon.
|
||||||
3. Ask Codex to work on the vault or project from the composer.
|
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. Return to earlier work from **Codex Panel: Open thread...** or **Codex Panel: Open threads view**.
|
4. Follow the turn in the panel, respond to requests when needed, and review any file changes before continuing.
|
||||||
|
|
||||||
## Using Codex Panel
|
If Obsidian cannot find `codex`, set **Settings -> Codex Panel -> Codex executable** to an absolute path such as `/opt/homebrew/bin/codex`.
|
||||||
|
|
||||||
Each panel can keep a separate conversation, so related work can stay open side by side. 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.
|
## Working with Codex Panel
|
||||||
|
|
||||||
The composer understands Obsidian links. It suggests vault files and recent notes, keeps wikilinks readable, includes linked files with your prompt, and opens file links from Codex replies back in Obsidian. Use `@active-note` or `@selection` to include the active note or current Markdown selection without pasting it manually. Paste or drop files to save them in the attachment folder and reference them from the same prompt.
|
### Keep work separate or pick it up later
|
||||||
|
|
||||||
Completions cover slash commands, `$skill-name` skills, recent threads, models, and reasoning levels. Use `/help` for the current command list.
|
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.
|
||||||
|
|
||||||
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 message stream.
|
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.
|
||||||
|
|
||||||
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.
|
### Bring in the right context
|
||||||
|
|
||||||
Use the toolbar, composer status row, or slash commands to inspect usage, adjust turn settings, and check Codex connection details.
|
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.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
## Compatibility
|
## Compatibility
|
||||||
|
|
||||||
| Key | Version | Policy |
|
| Key | Version | Policy |
|
||||||
| ------------------------ | --------- | --------------------------------------------------------------------------------------------------- |
|
| -------------------------------------- | --------- | --------------------------------------------------------------------------------------------------- |
|
||||||
| `manifest.minAppVersion` | `1.12.0` | Minimum Obsidian desktop version declared for plugin loading. |
|
| `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. |
|
| `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.142.0` | Track app-server compatibility by Codex CLI minor version. |
|
| `codexAppServer.testedCliVersion` | `0.145.0` | 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.
|
Codex Panel depends on the experimental `codex app-server` API.
|
||||||
|
|
||||||
|
|
|
||||||
45
biome.jsonc
45
biome.jsonc
|
|
@ -56,11 +56,21 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "./scripts/grit/import-boundaries/no-app-server-connection-boundary-imports.grit",
|
"path": "./scripts/grit/import-boundaries/no-app-server-connection-boundary-imports.grit",
|
||||||
"includes": ["**/src/app-server/protocol/**/*.ts", "**/src/domain/**/*.ts", "**/src/shared/**/*.{ts,tsx}"]
|
"includes": [
|
||||||
|
"**/src/app-server/protocol/**/*.ts",
|
||||||
|
"**/src/domain/**/*.ts",
|
||||||
|
"**/src/shared/**/*.{ts,tsx}",
|
||||||
|
"**/src/features/selection-rewrite/**/*.{ts,tsx}",
|
||||||
|
"!**/src/features/selection-rewrite/app-server-transport.ts"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "./scripts/grit/import-boundaries/no-app-server-protocol-boundary-imports.grit",
|
"path": "./scripts/grit/import-boundaries/no-app-server-protocol-boundary-imports.grit",
|
||||||
"includes": ["**/src/**/*.{ts,tsx}", "!**/src/app-server/**"]
|
"includes": ["**/src/**/*.{ts,tsx}", "!**/src/app-server/**", "!**/src/features/chat/app-server/mappers/thread-stream/turn-items.ts"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./scripts/grit/import-boundaries/no-chat-turn-item-non-turn-protocol-imports.grit",
|
||||||
|
"includes": ["**/src/features/chat/app-server/mappers/thread-stream/turn-items.ts"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "./scripts/grit/import-boundaries/no-generated-app-server-boundary-imports.grit",
|
"path": "./scripts/grit/import-boundaries/no-generated-app-server-boundary-imports.grit",
|
||||||
|
|
@ -68,6 +78,7 @@
|
||||||
"**/src/**/*.{ts,tsx}",
|
"**/src/**/*.{ts,tsx}",
|
||||||
"!**/src/app-server/connection/**",
|
"!**/src/app-server/connection/**",
|
||||||
"!**/src/app-server/protocol/server-requests.ts",
|
"!**/src/app-server/protocol/server-requests.ts",
|
||||||
|
"!**/src/app-server/protocol/thread.ts",
|
||||||
"!**/src/app-server/protocol/turn.ts"
|
"!**/src/app-server/protocol/turn.ts"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
@ -88,6 +99,10 @@
|
||||||
"path": "./scripts/grit/import-boundaries/no-workspace-chat-internal-imports.grit",
|
"path": "./scripts/grit/import-boundaries/no-workspace-chat-internal-imports.grit",
|
||||||
"includes": ["**/src/workspace/**/*.ts"]
|
"includes": ["**/src/workspace/**/*.ts"]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "./scripts/grit/import-boundaries/no-thread-workflow-app-server-imports.grit",
|
||||||
|
"includes": ["**/src/features/threads/workflows/**/*.ts"]
|
||||||
|
},
|
||||||
|
|
||||||
// Chat feature layer boundaries.
|
// Chat feature layer boundaries.
|
||||||
{
|
{
|
||||||
|
|
@ -116,32 +131,6 @@
|
||||||
},
|
},
|
||||||
|
|
||||||
// Chat state and runtime ownership.
|
// 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",
|
"path": "./scripts/grit/runtime/no-state-module-side-effects.grit",
|
||||||
"includes": ["**/src/features/chat/application/state/**/*.ts"]
|
"includes": ["**/src/features/chat/application/state/**/*.ts"]
|
||||||
|
|
|
||||||
9
commitlint.config.mjs
Normal file
9
commitlint.config.mjs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
export default {
|
||||||
|
defaultIgnores: false,
|
||||||
|
extends: ["@commitlint/config-conventional"],
|
||||||
|
ignores: [(message) => /^Merge pull request #\d+ from \S+/.test(message)],
|
||||||
|
rules: {
|
||||||
|
"subject-case": [0],
|
||||||
|
"type-enum": [2, "always", ["build", "chore", "ci", "docs", "feat", "fix", "perf", "refactor", "revert", "test"]],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -8,7 +8,15 @@ This document records durable design direction. User-facing behavior belongs in
|
||||||
|
|
||||||
Keep the panel thin. Codex Panel owns the Obsidian experience around Codex: panels, composing, vault-aware link handoff, approvals and user input, file-change review, archive export, panel preferences, diagnostics, and selection rewrite.
|
Keep the panel thin. Codex Panel owns the Obsidian experience around Codex: panels, composing, vault-aware link handoff, approvals and user input, file-change review, archive export, panel preferences, diagnostics, and selection rewrite.
|
||||||
|
|
||||||
Codex owns runtime behavior and thread state: models, credentials, sandboxing, approval policy, MCP servers, hooks, providers, 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.
|
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.
|
Panel settings should store only panel-specific preferences. Do not mirror Codex configuration in Obsidian settings just to display or inspect it.
|
||||||
|
|
||||||
|
|
@ -18,7 +26,11 @@ Panel settings should store only panel-specific preferences. Do not mirror Codex
|
||||||
|
|
||||||
The app-server API is experimental. The project tracks the supported Codex CLI minor and favors a clean current flow over broad old-protocol compatibility.
|
The app-server API is experimental. The project tracks the supported Codex CLI minor and favors a clean current flow over broad old-protocol compatibility.
|
||||||
|
|
||||||
Runtime controls should express visible user intent for the active thread. They should not copy Codex configuration, and diagnostics should expose only actionable troubleshooting facts.
|
Runtime controls should express visible user intent for the active thread rather than copy Codex configuration. Diagnostics should expose only actionable troubleshooting facts.
|
||||||
|
|
||||||
|
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
|
## Code Boundaries
|
||||||
|
|
||||||
|
|
@ -36,27 +48,45 @@ Do not hide complexity behind forwarding layers. Add an abstraction only when it
|
||||||
|
|
||||||
## UI Ownership
|
## UI Ownership
|
||||||
|
|
||||||
Runtime UI composition is Preact-owned. Preact components should render the panel shell, toolbar, message stream, composer, and request controls.
|
Runtime UI composition is Preact-owned. Preact components should render the panel shell, toolbar, thread stream, composer, and request controls.
|
||||||
|
|
||||||
Obsidian and app-server boundaries stay outside Preact components. External lifecycles, app-server connections, editor/workspace APIs, and rendering bridges belong in boundary modules.
|
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 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.
|
Chat-visible state should have one authoritative owner. Components should consume narrow projections of that state rather than mirror it into another reactive store.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## Interaction Principles
|
## 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.
|
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.
|
||||||
|
|
||||||
Thread history, archived state, forks, catalog snapshots, 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 of Codex state, not replacements for Codex history.
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Routine thread lists should load a bounded recent set and paginate older threads on demand instead of eagerly fetching complete inventories.
|
||||||
|
|
||||||
Selection rewrite is intentionally scoped to a focused edit-and-review workflow. Avoid expanding it into a broader writing assistant without a separate design decision.
|
Selection rewrite is intentionally scoped to a focused edit-and-review workflow. Avoid expanding it into a broader writing assistant without a separate design decision.
|
||||||
|
|
||||||
Server requests should become panel UI only when the user can naturally answer them in context. Unknown or unsupported requests should stay diagnostic instead of pretending to be normal conversation text.
|
Server requests should become panel UI only when the user can naturally answer them in context. Unknown or unsupported requests should stay diagnostic instead of pretending to be normal conversation text.
|
||||||
|
|
||||||
Message stream display should separate primary conversation from diagnostic detail and progress/status. Preserve stable item identity across history, streaming, and rendering updates.
|
Thread stream display should separate primary conversation from diagnostic detail and progress/status. Preserve stable item identity across history, streaming, and rendering updates.
|
||||||
|
|
||||||
Codex Panel UI should feel native inside Obsidian. Prefer Obsidian variables, standard classes, and side-panel patterns. Add custom visual treatment only when Codex-specific state would otherwise be hard to read.
|
Codex Panel UI should feel native inside Obsidian. Prefer Obsidian variables, standard classes, and side-panel patterns. Add custom visual treatment only when Codex-specific state would otherwise be hard to read.
|
||||||
|
|
||||||
|
|
@ -64,8 +94,8 @@ Codex Panel UI should feel native inside Obsidian. Prefer Obsidian variables, st
|
||||||
|
|
||||||
Tests should protect user expectations, app-server/panel responsibility boundaries, and state-transition invariants.
|
Tests should protect user expectations, app-server/panel responsibility boundaries, and state-transition invariants.
|
||||||
|
|
||||||
Prefer tests for visible behavior: independent panels, thread-scoped resets, pending request handling, approval flows, readable transcript/detail/status grouping, scroll preservation, and display fallbacks for structured app-server values.
|
Prefer tests for visible behavior and state-transition invariants across panels, threads, requests, streams, and display fallbacks.
|
||||||
|
|
||||||
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.
|
Avoid tests that freeze incidental implementation details 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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,37 @@ Use this document for day-to-day implementation mechanics: commands, generated f
|
||||||
npm ci
|
npm ci
|
||||||
npm run fix
|
npm run fix
|
||||||
npm run check
|
npm run check
|
||||||
|
npm run test:coverage
|
||||||
|
npm run test:mutation
|
||||||
```
|
```
|
||||||
|
|
||||||
Use this as the normal edit loop: make the change, run `npm run fix`, then run `npm run check`. Treat `npm run fix` as trusted mechanical cleanup for formatting, import ordering, and Knip safe fixes; review the resulting diff at normal change-boundary checkpoints rather than after each tool adjustment.
|
Use the Node.js version in `.node-version`.
|
||||||
|
|
||||||
Use focused scripts such as `npm run typecheck`, `npm run test`, or `npm run build` only when diagnosing a specific failure or when a full check would obscure the signal while iterating. Do not treat focused scripts as a substitute for the final `npm run check`. CI and release preflight run the same `npm run check` command as local development.
|
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:
|
||||||
|
|
||||||
|
```text
|
||||||
|
feat(composer): add daily note context suggestions
|
||||||
|
fix: prevent manual titles from being overwritten
|
||||||
|
```
|
||||||
|
|
||||||
|
Use one of `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, or `test`. Scopes are optional. Keep the description concise and omit a trailing period; capitalization is unrestricted. Use `!` before the colon or a `BREAKING CHANGE:` footer for a disruptive change.
|
||||||
|
|
||||||
|
CI checks commits introduced by pull requests and direct pushes; GitHub-generated pull-request merge commits are exempt. To check a range locally, run `npm run commitlint -- --from <base> --to <head> --verbose`.
|
||||||
|
|
||||||
|
Use focused scripts while iterating, but run `npm run check` before handoff. CI and release preflight use the same check.
|
||||||
|
|
||||||
Keep rule suppressions local and include the Obsidian-specific reason when a native Obsidian UI pattern intentionally diverges from a generic browser rule.
|
Keep rule suppressions local and include the Obsidian-specific reason when a native Obsidian UI pattern intentionally diverges from a generic browser rule.
|
||||||
|
|
||||||
|
Grit policy cases protect matcher semantics rather than diagnostic wording, source locations, or the exact shape of Biome configuration. Keep one valid and invalid source case for each policy, and change a case only when that policy's accepted or rejected source shape intentionally changes.
|
||||||
|
|
||||||
## Generated and Loaded Files
|
## Generated and Loaded Files
|
||||||
|
|
||||||
`main.js`, `styles.css`, `data.json`, and `node_modules/` are ignored by Git. `main.js` and `styles.css` are still the files Obsidian loads, so run `npm run build` before live Obsidian validation if you have not already run `npm run check` after the source change.
|
`main.js`, `styles.css`, `data.json`, and `node_modules/` are ignored by Git. `main.js` and `styles.css` are still the files Obsidian loads, so run `npm run build` before live Obsidian validation if you have not already run `npm run check` after the source change.
|
||||||
|
|
@ -26,60 +49,45 @@ The app-server TypeScript bindings in `src/generated/app-server/` are generated
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm run generate:app-server-types
|
npm run generate:app-server-types
|
||||||
|
npm run generate:app-server-types:check
|
||||||
npm run check
|
npm run check
|
||||||
```
|
```
|
||||||
|
|
||||||
The generation script uses `codex app-server generate-ts --experimental` because the panel depends on experimental app-server fields. Do not hand-edit generated bindings.
|
Do not hand-edit the bindings. `src/app-server/connection/compatibility.json` records their CLI patch and generation arguments; the check command regenerates and compares them without replacing tracked files.
|
||||||
|
|
||||||
## Placement Rules
|
## Placement Rules
|
||||||
|
|
||||||
The source tree is organized by implementation ownership, not by the single Obsidian plugin entrypoint. Put behavior where its reason to change lives: app-server protocol adaptation at the app-server boundary, app-server-independent domain models in domain code, cross-feature reusable adapters and primitives under `src/shared/`, and feature workflows under their owning feature. Keep shared subfolders named by the boundary or primitive they own, such as DOM, Obsidian, runtime, or UI rendering.
|
Follow the ownership boundaries in `docs/design.md` and the import and source-shape lint rules. Put behavior where its reason to change lives, and update the lint policy and its tests when intentionally changing a boundary.
|
||||||
|
|
||||||
Within chat, keep state transitions and workflow orchestration separate from app-server adaptation, session/Obsidian wiring, and rendering surfaces. Tests should mirror the ownership boundary of the code under test.
|
Keep generated protocol types behind app-server adapters and expose panel-owned contracts or projections to features. Keep direct DOM work in explicit bridge, measurement, or rendering modules; use the established filename suffixes for those boundaries and reserve `.tsx` for rendering.
|
||||||
|
|
||||||
Keep new code near the state or API it owns. A feature may import another feature only for a capability that feature owns. When moving behavior across an ownership boundary or adding a new shared classification, update the lint policy and its tests with that boundary in the same change.
|
|
||||||
|
|
||||||
Generated app-server types should stay behind app-server connection and protocol adapter modules. If domain, shared code, settings, workspace, or feature code needs app-server data, add or reuse a panel-owned projection at the boundary instead of importing generated payload types directly.
|
|
||||||
|
|
||||||
Chat application workflows should receive chat-owned contracts, not root `src/app-server/` modules or direct `AppServerClient` access. Keep app-server access, connection freshness checks, vault-path injection, and payload projection in `src/features/chat/app-server/` transports or host-owned wiring.
|
|
||||||
|
|
||||||
Chat panel-visible state belongs in `ChatStateStore` and should flow through named reducer actions and the panel read model adapter. Use Preact Signals only in chat panel rendering adapters such as the shell read model and surface projections. When a surface needs fewer dependencies, narrow the read model instead of passing broad reducer slices.
|
|
||||||
|
|
||||||
Chat feature dependencies should flow from pure workflow and meaning code toward owned adapters and render surfaces. Lower layers must not reach into host/session wiring, panel internals, or UI implementation details.
|
|
||||||
|
|
||||||
Chat modules should not import `src/workspace/` directly; workspace operations enter chat through host contracts, while workspace modules may coordinate concrete Obsidian chat views.
|
|
||||||
|
|
||||||
Use DOM reads, writes, measurements, hit-tests, focus/selection operations, and DOM event listener wiring only from explicit bridge modules, Obsidian-owned API boundaries, or rendering and measurement code that cannot be expressed cleanly as Preact components. Normal modules may keep refs and call named adapters, but they should not interpret DOM structure or layout directly. Name bridge files with a `.dom`, `.obsidian`, or `.measure` suffix.
|
|
||||||
|
|
||||||
Use `.tsx` only where the module owns rendering or a host rendering bridge. Non-rendering source should use `.ts`; when a new rendering-owned location is intentional, update the source-shape lint policy and its tests rather than relying on convention alone.
|
|
||||||
|
|
||||||
## CSS Rules
|
## CSS Rules
|
||||||
|
|
||||||
CSS should stay native to Obsidian. Prefer Obsidian variables and Codex Panel tokens for color, typography, spacing, and layout dimensions instead of hardcoded values.
|
CSS should stay native to Obsidian. Prefer Obsidian variables and Codex Panel tokens for color, typography, spacing, and layout dimensions instead of hardcoded values.
|
||||||
|
|
||||||
Keep selectors shallow and specific to Codex Panel classes. Avoid broad invalidation patterns such as `:has()`, hidden specificity inside `:where()`, IDs, and universal selectors. Keep needed type selectors under Codex Panel-owned class roots for Obsidian-rendered or semantic child DOM. Add new authored CSS files to `src/styles/order.json`, and remove unused or test-only `codex-panel` classes instead of keeping dead styles.
|
Keep selectors shallow and scoped to Codex Panel classes. Add new authored CSS files to `src/styles/order.json`, and remove unused or test-only classes.
|
||||||
|
|
||||||
## Naming Conventions
|
## Naming Conventions
|
||||||
|
|
||||||
Name modules by responsibility: use `Controller` only for stateful lifecycle/control surfaces, `Handler` for inbound event or request entrypoints, `Actions` for caller-facing command or callback bundles without lifecycle ownership, `Coordinator` for stateful background or cross-surface coordination, `Service` for reusable domain capabilities, `Presenter` for UI state projection, `Renderer` for render-only UI contracts, and `Host`/`Ports` for dependency boundary objects. Use `State`, `Snapshot`, `Projection`, `ViewModel`, `Options`, `Context`, `Result`, `Target`, `Capabilities`, or `ActionTargets` for passive value objects; do not use `Actions` for passive values. Use boundary/infrastructure nouns such as `Client`, `Transport`, `Cache`, `Store`, `Catalog`, `Manager`, `Bridge`, `Tracker`, `Session`, `Runtime`, `Provider`, or `Adapter` only when the object owns that concrete boundary or lifecycle role.
|
Name modules by owned responsibility. Use lifecycle or boundary nouns only when the object owns that lifecycle or boundary, and passive-data names for values.
|
||||||
|
|
||||||
Prefer functions and factory-created objects over classes. Use a class only when it owns mutable lifecycle/resource state, extends or implements an external class-based API such as Obsidian views/modals/tabs, or represents an `Error`; do not use a class merely to group pure functions or dependencies.
|
Prefer functions and factories. Reserve classes for mutable resource ownership, external class APIs, and `Error` types.
|
||||||
|
|
||||||
## Common Pitfalls
|
## Common Pitfalls
|
||||||
|
|
||||||
- Build before Obsidian validation. Obsidian loads ignored root assets, not the TypeScript or authored CSS sources directly.
|
- Build before Obsidian validation. Obsidian loads ignored root assets, not the TypeScript or authored CSS sources directly.
|
||||||
- Preserve last-known-good app-server state on refresh failure. Do not turn disconnected reads into authoritative empty thread lists, settings snapshots, hook inventories, or diagnostics.
|
- Preserve last-known-good app-server state on refresh failure. Do not turn disconnected reads into authoritative empty thread lists, settings snapshots, hook inventories, or diagnostics.
|
||||||
- Normalize optional and nullable app-server values before display. Users should not see raw `undefined`, `null`, protocol enum gaps, or fallback labels that imply Panel owns a Codex runtime setting.
|
- Normalize optional and nullable app-server values before display. Users should not see raw `undefined`, `null`, protocol enum gaps, or fallback labels that imply Panel owns a Codex runtime setting.
|
||||||
- Do not use DOM order as message history state. Message stream DOM is a presentation surface, and delayed Markdown rendering can change heights after initial render.
|
- Do not use DOM order as thread history state. Thread stream DOM is a presentation surface, and delayed Markdown rendering can change heights after initial render.
|
||||||
|
|
||||||
## API Baselines
|
## API Baselines
|
||||||
|
|
||||||
Use the local API baseline report when checking whether the development environment matches the supported API policy:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm run api:baseline
|
npm run api:baseline
|
||||||
```
|
```
|
||||||
|
|
||||||
Obsidian runtime compatibility is declared through `manifest.json` and `versions.json`. The `obsidian` npm package provides compile-time TypeScript API definitions; it is not runtime validation for an Obsidian app-version matrix. Because the project does not run app-version smoke tests, keep the API type package in the same minor as `manifest.minAppVersion` and use the latest patch in that minor for local type checking. `npm run api:baseline` exits non-zero when the local environment or recorded baselines drift. Raise `manifest.minAppVersion` only when intentionally adopting a newer Obsidian app/API minor.
|
Obsidian runtime compatibility is declared through `manifest.json` and `versions.json`. The `obsidian` npm package provides compile-time TypeScript API definitions; it is not runtime validation for an Obsidian app-version matrix. Because the project does not run app-version smoke tests, keep the API type package in the same minor as `manifest.minAppVersion` and use the latest patch in that minor for local type checking. `npm run api:baseline` exits non-zero when the local environment or recorded baselines drift. Raise `manifest.minAppVersion` only when intentionally adopting a newer Obsidian app/API minor.
|
||||||
|
|
||||||
Codex app-server compatibility is managed by Codex CLI minor version. README records the tested Codex CLI patch version, and the baseline check verifies that the local `codex --version` is in the same minor before app-server binding or compatibility work.
|
Codex app-server compatibility is managed by CLI minor version. `src/app-server/connection/compatibility.json` is the source of truth for the exact generation patch and app-server capabilities; README displays that patch.
|
||||||
|
|
||||||
|
Local compatibility work should run `npm run api:baseline` and `npm run generate:app-server-types:check`; CI runs the same verification with the recorded CLI.
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,15 @@ GitHub Releases attach only `main.js`, `manifest.json`, and `styles.css` as Obsi
|
||||||
|
|
||||||
Release work is Jujutsu-first in a colocated Git repository; Git is still used to push the tag that triggers the GitHub Release workflow. If the checkout has not been initialized for Jujutsu yet, run `jj git init --colocate` and `jj bookmark track main --remote=origin` once.
|
Release work is Jujutsu-first in a colocated Git repository; Git is still used to push the tag that triggers the GitHub Release workflow. If the checkout has not been initialized for Jujutsu yet, run `jj git init --colocate` and `jj bookmark track main --remote=origin` once.
|
||||||
|
|
||||||
Plugin versions use SemVer-shaped numbers for Obsidian distribution, but they are not a library API compatibility contract. Prefer patch releases for fixes, dependency updates, internal changes, and compatibility refreshes that preserve existing workflows, including routine Codex CLI app-server compatibility updates. Prefer minor releases for user-visible capabilities, settings, workflow additions, or supported-runtime baseline changes such as raising the minimum supported Obsidian app/API version. Reserve major releases for disruptive workflow, settings, storage, or support-policy changes.
|
Plugin versions are for Obsidian distribution, not a library API compatibility contract. Use patch releases for workflow-preserving changes, minor releases for visible capabilities or raised runtime baselines, and major releases for disruptive workflow, settings, storage, or support-policy changes.
|
||||||
|
|
||||||
Create a release by preparing the next version, editing the generated release notes, committing the release changes, then running the preflight before pushing the matching tag:
|
Create a release by preparing the next version, reviewing and editing the generated release notes, committing the release changes, then running the preflight before pushing the matching tag:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm run release:prepare -- X.Y.Z
|
npm run release:prepare -- X.Y.Z
|
||||||
# Edit .github/release-notes/X.Y.Z.md.
|
# Review and edit .github/release-notes/X.Y.Z.md.
|
||||||
jj status
|
jj status
|
||||||
jj commit -m "Bump version to X.Y.Z"
|
jj commit -m "chore(release): X.Y.Z"
|
||||||
jj bookmark move main --to @-
|
jj bookmark move main --to @-
|
||||||
npm run release:preflight
|
npm run release:preflight
|
||||||
jj tag set X.Y.Z -r main
|
jj tag set X.Y.Z -r main
|
||||||
|
|
@ -20,8 +20,10 @@ jj git push --remote origin --bookmark main
|
||||||
git push origin X.Y.Z
|
git push origin X.Y.Z
|
||||||
```
|
```
|
||||||
|
|
||||||
`release:prepare` updates the version files and creates a `## Changes` release notes template. `release:preflight` verifies the local Jujutsu/Git state, release metadata, API baselines, lockfile, and the same `npm run check` validation used by CI after the release commit is on `main`.
|
`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.
|
||||||
|
|
||||||
|
Run `release:preflight` after the release commit is on `main`; it validates the commit range and release readiness.
|
||||||
|
|
||||||
Release notes should normally include only user-facing changes. Internal implementation changes, validation details, and release procedure notes should be omitted when minor; when they are important enough to mention, group them into at most one concise bullet.
|
Release notes should normally include only user-facing changes. Internal implementation changes, validation details, and release procedure notes should be omitted when minor; when they are important enough to mention, group them into at most one concise bullet.
|
||||||
|
|
||||||
The release workflow runs `npm ci`, `npm run release:check`, `npm run check`, attaches the install assets, and generates GitHub artifact attestations for them. The release notes file is required and must contain a single `## Changes` section. If a tag-triggered release fails before creating the GitHub Release, fix the commit, move the local tag with `jj tag set --allow-move -r main X.Y.Z`, then update the remote tag with `git push --force origin X.Y.Z`.
|
The release notes file must contain a single `## Changes` section. The tag-triggered workflow validates and publishes the install assets. If it fails before creating the GitHub Release, fix the commit, move the local tag with `jj tag set --allow-move -r main X.Y.Z`, then update the remote tag with `git push --force origin X.Y.Z`.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"id": "codex-panel",
|
"id": "codex-panel",
|
||||||
"name": "Codex Panel",
|
"name": "Codex Panel",
|
||||||
"version": "4.1.1",
|
"version": "5.2.1",
|
||||||
"minAppVersion": "1.12.0",
|
"minAppVersion": "1.12.0",
|
||||||
"description": "Codex in your sidebar.",
|
"description": "Codex in your sidebar.",
|
||||||
"author": "murashit",
|
"author": "murashit",
|
||||||
|
|
|
||||||
4270
package-lock.json
generated
4270
package-lock.json
generated
File diff suppressed because it is too large
Load diff
48
package.json
48
package.json
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "codex-panel",
|
"name": "codex-panel",
|
||||||
"version": "4.1.1",
|
"version": "5.2.1",
|
||||||
"description": "Codex in your Obsidian sidebar.",
|
"description": "Codex in your Obsidian sidebar.",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"author": "murashit",
|
"author": "murashit",
|
||||||
|
|
@ -13,10 +13,14 @@
|
||||||
"url": "https://github.com/murashit/codex-panel/issues"
|
"url": "https://github.com/murashit/codex-panel/issues"
|
||||||
},
|
},
|
||||||
"homepage": "https://github.com/murashit/codex-panel#readme",
|
"homepage": "https://github.com/murashit/codex-panel#readme",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=26"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"api:baseline": "node scripts/api-baseline.mjs",
|
"api:baseline": "node scripts/api-baseline.mjs",
|
||||||
"build": "node esbuild.config.mjs",
|
"build": "node esbuild.config.mjs",
|
||||||
"build:styles": "node scripts/build-styles.mjs",
|
"build:styles": "node scripts/build-styles.mjs",
|
||||||
|
"commitlint": "commitlint",
|
||||||
"check": "concurrently --group --pad-prefix 'node:check:*' && node --run build",
|
"check": "concurrently --group --pad-prefix 'node:check:*' && node --run build",
|
||||||
"check:biome": "biome check --diagnostic-level=warn --error-on-warnings",
|
"check:biome": "biome check --diagnostic-level=warn --error-on-warnings",
|
||||||
"check:css-usage": "node scripts/check-css-usage.mjs",
|
"check:css-usage": "node scripts/check-css-usage.mjs",
|
||||||
|
|
@ -24,32 +28,50 @@
|
||||||
"check:test": "node --run test",
|
"check:test": "node --run test",
|
||||||
"check:typecheck": "node --run typecheck",
|
"check:typecheck": "node --run typecheck",
|
||||||
"check:unused": "knip --no-progress",
|
"check:unused": "knip --no-progress",
|
||||||
"fix": "biome check --fix --diagnostic-level=warn --error-on-warnings && knip --fix --no-progress",
|
"fix": "biome check --fix --skip=plugin --diagnostic-level=warn --error-on-warnings && knip --fix --no-progress",
|
||||||
"generate:app-server-types": "node scripts/generate-app-server-types.mjs",
|
"generate:app-server-types": "node scripts/generate-app-server-types.mjs",
|
||||||
|
"generate:app-server-types:check": "node scripts/generate-app-server-types.mjs --check",
|
||||||
"release:check": "node scripts/release/check.mjs",
|
"release:check": "node scripts/release/check.mjs",
|
||||||
|
"release:notes": "node scripts/release/notes.mjs",
|
||||||
"release:preflight": "node scripts/release/preflight.mjs",
|
"release:preflight": "node scripts/release/preflight.mjs",
|
||||||
"release:prepare": "node scripts/release/prepare.mjs",
|
"release:prepare": "node scripts/release/prepare.mjs",
|
||||||
"test": "vitest run --pool threads --maxWorkers 6",
|
"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"
|
"typecheck": "tsc -p tsconfig.json --noEmit --incremental --tsBuildInfoFile node_modules/.cache/typescript/tsconfig.tsbuildinfo"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.5.1",
|
"@biomejs/biome": "^2.5.3",
|
||||||
"@types/node": "^25.9.3",
|
"@commitlint/cli": "^21.2.1",
|
||||||
"concurrently": "^10.0.3",
|
"@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",
|
||||||
|
"conventional-changelog-conventionalcommits": "^10.2.1",
|
||||||
|
"conventional-commits-parser": "^7.1.0",
|
||||||
"esbuild": "^0.28.0",
|
"esbuild": "^0.28.0",
|
||||||
"eslint": "^10.5.0",
|
"eslint": "^10.6.0",
|
||||||
"eslint-plugin-obsidianmd": "^0.3.0",
|
"eslint-plugin-obsidianmd": "^0.4.1",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
"knip": "^6.17.1",
|
"knip": "^6.25.0",
|
||||||
|
"moment": "^2.30.1",
|
||||||
"obsidian": "~1.12.3",
|
"obsidian": "~1.12.3",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.61.1",
|
"typescript-eslint": "^8.64.0",
|
||||||
"vitest": "^4.1.9"
|
"vitest": "^4.1.10"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@preact/signals": "^2.9.2",
|
"@tanstack/query-core": "^5.101.2",
|
||||||
"@tanstack/query-core": "^5.101.0",
|
"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",
|
"micromark": "^4.0.2",
|
||||||
"preact": "^10.29.2"
|
"obsidian-daily-notes-interface": "^0.9.5",
|
||||||
|
"preact": "^10.29.7",
|
||||||
|
"unist-util-visit": "^5.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,8 +59,10 @@ export async function createApiBaselineReport(options = {}) {
|
||||||
const readmeBaselines = readCompatibilityBaselines(inputs.readme);
|
const readmeBaselines = readCompatibilityBaselines(inputs.readme);
|
||||||
|
|
||||||
const codexReadmeVersion = readmeBaselines.codexTestedCliVersion;
|
const codexReadmeVersion = readmeBaselines.codexTestedCliVersion;
|
||||||
|
const codexRecordedVersion = inputs.appServerCompatibilityJson.codexAppServer?.testedCliVersion ?? null;
|
||||||
const codexLocalVersion = options.readCodexVersion ? await options.readCodexVersion() : readCodexVersion();
|
const codexLocalVersion = options.readCodexVersion ? await options.readCodexVersion() : readCodexVersion();
|
||||||
const codexReadmeSemver = parseSemver(codexReadmeVersion);
|
const codexReadmeSemver = parseSemver(codexReadmeVersion);
|
||||||
|
const codexRecordedSemver = parseSemver(codexRecordedVersion);
|
||||||
const codexLocalSemver = parseSemver(codexLocalVersion);
|
const codexLocalSemver = parseSemver(codexLocalVersion);
|
||||||
|
|
||||||
const obsidianMinVersion = inputs.manifestJson.minAppVersion;
|
const obsidianMinVersion = inputs.manifestJson.minAppVersion;
|
||||||
|
|
@ -73,25 +75,42 @@ export async function createApiBaselineReport(options = {}) {
|
||||||
const obsidianLockSemver = parseSemver(obsidianLockVersion);
|
const obsidianLockSemver = parseSemver(obsidianLockVersion);
|
||||||
const obsidianMinSemver = parseSemver(obsidianMinVersion);
|
const obsidianMinSemver = parseSemver(obsidianMinVersion);
|
||||||
|
|
||||||
const appServerGenerationExperimental =
|
const generationArguments = inputs.appServerCompatibilityJson.codexAppServer?.typeGeneration?.arguments ?? null;
|
||||||
inputs.appServerGenerateSource.includes("app-server") &&
|
const expectedGenerationArguments = ["app-server", "generate-ts", "--experimental"];
|
||||||
inputs.appServerGenerateSource.includes("generate-ts") &&
|
const appServerGenerationArgumentsDeclared =
|
||||||
inputs.appServerGenerateSource.includes("--experimental");
|
Array.isArray(generationArguments) && generationArguments.every((argument) => typeof argument === "string");
|
||||||
const initializeExperimentalApi = /experimentalApi:\s*true/.test(inputs.clientSource);
|
const appServerGenerationArgumentsSupported =
|
||||||
const initializeRequestAttestationDisabled = /requestAttestation:\s*false/.test(inputs.clientSource);
|
appServerGenerationArgumentsDeclared && arraysEqual(generationArguments, expectedGenerationArguments);
|
||||||
|
const initializeCapabilities = inputs.appServerCompatibilityJson.codexAppServer?.initialize?.capabilities ?? {};
|
||||||
|
const initializeExperimentalApi = initializeCapabilities.experimentalApi === true;
|
||||||
|
const initializeRequestAttestationDisabled = initializeCapabilities.requestAttestation === false;
|
||||||
|
|
||||||
if (!codexReadmeSemver) {
|
if (!codexReadmeSemver) {
|
||||||
fail("README.md Compatibility table must define `codex.testedCliVersion` as X.Y.Z.");
|
fail("README.md Compatibility table must define `codexAppServer.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.");
|
||||||
}
|
}
|
||||||
if (!codexLocalSemver) {
|
if (!codexLocalSemver) {
|
||||||
fail("local codex --version could not be read.");
|
fail("local codex --version could not be read.");
|
||||||
}
|
}
|
||||||
if (codexReadmeSemver && codexLocalSemver && minorKey(codexReadmeSemver) !== minorKey(codexLocalSemver)) {
|
if (codexReadmeVersion && codexRecordedVersion && codexReadmeVersion !== codexRecordedVersion) {
|
||||||
fail(`local Codex CLI minor ${minorKey(codexLocalSemver)} does not match compatibility table minor ${minorKey(codexReadmeSemver)}.`);
|
fail(`README Codex CLI ${codexReadmeVersion} does not match recorded tested CLI ${codexRecordedVersion}.`);
|
||||||
|
}
|
||||||
|
if (codexLocalSemver && codexRecordedSemver && codexLocalSemver.version !== codexRecordedSemver.version) {
|
||||||
|
fail(`local Codex CLI ${codexLocalSemver.version} does not match recorded tested CLI ${codexRecordedSemver.version}.`);
|
||||||
|
}
|
||||||
|
if (!appServerGenerationArgumentsDeclared) {
|
||||||
|
fail("src/app-server/connection/compatibility.json must declare codexAppServer.typeGeneration.arguments as a string array.");
|
||||||
|
} else if (!appServerGenerationArgumentsSupported) {
|
||||||
|
fail(`app-server type generation arguments must be ${expectedGenerationArguments.join(" ")}.`);
|
||||||
|
}
|
||||||
|
if (!initializeExperimentalApi) {
|
||||||
|
fail("src/app-server/connection/compatibility.json must declare codexAppServer.initialize.capabilities.experimentalApi: true.");
|
||||||
|
}
|
||||||
|
if (!initializeRequestAttestationDisabled) {
|
||||||
|
fail("src/app-server/connection/compatibility.json must declare codexAppServer.initialize.capabilities.requestAttestation: false.");
|
||||||
}
|
}
|
||||||
if (!appServerGenerationExperimental) fail("generate:app-server-types must use codex app-server generate-ts --experimental.");
|
|
||||||
if (!initializeExperimentalApi) fail("app-server initialize must declare experimentalApi: true.");
|
|
||||||
if (!initializeRequestAttestationDisabled) fail("app-server initialize must declare requestAttestation: false.");
|
|
||||||
|
|
||||||
if (!obsidianMinSemver) fail("manifest.json minAppVersion must be X.Y.Z.");
|
if (!obsidianMinSemver) fail("manifest.json minAppVersion must be X.Y.Z.");
|
||||||
if (obsidianMinSemver && obsidianMinSemver.patch !== 0) {
|
if (obsidianMinSemver && obsidianMinSemver.patch !== 0) {
|
||||||
|
|
@ -126,13 +145,17 @@ export async function createApiBaselineReport(options = {}) {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
codex: {
|
codex: {
|
||||||
policy: "managed by minor version",
|
policy: "compatibility is managed by minor; generated bindings are proven against an exact CLI patch",
|
||||||
|
recordedTestedCliVersion: codexRecordedVersion,
|
||||||
readmeTestedCliVersion: codexReadmeVersion,
|
readmeTestedCliVersion: codexReadmeVersion,
|
||||||
readmeTestedMinor: minorKey(codexReadmeSemver),
|
readmeTestedMinor: minorKey(codexReadmeSemver),
|
||||||
localCliVersion: codexLocalVersion,
|
localCliVersion: codexLocalVersion,
|
||||||
localCliMinor: minorKey(codexLocalSemver),
|
localCliMinor: minorKey(codexLocalSemver),
|
||||||
localCliMatchesTestedMinor: codexReadmeSemver && codexLocalSemver ? minorKey(codexReadmeSemver) === minorKey(codexLocalSemver) : null,
|
readmeMatchesRecordedVersion: codexReadmeVersion && codexRecordedVersion ? codexReadmeVersion === codexRecordedVersion : false,
|
||||||
appServerGenerationExperimental,
|
localCliMatchesRecordedVersion:
|
||||||
|
codexLocalSemver && codexRecordedSemver ? codexLocalSemver.version === codexRecordedSemver.version : null,
|
||||||
|
generationArguments,
|
||||||
|
appServerGenerationArgumentsSupported,
|
||||||
initializeExperimentalApi,
|
initializeExperimentalApi,
|
||||||
initializeRequestAttestationDisabled,
|
initializeRequestAttestationDisabled,
|
||||||
},
|
},
|
||||||
|
|
@ -157,7 +180,7 @@ function readCompatibilityBaselines(readme) {
|
||||||
const section = markdownSection(readme, "Compatibility");
|
const section = markdownSection(readme, "Compatibility");
|
||||||
const table = readMarkdownTableValues(section);
|
const table = readMarkdownTableValues(section);
|
||||||
return {
|
return {
|
||||||
codexTestedCliVersion: table.get("codex.testedCliVersion") ?? null,
|
codexTestedCliVersion: table.get("codexAppServer.testedCliVersion") ?? null,
|
||||||
obsidianApiTypesVersion: table.get("obsidian") ?? null,
|
obsidianApiTypesVersion: table.get("obsidian") ?? null,
|
||||||
obsidianMinAppVersion: table.get("manifest.minAppVersion") ?? null,
|
obsidianMinAppVersion: table.get("manifest.minAppVersion") ?? null,
|
||||||
};
|
};
|
||||||
|
|
@ -224,20 +247,22 @@ function displayValue(value) {
|
||||||
return value ?? "(missing)";
|
return value ?? "(missing)";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function arraysEqual(left, right) {
|
||||||
|
return left.length === right.length && left.every((value, index) => value === right[index]);
|
||||||
|
}
|
||||||
|
|
||||||
async function readBaselineInputs(cwd) {
|
async function readBaselineInputs(cwd) {
|
||||||
const [packageJson, packageLockJson, manifestJson, versionsJson, readme, clientSource, appServerGenerateSource] = await Promise.all([
|
const [packageJson, packageLockJson, manifestJson, versionsJson, readme, appServerCompatibilityJson] = await Promise.all([
|
||||||
readJson(cwd, "package.json"),
|
readJson(cwd, "package.json"),
|
||||||
readJson(cwd, "package-lock.json"),
|
readJson(cwd, "package-lock.json"),
|
||||||
readJson(cwd, "manifest.json"),
|
readJson(cwd, "manifest.json"),
|
||||||
readJson(cwd, "versions.json"),
|
readJson(cwd, "versions.json"),
|
||||||
readFile(path.join(cwd, "README.md"), "utf8"),
|
readFile(path.join(cwd, "README.md"), "utf8"),
|
||||||
readFile(path.join(cwd, "src/app-server/connection/client.ts"), "utf8"),
|
readJson(cwd, "src/app-server/connection/compatibility.json"),
|
||||||
readFile(path.join(cwd, "scripts/generate-app-server-types.mjs"), "utf8"),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
appServerGenerateSource,
|
appServerCompatibilityJson,
|
||||||
clientSource,
|
|
||||||
manifestJson,
|
manifestJson,
|
||||||
packageJson,
|
packageJson,
|
||||||
packageLockJson,
|
packageLockJson,
|
||||||
|
|
@ -260,10 +285,11 @@ function printReport(report) {
|
||||||
console.log("Codex app-server");
|
console.log("Codex app-server");
|
||||||
console.log(` policy: ${report.codex.policy}`);
|
console.log(` policy: ${report.codex.policy}`);
|
||||||
console.log(` compatibility table CLI: ${displayValue(report.codex.readmeTestedCliVersion)}`);
|
console.log(` compatibility table CLI: ${displayValue(report.codex.readmeTestedCliVersion)}`);
|
||||||
|
console.log(` recorded tested CLI: ${displayValue(report.codex.recordedTestedCliVersion)}`);
|
||||||
console.log(` compatibility table minor: ${displayValue(report.codex.readmeTestedMinor)}`);
|
console.log(` compatibility table minor: ${displayValue(report.codex.readmeTestedMinor)}`);
|
||||||
console.log(` local codex CLI: ${displayValue(report.codex.localCliVersion)}`);
|
console.log(` local codex CLI: ${displayValue(report.codex.localCliVersion)}`);
|
||||||
console.log(` local codex minor: ${displayValue(report.codex.localCliMinor)}`);
|
console.log(` local codex minor: ${displayValue(report.codex.localCliMinor)}`);
|
||||||
console.log(` generate-ts --experimental: ${report.codex.appServerGenerationExperimental ? "yes" : "no"}`);
|
console.log(` generation arguments: ${report.codex.generationArguments?.join(" ") ?? "(missing)"}`);
|
||||||
console.log(` initialize experimentalApi: ${report.codex.initializeExperimentalApi ? "yes" : "no"}`);
|
console.log(` initialize experimentalApi: ${report.codex.initializeExperimentalApi ? "yes" : "no"}`);
|
||||||
console.log(` initialize requestAttestation disabled: ${report.codex.initializeRequestAttestationDisabled ? "yes" : "no"}`);
|
console.log(` initialize requestAttestation disabled: ${report.codex.initializeRequestAttestationDisabled ? "yes" : "no"}`);
|
||||||
console.log("");
|
console.log("");
|
||||||
|
|
|
||||||
49
scripts/app-server-compatibility.mjs
Normal file
49
scripts/app-server-compatibility.mjs
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const compatibilityRelativePath = "src/app-server/connection/compatibility.json";
|
||||||
|
const exactSemverPattern = /^\d+\.\d+\.\d+$/;
|
||||||
|
|
||||||
|
if (isMain()) {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
if (args.length !== 1 || args[0] !== "--tested-cli-version") {
|
||||||
|
console.error("Usage: node scripts/app-server-compatibility.mjs --tested-cli-version");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const policy = await readAppServerGenerationPolicy(process.cwd());
|
||||||
|
console.log(policy.testedCliVersion);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error instanceof Error ? error.message : String(error));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readAppServerGenerationPolicy(cwd) {
|
||||||
|
const compatibilityPath = path.join(cwd, compatibilityRelativePath);
|
||||||
|
const compatibility = JSON.parse(await readFile(compatibilityPath, "utf8"));
|
||||||
|
const testedCliVersion = compatibility.codexAppServer?.testedCliVersion;
|
||||||
|
const generationArguments = compatibility.codexAppServer?.typeGeneration?.arguments;
|
||||||
|
|
||||||
|
if (typeof testedCliVersion !== "string" || !exactSemverPattern.test(testedCliVersion)) {
|
||||||
|
throw new Error(`${compatibilityRelativePath} must declare codexAppServer.testedCliVersion as X.Y.Z.`);
|
||||||
|
}
|
||||||
|
if (!isStringArray(generationArguments) || generationArguments.length === 0) {
|
||||||
|
throw new Error(`${compatibilityRelativePath} must declare codexAppServer.typeGeneration.arguments as a non-empty string array.`);
|
||||||
|
}
|
||||||
|
if (generationArguments.includes("--out")) {
|
||||||
|
throw new Error(`${compatibilityRelativePath} must not include --out in codexAppServer.typeGeneration.arguments.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { generationArguments, testedCliVersion };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStringArray(value) {
|
||||||
|
return Array.isArray(value) && value.every((entry) => typeof entry === "string" && entry.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMain() {
|
||||||
|
return process.argv[1] ? path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) : false;
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,7 @@ export async function buildStyles() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function renderStyles() {
|
export async function renderStyles() {
|
||||||
|
const { version } = JSON.parse(await readFile("package.json", "utf8"));
|
||||||
const parts = [];
|
const parts = [];
|
||||||
|
|
||||||
const sourceFiles = await readStyleOrder();
|
const sourceFiles = await readStyleOrder();
|
||||||
|
|
@ -29,7 +30,7 @@ export async function renderStyles() {
|
||||||
parts.push(content.trimEnd());
|
parts.push(content.trimEnd());
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${parts.join("\n\n")}\n`;
|
return `/* Codex Panel v${version} */\n\n${parts.join("\n\n")}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readStyleOrder() {
|
async function readStyleOrder() {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ const sourceFiles = await filesInTree(path.join(root, "src"), new Set([".ts", ".
|
||||||
const testFiles = await filesInTree(path.join(root, "tests"), new Set([".ts", ".tsx"]));
|
const testFiles = await filesInTree(path.join(root, "tests"), new Set([".ts", ".tsx"]));
|
||||||
|
|
||||||
const cssClasses = await collectCssClasses(cssFiles);
|
const cssClasses = await collectCssClasses(cssFiles);
|
||||||
|
const cssCustomProperties = await collectCssCustomProperties(cssFiles);
|
||||||
|
const referencedCssCustomProperties = await collectReferencedCssCustomProperties(cssFiles);
|
||||||
const sourceTexts = await readTexts(sourceFiles);
|
const sourceTexts = await readTexts(sourceFiles);
|
||||||
const testTexts = await readTexts(testFiles);
|
const testTexts = await readTexts(testFiles);
|
||||||
const sourceText = sourceTexts.map((item) => item.text).join("\n");
|
const sourceText = sourceTexts.map((item) => item.text).join("\n");
|
||||||
|
|
@ -22,6 +24,7 @@ const dynamicPrefixes = collectDynamicClassPrefixes(sourceText);
|
||||||
|
|
||||||
const testOnlyCandidates = [];
|
const testOnlyCandidates = [];
|
||||||
const candidates = [];
|
const candidates = [];
|
||||||
|
const unusedCustomProperties = [];
|
||||||
|
|
||||||
for (const [className, locations] of cssClasses) {
|
for (const [className, locations] of cssClasses) {
|
||||||
const sourceMatches = locationsInTexts(sourceTexts, className);
|
const sourceMatches = locationsInTexts(sourceTexts, className);
|
||||||
|
|
@ -36,8 +39,13 @@ for (const [className, locations] of cssClasses) {
|
||||||
candidates.push({ className, locations });
|
candidates.push({ className, locations });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (candidates.length + testOnlyCandidates.length + dynamicPrefixes.length > 0) {
|
for (const [propertyName, locations] of cssCustomProperties) {
|
||||||
printCandidates({ testOnlyCandidates, candidates, dynamicPrefixes });
|
if (referencedCssCustomProperties.has(propertyName) || locationsInTexts(sourceTexts, propertyName).length > 0) continue;
|
||||||
|
unusedCustomProperties.push({ propertyName, locations });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidates.length + testOnlyCandidates.length + unusedCustomProperties.length + dynamicPrefixes.length > 0) {
|
||||||
|
printCandidates({ testOnlyCandidates, candidates, unusedCustomProperties, dynamicPrefixes });
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,12 +61,11 @@ async function orderedCssFiles() {
|
||||||
async function collectCssClasses(files) {
|
async function collectCssClasses(files) {
|
||||||
const result = new Map();
|
const result = new Map();
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const text = await readFile(file, "utf8");
|
const text = stripCssComments(await readFile(file, "utf8"));
|
||||||
const lines = text.split("\n");
|
const lines = text.split("\n");
|
||||||
for (const [index, line] of lines.entries()) {
|
for (const [index, line] of lines.entries()) {
|
||||||
const withoutComment = line.replace(/\/\*.*?\*\//g, "");
|
|
||||||
const classPattern = /(^|[^\\])\.([_a-zA-Z][\w-]*)/g;
|
const classPattern = /(^|[^\\])\.([_a-zA-Z][\w-]*)/g;
|
||||||
for (const match of withoutComment.matchAll(classPattern)) {
|
for (const match of line.matchAll(classPattern)) {
|
||||||
const className = match[2];
|
const className = match[2];
|
||||||
if (!className.startsWith("codex-panel")) continue;
|
if (!className.startsWith("codex-panel")) continue;
|
||||||
const locations = result.get(className) ?? [];
|
const locations = result.get(className) ?? [];
|
||||||
|
|
@ -70,6 +77,38 @@ async function collectCssClasses(files) {
|
||||||
return new Map([...result.entries()].sort(([left], [right]) => left.localeCompare(right)));
|
return new Map([...result.entries()].sort(([left], [right]) => left.localeCompare(right)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function collectCssCustomProperties(files) {
|
||||||
|
const result = new Map();
|
||||||
|
for (const file of files) {
|
||||||
|
const text = stripCssComments(await readFile(file, "utf8"));
|
||||||
|
const lines = text.split("\n");
|
||||||
|
for (const [index, line] of lines.entries()) {
|
||||||
|
for (const match of line.matchAll(/(--codex-panel-[\w-]+)\s*:/g)) {
|
||||||
|
const propertyName = match[1];
|
||||||
|
const locations = result.get(propertyName) ?? [];
|
||||||
|
locations.push(`${relative(file)}:${index + 1}`);
|
||||||
|
result.set(propertyName, locations);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Map([...result.entries()].sort(([left], [right]) => left.localeCompare(right)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectReferencedCssCustomProperties(files) {
|
||||||
|
const result = new Set();
|
||||||
|
for (const file of files) {
|
||||||
|
const text = stripCssComments(await readFile(file, "utf8"));
|
||||||
|
for (const match of text.matchAll(/var\(\s*(--codex-panel-[\w-]+)/g)) {
|
||||||
|
result.add(match[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripCssComments(text) {
|
||||||
|
return text.replace(/\/\*[\s\S]*?\*\//g, (comment) => comment.replace(/[^\n]/g, ""));
|
||||||
|
}
|
||||||
|
|
||||||
function collectDynamicClassPrefixes(text) {
|
function collectDynamicClassPrefixes(text) {
|
||||||
const prefixes = new Set();
|
const prefixes = new Set();
|
||||||
const templatePrefixPattern = /codex-panel(?:-[a-z]+)?__[a-z0-9-]+--\$\{/g;
|
const templatePrefixPattern = /codex-panel(?:-[a-z]+)?__[a-z0-9-]+--\$\{/g;
|
||||||
|
|
@ -89,9 +128,12 @@ async function readTexts(files) {
|
||||||
|
|
||||||
function locationsInTexts(texts, needle) {
|
function locationsInTexts(texts, needle) {
|
||||||
const locations = [];
|
const locations = [];
|
||||||
|
const escapedNeedle = needle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const pattern = new RegExp(`(^|[^\\w-])${escapedNeedle}(?![\\w-])`);
|
||||||
for (const { file, text } of texts) {
|
for (const { file, text } of texts) {
|
||||||
const offset = text.indexOf(needle);
|
const match = pattern.exec(text);
|
||||||
if (offset === -1) continue;
|
if (!match) continue;
|
||||||
|
const offset = match.index + match[1].length;
|
||||||
const line = text.slice(0, offset).split("\n").length;
|
const line = text.slice(0, offset).split("\n").length;
|
||||||
locations.push(`${relative(file)}:${line}`);
|
locations.push(`${relative(file)}:${line}`);
|
||||||
}
|
}
|
||||||
|
|
@ -119,7 +161,7 @@ async function collectFiles(directory, extensions, excludedPrefixes, result) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function printCandidates({ testOnlyCandidates, candidates, dynamicPrefixes }) {
|
function printCandidates({ testOnlyCandidates, candidates, unusedCustomProperties, dynamicPrefixes }) {
|
||||||
console.error("CSS usage check failed.");
|
console.error("CSS usage check failed.");
|
||||||
if (dynamicPrefixes.length > 0) {
|
if (dynamicPrefixes.length > 0) {
|
||||||
console.error("");
|
console.error("");
|
||||||
|
|
@ -147,6 +189,15 @@ function printCandidates({ testOnlyCandidates, candidates, dynamicPrefixes }) {
|
||||||
console.error(` css: ${item.locations.join(", ")}`);
|
console.error(` css: ${item.locations.join(", ")}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (unusedCustomProperties.length > 0) {
|
||||||
|
console.error("");
|
||||||
|
console.error("Unused CSS custom property candidates:");
|
||||||
|
for (const item of unusedCustomProperties) {
|
||||||
|
console.error(` ${item.propertyName}`);
|
||||||
|
console.error(` css: ${item.locations.join(", ")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function relative(file) {
|
function relative(file) {
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,102 @@
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { readAppServerGenerationPolicy } from "./app-server-compatibility.mjs";
|
||||||
|
|
||||||
const generatedRelativeDir = "src/generated/app-server";
|
const generatedRelativeDir = "src/generated/app-server";
|
||||||
const generatedHeader = "// GENERATED CODE! DO NOT MODIFY BY HAND!";
|
const generatedHeader = "// GENERATED CODE! DO NOT MODIFY BY HAND!";
|
||||||
const normalizationNotice = "// This file was mechanically normalized after generation by scripts/generate-app-server-types.mjs.";
|
const normalizationNotice = "// This file was mechanically normalized after generation by scripts/generate-app-server-types.mjs.";
|
||||||
|
|
||||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||||
await generateAppServerTypes();
|
try {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
if (args.some((arg) => arg !== "--check") || args.length > 1) {
|
||||||
|
throw new Error("Usage: node scripts/generate-app-server-types.mjs [--check]");
|
||||||
|
}
|
||||||
|
await generateAppServerTypes({ check: args.includes("--check") });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error instanceof Error ? error.message : String(error));
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateAppServerTypes(options = {}) {
|
export async function generateAppServerTypes(options = {}) {
|
||||||
const cwd = options.cwd ?? process.cwd();
|
const cwd = options.cwd ?? process.cwd();
|
||||||
const generatedDir = path.resolve(cwd, generatedRelativeDir);
|
const generatedDir = path.resolve(cwd, generatedRelativeDir);
|
||||||
const runCommand = options.runCommand ?? run;
|
const runCommand = options.runCommand ?? run;
|
||||||
await cleanGeneratedTypes(generatedDir);
|
const readCodexVersion = options.readCodexVersion ?? readInstalledCodexVersion;
|
||||||
await runCommand("codex", ["app-server", "generate-ts", "--experimental", "--out", generatedRelativeDir], { cwd });
|
const policy = await readAppServerGenerationPolicy(cwd);
|
||||||
await normalizeGeneratedTypes(generatedDir);
|
const installedCliVersion = await readCodexVersion(cwd);
|
||||||
|
if (installedCliVersion !== policy.testedCliVersion) {
|
||||||
|
throw new Error(
|
||||||
|
`Codex CLI ${policy.testedCliVersion} is required to generate app-server bindings; found ${installedCliVersion ?? "an unreadable version"}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const generatedParent = path.dirname(generatedDir);
|
||||||
|
await mkdir(generatedParent, { recursive: true });
|
||||||
|
const stagedDir = await mkdtemp(path.join(generatedParent, ".app-server-"));
|
||||||
|
try {
|
||||||
|
const stagedRelativeDir = path.relative(cwd, stagedDir);
|
||||||
|
await runCommand("codex", [...policy.generationArguments, "--out", stagedRelativeDir], { cwd });
|
||||||
|
await normalizeGeneratedTypes(stagedDir);
|
||||||
|
if (options.check === true) {
|
||||||
|
const differences = await compareGeneratedTrees(generatedDir, stagedDir);
|
||||||
|
if (differences.length > 0) {
|
||||||
|
throw new Error(`generated app-server bindings are out of date:\n${differences.map((difference) => ` ${difference}`).join("\n")}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await replaceGeneratedTypes(generatedDir, stagedDir);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await rm(stagedDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cleanGeneratedTypes(generatedDir) {
|
async function compareGeneratedTrees(trackedDir, stagedDir) {
|
||||||
await rm(generatedDir, { recursive: true, force: true });
|
const [trackedFiles, stagedFiles] = await Promise.all([listFiles(trackedDir), listFiles(stagedDir)]);
|
||||||
await mkdir(generatedDir, { recursive: true });
|
const trackedSet = new Set(trackedFiles);
|
||||||
|
const stagedSet = new Set(stagedFiles);
|
||||||
|
const differences = [];
|
||||||
|
|
||||||
|
for (const file of stagedFiles) {
|
||||||
|
if (!trackedSet.has(file)) {
|
||||||
|
differences.push(`added: ${file}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const [trackedSource, stagedSource] = await Promise.all([readFile(path.join(trackedDir, file)), readFile(path.join(stagedDir, file))]);
|
||||||
|
if (!trackedSource.equals(stagedSource)) differences.push(`changed: ${file}`);
|
||||||
|
}
|
||||||
|
for (const file of trackedFiles) {
|
||||||
|
if (!stagedSet.has(file)) differences.push(`removed: ${file}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return differences;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replaceGeneratedTypes(generatedDir, stagedDir) {
|
||||||
|
const backupDir = `${generatedDir}.backup-${process.pid.toString()}-${Date.now().toString()}`;
|
||||||
|
let hasBackup = false;
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
await rename(generatedDir, backupDir);
|
||||||
|
hasBackup = true;
|
||||||
|
} catch (error) {
|
||||||
|
if (!isMissingPathError(error)) throw error;
|
||||||
|
}
|
||||||
|
await rename(stagedDir, generatedDir);
|
||||||
|
if (hasBackup) await rm(backupDir, { recursive: true, force: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (hasBackup) {
|
||||||
|
await rm(generatedDir, { recursive: true, force: true });
|
||||||
|
await rename(backupDir, generatedDir);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMissingPathError(error) {
|
||||||
|
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function normalizeGeneratedTypes(generatedDir) {
|
async function normalizeGeneratedTypes(generatedDir) {
|
||||||
|
|
@ -50,6 +124,24 @@ async function listTypeScriptFiles(dir) {
|
||||||
return files.flat();
|
return files.flat();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function listFiles(dir, relativeDir = "") {
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = await readdir(path.join(dir, relativeDir), { withFileTypes: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (isMissingPathError(error)) return [];
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const files = await Promise.all(
|
||||||
|
entries.map((entry) => {
|
||||||
|
const relativePath = path.join(relativeDir, entry.name);
|
||||||
|
if (entry.isDirectory()) return listFiles(dir, relativePath);
|
||||||
|
return entry.isFile() ? [relativePath.replaceAll(path.sep, "/")] : [];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return files.flat().sort();
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeSource(source) {
|
function normalizeSource(source) {
|
||||||
let normalized = source;
|
let normalized = source;
|
||||||
do {
|
do {
|
||||||
|
|
@ -72,8 +164,18 @@ function run(command, args, options = {}) {
|
||||||
shell: false,
|
shell: false,
|
||||||
});
|
});
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
console.error(`Failed to run ${command} ${args.join(" ")}: ${result.error.message}`);
|
throw new Error(`Failed to run ${command} ${args.join(" ")}: ${result.error.message}`);
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
if (result.status !== 0) process.exit(result.status ?? 1);
|
if (result.status !== 0) throw new Error(`${command} ${args.join(" ")} exited with status ${(result.status ?? 1).toString()}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readInstalledCodexVersion(cwd) {
|
||||||
|
const result = spawnSync("codex", ["--version"], {
|
||||||
|
cwd,
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: "pipe",
|
||||||
|
shell: false,
|
||||||
|
});
|
||||||
|
if (result.error || result.status !== 0) return null;
|
||||||
|
return `${result.stdout}\n${result.stderr}`.match(/\b\d+\.\d+\.\d+\b/)?.[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,7 @@ private pattern js_module_reference() {
|
||||||
|
|
||||||
private pattern app_server_protocol_source() { r"^[\"'](?:(?:\.\./)+app-server/protocol|src/app-server/protocol)/[^/\"']+(?:/.*)?[\"']$" }
|
private pattern app_server_protocol_source() { r"^[\"'](?:(?:\.\./)+app-server/protocol|src/app-server/protocol)/[^/\"']+(?:/.*)?[\"']$" }
|
||||||
|
|
||||||
private pattern turn_protocol_source() { r"^[\"'](?:(?:\.\./)+app-server/protocol/turn|src/app-server/protocol/turn)(?:/.*)?[\"']$" }
|
|
||||||
|
|
||||||
js_module_reference() as $stmt where {
|
js_module_reference() as $stmt where {
|
||||||
$stmt <: contains `$source` where {
|
$stmt <: contains `$source` where { $source <: app_server_protocol_source() },
|
||||||
$source <: app_server_protocol_source(),
|
|
||||||
not { $filename <: r".*/src/features/chat/app-server/mappers/message-stream/turn-items\.ts$", $source <: turn_protocol_source() }
|
|
||||||
},
|
|
||||||
register_diagnostic(span=$stmt, message="Source modules outside root src/app-server must use domain models and app-server services instead of app-server protocol modules. Chat turn-item conversion may consume turn protocol at its app-server boundary; feature state and UI must use Panel-owned models.", severity="error")
|
register_diagnostic(span=$stmt, message="Source modules outside root src/app-server must use domain models and app-server services instead of app-server protocol modules. Chat turn-item conversion may consume turn protocol at its app-server boundary; feature state and UI must use Panel-owned models.", severity="error")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ private pattern js_module_reference() {
|
||||||
|
|
||||||
js_module_reference() as $stmt where {
|
js_module_reference() as $stmt where {
|
||||||
$stmt <: contains `$source` where {
|
$stmt <: contains `$source` where {
|
||||||
$source <: r"^[\"'](?:(?:\.\./)+(?:app-server|host|panel|presentation|ui)|src/(?:app-server|features/chat/(?:app-server|host|panel|presentation|ui)))(?:/.*)?[\"']$"
|
$source <: r"^[\"'](?:(?:\.\./)+(?:app-server|host|panel|presentation|ui)|(?:\.\./){2,}(?:selection-rewrite|thread-picker|threads|threads-view|turn-diff)|src/(?:app-server|features/(?:chat/(?:app-server|host|panel|presentation|ui)|selection-rewrite|thread-picker|threads|threads-view|turn-diff)))(?:/.*)?[\"']$"
|
||||||
},
|
},
|
||||||
register_diagnostic(span=$stmt, message="Chat application modules must not import app-server, host, panel, presentation, or UI layers; expose state and workflow contracts instead.", severity="error")
|
register_diagnostic(span=$stmt, message="Chat application modules must not import app-server, sibling feature, host, panel, presentation, or UI layers; expose state and workflow contracts instead.", severity="error")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
language js
|
||||||
|
|
||||||
|
private pattern js_module_reference() {
|
||||||
|
or {
|
||||||
|
JsImport(),
|
||||||
|
JsImportCallExpression()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private pattern app_server_protocol_source() { r"^[\"'](?:(?:\.\./)+app-server/protocol|src/app-server/protocol)/[^/\"']+(?:/.*)?[\"']$" }
|
||||||
|
|
||||||
|
private pattern turn_protocol_source() { r"^[\"'](?:(?:\.\./)+app-server/protocol/turn|src/app-server/protocol/turn)(?:/.*)?[\"']$" }
|
||||||
|
|
||||||
|
js_module_reference() as $stmt where {
|
||||||
|
$stmt <: contains `$source` where { $source <: app_server_protocol_source(), not { $source <: turn_protocol_source() } },
|
||||||
|
register_diagnostic(span=$stmt, message="Source modules outside root src/app-server must use domain models and app-server services instead of app-server protocol modules. Chat turn-item conversion may consume turn protocol at its app-server boundary; feature state and UI must use Panel-owned models.", severity="error")
|
||||||
|
}
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
language js
|
||||||
|
|
||||||
|
private pattern js_module_reference() {
|
||||||
|
or {
|
||||||
|
JsImport(),
|
||||||
|
JsImportCallExpression()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
js_module_reference() as $stmt where {
|
||||||
|
$stmt <: contains `$source` where {
|
||||||
|
$source <: r"^[\"'](?:(?:\.\./)+app-server/(?:connection|services)|src/app-server/(?:connection|services))(?:/.*)?[\"']$"
|
||||||
|
},
|
||||||
|
register_diagnostic(span=$stmt, message="Thread workflows must depend on feature-owned ports instead of app-server clients or services.", severity="error")
|
||||||
|
}
|
||||||
73
scripts/release/notes.mjs
Normal file
73
scripts/release/notes.mjs
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import conventionalCommits from "conventional-changelog-conventionalcommits";
|
||||||
|
import { CommitParser } from "conventional-commits-parser";
|
||||||
|
|
||||||
|
const releaseNoteTypes = new Set(["feat", "fix", "perf"]);
|
||||||
|
const parser = new CommitParser(conventionalCommits().parser);
|
||||||
|
|
||||||
|
function fail(message) {
|
||||||
|
throw new Error(`release notes generation failed: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runGit(args, cwd) {
|
||||||
|
const result = spawnSync("git", args, {
|
||||||
|
cwd,
|
||||||
|
encoding: "utf8",
|
||||||
|
shell: false,
|
||||||
|
});
|
||||||
|
if (result.error) fail(result.error.message);
|
||||||
|
if (result.status !== 0) {
|
||||||
|
fail(`${args.join(" ")} exited with ${result.status}: ${result.stderr.trim()}`);
|
||||||
|
}
|
||||||
|
return result.stdout;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSubject(subject) {
|
||||||
|
const sentence = subject.replace(/^([a-z])/, (letter) => letter.toUpperCase());
|
||||||
|
return /[.!?]$/.test(sentence) ? sentence : `${sentence}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function releaseNoteForCommit(message) {
|
||||||
|
const commit = parser.parse(message);
|
||||||
|
if (!commit.subject) return null;
|
||||||
|
|
||||||
|
const isBreaking = commit.notes.length > 0;
|
||||||
|
if (!isBreaking && !releaseNoteTypes.has(commit.type ?? "")) return null;
|
||||||
|
|
||||||
|
return formatSubject(commit.subject);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderReleaseNotes(messages) {
|
||||||
|
const entries = messages.map(releaseNoteForCommit).filter((entry) => entry !== null);
|
||||||
|
const bullets = entries.length > 0 ? entries.map((entry) => `- ${entry}`).join("\n") : "- ";
|
||||||
|
return `## Changes\n\n${bullets}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readCommitMessagesSince(tag, cwd = process.cwd()) {
|
||||||
|
runGit(["rev-parse", "--verify", `refs/tags/${tag}`], cwd);
|
||||||
|
const output = runGit(["log", "--format=%B%x00", `${tag}..HEAD`], cwd);
|
||||||
|
return output
|
||||||
|
.split("\0")
|
||||||
|
.map((message) => message.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateReleaseNotes(tag, cwd = process.cwd()) {
|
||||||
|
return renderReleaseNotes(readCommitMessagesSince(tag, cwd));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||||
|
const previousTag = process.argv[2];
|
||||||
|
if (!previousTag) {
|
||||||
|
console.error("usage: npm run release:notes -- X.Y.Z");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.stdout.write(generateReleaseNotes(previousTag));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error instanceof Error ? error.message : error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
function fail(message) {
|
function fail(message) {
|
||||||
console.error(`release preflight failed: ${message}`);
|
console.error(`release preflight failed: ${message}`);
|
||||||
|
|
@ -81,8 +82,14 @@ if (maybeRun("jj", ["root"])) {
|
||||||
assertGitReleaseState(packageVersion);
|
assertGitReleaseState(packageVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const versionKeys = Object.keys(JSON.parse(readFileSync("versions.json", "utf8")));
|
||||||
|
const previousTag = versionKeys.at(-2);
|
||||||
|
if (!previousTag) fail("versions.json must contain a release before the prepared version");
|
||||||
|
run("git", ["rev-parse", "--verify", `refs/tags/${previousTag}`], { capture: true });
|
||||||
|
run("npm", ["run", "commitlint", "--", "--from", previousTag, "--to", "main", "--verbose"]);
|
||||||
run("npm", ["run", "release:check"]);
|
run("npm", ["run", "release:check"]);
|
||||||
run("npm", ["run", "api:baseline"]);
|
run("npm", ["run", "api:baseline"]);
|
||||||
|
run("npm", ["run", "generate:app-server-types:check"]);
|
||||||
run("npm", ["ci", "--dry-run"]);
|
run("npm", ["ci", "--dry-run"]);
|
||||||
run("npm", ["run", "check"]);
|
run("npm", ["run", "check"]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { generateReleaseNotes } from "./notes.mjs";
|
||||||
import { compareVersions, isExpectedNextVersion, parseVersion } from "./versioning.mjs";
|
import { compareVersions, isExpectedNextVersion, parseVersion } from "./versioning.mjs";
|
||||||
|
|
||||||
function fail(message) {
|
function fail(message) {
|
||||||
|
|
@ -38,6 +39,13 @@ try {
|
||||||
if (error.code !== "ENOENT") throw error;
|
if (error.code !== "ENOENT") throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let releaseNotes;
|
||||||
|
try {
|
||||||
|
releaseNotes = generateReleaseNotes(previousVersionKey);
|
||||||
|
} catch (error) {
|
||||||
|
fail(error instanceof Error ? error.message : String(error));
|
||||||
|
}
|
||||||
|
|
||||||
packageJson.version = releaseVersion;
|
packageJson.version = releaseVersion;
|
||||||
packageLockJson.version = releaseVersion;
|
packageLockJson.version = releaseVersion;
|
||||||
if (!packageLockJson.packages?.[""]) fail('package-lock.json is missing packages[""]');
|
if (!packageLockJson.packages?.[""]) fail('package-lock.json is missing packages[""]');
|
||||||
|
|
@ -51,7 +59,7 @@ await writeFile("manifest.json", `${JSON.stringify(manifestJson, null, 2)}\n`);
|
||||||
await writeFile("versions.json", `${JSON.stringify(versionsJson, null, 2)}\n`);
|
await writeFile("versions.json", `${JSON.stringify(versionsJson, null, 2)}\n`);
|
||||||
|
|
||||||
await mkdir(notesDir, { recursive: true });
|
await mkdir(notesDir, { recursive: true });
|
||||||
await writeFile(notesPath, "## Changes\n\n- \n");
|
await writeFile(notesPath, releaseNotes);
|
||||||
|
|
||||||
console.log(`prepared release ${releaseVersion}`);
|
console.log(`prepared release ${releaseVersion}`);
|
||||||
console.log(`edit ${notesPath}, then run npm run release:preflight after committing`);
|
console.log(`review and edit ${notesPath}, then run npm run release:preflight after committing`);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { AppServerClient } from "./client";
|
import type { AppServerClient } from "./client";
|
||||||
|
|
||||||
export type AppServerClientRequestPolicy = { kind: "interactive" } | { kind: "reject"; message: string };
|
export type AppServerClientRequestPolicy = { kind: "reject"; message: string };
|
||||||
|
|
||||||
export interface AppServerClientAccessOptions {
|
export interface AppServerClientAccessOptions {
|
||||||
serverRequests?: AppServerClientRequestPolicy;
|
serverRequests?: AppServerClientRequestPolicy;
|
||||||
|
|
|
||||||
32
src/app-server/connection/client-profile.ts
Normal file
32
src/app-server/connection/client-profile.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { CLIENT_VERSION } from "../../constants";
|
||||||
|
import type { InitializeCapabilities } from "../../generated/app-server/InitializeCapabilities";
|
||||||
|
import type { InitializeParams } from "../../generated/app-server/InitializeParams";
|
||||||
|
import compatibility from "./compatibility.json";
|
||||||
|
|
||||||
|
interface AppServerCompatibility {
|
||||||
|
codexAppServer: {
|
||||||
|
testedCliVersion: string;
|
||||||
|
typeGeneration: {
|
||||||
|
arguments: string[];
|
||||||
|
};
|
||||||
|
initialize: {
|
||||||
|
capabilities: Pick<InitializeCapabilities, "experimentalApi" | "requestAttestation">;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const appServerCompatibility = compatibility satisfies AppServerCompatibility;
|
||||||
|
|
||||||
|
export function codexPanelAppServerInitializeParams(): InitializeParams {
|
||||||
|
return {
|
||||||
|
clientInfo: {
|
||||||
|
name: "obsidian_codex_panel",
|
||||||
|
title: "Codex Panel",
|
||||||
|
version: CLIENT_VERSION,
|
||||||
|
},
|
||||||
|
capabilities: {
|
||||||
|
experimentalApi: appServerCompatibility.codexAppServer.initialize.capabilities.experimentalApi,
|
||||||
|
requestAttestation: appServerCompatibility.codexAppServer.initialize.capabilities.requestAttestation,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { CLIENT_VERSION } from "../../constants";
|
import type { InitializeParams } from "../../generated/app-server/InitializeParams";
|
||||||
import type { InitializeResponse } from "../../generated/app-server/InitializeResponse";
|
import type { InitializeResponse } from "../../generated/app-server/InitializeResponse";
|
||||||
import type { RequestId } from "../../generated/app-server/RequestId";
|
import type { RequestId } from "../../generated/app-server/RequestId";
|
||||||
import type { ServerNotification } from "../../generated/app-server/ServerNotification";
|
import type { ServerNotification } from "../../generated/app-server/ServerNotification";
|
||||||
|
|
@ -7,12 +7,14 @@ import type { AppsListResponse } from "../../generated/app-server/v2/AppsListRes
|
||||||
import type { CollaborationModeListResponse } from "../../generated/app-server/v2/CollaborationModeListResponse";
|
import type { CollaborationModeListResponse } from "../../generated/app-server/v2/CollaborationModeListResponse";
|
||||||
import type { ConfigReadResponse } from "../../generated/app-server/v2/ConfigReadResponse";
|
import type { ConfigReadResponse } from "../../generated/app-server/v2/ConfigReadResponse";
|
||||||
import type { ConfigWriteResponse } from "../../generated/app-server/v2/ConfigWriteResponse";
|
import type { ConfigWriteResponse } from "../../generated/app-server/v2/ConfigWriteResponse";
|
||||||
|
import type { EnvironmentInfoResponse } from "../../generated/app-server/v2/EnvironmentInfoResponse";
|
||||||
import type { FsReadFileResponse } from "../../generated/app-server/v2/FsReadFileResponse";
|
import type { FsReadFileResponse } from "../../generated/app-server/v2/FsReadFileResponse";
|
||||||
import type { GetAccountRateLimitsResponse } from "../../generated/app-server/v2/GetAccountRateLimitsResponse";
|
import type { GetAccountRateLimitsResponse } from "../../generated/app-server/v2/GetAccountRateLimitsResponse";
|
||||||
import type { HooksListResponse } from "../../generated/app-server/v2/HooksListResponse";
|
import type { HooksListResponse } from "../../generated/app-server/v2/HooksListResponse";
|
||||||
import type { ListMcpServerStatusResponse } from "../../generated/app-server/v2/ListMcpServerStatusResponse";
|
import type { ListMcpServerStatusResponse } from "../../generated/app-server/v2/ListMcpServerStatusResponse";
|
||||||
import type { ModelListResponse } from "../../generated/app-server/v2/ModelListResponse";
|
import type { ModelListResponse } from "../../generated/app-server/v2/ModelListResponse";
|
||||||
import type { ModelProviderCapabilitiesReadResponse } from "../../generated/app-server/v2/ModelProviderCapabilitiesReadResponse";
|
import type { ModelProviderCapabilitiesReadResponse } from "../../generated/app-server/v2/ModelProviderCapabilitiesReadResponse";
|
||||||
|
import type { PermissionProfileListResponse } from "../../generated/app-server/v2/PermissionProfileListResponse";
|
||||||
import type { PluginInstalledResponse } from "../../generated/app-server/v2/PluginInstalledResponse";
|
import type { PluginInstalledResponse } from "../../generated/app-server/v2/PluginInstalledResponse";
|
||||||
import type { PluginReadResponse } from "../../generated/app-server/v2/PluginReadResponse";
|
import type { PluginReadResponse } from "../../generated/app-server/v2/PluginReadResponse";
|
||||||
import type { SkillsListResponse } from "../../generated/app-server/v2/SkillsListResponse";
|
import type { SkillsListResponse } from "../../generated/app-server/v2/SkillsListResponse";
|
||||||
|
|
@ -24,15 +26,16 @@ import type { ThreadGoalClearResponse } from "../../generated/app-server/v2/Thre
|
||||||
import type { ThreadGoalGetResponse } from "../../generated/app-server/v2/ThreadGoalGetResponse";
|
import type { ThreadGoalGetResponse } from "../../generated/app-server/v2/ThreadGoalGetResponse";
|
||||||
import type { ThreadGoalSetResponse } from "../../generated/app-server/v2/ThreadGoalSetResponse";
|
import type { ThreadGoalSetResponse } from "../../generated/app-server/v2/ThreadGoalSetResponse";
|
||||||
import type { ThreadInjectItemsResponse } from "../../generated/app-server/v2/ThreadInjectItemsResponse";
|
import type { ThreadInjectItemsResponse } from "../../generated/app-server/v2/ThreadInjectItemsResponse";
|
||||||
|
import type { ThreadItemsListResponse } from "../../generated/app-server/v2/ThreadItemsListResponse";
|
||||||
import type { ThreadListResponse } from "../../generated/app-server/v2/ThreadListResponse";
|
import type { ThreadListResponse } from "../../generated/app-server/v2/ThreadListResponse";
|
||||||
import type { ThreadReadResponse } from "../../generated/app-server/v2/ThreadReadResponse";
|
import type { ThreadReadResponse } from "../../generated/app-server/v2/ThreadReadResponse";
|
||||||
import type { ThreadResumeResponse } from "../../generated/app-server/v2/ThreadResumeResponse";
|
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 { ThreadSetNameResponse } from "../../generated/app-server/v2/ThreadSetNameResponse";
|
||||||
import type { ThreadSettingsUpdateResponse } from "../../generated/app-server/v2/ThreadSettingsUpdateResponse";
|
import type { ThreadSettingsUpdateResponse } from "../../generated/app-server/v2/ThreadSettingsUpdateResponse";
|
||||||
import type { ThreadStartResponse } from "../../generated/app-server/v2/ThreadStartResponse";
|
import type { ThreadStartResponse } from "../../generated/app-server/v2/ThreadStartResponse";
|
||||||
import type { ThreadTurnsListResponse } from "../../generated/app-server/v2/ThreadTurnsListResponse";
|
import type { ThreadTurnsListResponse } from "../../generated/app-server/v2/ThreadTurnsListResponse";
|
||||||
import type { ThreadUnarchiveResponse } from "../../generated/app-server/v2/ThreadUnarchiveResponse";
|
import type { ThreadUnarchiveResponse } from "../../generated/app-server/v2/ThreadUnarchiveResponse";
|
||||||
|
import type { ThreadUnsubscribeResponse } from "../../generated/app-server/v2/ThreadUnsubscribeResponse";
|
||||||
import type { TurnInterruptResponse } from "../../generated/app-server/v2/TurnInterruptResponse";
|
import type { TurnInterruptResponse } from "../../generated/app-server/v2/TurnInterruptResponse";
|
||||||
import type { TurnStartResponse } from "../../generated/app-server/v2/TurnStartResponse";
|
import type { TurnStartResponse } from "../../generated/app-server/v2/TurnStartResponse";
|
||||||
import type { TurnSteerResponse } from "../../generated/app-server/v2/TurnSteerResponse";
|
import type { TurnSteerResponse } from "../../generated/app-server/v2/TurnSteerResponse";
|
||||||
|
|
@ -56,6 +59,15 @@ export interface AppServerServerRequestResponder {
|
||||||
|
|
||||||
export type AppServerTransportFactory = (handlers: AppServerTransportHandlers) => AppServerTransport;
|
export type AppServerTransportFactory = (handlers: AppServerTransportHandlers) => AppServerTransport;
|
||||||
|
|
||||||
|
export interface AppServerClientOptions {
|
||||||
|
codexPath: string;
|
||||||
|
cwd: string;
|
||||||
|
handlers: AppServerClientHandlers;
|
||||||
|
initializeParams: InitializeParams;
|
||||||
|
requestTimeoutMs?: number;
|
||||||
|
transportFactory?: AppServerTransportFactory;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ClientResponseByMethod {
|
export interface ClientResponseByMethod {
|
||||||
initialize: InitializeResponse;
|
initialize: InitializeResponse;
|
||||||
"config/batchWrite": ConfigWriteResponse;
|
"config/batchWrite": ConfigWriteResponse;
|
||||||
|
|
@ -72,16 +84,18 @@ export interface ClientResponseByMethod {
|
||||||
"thread/read": ThreadReadResponse;
|
"thread/read": ThreadReadResponse;
|
||||||
"thread/archive": ThreadArchiveResponse;
|
"thread/archive": ThreadArchiveResponse;
|
||||||
"thread/delete": ThreadDeleteResponse;
|
"thread/delete": ThreadDeleteResponse;
|
||||||
|
"thread/unsubscribe": ThreadUnsubscribeResponse;
|
||||||
"thread/unarchive": ThreadUnarchiveResponse;
|
"thread/unarchive": ThreadUnarchiveResponse;
|
||||||
"thread/rollback": ThreadRollbackResponse;
|
|
||||||
"thread/name/set": ThreadSetNameResponse;
|
"thread/name/set": ThreadSetNameResponse;
|
||||||
"thread/settings/update": ThreadSettingsUpdateResponse;
|
"thread/settings/update": ThreadSettingsUpdateResponse;
|
||||||
"thread/turns/list": ThreadTurnsListResponse;
|
"thread/turns/list": ThreadTurnsListResponse;
|
||||||
|
"thread/items/list": ThreadItemsListResponse;
|
||||||
"skills/list": SkillsListResponse;
|
"skills/list": SkillsListResponse;
|
||||||
"app/list": AppsListResponse;
|
"app/list": AppsListResponse;
|
||||||
"plugin/installed": PluginInstalledResponse;
|
"plugin/installed": PluginInstalledResponse;
|
||||||
"plugin/read": PluginReadResponse;
|
"plugin/read": PluginReadResponse;
|
||||||
"model/list": ModelListResponse;
|
"model/list": ModelListResponse;
|
||||||
|
"permissionProfile/list": PermissionProfileListResponse;
|
||||||
"account/rateLimits/read": GetAccountRateLimitsResponse;
|
"account/rateLimits/read": GetAccountRateLimitsResponse;
|
||||||
"mcpServerStatus/list": ListMcpServerStatusResponse;
|
"mcpServerStatus/list": ListMcpServerStatusResponse;
|
||||||
"collaborationMode/list": CollaborationModeListResponse;
|
"collaborationMode/list": CollaborationModeListResponse;
|
||||||
|
|
@ -91,6 +105,7 @@ export interface ClientResponseByMethod {
|
||||||
"turn/steer": TurnSteerResponse;
|
"turn/steer": TurnSteerResponse;
|
||||||
"turn/interrupt": TurnInterruptResponse;
|
"turn/interrupt": TurnInterruptResponse;
|
||||||
"fs/readFile": FsReadFileResponse;
|
"fs/readFile": FsReadFileResponse;
|
||||||
|
"environment/info": EnvironmentInfoResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TypedClientRequestMethod = Extract<ClientRequestMethod, keyof ClientResponseByMethod>;
|
export type TypedClientRequestMethod = Extract<ClientRequestMethod, keyof ClientResponseByMethod>;
|
||||||
|
|
@ -104,14 +119,20 @@ export class AppServerClient {
|
||||||
private lifecycle: AppServerClientLifecycleState = { kind: "disconnected" };
|
private lifecycle: AppServerClientLifecycleState = { kind: "disconnected" };
|
||||||
private readonly rpc: JsonRpcClient;
|
private readonly rpc: JsonRpcClient;
|
||||||
private readonly intentionallyStoppedTransports = new WeakSet<AppServerTransport>();
|
private readonly intentionallyStoppedTransports = new WeakSet<AppServerTransport>();
|
||||||
|
private readonly codexPath: string;
|
||||||
|
private readonly cwd: string;
|
||||||
|
private readonly handlers: AppServerClientHandlers;
|
||||||
|
private readonly initializeParams: InitializeParams;
|
||||||
|
private readonly requestTimeoutMs: number;
|
||||||
|
private readonly transportFactory: AppServerTransportFactory | undefined;
|
||||||
|
|
||||||
constructor(
|
constructor(options: AppServerClientOptions) {
|
||||||
private readonly codexPath: string,
|
this.codexPath = options.codexPath;
|
||||||
private readonly cwd: string,
|
this.cwd = options.cwd;
|
||||||
private readonly handlers: AppServerClientHandlers,
|
this.handlers = options.handlers;
|
||||||
private readonly requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
|
this.initializeParams = options.initializeParams;
|
||||||
private readonly transportFactory?: AppServerTransportFactory,
|
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||||
) {
|
this.transportFactory = options.transportFactory;
|
||||||
this.rpc = new JsonRpcClient({
|
this.rpc = new JsonRpcClient({
|
||||||
requestTimeoutMs: this.requestTimeoutMs,
|
requestTimeoutMs: this.requestTimeoutMs,
|
||||||
send: (message) => {
|
send: (message) => {
|
||||||
|
|
@ -176,17 +197,7 @@ export class AppServerClient {
|
||||||
this.lifecycle = { kind: "starting", transport };
|
this.lifecycle = { kind: "starting", transport };
|
||||||
try {
|
try {
|
||||||
transport.start();
|
transport.start();
|
||||||
const init = await this.request("initialize", {
|
const init = await this.request("initialize", this.initializeParams);
|
||||||
clientInfo: {
|
|
||||||
name: "obsidian_codex_panel",
|
|
||||||
title: "Codex Panel",
|
|
||||||
version: CLIENT_VERSION,
|
|
||||||
},
|
|
||||||
capabilities: {
|
|
||||||
experimentalApi: true,
|
|
||||||
requestAttestation: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
this.notify({ method: "initialized" });
|
this.notify({ method: "initialized" });
|
||||||
this.lifecycle = { kind: "initialized", transport, initializeResponse: init };
|
this.lifecycle = { kind: "initialized", transport, initializeResponse: init };
|
||||||
return init;
|
return init;
|
||||||
|
|
|
||||||
14
src/app-server/connection/compatibility.json
Normal file
14
src/app-server/connection/compatibility.json
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"codexAppServer": {
|
||||||
|
"testedCliVersion": "0.145.0",
|
||||||
|
"typeGeneration": {
|
||||||
|
"arguments": ["app-server", "generate-ts", "--experimental"]
|
||||||
|
},
|
||||||
|
"initialize": {
|
||||||
|
"capabilities": {
|
||||||
|
"experimentalApi": true,
|
||||||
|
"requestAttestation": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,34 +1,27 @@
|
||||||
import type { ServerInitialization } from "../../domain/server/initialization";
|
import type { ServerInitialization } from "../../domain/server/initialization";
|
||||||
|
import type { InitializeParams } from "../../generated/app-server/InitializeParams";
|
||||||
import type { ServerNotification } from "../../generated/app-server/ServerNotification";
|
import type { ServerNotification } from "../../generated/app-server/ServerNotification";
|
||||||
import type { ServerRequest } from "../../generated/app-server/ServerRequest";
|
import type { ServerRequest } from "../../generated/app-server/ServerRequest";
|
||||||
import { appServerInitializationFromResponse } from "../protocol/initialization";
|
import { AppServerClient, type AppServerClientHandlers, type AppServerServerRequestResponder } from "./client";
|
||||||
import { AppServerClient, type AppServerClientHandlers } from "./client";
|
|
||||||
|
|
||||||
export interface ConnectionManagerHandlers {
|
export interface ConnectionManagerHandlers {
|
||||||
onNotification: (notification: ServerNotification) => void;
|
onNotification: (notification: ServerNotification) => void;
|
||||||
onServerRequest: (request: ServerRequest) => void;
|
onServerRequest: (request: ServerRequest, responder: AppServerServerRequestResponder) => void;
|
||||||
onLog: (message: string) => void;
|
onLog: (message: string) => void;
|
||||||
onExit: () => void;
|
onExit: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AppServerClientFactory = (codexPath: string, cwd: string, handlers: AppServerClientHandlers) => AppServerClient;
|
type AppServerClientFactory = (codexPath: string, cwd: string, handlers: AppServerClientHandlers) => AppServerClient;
|
||||||
|
|
||||||
export interface AppServerConnectionContext {
|
|
||||||
codexPath: string;
|
|
||||||
cwd: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ConnectionLifecycleState =
|
type ConnectionLifecycleState =
|
||||||
| { kind: "idle"; generation: number }
|
| { kind: "idle"; generation: number }
|
||||||
| {
|
| {
|
||||||
kind: "connecting";
|
kind: "connecting";
|
||||||
generation: number;
|
generation: number;
|
||||||
codexPath: string;
|
|
||||||
cwd: string;
|
|
||||||
client: AppServerClient;
|
client: AppServerClient;
|
||||||
promise: Promise<ServerInitialization>;
|
promise: Promise<ServerInitialization>;
|
||||||
}
|
}
|
||||||
| { kind: "connected"; generation: number; codexPath: string; cwd: string; client: AppServerClient }
|
| { kind: "connected"; generation: number; client: AppServerClient }
|
||||||
| { kind: "disconnected"; generation: number };
|
| { kind: "disconnected"; generation: number };
|
||||||
|
|
||||||
export class StaleConnectionError extends Error {
|
export class StaleConnectionError extends Error {
|
||||||
|
|
@ -42,22 +35,15 @@ export class ConnectionManager {
|
||||||
private state: ConnectionLifecycleState = { kind: "idle", generation: 0 };
|
private state: ConnectionLifecycleState = { kind: "idle", generation: 0 };
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly codexPath: () => string,
|
private readonly codexPath: string,
|
||||||
private readonly cwd: string,
|
private readonly cwd: string,
|
||||||
private readonly clientFactory: AppServerClientFactory = (codexPath, cwd, handlers) => new AppServerClient(codexPath, cwd, handlers),
|
initializeParams: InitializeParams,
|
||||||
|
private readonly clientFactory: AppServerClientFactory = (codexPath, cwd, handlers) =>
|
||||||
|
new AppServerClient({ codexPath, cwd, handlers, initializeParams }),
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
currentClient(): AppServerClient | null {
|
currentClient(): AppServerClient | null {
|
||||||
return this.state.kind === "connected" && this.state.client.isConnected() && this.stateMatchesCurrentContext(this.state)
|
return this.state.kind === "connected" && this.state.client.isConnected() ? this.state.client : null;
|
||||||
? 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 {
|
isConnected(): boolean {
|
||||||
|
|
@ -67,22 +53,22 @@ export class ConnectionManager {
|
||||||
async connect(handlers: ConnectionManagerHandlers): Promise<ServerInitialization> {
|
async connect(handlers: ConnectionManagerHandlers): Promise<ServerInitialization> {
|
||||||
const currentClient = this.currentClient();
|
const currentClient = this.currentClient();
|
||||||
if (currentClient) {
|
if (currentClient) {
|
||||||
return appServerInitializationFromResponse(currentClient.initializeResponse);
|
return serverInitializationFromResponse(currentClient.initializeResponse);
|
||||||
}
|
}
|
||||||
if (this.state.kind === "connecting" && this.stateMatchesCurrentContext(this.state)) return this.state.promise;
|
if (this.state.kind === "connecting") return this.state.promise;
|
||||||
if (this.state.kind === "connecting" || this.state.kind === "connected") this.disconnect();
|
if (this.state.kind === "connected") this.disconnect();
|
||||||
|
|
||||||
const generation = this.state.generation + 1;
|
const generation = this.state.generation + 1;
|
||||||
const codexPath = this.codexPath();
|
const codexPath = this.codexPath;
|
||||||
const cwd = this.cwd;
|
const cwd = this.cwd;
|
||||||
const client = this.clientFactory(codexPath, cwd, {
|
const client = this.clientFactory(codexPath, cwd, {
|
||||||
onNotification: (notification) => {
|
onNotification: (notification) => {
|
||||||
if (this.isStale(generation)) return;
|
if (this.isStale(generation)) return;
|
||||||
handlers.onNotification(notification);
|
handlers.onNotification(notification);
|
||||||
},
|
},
|
||||||
onServerRequest: (request) => {
|
onServerRequest: (request, responder) => {
|
||||||
if (this.isStale(generation)) return;
|
if (this.isStale(generation)) return;
|
||||||
handlers.onServerRequest(request);
|
handlers.onServerRequest(request, responder);
|
||||||
},
|
},
|
||||||
onLog: (message) => {
|
onLog: (message) => {
|
||||||
if (this.isStale(generation)) return;
|
if (this.isStale(generation)) return;
|
||||||
|
|
@ -101,8 +87,8 @@ export class ConnectionManager {
|
||||||
client.disconnect();
|
client.disconnect();
|
||||||
throw new StaleConnectionError();
|
throw new StaleConnectionError();
|
||||||
}
|
}
|
||||||
this.state = { kind: "connected", generation, codexPath, cwd, client };
|
this.state = { kind: "connected", generation, client };
|
||||||
return appServerInitializationFromResponse(response);
|
return serverInitializationFromResponse(response);
|
||||||
})
|
})
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
if (this.isStale(generation)) {
|
if (this.isStale(generation)) {
|
||||||
|
|
@ -115,14 +101,10 @@ export class ConnectionManager {
|
||||||
throw error;
|
throw error;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.state = { kind: "connecting", generation, codexPath, cwd, client, promise };
|
this.state = { kind: "connecting", generation, client, promise };
|
||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
resetConnection(): void {
|
|
||||||
this.disconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
const previous = this.state;
|
const previous = this.state;
|
||||||
this.state = { kind: "disconnected", generation: previous.generation + 1 };
|
this.state = { kind: "disconnected", generation: previous.generation + 1 };
|
||||||
|
|
@ -134,8 +116,13 @@ export class ConnectionManager {
|
||||||
private isStale(generation: number): boolean {
|
private isStale(generation: number): boolean {
|
||||||
return generation !== this.state.generation;
|
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 {
|
||||||
}
|
return {
|
||||||
|
userAgent: response.userAgent,
|
||||||
|
codexHome: response.codexHome,
|
||||||
|
platformFamily: response.platformFamily,
|
||||||
|
platformOs: response.platformOs,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
4
src/app-server/connection/execution-context.ts
Normal file
4
src/app-server/connection/execution-context.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
export interface AppServerExecutionContext {
|
||||||
|
readonly codexPath: string;
|
||||||
|
readonly vaultPath: string;
|
||||||
|
}
|
||||||
|
|
@ -99,16 +99,34 @@ export class JsonRpcClient {
|
||||||
handleLine(line: string): void {
|
handleLine(line: string): void {
|
||||||
if (line.trim().length === 0) return;
|
if (line.trim().length === 0) return;
|
||||||
|
|
||||||
let message: RpcInboundMessage;
|
let parsed: unknown;
|
||||||
try {
|
try {
|
||||||
message = JSON.parse(line) as RpcInboundMessage;
|
parsed = JSON.parse(line) as unknown;
|
||||||
} catch {
|
} catch {
|
||||||
this.options.onLog(`Invalid app-server JSON: ${line}`);
|
this.options.onLog(`Invalid app-server JSON: ${line}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const invalidParamsRequest = invalidKnownParamsRequest(parsed);
|
||||||
|
if (invalidParamsRequest) {
|
||||||
|
this.options.onLog(`Invalid app-server params: ${invalidParamsRequest.method}`);
|
||||||
|
this.reject(invalidParamsRequest.id, -32602, "Invalid params.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = rpcInboundMessage(parsed);
|
||||||
|
if (!message) {
|
||||||
|
this.options.onLog(`Invalid app-server JSON-RPC message: ${line}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if ("id" in message && "method" in message) {
|
if ("id" in message && "method" in message) {
|
||||||
this.options.onServerRequest(message);
|
try {
|
||||||
|
this.options.onServerRequest(message);
|
||||||
|
} catch (error) {
|
||||||
|
this.options.onLog(`App-server request handler failed: ${errorMessage(error)}`);
|
||||||
|
this.reject(message.id, -32603, "Codex Panel failed to handle the app-server request.");
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,7 +136,11 @@ export class JsonRpcClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("method" in message) {
|
if ("method" in message) {
|
||||||
this.options.onNotification(message);
|
try {
|
||||||
|
this.options.onNotification(message);
|
||||||
|
} catch (error) {
|
||||||
|
this.options.onLog(`App-server notification handler failed: ${errorMessage(error)}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -162,6 +184,62 @@ export class JsonRpcClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rpcInboundMessage(value: unknown): RpcInboundMessage | null {
|
||||||
|
if (!isRecord(value)) return null;
|
||||||
|
const hasId = Object.hasOwn(value, "id");
|
||||||
|
const hasMethod = Object.hasOwn(value, "method");
|
||||||
|
if (hasId && !isRequestId(value["id"])) return null;
|
||||||
|
if (hasMethod && (typeof value["method"] !== "string" || value["method"].length === 0 || !isRecord(value["params"]))) return null;
|
||||||
|
if (hasId && hasMethod) return value as unknown as ServerRequest;
|
||||||
|
if (hasId) {
|
||||||
|
const hasResult = Object.hasOwn(value, "result");
|
||||||
|
const hasError = Object.hasOwn(value, "error");
|
||||||
|
return hasResult !== hasError ? (value as unknown as RpcInboundMessage) : null;
|
||||||
|
}
|
||||||
|
return hasMethod ? (value as unknown as ServerNotification) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRequestId(value: unknown): value is RequestId {
|
||||||
|
return typeof value === "string" || (typeof value === "number" && Number.isFinite(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidKnownParamsRequest(value: unknown): { id: RequestId; method: string } | null {
|
||||||
|
if (!isRecord(value) || !Object.hasOwn(value, "id") || typeof value["method"] !== "string") return null;
|
||||||
|
const method = value["method"];
|
||||||
|
const id = value["id"];
|
||||||
|
const params = value["params"];
|
||||||
|
const required = requiredStringFields(method);
|
||||||
|
if (!required) return null;
|
||||||
|
if (!isRecord(params)) return isRequestId(id) ? { id, method } : null;
|
||||||
|
return required.every((field) => typeof params[field] === "string") ? null : isRequestId(id) ? { id, method } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredStringFields(method: string): readonly string[] | null {
|
||||||
|
switch (method) {
|
||||||
|
case "item/commandExecution/requestApproval":
|
||||||
|
case "item/fileChange/requestApproval":
|
||||||
|
return ["threadId", "turnId", "itemId"];
|
||||||
|
case "item/tool/requestUserInput":
|
||||||
|
case "mcpServer/elicitation/request":
|
||||||
|
return ["threadId", "turnId", "itemId"];
|
||||||
|
case "currentTime/read":
|
||||||
|
return ["threadId"];
|
||||||
|
case "applyPatchApproval":
|
||||||
|
case "execCommandApproval":
|
||||||
|
return ["conversationId", "callId"];
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
function isRpcError(value: unknown): value is RpcError {
|
function isRpcError(value: unknown): value is RpcError {
|
||||||
return value !== null && typeof value === "object" && typeof (value as { message?: unknown }).message === "string";
|
return value !== null && typeof value === "object" && typeof (value as { message?: unknown }).message === "string";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,45 @@
|
||||||
import { AppServerClient } from "./client";
|
import { AppServerClient } from "./client";
|
||||||
import type { AppServerClientAccessOptions } from "./client-access";
|
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>(
|
export async function withShortLivedAppServerClient<T>(
|
||||||
codexPath: string,
|
codexPath: string,
|
||||||
cwd: string,
|
cwd: string,
|
||||||
operation: (client: AppServerClient) => Promise<T>,
|
operation: (client: AppServerClient) => Promise<T>,
|
||||||
options: AppServerClientAccessOptions = {},
|
options: AppServerClientAccessOptions = {},
|
||||||
|
lifetime?: ShortLivedAppServerClientLifetime,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const client = new AppServerClient(codexPath, cwd, {
|
const client = new AppServerClient({
|
||||||
onNotification: () => undefined,
|
codexPath,
|
||||||
onServerRequest: (request, responder) => {
|
cwd,
|
||||||
void request;
|
initializeParams: codexPanelAppServerInitializeParams(),
|
||||||
responder.reject(
|
handlers: {
|
||||||
-32601,
|
onNotification: () => undefined,
|
||||||
options.serverRequests?.kind === "reject"
|
onServerRequest: (request, responder) => {
|
||||||
? options.serverRequests.message
|
void request;
|
||||||
: "This Codex Panel view does not handle server requests.",
|
responder.reject(
|
||||||
);
|
-32601,
|
||||||
|
options.serverRequests?.kind === "reject"
|
||||||
|
? options.serverRequests.message
|
||||||
|
: "This Codex Panel view does not handle server requests.",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onLog: () => undefined,
|
||||||
|
onExit: () => undefined,
|
||||||
},
|
},
|
||||||
onLog: () => undefined,
|
|
||||||
onExit: () => undefined,
|
|
||||||
});
|
});
|
||||||
|
lifetime?.created(client);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.connect();
|
await client.connect();
|
||||||
return await operation(client);
|
return await operation(client);
|
||||||
} finally {
|
} finally {
|
||||||
client.disconnect();
|
client.disconnect();
|
||||||
|
lifetime?.disposed(client);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,10 +24,7 @@ export interface AppServerTransportHandlers {
|
||||||
onError: (error: Error) => void;
|
onError: (error: Error) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createAppServerSpawnSpec(
|
function createAppServerSpawnSpec(codexPath: string, options: { platform?: NodeJS.Platform; comSpec?: string } = {}): AppServerSpawnSpec {
|
||||||
codexPath: string,
|
|
||||||
options: { platform?: NodeJS.Platform; comSpec?: string } = {},
|
|
||||||
): AppServerSpawnSpec {
|
|
||||||
const platform = options.platform ?? process.platform;
|
const platform = options.platform ?? process.platform;
|
||||||
if (platform !== "win32" || !isWindowsCommandScript(codexPath)) {
|
if (platform !== "win32" || !isWindowsCommandScript(codexPath)) {
|
||||||
return { command: codexPath, args: ["app-server"], killProcessTreeOnStop: false };
|
return { command: codexPath, args: ["app-server"], killProcessTreeOnStop: false };
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,9 @@ export interface CatalogModel {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
description: string;
|
description: string;
|
||||||
hidden: boolean;
|
hidden: boolean;
|
||||||
supportedReasoningEfforts: readonly { reasoningEffort: string }[];
|
supportedReasoningEfforts: readonly { reasoningEffort: string; description: string }[];
|
||||||
defaultReasoningEffort: string | null;
|
defaultReasoningEffort: string | null;
|
||||||
inputModalities: readonly string[];
|
inputModalities: readonly string[];
|
||||||
additionalSpeedTiers: readonly string[];
|
|
||||||
serviceTiers: readonly { id: string; name: string }[];
|
serviceTiers: readonly { id: string; name: string }[];
|
||||||
defaultServiceTier: string | null;
|
defaultServiceTier: string | null;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
|
|
@ -56,9 +55,12 @@ function modelMetadataFromCatalogModel(model: CatalogModel): ModelMetadata {
|
||||||
description: model.description,
|
description: model.description,
|
||||||
hidden: model.hidden,
|
hidden: model.hidden,
|
||||||
supportedReasoningEfforts: model.supportedReasoningEfforts.map((option) => option.reasoningEffort),
|
supportedReasoningEfforts: model.supportedReasoningEfforts.map((option) => option.reasoningEffort),
|
||||||
|
reasoningEffortOptions: model.supportedReasoningEfforts.map((option) => ({
|
||||||
|
reasoningEffort: option.reasoningEffort,
|
||||||
|
description: option.description,
|
||||||
|
})),
|
||||||
defaultReasoningEffort: model.defaultReasoningEffort,
|
defaultReasoningEffort: model.defaultReasoningEffort,
|
||||||
inputModalities: [...model.inputModalities],
|
inputModalities: [...model.inputModalities],
|
||||||
additionalSpeedTiers: [...model.additionalSpeedTiers],
|
|
||||||
serviceTiers: model.serviceTiers.map((tier) => ({ id: tier.id, name: tier.name })),
|
serviceTiers: model.serviceTiers.map((tier) => ({ id: tier.id, name: tier.name })),
|
||||||
defaultServiceTier: model.defaultServiceTier,
|
defaultServiceTier: model.defaultServiceTier,
|
||||||
isDefault: model.isDefault,
|
isDefault: model.isDefault,
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import type { ServerInitialization } from "../../domain/server/initialization";
|
|
||||||
|
|
||||||
interface AppServerInitializeResponse {
|
|
||||||
userAgent: string;
|
|
||||||
codexHome: string;
|
|
||||||
platformFamily: string;
|
|
||||||
platformOs: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function appServerInitializationFromResponse(response: AppServerInitializeResponse): ServerInitialization {
|
|
||||||
return {
|
|
||||||
userAgent: response.userAgent,
|
|
||||||
codexHome: response.codexHome,
|
|
||||||
platformFamily: response.platformFamily,
|
|
||||||
platformOs: response.platformOs,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
148
src/app-server/protocol/legacy-panel-user-message.ts
Normal file
148
src/app-server/protocol/legacy-panel-user-message.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
222
src/app-server/protocol/legacy-turn-context-manifest.ts
Normal file
222
src/app-server/protocol/legacy-turn-context-manifest.ts
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
|
import { splitUtf8Context } from "../../domain/chat/context-budget";
|
||||||
import type { CodexInputItem } from "../../domain/chat/input";
|
import type { CodexInputItem } from "../../domain/chat/input";
|
||||||
|
import { turnContextSubmissionId } from "../../domain/chat/submission-id";
|
||||||
|
|
||||||
type AppServerUserInputImageDetail = "auto" | "low" | "high" | "original";
|
type AppServerUserInputImageDetail = "auto" | "low" | "high" | "original";
|
||||||
|
|
||||||
|
|
@ -6,28 +8,75 @@ type AppServerUserInput =
|
||||||
| { type: "text"; text: string; text_elements: [] }
|
| { type: "text"; text: string; text_elements: [] }
|
||||||
| { type: "image"; detail?: AppServerUserInputImageDetail; url: string }
|
| { type: "image"; detail?: AppServerUserInputImageDetail; url: string }
|
||||||
| { type: "localImage"; detail?: AppServerUserInputImageDetail; path: 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 {
|
interface AppServerAdditionalContextEntry {
|
||||||
value: string;
|
value: string;
|
||||||
kind: "untrusted" | "application";
|
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[] {
|
export function toAppServerUserInput(input: readonly CodexInputItem[]): AppServerUserInput[] {
|
||||||
return input.flatMap((item) => appServerUserInputItemFromCodexInputItem(item));
|
return input.flatMap((item) => appServerUserInputItemFromCodexInputItem(item));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function additionalContextFromCodexInput(
|
export function appServerTurnInputFromCodexInput(input: readonly CodexInputItem[], submissionId: string): AppServerTurnInput {
|
||||||
input: readonly CodexInputItem[],
|
|
||||||
): Record<string, AppServerAdditionalContextEntry> | undefined {
|
|
||||||
const additionalContext: Record<string, AppServerAdditionalContextEntry> = {};
|
const additionalContext: Record<string, AppServerAdditionalContextEntry> = {};
|
||||||
for (const item of input) {
|
const contexts = input.filter(
|
||||||
if (item.type !== "additionalContext") continue;
|
(item): item is Extract<CodexInputItem, { type: "additionalContext" }> =>
|
||||||
if (!item.key || !item.value) continue;
|
item.type === "additionalContext" && Boolean(item.key) && Boolean(item.value),
|
||||||
additionalContext[item.key] = { value: item.value, kind: item.kind };
|
);
|
||||||
|
if (contexts.length > ADDITIONAL_CONTEXT_MAX_PARTS) {
|
||||||
|
throw new Error(`Too many additional context sources (${String(contexts.length)}).`);
|
||||||
}
|
}
|
||||||
return Object.keys(additionalContext).length > 0 ? additionalContext : undefined;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
function appServerUserInputItemFromCodexInputItem(item: CodexInputItem): AppServerUserInput[] {
|
function appServerUserInputItemFromCodexInputItem(item: CodexInputItem): AppServerUserInput[] {
|
||||||
|
|
@ -40,13 +89,18 @@ function appServerUserInputItemFromCodexInputItem(item: CodexInputItem): AppServ
|
||||||
return [{ type: "localImage", path: item.path, ...appServerImageDetailProp(item.detail) }];
|
return [{ type: "localImage", path: item.path, ...appServerImageDetailProp(item.detail) }];
|
||||||
case "skill":
|
case "skill":
|
||||||
return [{ type: "skill", name: item.name, path: item.path }];
|
return [{ type: "skill", name: item.name, path: item.path }];
|
||||||
case "mention":
|
case "fileReference":
|
||||||
return [{ type: "mention", name: item.name, path: item.path }];
|
return [];
|
||||||
case "additionalContext":
|
case "additionalContext":
|
||||||
return [];
|
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"]): {
|
function appServerImageDetailProp(detail: Extract<CodexInputItem, { type: "image" | "localImage" }>["detail"]): {
|
||||||
detail?: AppServerUserInputImageDetail;
|
detail?: AppServerUserInputImageDetail;
|
||||||
} {
|
} {
|
||||||
|
|
|
||||||
|
|
@ -36,15 +36,15 @@ export function runtimeConfigSnapshotFromAppServerConfig(response: ConfigReadRes
|
||||||
}
|
}
|
||||||
|
|
||||||
function startupPermissionsFromConfig(config: Record<string, unknown>): RuntimePermissionState {
|
function startupPermissionsFromConfig(config: Record<string, unknown>): RuntimePermissionState {
|
||||||
|
const activePermissionProfile = permissionProfileFromConfig(config);
|
||||||
return {
|
return {
|
||||||
approvalPolicy: approvalPolicyOrNull(config["approval_policy"]),
|
approvalPolicy: approvalPolicyOrNull(config["approval_policy"]),
|
||||||
sandboxPolicy: sandboxPolicyFromConfig(config),
|
sandboxPolicy: activePermissionProfile ? null : sandboxPolicyFromConfig(config),
|
||||||
activePermissionProfile: permissionProfileFromConfig(config),
|
activePermissionProfile,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function permissionProfileFromConfig(config: Record<string, unknown>): RuntimePermissionState["activePermissionProfile"] {
|
function permissionProfileFromConfig(config: Record<string, unknown>): RuntimePermissionState["activePermissionProfile"] {
|
||||||
if (typeof config["sandbox_mode"] === "string") return null;
|
|
||||||
const profile = nonEmptyStringOrNull(config["default_permissions"]);
|
const profile = nonEmptyStringOrNull(config["default_permissions"]);
|
||||||
return profile ? { id: profile, extends: null } : null;
|
return profile ? { id: profile, extends: null } : null;
|
||||||
}
|
}
|
||||||
|
|
@ -66,7 +66,7 @@ function sandboxPolicyFromConfig(config: Record<string, unknown>): RuntimeSandbo
|
||||||
}
|
}
|
||||||
|
|
||||||
function approvalPolicyOrNull(value: unknown): RuntimeApprovalPolicy | null {
|
function approvalPolicyOrNull(value: unknown): RuntimeApprovalPolicy | null {
|
||||||
if (value === "untrusted" || value === "on-failure" || value === "on-request" || value === "never") return value;
|
if (value === "untrusted" || value === "on-request" || value === "never") return value;
|
||||||
if (!value || typeof value !== "object") return null;
|
if (!value || typeof value !== "object") return null;
|
||||||
const granular = (value as Record<string, unknown>)["granular"];
|
const granular = (value as Record<string, unknown>)["granular"];
|
||||||
if (!granular || typeof granular !== "object") return null;
|
if (!granular || typeof granular !== "object") return null;
|
||||||
|
|
|
||||||
|
|
@ -53,16 +53,12 @@ type AppServerMcpElicitationPrimitiveSchema =
|
||||||
title?: unknown;
|
title?: unknown;
|
||||||
description?: unknown;
|
description?: unknown;
|
||||||
default?: unknown;
|
default?: unknown;
|
||||||
minimum?: unknown;
|
|
||||||
maximum?: unknown;
|
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: "array";
|
type: "array";
|
||||||
title?: unknown;
|
title?: unknown;
|
||||||
description?: unknown;
|
description?: unknown;
|
||||||
default?: unknown;
|
default?: unknown;
|
||||||
minItems?: unknown;
|
|
||||||
maxItems?: unknown;
|
|
||||||
items?: unknown;
|
items?: unknown;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
|
|
@ -70,12 +66,8 @@ type AppServerMcpElicitationPrimitiveSchema =
|
||||||
title?: unknown;
|
title?: unknown;
|
||||||
description?: unknown;
|
description?: unknown;
|
||||||
default?: unknown;
|
default?: unknown;
|
||||||
format?: unknown;
|
|
||||||
minLength?: unknown;
|
|
||||||
maxLength?: unknown;
|
|
||||||
oneOf?: unknown;
|
oneOf?: unknown;
|
||||||
enum?: unknown;
|
enum?: unknown;
|
||||||
enumNames?: unknown;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface NormalizedMcpElicitationParams {
|
interface NormalizedMcpElicitationParams {
|
||||||
|
|
@ -167,6 +159,8 @@ export function appServerMcpElicitationRequest(request: AppServerRequest): Pendi
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const fields = mcpElicitationFieldsFromRequestedSchema(params.requestedSchema);
|
||||||
|
if (!fields) return null;
|
||||||
return {
|
return {
|
||||||
requestId: request.id,
|
requestId: request.id,
|
||||||
params: {
|
params: {
|
||||||
|
|
@ -176,7 +170,7 @@ export function appServerMcpElicitationRequest(request: AppServerRequest): Pendi
|
||||||
mode: "form",
|
mode: "form",
|
||||||
message: params.message,
|
message: params.message,
|
||||||
meta: params.meta,
|
meta: params.meta,
|
||||||
fields: mcpElicitationFieldsFromRequestedSchema(params.requestedSchema),
|
fields,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -542,6 +536,10 @@ function nullableString(value: unknown): string | null {
|
||||||
return typeof value === "string" ? value : null;
|
return typeof value === "string" ? value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function numberOrNull(value: unknown): number | null {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
function asRecordOrNull(value: unknown): Record<string, unknown> | null {
|
function asRecordOrNull(value: unknown): Record<string, unknown> | null {
|
||||||
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
|
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
|
||||||
}
|
}
|
||||||
|
|
@ -608,21 +606,19 @@ function normalizeMcpElicitationParams(params: McpElicitationParams): Normalized
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function numberOrNull(value: unknown): number | null {
|
function mcpElicitationFieldsFromRequestedSchema(schema: unknown): PendingMcpElicitationField[] | null {
|
||||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mcpElicitationFieldsFromRequestedSchema(schema: unknown): PendingMcpElicitationField[] {
|
|
||||||
const record = asRecordOrNull(schema);
|
const record = asRecordOrNull(schema);
|
||||||
const properties = asRecordOrNull(record?.["properties"]);
|
const properties = asRecordOrNull(record?.["properties"]);
|
||||||
if (!properties) return [];
|
if (record?.["type"] !== "object" || !properties) return null;
|
||||||
const required = new Set(
|
const required = new Set(
|
||||||
Array.isArray(record?.["required"]) ? record["required"].filter((item): item is string => typeof item === "string") : [],
|
Array.isArray(record["required"]) ? record["required"].filter((item): item is string => typeof item === "string") : [],
|
||||||
);
|
);
|
||||||
return Object.entries(properties).flatMap(([id, fieldSchema]) => {
|
const fields: PendingMcpElicitationField[] = [];
|
||||||
if (!isMcpElicitationPrimitiveSchema(fieldSchema)) return [];
|
for (const [id, fieldSchema] of Object.entries(properties)) {
|
||||||
return [mcpElicitationFieldFromSchema(id, fieldSchema, required.has(id))];
|
if (!isMcpElicitationPrimitiveSchema(fieldSchema)) return null;
|
||||||
});
|
fields.push(mcpElicitationFieldFromSchema(id, fieldSchema, required.has(id)));
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isMcpElicitationPrimitiveSchema(schema: unknown): schema is AppServerMcpElicitationPrimitiveSchema {
|
function isMcpElicitationPrimitiveSchema(schema: unknown): schema is AppServerMcpElicitationPrimitiveSchema {
|
||||||
|
|
@ -651,17 +647,13 @@ function mcpElicitationFieldFromSchema(
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
type: schema.type,
|
type: schema.type,
|
||||||
minimum: numberOrNull(schema.minimum),
|
defaultValue: typeof schema.default === "number" && Number.isFinite(schema.default) ? schema.default : null,
|
||||||
maximum: numberOrNull(schema.maximum),
|
|
||||||
defaultValue: numberOrNull(schema.default),
|
|
||||||
};
|
};
|
||||||
case "array":
|
case "array":
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
type: "multi-select",
|
type: "multi-select",
|
||||||
options: multiSelectOptions(schema),
|
options: multiSelectOptions(schema),
|
||||||
minItems: bigintToNumberOrNull(schema.minItems),
|
|
||||||
maxItems: bigintToNumberOrNull(schema.maxItems),
|
|
||||||
defaultValue: stringArrayOrEmpty(schema.default),
|
defaultValue: stringArrayOrEmpty(schema.default),
|
||||||
};
|
};
|
||||||
case "string": {
|
case "string": {
|
||||||
|
|
@ -677,9 +669,6 @@ function mcpElicitationFieldFromSchema(
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
type: "string",
|
type: "string",
|
||||||
format: nullableString(schema.format),
|
|
||||||
minLength: numberOrNull(schema.minLength),
|
|
||||||
maxLength: numberOrNull(schema.maxLength),
|
|
||||||
defaultValue: typeof schema.default === "string" ? schema.default : "",
|
defaultValue: typeof schema.default === "string" ? schema.default : "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -694,7 +683,7 @@ function singleSelectOptions(schema: Extract<AppServerMcpElicitationPrimitiveSch
|
||||||
});
|
});
|
||||||
if (options.length > 0) return options;
|
if (options.length > 0) return options;
|
||||||
}
|
}
|
||||||
return enumOptions(schema.enum, schema.enumNames);
|
return enumOptions(schema.enum);
|
||||||
}
|
}
|
||||||
|
|
||||||
function multiSelectOptions(schema: Extract<AppServerMcpElicitationPrimitiveSchema, { type: "array" }>): PendingMcpElicitationOption[] {
|
function multiSelectOptions(schema: Extract<AppServerMcpElicitationPrimitiveSchema, { type: "array" }>): PendingMcpElicitationOption[] {
|
||||||
|
|
@ -707,7 +696,7 @@ function multiSelectOptions(schema: Extract<AppServerMcpElicitationPrimitiveSche
|
||||||
});
|
});
|
||||||
if (options.length > 0) return options;
|
if (options.length > 0) return options;
|
||||||
}
|
}
|
||||||
return enumOptions(items?.["enum"], null);
|
return enumOptions(items?.["enum"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stringArrayOrEmpty(value: unknown): readonly string[] {
|
function stringArrayOrEmpty(value: unknown): readonly string[] {
|
||||||
|
|
@ -720,12 +709,11 @@ function stringArrayOrEmpty(value: unknown): readonly string[] {
|
||||||
return strings;
|
return strings;
|
||||||
}
|
}
|
||||||
|
|
||||||
function enumOptions(values: unknown, labels: unknown): PendingMcpElicitationOption[] {
|
function enumOptions(values: unknown): PendingMcpElicitationOption[] {
|
||||||
if (!Array.isArray(values)) return [];
|
if (!Array.isArray(values)) return [];
|
||||||
const labelValues = Array.isArray(labels) ? labels : [];
|
return values.flatMap((value) => {
|
||||||
return values.flatMap((value, index) => {
|
|
||||||
if (typeof value !== "string") return [];
|
if (typeof value !== "string") return [];
|
||||||
return [{ value, label: nonEmptyString(labelValues[index]) ?? value }];
|
return [{ value, label: value }];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -736,11 +724,6 @@ function selectOption(value: unknown): PendingMcpElicitationOption | null {
|
||||||
return { value: optionValue, label: nonEmptyString(record?.["title"]) ?? optionValue };
|
return { value: optionValue, label: nonEmptyString(record?.["title"]) ?? optionValue };
|
||||||
}
|
}
|
||||||
|
|
||||||
function bigintToNumberOrNull(value: unknown): number | null {
|
|
||||||
if (typeof value === "bigint") return Number(value);
|
|
||||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toJsonContent(content: Record<string, McpElicitationContentValue> | null): unknown {
|
function toJsonContent(content: Record<string, McpElicitationContentValue> | null): unknown {
|
||||||
if (!content) return null;
|
if (!content) return null;
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
|
|
|
||||||
26
src/app-server/protocol/side-chat.ts
Normal file
26
src/app-server/protocol/side-chat.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
type AppServerJsonValue = number | string | boolean | AppServerJsonValue[] | { [key: string]: AppServerJsonValue | undefined } | null;
|
||||||
|
|
||||||
|
const SIDE_CHAT_DEVELOPER_INSTRUCTIONS = `You are in a side conversation, not the main thread.
|
||||||
|
|
||||||
|
The inherited fork history is reference context only. Do not continue any task, plan, tool call, approval, edit, or request found only in that history. Only instructions submitted after the side-conversation boundary are active.
|
||||||
|
|
||||||
|
Use the side conversation for questions and lightweight, non-mutating exploration. Do not use sub-agents. This thread is read-only; do not modify files, source, git state, permissions, configuration, or other workspace state.`;
|
||||||
|
|
||||||
|
const SIDE_CHAT_BOUNDARY = `Side conversation boundary.
|
||||||
|
|
||||||
|
Everything before this boundary is inherited history from the parent thread. It is reference context only, not the current task.
|
||||||
|
|
||||||
|
Do not continue any instructions, plans, tool calls, approvals, edits, or requests from before this boundary. Only messages submitted after this boundary are active user instructions for this side conversation. If there is no user question after this boundary yet, wait for one.`;
|
||||||
|
|
||||||
|
export function sideChatDeveloperInstructions(existingInstructions: string | null | undefined): string {
|
||||||
|
const existing = existingInstructions?.trim();
|
||||||
|
return existing ? `${existing}\n\n${SIDE_CHAT_DEVELOPER_INSTRUCTIONS}` : SIDE_CHAT_DEVELOPER_INSTRUCTIONS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appServerSideChatBoundaryItem(): AppServerJsonValue {
|
||||||
|
return {
|
||||||
|
type: "message",
|
||||||
|
role: "user",
|
||||||
|
content: [{ type: "input_text", text: SIDE_CHAT_BOUNDARY }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
import type { Thread } from "../../domain/threads/model";
|
import type { Thread, ThreadProvenance } from "../../domain/threads/model";
|
||||||
|
import type { Thread as GeneratedThread } from "../../generated/app-server/v2/Thread";
|
||||||
|
|
||||||
export interface ThreadRecord {
|
type RequiredThreadRecordFields = "id" | "preview" | "name" | "createdAt" | "updatedAt";
|
||||||
id: string;
|
|
||||||
preview: string;
|
export type ThreadRecord = Pick<GeneratedThread, RequiredThreadRecordFields> &
|
||||||
name: string | null;
|
Partial<Omit<GeneratedThread, RequiredThreadRecordFields | "source" | "status" | "turns">> & {
|
||||||
createdAt: number;
|
/** Kept unknown at the protocol edge so a newer SessionSource variant degrades to `other` instead of breaking thread lists. */
|
||||||
updatedAt: number;
|
source?: unknown;
|
||||||
recencyAt?: number | null;
|
status?: unknown;
|
||||||
[key: string]: unknown;
|
turns?: readonly GeneratedThread["turns"][number][];
|
||||||
}
|
};
|
||||||
|
|
||||||
export function threadFromThreadRecord(thread: ThreadRecord, options: { archived?: boolean } = {}): Thread {
|
export function threadFromThreadRecord(thread: ThreadRecord, options: { archived?: boolean } = {}): Thread {
|
||||||
const hasRecencyAt = Object.hasOwn(thread, "recencyAt");
|
const hasRecencyAt = Object.hasOwn(thread, "recencyAt");
|
||||||
|
|
@ -20,10 +21,54 @@ export function threadFromThreadRecord(thread: ThreadRecord, options: { archived
|
||||||
archived: options.archived ?? false,
|
archived: options.archived ?? false,
|
||||||
createdAt: finiteTimestamp(thread.createdAt),
|
createdAt: finiteTimestamp(thread.createdAt),
|
||||||
updatedAt: finiteTimestamp(thread.updatedAt),
|
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 } : {}),
|
...(hasRecencyAt ? { recencyAt: typeof recencyAt === "number" && Number.isFinite(recencyAt) ? recencyAt : null } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function threadProvenance(thread: ThreadRecord): ThreadProvenance {
|
||||||
|
const source = recordOrNull(thread["source"]);
|
||||||
|
const subagent = source?.["subAgent"];
|
||||||
|
const threadSource = stringOrNull(thread["threadSource"]);
|
||||||
|
if (subagent === undefined && stringOrNull(thread["parentThreadId"]) === null && !threadSource?.startsWith("subAgent")) {
|
||||||
|
return { kind: "interactive" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const spawn = recordOrNull(recordOrNull(subagent)?.["thread_spawn"]);
|
||||||
|
return {
|
||||||
|
kind: "subagent",
|
||||||
|
subagentKind: subagentKind(subagent, threadSource),
|
||||||
|
parentThreadId: stringOrNull(thread["parentThreadId"]) ?? stringOrNull(spawn?.["parent_thread_id"]),
|
||||||
|
sessionId: stringOrNull(thread["sessionId"]),
|
||||||
|
depth: finiteNumberOrNull(spawn?.["depth"]),
|
||||||
|
agentNickname: stringOrNull(thread["agentNickname"]) ?? stringOrNull(spawn?.["agent_nickname"]),
|
||||||
|
agentRole: stringOrNull(thread["agentRole"]) ?? stringOrNull(spawn?.["agent_role"]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function subagentKind(value: unknown, threadSource: string | null): Extract<ThreadProvenance, { kind: "subagent" }>["subagentKind"] {
|
||||||
|
if (value === "review" || value === "compact" || value === "memory_consolidation") {
|
||||||
|
return value === "memory_consolidation" ? "memory-consolidation" : value;
|
||||||
|
}
|
||||||
|
if (recordOrNull(value)?.["thread_spawn"] || threadSource === "subAgentThreadSpawn") return "thread-spawn";
|
||||||
|
if (threadSource === "subAgentReview") return "review";
|
||||||
|
if (threadSource === "subAgentCompact") return "compact";
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordOrNull(value: unknown): Record<string, unknown> | null {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringOrNull(value: unknown): string | null {
|
||||||
|
return typeof value === "string" && value.length > 0 ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finiteNumberOrNull(value: unknown): number | null {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function threadsFromThreadRecords(threads: readonly ThreadRecord[], options: { archived?: boolean } = {}): Thread[] {
|
export function threadsFromThreadRecords(threads: readonly ThreadRecord[], options: { archived?: boolean } = {}): Thread[] {
|
||||||
return threads.map((thread) => threadFromThreadRecord(thread, options));
|
return threads.map((thread) => threadFromThreadRecord(thread, options));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ interface ToolInventoryPluginSummary {
|
||||||
type ToolInventoryPluginSource =
|
type ToolInventoryPluginSource =
|
||||||
| { type: "local"; path: string }
|
| { type: "local"; path: string }
|
||||||
| { type: "git"; url: string; path: string | null; refName: string | null; sha: string | null }
|
| { type: "git"; url: string; path: string | null; refName: string | null; sha: string | null }
|
||||||
|
| { type: "npm"; package: string; version: string | null; registry: string | null }
|
||||||
| { type: "remote" };
|
| { type: "remote" };
|
||||||
|
|
||||||
interface ToolInventoryMarketplaceLoadError {
|
interface ToolInventoryMarketplaceLoadError {
|
||||||
|
|
@ -80,5 +81,6 @@ function pluginSourceLabel(source: ToolInventoryPluginSource): string {
|
||||||
const path = source.path ? `/${source.path}` : "";
|
const path = source.path ? `/${source.path}` : "";
|
||||||
return `${source.url}${path}${ref}`;
|
return `${source.url}${path}${ref}`;
|
||||||
}
|
}
|
||||||
|
if (source.type === "npm") return source.version ? `${source.package}@${source.version}` : source.package;
|
||||||
return "remote";
|
return "remote";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,23 @@
|
||||||
|
import type { VaultFileReference } from "../../domain/chat/input";
|
||||||
|
import type { ReferencedThreadMetadata } from "../../domain/threads/reference";
|
||||||
import {
|
import {
|
||||||
conversationSummaryFromTranscriptEntries,
|
nonEmptyTurnTranscriptSummaries,
|
||||||
nonEmptyConversationSummaries,
|
|
||||||
type ThreadConversationSummary,
|
|
||||||
type ThreadTranscriptEntry,
|
type ThreadTranscriptEntry,
|
||||||
|
type TurnTranscriptSummary,
|
||||||
|
turnTranscriptSummaryFromTranscriptEntries,
|
||||||
} from "../../domain/threads/transcript";
|
} from "../../domain/threads/transcript";
|
||||||
import type { ThreadItem as GeneratedThreadItem } from "../../generated/app-server/v2/ThreadItem";
|
import type { ThreadItem as GeneratedThreadItem } from "../../generated/app-server/v2/ThreadItem";
|
||||||
import type { Turn as GeneratedTurn } from "../../generated/app-server/v2/Turn";
|
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 TurnItem = GeneratedThreadItem;
|
||||||
export type TurnRecord = GeneratedTurn;
|
export type TurnRecord = GeneratedTurn;
|
||||||
|
|
||||||
type AppServerUserInput = Extract<TurnItem, { type: "userMessage" }>["content"][number];
|
|
||||||
type AppServerTextUserInput = Extract<AppServerUserInput, { type: "text" }>;
|
|
||||||
|
|
||||||
function transcriptEntriesFromTurnRecord(turn: TurnRecord): ThreadTranscriptEntry[] {
|
function transcriptEntriesFromTurnRecord(turn: TurnRecord): ThreadTranscriptEntry[] {
|
||||||
return turn.items.flatMap((item) => transcriptEntriesFromTurnItem(item, turn));
|
return turn.items.flatMap((item) => transcriptEntriesFromTurnItem(item, turn));
|
||||||
}
|
}
|
||||||
|
|
@ -21,35 +26,69 @@ export function transcriptEntriesFromTurnRecords(turns: readonly TurnRecord[]):
|
||||||
return turns.flatMap(transcriptEntriesFromTurnRecord);
|
return turns.flatMap(transcriptEntriesFromTurnRecord);
|
||||||
}
|
}
|
||||||
|
|
||||||
function conversationSummaryFromTurnRecord(turn: TurnRecord): ThreadConversationSummary {
|
function turnTranscriptSummaryFromTurnRecord(turn: TurnRecord): TurnTranscriptSummary {
|
||||||
return conversationSummaryFromTranscriptEntries(transcriptEntriesFromTurnRecord(turn));
|
return turnTranscriptSummaryFromTranscriptEntries(transcriptEntriesFromTurnRecord(turn));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function conversationAssistantTextFromTurnRecord(turn: TurnRecord): string | null {
|
export function turnTranscriptAssistantTextFromTurnRecord(turn: TurnRecord): string | null {
|
||||||
return conversationSummaryFromTurnRecord(turn).assistantText;
|
return turnTranscriptSummaryFromTurnRecord(turn).assistantText;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function completedConversationSummaryFromTurnRecord(turn: TurnRecord): ThreadConversationSummary | null {
|
export function completedTurnTranscriptSummaryFromTurnRecord(turn: TurnRecord): TurnTranscriptSummary | null {
|
||||||
if (turn.status !== "completed") return null;
|
if (turn.status !== "completed") return null;
|
||||||
const summary = conversationSummaryFromTurnRecord(turn);
|
const summary = turnTranscriptSummaryFromTurnRecord(turn);
|
||||||
return summary.userText && summary.assistantText ? summary : null;
|
return summary.userText && summary.assistantText ? summary : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function completedConversationSummariesFromTurnRecords(turns: readonly TurnRecord[]): ThreadConversationSummary[] {
|
export function completedTurnTranscriptSummariesFromTurnRecords(turns: readonly TurnRecord[]): TurnTranscriptSummary[] {
|
||||||
return turns.flatMap((turn) => {
|
return turns.flatMap((turn) => {
|
||||||
const summary = completedConversationSummaryFromTurnRecord(turn);
|
const summary = completedTurnTranscriptSummaryFromTurnRecord(turn);
|
||||||
return summary ? [summary] : [];
|
return summary ? [summary] : [];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function chronologicalConversationSummariesFromTurnRecords(turns: readonly TurnRecord[]): ThreadConversationSummary[] {
|
export function chronologicalTurnTranscriptSummariesFromTurnRecords(turns: readonly TurnRecord[]): TurnTranscriptSummary[] {
|
||||||
return nonEmptyConversationSummaries(
|
return nonEmptyTurnTranscriptSummaries(
|
||||||
[...turns].sort((a, b) => (a.startedAt ?? 0) - (b.startedAt ?? 0)).map(conversationSummaryFromTurnRecord),
|
[...turns].sort((a, b) => (a.startedAt ?? 0) - (b.startedAt ?? 0)).map(turnTranscriptSummaryFromTurnRecord),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function turnUserItemText(item: Extract<TurnItem, { type: "userMessage" }>): string {
|
export interface TurnUserItemProjection {
|
||||||
return userInputText(item.content);
|
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 lastAgentMessageTextFromTurnRecord(turn: TurnRecord): string | null {
|
export function lastAgentMessageTextFromTurnRecord(turn: TurnRecord): string | null {
|
||||||
|
|
@ -65,8 +104,20 @@ export function lastAgentMessageTextFromTurnRecord(turn: TurnRecord): string | n
|
||||||
|
|
||||||
function transcriptEntriesFromTurnItem(item: TurnItem, turn: TurnRecord): ThreadTranscriptEntry[] {
|
function transcriptEntriesFromTurnItem(item: TurnItem, turn: TurnRecord): ThreadTranscriptEntry[] {
|
||||||
if (item.type === "userMessage") {
|
if (item.type === "userMessage") {
|
||||||
const text = turnUserItemText(item).trim();
|
const projection = turnUserItemProjection(item);
|
||||||
return text ? [{ kind: "user", text, timestamp: turn.startedAt }] : [];
|
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 } : {}),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
}
|
}
|
||||||
if (item.type === "agentMessage") {
|
if (item.type === "agentMessage") {
|
||||||
const text = item.text.trim();
|
const text = item.text.trim();
|
||||||
|
|
@ -79,22 +130,36 @@ function transcriptEntriesFromTurnItem(item: TurnItem, turn: TurnRecord): Thread
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function userInputText(content: readonly AppServerUserInput[]): string {
|
function legacyContextMetadata(
|
||||||
const textItems = content.filter(isTextUserInput);
|
contexts: readonly { kind: "referencedThread" | "web" | "obsidian"; truncated: boolean; inlineExcerpts?: number }[],
|
||||||
const hasText = textItems.some((item) => item.text.length > 0);
|
): TurnUserContextMetadata[] {
|
||||||
const textIncludes = (value: string) => value.length > 0 && textItems.some((item) => item.text.includes(value));
|
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);
|
||||||
return content
|
return content
|
||||||
.map((item) => {
|
.map((item, index) => {
|
||||||
if (item.type === "text") return item.text;
|
|
||||||
if (item.type === "localImage") return hasText && textIncludes(item.path) ? "" : `[local image] ${item.path}`;
|
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 === "image") return hasText && textIncludes(item.url) ? "" : `[image] ${item.url}`;
|
||||||
if (item.type === "mention") return hasText ? "" : `[@${item.name}] ${item.path}`;
|
if (item.type === "mention") return mentionTextByContentIndex?.get(index) ?? "";
|
||||||
return hasText ? "" : `[$${item.name}] ${item.path}`;
|
if (item.type === "skill") return hasText ? "" : `[$${item.name}] ${item.path}`;
|
||||||
|
return "";
|
||||||
})
|
})
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTextUserInput(item: AppServerUserInput): item is AppServerTextUserInput {
|
|
||||||
return item.type === "text";
|
|
||||||
}
|
|
||||||
|
|
|
||||||
65
src/app-server/query/active-thread-inventory.ts
Normal file
65
src/app-server/query/active-thread-inventory.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
@ -1,389 +1,692 @@
|
||||||
import { QueryClient, QueryObserver, type QueryObserverResult } from "@tanstack/query-core";
|
import {
|
||||||
import type { ModelMetadata } from "../../domain/catalog/metadata";
|
CancelledError,
|
||||||
import { createServerDiagnostics, diagnosticProbeError, diagnosticProbeOk, diagnosticsWithProbe } from "../../domain/server/diagnostics";
|
InfiniteQueryObserver,
|
||||||
import type { SharedServerMetadata } from "../../domain/server/metadata";
|
type InfiniteQueryObserverOptions,
|
||||||
|
type InfiniteQueryObserverResult,
|
||||||
|
QueryClient,
|
||||||
|
QueryObserver,
|
||||||
|
type QueryObserverResult,
|
||||||
|
} from "@tanstack/query-core";
|
||||||
|
import type { ModelMetadata, SkillMetadata } from "../../domain/catalog/metadata";
|
||||||
|
import { cloneRuntimeConfigSnapshot, type RuntimeConfigSnapshot } from "../../domain/runtime/config";
|
||||||
|
import type { RateLimitSnapshot } from "../../domain/runtime/metrics";
|
||||||
|
import type { RuntimePermissionProfileSummary } from "../../domain/runtime/permissions";
|
||||||
|
import {
|
||||||
|
createServerDiagnostics,
|
||||||
|
type DiagnosticProbeResult,
|
||||||
|
diagnosticProbeError,
|
||||||
|
diagnosticProbeOk,
|
||||||
|
diagnosticsWithProbe,
|
||||||
|
} from "../../domain/server/diagnostics";
|
||||||
|
import type { SharedServerMetadata, SharedServerMetadataResource } from "../../domain/server/metadata";
|
||||||
import type { Thread } from "../../domain/threads/model";
|
import type { Thread } from "../../domain/threads/model";
|
||||||
|
import { StaleExecutionRuntimeError } from "../../shared/runtime/execution-runtime-lifetime";
|
||||||
import type { AppServerClient } from "../connection/client";
|
import type { AppServerClient } from "../connection/client";
|
||||||
import type { AppServerClientAccessOptions } from "../connection/client-access";
|
import type { AppServerClientAccess, AppServerClientAccessOptions } from "../connection/client-access";
|
||||||
|
import type { AppServerExecutionContext } from "../connection/execution-context";
|
||||||
import { runtimeConfigSnapshotFromAppServerConfig } from "../protocol/runtime-config";
|
import { runtimeConfigSnapshotFromAppServerConfig } from "../protocol/runtime-config";
|
||||||
import { listModelMetadata } from "../services/catalog";
|
import { listModelMetadata } from "../services/catalog";
|
||||||
import { readEffectiveConfig } from "../services/runtime-metadata";
|
import { readEffectiveConfig } from "../services/runtime-metadata";
|
||||||
import { listThreads } from "../services/threads";
|
import { listThreads, readThreadPage, type ThreadPage } from "../services/threads";
|
||||||
import {
|
import {
|
||||||
type AppServerQueryContext,
|
type ActiveThreadCursor,
|
||||||
activeThreadsQueryKey,
|
type ActiveThreadData,
|
||||||
appServerMetadataQueryKey,
|
activeThreadDataHasMore,
|
||||||
appServerModelsQueryKey,
|
activeThreadsFromData,
|
||||||
appServerQueriesFilter,
|
applyActiveThreadMutation,
|
||||||
appServerQueryContextIsComplete,
|
recentActiveThreadsFromData,
|
||||||
archivedThreadsQueryKey,
|
} from "./active-thread-inventory";
|
||||||
cloneAppServerQueryContext,
|
import { readPermissionProfileMetadataProbe, readRateLimitMetadataProbe, readSkillMetadataProbe } from "./metadata-probes";
|
||||||
} from "./keys";
|
import type { ObservedPaginatedResult, ObservedPaginatedResultListener, ObservedResult, ObservedResultListener } from "./observed-result";
|
||||||
import { readRateLimitMetadataProbe, readSkillMetadataProbe } from "./metadata-probes";
|
import { cloneModelMetadata, cloneSharedServerMetadata, cloneSharedServerMetadataResource, cloneThreads } from "./snapshots";
|
||||||
import type { ObservedResult, ObservedResultListener } from "./observed-result";
|
import { applyThreadListMutation, type ThreadListKind, type ThreadListMutation } from "./thread-list-mutation";
|
||||||
import { cloneModelMetadata, cloneSharedServerMetadata, cloneThreads } from "./snapshots";
|
|
||||||
|
|
||||||
const THREAD_LIST_STALE_TIME_MS = 10_000;
|
|
||||||
const APP_SERVER_METADATA_STALE_TIME_MS = 10_000;
|
|
||||||
const MODELS_STALE_TIME_MS = 60_000;
|
const MODELS_STALE_TIME_MS = 60_000;
|
||||||
|
const ACTIVE_THREADS_QUERY_KEY = ["threads", "active"] as const;
|
||||||
export interface AppServerQueryClientRunner {
|
const ACTIVE_THREAD_SEARCH_INVENTORY_QUERY_KEY = ["threads", "active-search-inventory"] as const;
|
||||||
runWithClient<T>(
|
const ARCHIVED_THREADS_QUERY_KEY = ["threads", "archived"] as const;
|
||||||
context: AppServerQueryContext,
|
const MODELS_QUERY_KEY = ["models"] as const;
|
||||||
operation: (client: AppServerClient) => Promise<T>,
|
const RUNTIME_CONFIG_QUERY_KEY = ["runtime-config"] as const;
|
||||||
options?: AppServerClientAccessOptions,
|
const SKILLS_QUERY_KEY = ["skills"] as const;
|
||||||
): Promise<T>;
|
const PERMISSION_PROFILES_QUERY_KEY = ["permission-profiles"] as const;
|
||||||
}
|
const RATE_LIMITS_QUERY_KEY = ["rate-limits"] as const;
|
||||||
|
|
||||||
interface AppServerQueryOptions<T> {
|
interface AppServerQueryOptions<T> {
|
||||||
readonly queryKey: readonly unknown[];
|
readonly queryKey: readonly unknown[];
|
||||||
readonly queryFn: () => Promise<T>;
|
readonly queryFn: (context: { signal: AbortSignal }) => Promise<T>;
|
||||||
readonly staleTime: number;
|
readonly staleTime?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ThreadListKind = "active" | "archived";
|
type ActiveThreadsQueryKey = typeof ACTIVE_THREADS_QUERY_KEY;
|
||||||
type ThreadListUpdater = (threads: readonly Thread[] | null) => readonly Thread[] | null;
|
|
||||||
|
interface MetadataResourceSnapshot<T> {
|
||||||
|
readonly value: T;
|
||||||
|
readonly probe: DiagnosticProbeResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MetadataResourceKind = "skills" | "permissionProfiles" | "rateLimits";
|
||||||
|
type MetadataResourceValue = readonly SkillMetadata[] | readonly RuntimePermissionProfileSummary[] | RateLimitSnapshot | null;
|
||||||
|
|
||||||
export class AppServerQueryCache {
|
export class AppServerQueryCache {
|
||||||
readonly client: QueryClient;
|
private readonly context: Readonly<AppServerExecutionContext>;
|
||||||
private readonly clientRunner: AppServerQueryClientRunner | null;
|
private readonly client: QueryClient;
|
||||||
|
private readonly clientAccess: AppServerClientAccess;
|
||||||
|
private readonly observerUnsubscribes = new Set<() => void>();
|
||||||
|
private disposed = false;
|
||||||
|
|
||||||
constructor(options: { client?: QueryClient; clientRunner?: AppServerQueryClientRunner } = {}) {
|
constructor(context: AppServerExecutionContext, clientAccess: AppServerClientAccess) {
|
||||||
this.client = options.client ?? createAppServerQueryClient();
|
this.context = Object.freeze({ ...context });
|
||||||
this.clientRunner = options.clientRunner ?? null;
|
this.client = createAppServerQueryClient();
|
||||||
|
this.clientAccess = clientAccess;
|
||||||
}
|
}
|
||||||
|
|
||||||
clear(): void {
|
dispose(): void {
|
||||||
|
if (this.disposed) return;
|
||||||
|
this.disposed = true;
|
||||||
|
for (const unsubscribe of [...this.observerUnsubscribes]) unsubscribe();
|
||||||
|
this.observerUnsubscribes.clear();
|
||||||
this.client.clear();
|
this.client.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
clearContext(context: AppServerQueryContext): void {
|
activeThreadsSnapshot(): readonly Thread[] | null {
|
||||||
if (!appServerQueryContextIsComplete(context)) return;
|
if (this.disposed) return null;
|
||||||
const filter = appServerQueriesFilter(context);
|
const data = this.client.getQueryData<ActiveThreadData>(ACTIVE_THREADS_QUERY_KEY);
|
||||||
void this.client.cancelQueries(filter);
|
const threads = activeThreadsFromData(data);
|
||||||
this.client.removeQueries(filter);
|
|
||||||
}
|
|
||||||
|
|
||||||
activeThreadsSnapshot(context: AppServerQueryContext): readonly Thread[] | null {
|
|
||||||
return this.threadListSnapshot(context, "active");
|
|
||||||
}
|
|
||||||
|
|
||||||
archivedThreadsSnapshot(context: AppServerQueryContext): readonly Thread[] | null {
|
|
||||||
return this.threadListSnapshot(context, "archived");
|
|
||||||
}
|
|
||||||
|
|
||||||
observeActiveThreadsResult(
|
|
||||||
context: AppServerQueryContext,
|
|
||||||
listener: ObservedResultListener<readonly Thread[]>,
|
|
||||||
options: { emitCurrent?: boolean } = {},
|
|
||||||
): () => void {
|
|
||||||
return this.observeQueryResult(this.threadListQueryOptions(context, "active"), cloneThreads, listener, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
observeArchivedThreadsResult(
|
|
||||||
context: AppServerQueryContext,
|
|
||||||
listener: ObservedResultListener<readonly Thread[]>,
|
|
||||||
options: { emitCurrent?: boolean } = {},
|
|
||||||
): () => void {
|
|
||||||
return this.observeQueryResult(this.threadListQueryOptions(context, "archived"), cloneThreads, listener, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchActiveThreads(context: AppServerQueryContext, options: { force?: boolean } = {}): Promise<readonly Thread[]> {
|
|
||||||
return this.fetchThreadList(context, "active", options);
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchArchivedThreads(context: AppServerQueryContext, options: { force?: boolean } = {}): Promise<readonly Thread[]> {
|
|
||||||
return this.fetchThreadList(context, "archived", options);
|
|
||||||
}
|
|
||||||
|
|
||||||
async refreshActiveThreads(context: AppServerQueryContext): Promise<readonly Thread[]> {
|
|
||||||
return this.fetchActiveThreads(context, { force: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
async refreshArchivedThreads(context: AppServerQueryContext): Promise<readonly Thread[]> {
|
|
||||||
return this.fetchArchivedThreads(context, { force: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
setActiveThreads(context: AppServerQueryContext, threads: readonly Thread[]): void {
|
|
||||||
this.setThreadList(context, "active", threads);
|
|
||||||
}
|
|
||||||
|
|
||||||
setArchivedThreads(context: AppServerQueryContext, threads: readonly Thread[]): void {
|
|
||||||
this.setThreadList(context, "archived", threads);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateActiveThreads(context: AppServerQueryContext, updater: ThreadListUpdater): readonly Thread[] | null {
|
|
||||||
return this.updateThreadList(context, "active", updater);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateArchivedThreads(context: AppServerQueryContext, updater: ThreadListUpdater): readonly Thread[] | null {
|
|
||||||
return this.updateThreadList(context, "archived", updater);
|
|
||||||
}
|
|
||||||
|
|
||||||
private threadListSnapshot(context: AppServerQueryContext, kind: ThreadListKind): readonly Thread[] | null {
|
|
||||||
if (!appServerQueryContextIsComplete(context)) return null;
|
|
||||||
const threads = this.client.getQueryData<readonly Thread[]>(this.threadListQueryKey(context, kind));
|
|
||||||
return threads ? cloneThreads(threads) : null;
|
return threads ? cloneThreads(threads) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fetchThreadList(
|
recentActiveThreadsSnapshot(): readonly Thread[] | null {
|
||||||
context: AppServerQueryContext,
|
if (this.disposed) return null;
|
||||||
kind: ThreadListKind,
|
const data = this.client.getQueryData<ActiveThreadData>(ACTIVE_THREADS_QUERY_KEY);
|
||||||
options: { force?: boolean } = {},
|
const threads = recentActiveThreadsFromData(data);
|
||||||
): Promise<readonly Thread[]> {
|
return threads ? cloneThreads(threads) : null;
|
||||||
const refreshContext = cloneAppServerQueryContext(context);
|
|
||||||
if (!appServerQueryContextIsComplete(refreshContext)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const key = this.threadListQueryKey(refreshContext, kind);
|
|
||||||
if (options.force) await this.client.invalidateQueries({ queryKey: key });
|
|
||||||
const threads = await this.client.fetchQuery(this.threadListQueryOptions(refreshContext, kind));
|
|
||||||
return cloneThreads(threads);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private setThreadList(context: AppServerQueryContext, kind: ThreadListKind, threads: readonly Thread[]): void {
|
archivedThreadsSnapshot(): readonly Thread[] | null {
|
||||||
if (!appServerQueryContextIsComplete(context)) return;
|
if (this.disposed) return null;
|
||||||
this.client.setQueryData(this.threadListQueryKey(context, kind), cloneThreads(threads));
|
return this.threadListSnapshot("archived");
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateThreadList(context: AppServerQueryContext, kind: ThreadListKind, updater: ThreadListUpdater): readonly Thread[] | null {
|
observeActiveThreadsResult(
|
||||||
if (!appServerQueryContextIsComplete(context)) return null;
|
listener: ObservedPaginatedResultListener<readonly Thread[]>,
|
||||||
const current = this.threadListSnapshot(context, kind);
|
|
||||||
const next = updater(current);
|
|
||||||
if (!next) return null;
|
|
||||||
this.client.setQueryData(this.threadListQueryKey(context, kind), cloneThreads(next), current ? undefined : { updatedAt: 0 });
|
|
||||||
return cloneThreads(next);
|
|
||||||
}
|
|
||||||
|
|
||||||
appServerMetadataSnapshot(context: AppServerQueryContext): SharedServerMetadata | null {
|
|
||||||
if (!appServerQueryContextIsComplete(context)) return null;
|
|
||||||
const metadata = this.client.getQueryData<SharedServerMetadata>(appServerMetadataQueryKey(context));
|
|
||||||
return metadata ? cloneSharedServerMetadata(metadata) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
observeAppServerMetadataResult(
|
|
||||||
context: AppServerQueryContext,
|
|
||||||
listener: ObservedResultListener<SharedServerMetadata>,
|
|
||||||
options: { emitCurrent?: boolean } = {},
|
options: { emitCurrent?: boolean } = {},
|
||||||
): () => void {
|
): () => void {
|
||||||
return this.observeQueryResult(this.appServerMetadataQueryOptions(context), cloneSharedServerMetadata, listener, options);
|
this.assertUsable();
|
||||||
}
|
const observer = new InfiniteQueryObserver(this.client, {
|
||||||
|
...this.activeThreadsQueryOptions(),
|
||||||
async refreshAppServerMetadata(
|
enabled: false,
|
||||||
context: AppServerQueryContext,
|
|
||||||
options: { forceSkills?: boolean } = {},
|
|
||||||
): Promise<SharedServerMetadata | null> {
|
|
||||||
const refreshContext = cloneAppServerQueryContext(context);
|
|
||||||
if (!appServerQueryContextIsComplete(refreshContext)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const key = appServerMetadataQueryKey(refreshContext);
|
|
||||||
await Promise.all([
|
|
||||||
this.client.invalidateQueries({ queryKey: key }),
|
|
||||||
this.client.invalidateQueries({ queryKey: appServerModelsQueryKey(refreshContext) }),
|
|
||||||
]);
|
|
||||||
const metadata = await this.client.fetchQuery(this.appServerMetadataQueryOptions(refreshContext, options));
|
|
||||||
return this.writeAppServerMetadata(refreshContext, metadata);
|
|
||||||
}
|
|
||||||
|
|
||||||
writeAppServerMetadata(context: AppServerQueryContext, metadata: SharedServerMetadata): SharedServerMetadata | null {
|
|
||||||
if (!appServerQueryContextIsComplete(context)) return null;
|
|
||||||
const previous = this.appServerMetadataSnapshot(context);
|
|
||||||
const probes = metadata.serverDiagnostics.probes;
|
|
||||||
const next = cloneSharedServerMetadata({
|
|
||||||
...metadata,
|
|
||||||
availableModels: probes.models.status === "ok" ? metadata.availableModels : (this.modelsSnapshot(context) ?? []),
|
|
||||||
availableSkills: probes.skills.status === "ok" ? metadata.availableSkills : (previous?.availableSkills ?? []),
|
|
||||||
rateLimit: probes.rateLimits.status === "ok" ? metadata.rateLimit : (previous?.rateLimit ?? null),
|
|
||||||
});
|
});
|
||||||
this.client.setQueryData(appServerMetadataQueryKey(context), cloneSharedServerMetadata(next));
|
const emit = (result: InfiniteQueryObserverResult<ActiveThreadData>): void => {
|
||||||
if (probes.models.status === "ok") {
|
if (!this.disposed) listener(this.projectObservedActiveThreadsResult(result));
|
||||||
this.client.setQueryData(appServerModelsQueryKey(context), cloneModelMetadata(next.availableModels));
|
};
|
||||||
|
const unsubscribe = observer.subscribe(emit);
|
||||||
|
if (options.emitCurrent ?? true) emit(observer.getCurrentResult());
|
||||||
|
return this.trackObserver(() => {
|
||||||
|
unsubscribe();
|
||||||
|
observer.destroy();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
observeArchivedThreadsResult(listener: ObservedResultListener<readonly Thread[]>, options: { emitCurrent?: boolean } = {}): () => void {
|
||||||
|
this.assertUsable();
|
||||||
|
return this.observeQueryResult(this.archivedThreadsQueryOptions(), cloneThreads, listener, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchActiveThreads(options: { force?: boolean } = {}): Promise<readonly Thread[]> {
|
||||||
|
return this.runWhileActive(async () => {
|
||||||
|
const key = ACTIVE_THREADS_QUERY_KEY;
|
||||||
|
if (options.force) {
|
||||||
|
if (this.client.getQueryState(key)?.fetchMeta?.fetchMore?.direction === "forward") {
|
||||||
|
await this.client.cancelQueries({ queryKey: key, exact: true });
|
||||||
|
}
|
||||||
|
await this.client.invalidateQueries({ queryKey: key, refetchType: "none" });
|
||||||
|
this.assertUsable();
|
||||||
|
}
|
||||||
|
return this.readThroughQueryCancellation(
|
||||||
|
async () => {
|
||||||
|
const data = await this.client.fetchInfiniteQuery(this.activeThreadsQueryOptions());
|
||||||
|
return cloneThreads(activeThreadsFromData(data) ?? []);
|
||||||
|
},
|
||||||
|
() => this.activeThreadsSnapshot(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshActiveThreads(): Promise<readonly Thread[]> {
|
||||||
|
return this.fetchActiveThreads({ force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchActiveThreadSearchInventory(): Promise<readonly Thread[]> {
|
||||||
|
return this.runWhileActive(async () => {
|
||||||
|
const key = ACTIVE_THREAD_SEARCH_INVENTORY_QUERY_KEY;
|
||||||
|
const options = {
|
||||||
|
queryKey: key,
|
||||||
|
queryFn: ({ signal }: { signal: AbortSignal }) =>
|
||||||
|
this.runWithClient((client) => listThreads(client, this.context.vaultPath, { signal })),
|
||||||
|
};
|
||||||
|
return this.readFreshThroughQueryCancellation(key, async () => cloneThreads(await this.client.fetchQuery(options)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
hasMoreActiveThreads(): boolean {
|
||||||
|
if (this.disposed) return false;
|
||||||
|
return activeThreadDataHasMore(this.client.getQueryData<ActiveThreadData>(ACTIVE_THREADS_QUERY_KEY));
|
||||||
|
}
|
||||||
|
|
||||||
|
loadMoreActiveThreads(): Promise<readonly Thread[]> {
|
||||||
|
return this.runWhileActive(async () => {
|
||||||
|
const current = this.activeThreadsSnapshot() ?? (await this.fetchActiveThreads());
|
||||||
|
const observer = new InfiniteQueryObserver(this.client, {
|
||||||
|
...this.activeThreadsQueryOptions(),
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
if (!observer.getCurrentResult().hasNextPage) return current;
|
||||||
|
const result = await observer.fetchNextPage({ cancelRefetch: false, throwOnError: true });
|
||||||
|
this.assertUsable();
|
||||||
|
return result.data ? cloneThreads(activeThreadsFromData(result.data) ?? []) : current;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof CancelledError) return this.activeThreadsSnapshot() ?? current;
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
observer.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshArchivedThreads(): Promise<readonly Thread[]> {
|
||||||
|
return this.fetchArchivedThreads({ force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchArchivedThreads(options: { force?: boolean } = {}): Promise<readonly Thread[]> {
|
||||||
|
return this.runWhileActive(async () => {
|
||||||
|
const key = ARCHIVED_THREADS_QUERY_KEY;
|
||||||
|
if (options.force) {
|
||||||
|
await this.client.invalidateQueries({ queryKey: key, refetchType: "none" });
|
||||||
|
this.assertUsable();
|
||||||
|
}
|
||||||
|
return this.readFreshThroughQueryCancellation(key, async () =>
|
||||||
|
cloneThreads(await this.client.fetchQuery(this.archivedThreadsQueryOptions())),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
applyThreadListMutations(mutations: readonly ThreadListMutation[]): void {
|
||||||
|
this.assertUsable();
|
||||||
|
if (mutations.length === 0) return;
|
||||||
|
const activeMutations = mutations.filter((mutation) => mutation.list === "active");
|
||||||
|
if (activeMutations.length > 0) {
|
||||||
|
const key = ACTIVE_THREADS_QUERY_KEY;
|
||||||
|
const wasFetching = this.client.getQueryState(key)?.fetchStatus === "fetching";
|
||||||
|
const fetchIsNextPage = this.client.getQueryState(key)?.fetchMeta?.fetchMore?.direction === "forward";
|
||||||
|
void this.client.cancelQueries({ queryKey: key, exact: true });
|
||||||
|
const before = this.client.getQueryData<ActiveThreadData>(key);
|
||||||
|
const after = activeMutations.reduce<ActiveThreadData | undefined>(applyActiveThreadMutation, before);
|
||||||
|
if (after !== before) this.client.setQueryData(key, after);
|
||||||
|
void this.client.invalidateQueries({ queryKey: key, refetchType: "none" });
|
||||||
|
const searchKey = ACTIVE_THREAD_SEARCH_INVENTORY_QUERY_KEY;
|
||||||
|
void this.client.cancelQueries({ queryKey: searchKey, exact: true });
|
||||||
|
void this.client.invalidateQueries({ queryKey: searchKey, refetchType: "none" });
|
||||||
|
|
||||||
|
const refreshRequested = activeMutations.some((mutation) => mutation.kind === "refresh");
|
||||||
|
if ((wasFetching && !fetchIsNextPage) || (refreshRequested && before)) {
|
||||||
|
void this.fetchActiveThreads({ force: true }).catch(() => {
|
||||||
|
// Query observers retain refresh failures while the event projection remains last-known-good state.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const archivedMutations = mutations.filter((mutation) => mutation.list === "archived");
|
||||||
|
if (archivedMutations.length > 0) {
|
||||||
|
const key = ARCHIVED_THREADS_QUERY_KEY;
|
||||||
|
const wasFetching = this.client.getQueryState(key)?.fetchStatus === "fetching";
|
||||||
|
void this.client.cancelQueries({ queryKey: key, exact: true });
|
||||||
|
const before = this.archivedThreadsSnapshot();
|
||||||
|
const after = archivedMutations.reduce<readonly Thread[] | null>(applyThreadListMutation, before);
|
||||||
|
if (after !== before && after) this.client.setQueryData(key, cloneThreads(after));
|
||||||
|
void this.client.invalidateQueries({ queryKey: key, refetchType: "none" });
|
||||||
|
|
||||||
|
const refreshRequested = archivedMutations.some((mutation) => mutation.kind === "refresh");
|
||||||
|
if (wasFetching || (refreshRequested && before)) {
|
||||||
|
void this.fetchArchivedThreads({ force: true }).catch(() => {
|
||||||
|
// Query observers retain refresh failures while the event projection remains last-known-good state.
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return cloneSharedServerMetadata(next);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateAppServerMetadata(
|
private threadListSnapshot(kind: ThreadListKind): readonly Thread[] | null {
|
||||||
context: AppServerQueryContext,
|
if (kind === "active") return this.activeThreadsSnapshot();
|
||||||
updater: (metadata: SharedServerMetadata | null) => SharedServerMetadata | null,
|
const threads = this.client.getQueryData<readonly Thread[]>(ARCHIVED_THREADS_QUERY_KEY);
|
||||||
): SharedServerMetadata | null {
|
return threads ? cloneThreads(threads) : null;
|
||||||
if (!appServerQueryContextIsComplete(context)) return null;
|
|
||||||
const next = updater(this.appServerMetadataSnapshot(context));
|
|
||||||
return next ? this.writeAppServerMetadata(context, next) : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
modelsSnapshot(context: AppServerQueryContext): readonly ModelMetadata[] | null {
|
appServerMetadataSnapshot(): SharedServerMetadata | null {
|
||||||
if (!appServerQueryContextIsComplete(context)) return null;
|
if (this.disposed) return null;
|
||||||
const models = this.client.getQueryData<readonly ModelMetadata[]>(appServerModelsQueryKey(context));
|
const runtimeConfig = this.client.getQueryData<RuntimeConfigSnapshot>(RUNTIME_CONFIG_QUERY_KEY);
|
||||||
|
if (!runtimeConfig) return null;
|
||||||
|
const skills = this.metadataResourceState("skills");
|
||||||
|
const permissionProfiles = this.metadataResourceState("permissionProfiles");
|
||||||
|
const rateLimits = this.metadataResourceState("rateLimits");
|
||||||
|
const diagnostics = [this.modelsProbe(), skills.probe, permissionProfiles.probe, rateLimits.probe].reduce(
|
||||||
|
(current, probe) => diagnosticsWithProbe(current, probe),
|
||||||
|
createServerDiagnostics(),
|
||||||
|
);
|
||||||
|
return cloneSharedServerMetadata({
|
||||||
|
runtimeConfig,
|
||||||
|
availableSkills: skills.value ?? [],
|
||||||
|
availablePermissionProfiles: permissionProfiles.value ?? [],
|
||||||
|
rateLimit: rateLimits.value ?? null,
|
||||||
|
serverDiagnostics: diagnostics,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
observeAppServerMetadataResources(
|
||||||
|
listener: (resource: SharedServerMetadataResource) => void,
|
||||||
|
options: { emitCurrent?: boolean } = {},
|
||||||
|
): () => void {
|
||||||
|
this.assertUsable();
|
||||||
|
let unsubscribed = false;
|
||||||
|
const emit = (resource: SharedServerMetadataResource): void => {
|
||||||
|
if (!this.disposed && !unsubscribed) listener(cloneSharedServerMetadataResource(resource));
|
||||||
|
};
|
||||||
|
|
||||||
|
const runtimeConfig = new QueryObserver(this.client, { ...this.runtimeConfigQueryOptions(), enabled: false });
|
||||||
|
const models = new QueryObserver(this.client, { ...this.modelsQueryOptions(), enabled: false });
|
||||||
|
const skills = new QueryObserver(this.client, { ...this.skillsQueryOptions(), enabled: false });
|
||||||
|
const permissionProfiles = new QueryObserver(this.client, { ...this.permissionProfilesQueryOptions(), enabled: false });
|
||||||
|
const rateLimits = new QueryObserver(this.client, { ...this.rateLimitsQueryOptions(), enabled: false });
|
||||||
|
const emitRuntimeConfig = (result: QueryObserverResult<RuntimeConfigSnapshot>, includeFetching = false): void => {
|
||||||
|
if (result.isFetching && !includeFetching) return;
|
||||||
|
emit({ id: "runtimeConfig", value: result.data ? cloneRuntimeConfigSnapshot(result.data) : undefined });
|
||||||
|
};
|
||||||
|
const emitModels = (result: QueryObserverResult<readonly ModelMetadata[]>, includeFetching = false): void => {
|
||||||
|
if (result.isFetching && !includeFetching) return;
|
||||||
|
emit({ id: "models", value: result.data ? cloneModelMetadata(result.data) : undefined, probe: this.modelsProbe() });
|
||||||
|
};
|
||||||
|
const emitSkills = (result: QueryObserverResult<MetadataResourceSnapshot<readonly SkillMetadata[]>>, includeFetching = false): void => {
|
||||||
|
if (result.isFetching && !includeFetching) return;
|
||||||
|
const state = this.metadataResourceState("skills");
|
||||||
|
emit({ id: "skills", value: state.value ?? undefined, probe: state.probe });
|
||||||
|
};
|
||||||
|
const emitPermissionProfiles = (
|
||||||
|
result: QueryObserverResult<MetadataResourceSnapshot<readonly RuntimePermissionProfileSummary[]>>,
|
||||||
|
includeFetching = false,
|
||||||
|
): void => {
|
||||||
|
if (result.isFetching && !includeFetching) return;
|
||||||
|
const state = this.metadataResourceState("permissionProfiles");
|
||||||
|
emit({ id: "permissionProfiles", value: state.value ?? undefined, probe: state.probe });
|
||||||
|
};
|
||||||
|
const emitRateLimits = (
|
||||||
|
result: QueryObserverResult<MetadataResourceSnapshot<RateLimitSnapshot | null>>,
|
||||||
|
includeFetching = false,
|
||||||
|
): void => {
|
||||||
|
if (result.isFetching && !includeFetching) return;
|
||||||
|
const state = this.metadataResourceState("rateLimits");
|
||||||
|
emit({ id: "rateLimits", value: state.value, probe: state.probe });
|
||||||
|
};
|
||||||
|
const unsubscribers = [
|
||||||
|
runtimeConfig.subscribe(emitRuntimeConfig),
|
||||||
|
models.subscribe(emitModels),
|
||||||
|
skills.subscribe(emitSkills),
|
||||||
|
permissionProfiles.subscribe(emitPermissionProfiles),
|
||||||
|
rateLimits.subscribe(emitRateLimits),
|
||||||
|
];
|
||||||
|
if (options.emitCurrent ?? true) {
|
||||||
|
emitRuntimeConfig(runtimeConfig.getCurrentResult(), true);
|
||||||
|
emitModels(models.getCurrentResult(), true);
|
||||||
|
emitSkills(skills.getCurrentResult(), true);
|
||||||
|
emitPermissionProfiles(permissionProfiles.getCurrentResult(), true);
|
||||||
|
emitRateLimits(rateLimits.getCurrentResult(), true);
|
||||||
|
}
|
||||||
|
return this.trackObserver(() => {
|
||||||
|
unsubscribed = true;
|
||||||
|
for (const unsubscribe of unsubscribers) unsubscribe();
|
||||||
|
runtimeConfig.destroy();
|
||||||
|
models.destroy();
|
||||||
|
skills.destroy();
|
||||||
|
permissionProfiles.destroy();
|
||||||
|
rateLimits.destroy();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshAppServerMetadata(): Promise<void> {
|
||||||
|
return this.runWhileActive(async () => {
|
||||||
|
const runtimeResult = this.fetchRuntimeConfig().then(
|
||||||
|
() => ({ ok: true as const }),
|
||||||
|
(error: unknown) => ({ ok: false as const, error }),
|
||||||
|
);
|
||||||
|
const [, runtime] = await Promise.all([
|
||||||
|
Promise.allSettled([
|
||||||
|
this.fetchMetadataResource("skills"),
|
||||||
|
this.fetchMetadataResource("permissionProfiles"),
|
||||||
|
this.fetchMetadataResource("rateLimits"),
|
||||||
|
this.fetchModels({ force: true }),
|
||||||
|
]),
|
||||||
|
runtimeResult,
|
||||||
|
]);
|
||||||
|
this.assertUsable();
|
||||||
|
if (!runtime.ok) throw runtime.error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshSkills(): Promise<void> {
|
||||||
|
return this.runWhileActive(async () => {
|
||||||
|
await this.refreshNotifiedMetadataResource("skills");
|
||||||
|
this.assertUsable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshRateLimits(): Promise<void> {
|
||||||
|
return this.runWhileActive(async () => {
|
||||||
|
await this.refreshNotifiedMetadataResource("rateLimits");
|
||||||
|
this.assertUsable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
modelsSnapshot(): readonly ModelMetadata[] | null {
|
||||||
|
if (this.disposed) return null;
|
||||||
|
const models = this.client.getQueryData<readonly ModelMetadata[]>(MODELS_QUERY_KEY);
|
||||||
return models ? cloneModelMetadata(models) : null;
|
return models ? cloneModelMetadata(models) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
observeModelsResult(
|
observeModelsResult(listener: ObservedResultListener<readonly ModelMetadata[]>, options: { emitCurrent?: boolean } = {}): () => void {
|
||||||
context: AppServerQueryContext,
|
this.assertUsable();
|
||||||
listener: ObservedResultListener<readonly ModelMetadata[]>,
|
return this.observeQueryResult(this.modelsQueryOptions(), cloneModelMetadata, listener, options);
|
||||||
options: { emitCurrent?: boolean } = {},
|
|
||||||
): () => void {
|
|
||||||
return this.observeQueryResult(this.modelsQueryOptions(context), cloneModelMetadata, listener, options);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchModels(context: AppServerQueryContext, options: { force?: boolean } = {}): Promise<readonly ModelMetadata[]> {
|
fetchModels(options: { force?: boolean } = {}): Promise<readonly ModelMetadata[]> {
|
||||||
const refreshContext = cloneAppServerQueryContext(context);
|
return this.runWhileActive(async () => {
|
||||||
if (!appServerQueryContextIsComplete(refreshContext)) {
|
const key = MODELS_QUERY_KEY;
|
||||||
return [];
|
if (options.force) {
|
||||||
|
await this.client.invalidateQueries({ queryKey: key, refetchType: "none" });
|
||||||
|
this.assertUsable();
|
||||||
|
}
|
||||||
|
const models = await this.client.fetchQuery(this.modelsQueryOptions());
|
||||||
|
this.assertUsable();
|
||||||
|
return cloneModelMetadata(models);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshModels(): Promise<readonly ModelMetadata[]> {
|
||||||
|
return this.fetchModels({ force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private activeThreadsQueryOptions(): InfiniteQueryObserverOptions<
|
||||||
|
ThreadPage,
|
||||||
|
Error,
|
||||||
|
ActiveThreadData,
|
||||||
|
ActiveThreadsQueryKey,
|
||||||
|
ActiveThreadCursor
|
||||||
|
> {
|
||||||
|
return {
|
||||||
|
queryKey: ACTIVE_THREADS_QUERY_KEY,
|
||||||
|
queryFn: async ({ pageParam, signal }) => {
|
||||||
|
signal.throwIfAborted();
|
||||||
|
const page = await this.runWithClient((client) =>
|
||||||
|
readThreadPage(client, this.context.vaultPath, { cursor: pageParam, archived: false }),
|
||||||
|
);
|
||||||
|
signal.throwIfAborted();
|
||||||
|
return page;
|
||||||
|
},
|
||||||
|
initialPageParam: null,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
||||||
|
staleTime: Number.POSITIVE_INFINITY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private archivedThreadsQueryOptions(): AppServerQueryOptions<readonly Thread[]> {
|
||||||
|
return {
|
||||||
|
queryKey: ARCHIVED_THREADS_QUERY_KEY,
|
||||||
|
queryFn: ({ signal }: { signal: AbortSignal }) =>
|
||||||
|
this.runWithClient((client) => listThreads(client, this.context.vaultPath, { archived: true, signal })).then(cloneThreads),
|
||||||
|
staleTime: Number.POSITIVE_INFINITY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private runtimeConfigQueryOptions(): AppServerQueryOptions<RuntimeConfigSnapshot> {
|
||||||
|
return {
|
||||||
|
queryKey: RUNTIME_CONFIG_QUERY_KEY,
|
||||||
|
queryFn: async (): Promise<RuntimeConfigSnapshot> =>
|
||||||
|
this.runWithClient(async (client) =>
|
||||||
|
runtimeConfigSnapshotFromAppServerConfig(await readEffectiveConfig(client, this.context.vaultPath)),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private skillsQueryOptions(): AppServerQueryOptions<MetadataResourceSnapshot<readonly SkillMetadata[]>> {
|
||||||
|
return {
|
||||||
|
queryKey: SKILLS_QUERY_KEY,
|
||||||
|
queryFn: async () =>
|
||||||
|
this.runWithClient(async (client) => successfulMetadataResource(await readSkillMetadataProbe(client, this.context.vaultPath))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private permissionProfilesQueryOptions(): AppServerQueryOptions<MetadataResourceSnapshot<readonly RuntimePermissionProfileSummary[]>> {
|
||||||
|
return {
|
||||||
|
queryKey: PERMISSION_PROFILES_QUERY_KEY,
|
||||||
|
queryFn: async () =>
|
||||||
|
this.runWithClient(async (client) =>
|
||||||
|
successfulMetadataResource(await readPermissionProfileMetadataProbe(client, this.context.vaultPath)),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private rateLimitsQueryOptions(): AppServerQueryOptions<MetadataResourceSnapshot<RateLimitSnapshot | null>> {
|
||||||
|
return {
|
||||||
|
queryKey: RATE_LIMITS_QUERY_KEY,
|
||||||
|
queryFn: async () => this.runWithClient(async (client) => successfulMetadataResource(await readRateLimitMetadataProbe(client))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchRuntimeConfig(): Promise<RuntimeConfigSnapshot> {
|
||||||
|
const runtimeConfig = await this.client.fetchQuery(this.runtimeConfigQueryOptions());
|
||||||
|
this.assertUsable();
|
||||||
|
return cloneRuntimeConfigSnapshot(runtimeConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
private fetchMetadataResource(resource: MetadataResourceKind): Promise<void> {
|
||||||
|
return (async (): Promise<void> => {
|
||||||
|
if (resource === "skills") {
|
||||||
|
const options = this.skillsQueryOptions();
|
||||||
|
await this.client.fetchQuery(options);
|
||||||
|
this.assertUsable();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (resource === "permissionProfiles") {
|
||||||
|
const options = this.permissionProfilesQueryOptions();
|
||||||
|
await this.client.fetchQuery(options);
|
||||||
|
this.assertUsable();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const options = this.rateLimitsQueryOptions();
|
||||||
|
await this.client.fetchQuery(options);
|
||||||
|
this.assertUsable();
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshNotifiedMetadataResource(resource: "skills" | "rateLimits"): Promise<void> {
|
||||||
|
const queryKey = resource === "skills" ? SKILLS_QUERY_KEY : RATE_LIMITS_QUERY_KEY;
|
||||||
|
await this.client.cancelQueries({ queryKey, exact: true });
|
||||||
|
this.assertUsable();
|
||||||
|
try {
|
||||||
|
await this.fetchMetadataResource(resource);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof CancelledError)) throw error;
|
||||||
}
|
}
|
||||||
const key = appServerModelsQueryKey(refreshContext);
|
|
||||||
if (options.force) await this.client.invalidateQueries({ queryKey: key });
|
|
||||||
const models = await this.client.fetchQuery(this.modelsQueryOptions(refreshContext));
|
|
||||||
return cloneModelMetadata(models);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshModels(context: AppServerQueryContext): Promise<readonly ModelMetadata[]> {
|
private metadataResourceState(resource: "skills"): { value: readonly SkillMetadata[] | null; probe: DiagnosticProbeResult };
|
||||||
return this.fetchModels(context, { force: true });
|
private metadataResourceState(resource: "permissionProfiles"): {
|
||||||
}
|
value: readonly RuntimePermissionProfileSummary[] | null;
|
||||||
|
probe: DiagnosticProbeResult;
|
||||||
private threadListQueryOptions(context: AppServerQueryContext, kind: ThreadListKind): AppServerQueryOptions<readonly Thread[]> {
|
};
|
||||||
const refreshContext = cloneAppServerQueryContext(context);
|
private metadataResourceState(resource: "rateLimits"): { value: RateLimitSnapshot | null; probe: DiagnosticProbeResult };
|
||||||
|
private metadataResourceState(resource: MetadataResourceKind): { value: MetadataResourceValue; probe: DiagnosticProbeResult } {
|
||||||
|
const key =
|
||||||
|
resource === "skills" ? SKILLS_QUERY_KEY : resource === "permissionProfiles" ? PERMISSION_PROFILES_QUERY_KEY : RATE_LIMITS_QUERY_KEY;
|
||||||
|
const state = this.client.getQueryState<MetadataResourceSnapshot<MetadataResourceValue>>(key);
|
||||||
|
const failedProbe = diagnosticProbeFromError(state?.error);
|
||||||
return {
|
return {
|
||||||
queryKey: this.threadListQueryKey(refreshContext, kind),
|
value: state?.data?.value ?? null,
|
||||||
queryFn: async (): Promise<readonly Thread[]> => {
|
probe: failedProbe ?? state?.data?.probe ?? createServerDiagnostics().probes[resource],
|
||||||
return cloneThreads(
|
|
||||||
await this.runWithClient(refreshContext, (client) =>
|
|
||||||
listThreads(client, refreshContext.vaultPath, { archived: kind === "archived" }),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
staleTime: THREAD_LIST_STALE_TIME_MS,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private threadListQueryKey(
|
private modelsProbe(): DiagnosticProbeResult {
|
||||||
context: AppServerQueryContext,
|
const state = this.client.getQueryState<readonly ModelMetadata[]>(MODELS_QUERY_KEY);
|
||||||
kind: ThreadListKind,
|
return (
|
||||||
): ReturnType<typeof activeThreadsQueryKey> | ReturnType<typeof archivedThreadsQueryKey> {
|
diagnosticProbeFromError(state?.error) ??
|
||||||
return kind === "archived" ? archivedThreadsQueryKey(context) : activeThreadsQueryKey(context);
|
(state?.data
|
||||||
|
? diagnosticProbeOk("models", `${String(state.data.length)} models`, state.dataUpdatedAt)
|
||||||
|
: createServerDiagnostics().probes.models)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private appServerMetadataQueryOptions(
|
private modelsQueryOptions(): AppServerQueryOptions<readonly ModelMetadata[]> {
|
||||||
context: AppServerQueryContext,
|
|
||||||
options: { forceSkills?: boolean } = {},
|
|
||||||
): AppServerQueryOptions<SharedServerMetadata> {
|
|
||||||
const refreshContext = cloneAppServerQueryContext(context);
|
|
||||||
return {
|
return {
|
||||||
queryKey: appServerMetadataQueryKey(refreshContext),
|
queryKey: MODELS_QUERY_KEY,
|
||||||
queryFn: async (): Promise<SharedServerMetadata> => {
|
|
||||||
return this.runWithClient(refreshContext, async (client) => {
|
|
||||||
const runtimeConfig = runtimeConfigSnapshotFromAppServerConfig(await readEffectiveConfig(client, refreshContext.vaultPath));
|
|
||||||
const [models, skills, rateLimit] = await Promise.all([
|
|
||||||
this.readModelMetadataProbe(refreshContext, client),
|
|
||||||
readSkillMetadataProbe(client, refreshContext.vaultPath, options.forceSkills ?? false),
|
|
||||||
readRateLimitMetadataProbe(client),
|
|
||||||
]);
|
|
||||||
const diagnostics = [models.probe, skills.probe, rateLimit.probe].reduce(
|
|
||||||
(current, probe) => diagnosticsWithProbe(current, probe),
|
|
||||||
this.appServerMetadataSnapshot(refreshContext)?.serverDiagnostics ?? createServerDiagnostics(),
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
runtimeConfig,
|
|
||||||
availableModels: models.value,
|
|
||||||
availableSkills: skills.value,
|
|
||||||
rateLimit: rateLimit.value,
|
|
||||||
serverDiagnostics: diagnostics,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
staleTime: APP_SERVER_METADATA_STALE_TIME_MS,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private modelsQueryOptions(context: AppServerQueryContext): AppServerQueryOptions<readonly ModelMetadata[]> {
|
|
||||||
const refreshContext = cloneAppServerQueryContext(context);
|
|
||||||
return {
|
|
||||||
queryKey: appServerModelsQueryKey(refreshContext),
|
|
||||||
queryFn: async (): Promise<readonly ModelMetadata[]> => {
|
queryFn: async (): Promise<readonly ModelMetadata[]> => {
|
||||||
return cloneModelMetadata(
|
try {
|
||||||
await this.runWithClient(refreshContext, (client) => listModelMetadata(client), {
|
return cloneModelMetadata(
|
||||||
serverRequests: { kind: "reject", message: "Codex model list refresh does not handle server requests." },
|
await this.runWithClient((client) => listModelMetadata(client), {
|
||||||
}),
|
serverRequests: { kind: "reject", message: "Codex model list refresh does not handle server requests." },
|
||||||
);
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
throw new MetadataResourceQueryError(diagnosticProbeError("models", error, Date.now()));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
staleTime: MODELS_STALE_TIME_MS,
|
staleTime: MODELS_STALE_TIME_MS,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async readModelMetadataProbe(
|
private observeQueryResult<TQuery, TValue>(
|
||||||
context: AppServerQueryContext,
|
queryOptions: AppServerQueryOptions<TQuery>,
|
||||||
client: AppServerClient,
|
project: (value: TQuery) => TValue,
|
||||||
): Promise<{
|
listener: ObservedResultListener<TValue>,
|
||||||
value: readonly ModelMetadata[];
|
|
||||||
probe: SharedServerMetadata["serverDiagnostics"]["probes"]["models"];
|
|
||||||
}> {
|
|
||||||
try {
|
|
||||||
const models = cloneModelMetadata(await this.client.fetchQuery(this.modelsQueryOptionsWithClient(context, client)));
|
|
||||||
return { value: models, probe: diagnosticProbeOk("models", `${String(models.length)} models`, Date.now()) };
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
value: this.modelsSnapshot(context) ?? [],
|
|
||||||
probe: diagnosticProbeError("models", error, Date.now()),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private modelsQueryOptionsWithClient(
|
|
||||||
context: AppServerQueryContext,
|
|
||||||
client: AppServerClient,
|
|
||||||
): AppServerQueryOptions<readonly ModelMetadata[]> {
|
|
||||||
const refreshContext = cloneAppServerQueryContext(context);
|
|
||||||
return {
|
|
||||||
...this.modelsQueryOptions(refreshContext),
|
|
||||||
queryFn: async (): Promise<readonly ModelMetadata[]> => cloneModelMetadata(await listModelMetadata(client)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private observeQueryResult<T>(
|
|
||||||
queryOptions: AppServerQueryOptions<T>,
|
|
||||||
clone: (value: T) => T,
|
|
||||||
listener: ObservedResultListener<T>,
|
|
||||||
options: { emitCurrent?: boolean },
|
options: { emitCurrent?: boolean },
|
||||||
): () => void {
|
): () => void {
|
||||||
const observer = new QueryObserver<T>(this.client, {
|
const observer = new QueryObserver<TQuery>(this.client, {
|
||||||
...queryOptions,
|
...queryOptions,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
});
|
});
|
||||||
const emit = (result: QueryObserverResult<T>): void => {
|
const emit = (result: QueryObserverResult<TQuery>): void => {
|
||||||
listener(this.cloneObservedResult(result, clone));
|
if (!this.disposed) listener(this.projectObservedResult(result, project));
|
||||||
};
|
};
|
||||||
const unsubscribe = observer.subscribe(emit);
|
const unsubscribe = observer.subscribe(emit);
|
||||||
if (options.emitCurrent ?? true) emit(observer.getCurrentResult());
|
if (options.emitCurrent ?? true) emit(observer.getCurrentResult());
|
||||||
return unsubscribe;
|
return this.trackObserver(() => {
|
||||||
|
unsubscribe();
|
||||||
|
observer.destroy();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private cloneObservedResult<T>(result: QueryObserverResult<T>, clone: (value: T) => T): ObservedResult<T> {
|
private projectObservedResult<TQuery, TValue>(
|
||||||
|
result: QueryObserverResult<TQuery>,
|
||||||
|
project: (value: TQuery) => TValue,
|
||||||
|
): ObservedResult<TValue> {
|
||||||
return {
|
return {
|
||||||
value: result.data === undefined ? null : clone(result.data),
|
value: result.data === undefined ? null : project(result.data),
|
||||||
error: result.error instanceof Error ? result.error : null,
|
error: result.error instanceof Error ? result.error : null,
|
||||||
isFetching: result.isFetching,
|
isFetching: result.isFetching,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private runWithClient<T>(
|
private projectObservedActiveThreadsResult(
|
||||||
context: AppServerQueryContext,
|
result: InfiniteQueryObserverResult<ActiveThreadData>,
|
||||||
operation: (client: AppServerClient) => Promise<T>,
|
): ObservedPaginatedResult<readonly Thread[]> {
|
||||||
options: AppServerClientAccessOptions = {},
|
const threads = activeThreadsFromData(result.data);
|
||||||
): Promise<T> {
|
return {
|
||||||
if (!this.clientRunner) {
|
value: threads ? cloneThreads(threads) : null,
|
||||||
throw new Error("Codex app-server query client runner is not configured.");
|
error: result.error instanceof Error ? result.error : null,
|
||||||
}
|
isFetching: result.isFetching,
|
||||||
return this.clientRunner.runWithClient(context, operation, options);
|
hasMore: result.hasNextPage,
|
||||||
|
isFetchingNextPage: result.isFetchingNextPage,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private runWithClient<T>(operation: (client: AppServerClient) => Promise<T>, options: AppServerClientAccessOptions = {}): Promise<T> {
|
||||||
|
this.assertUsable();
|
||||||
|
return this.clientAccess.withClient(operation, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readThroughQueryCancellation<T>(read: () => Promise<T>, fallback?: () => T | null): Promise<T> {
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
const value = await read();
|
||||||
|
this.assertUsable();
|
||||||
|
return value;
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof CancelledError)) throw error;
|
||||||
|
this.assertUsable();
|
||||||
|
const fallbackValue = fallback?.();
|
||||||
|
if (fallbackValue != null) return fallbackValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readFreshThroughQueryCancellation<T>(queryKey: readonly unknown[], read: () => Promise<T>): Promise<T> {
|
||||||
|
for (;;) {
|
||||||
|
const value = await this.readThroughQueryCancellation(read);
|
||||||
|
this.assertUsable();
|
||||||
|
if (!this.client.getQueryState(queryKey)?.isInvalidated) return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertUsable(): void {
|
||||||
|
if (this.disposed) throw new StaleExecutionRuntimeError();
|
||||||
|
}
|
||||||
|
|
||||||
|
private runWhileActive<T>(operation: () => Promise<T>): Promise<T> {
|
||||||
|
this.assertUsable();
|
||||||
|
return (async () => {
|
||||||
|
try {
|
||||||
|
const result = await operation();
|
||||||
|
this.assertUsable();
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
if (this.disposed) throw new StaleExecutionRuntimeError();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
private trackObserver(unsubscribe: () => void): () => void {
|
||||||
|
let subscribed = true;
|
||||||
|
const trackedUnsubscribe = (): void => {
|
||||||
|
if (!subscribed) return;
|
||||||
|
subscribed = false;
|
||||||
|
this.observerUnsubscribes.delete(trackedUnsubscribe);
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
this.observerUnsubscribes.add(trackedUnsubscribe);
|
||||||
|
return trackedUnsubscribe;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MetadataResourceQueryError extends Error {
|
||||||
|
constructor(readonly probe: DiagnosticProbeResult) {
|
||||||
|
super(probe.message ?? `Codex app-server ${probe.id} query failed.`);
|
||||||
|
this.name = "MetadataResourceQueryError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function successfulMetadataResource<T>(result: MetadataResourceSnapshot<T>): MetadataResourceSnapshot<T> {
|
||||||
|
if (result.probe.status !== "ok") throw new MetadataResourceQueryError(result.probe);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagnosticProbeFromError(error: unknown): DiagnosticProbeResult | null {
|
||||||
|
return error instanceof MetadataResourceQueryError ? error.probe : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createAppServerQueryClient(): QueryClient {
|
function createAppServerQueryClient(): QueryClient {
|
||||||
return new QueryClient({
|
return new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
gcTime: Infinity,
|
networkMode: "always",
|
||||||
retry: false,
|
retry: false,
|
||||||
refetchOnReconnect: false,
|
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
},
|
},
|
||||||
mutations: {
|
mutations: {
|
||||||
|
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
import type { QueryKey } from "@tanstack/query-core";
|
|
||||||
|
|
||||||
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 AppServerMetadataQueryKey = readonly [...AppServerQueryScope, "metadata"];
|
|
||||||
export type AppServerModelsQueryKey = readonly [...AppServerQueryScope, "models"];
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 appServerMetadataQueryKey(context: AppServerQueryContext): AppServerMetadataQueryKey {
|
|
||||||
return [...appServerQueryScope(context), "metadata"];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function appServerModelsQueryKey(context: AppServerQueryContext): AppServerModelsQueryKey {
|
|
||||||
return [...appServerQueryScope(context), "models"];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function appServerQueriesFilter(context: AppServerQueryContext): { queryKey: QueryKey } {
|
|
||||||
return { queryKey: appServerQueryScope(context) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function nonEmptyString(value: string): boolean {
|
|
||||||
return value.trim().length > 0;
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import type { SkillMetadata } from "../../domain/catalog/metadata";
|
import type { SkillMetadata } from "../../domain/catalog/metadata";
|
||||||
import type { RateLimitSnapshot } from "../../domain/runtime/metrics";
|
import type { RateLimitSnapshot } from "../../domain/runtime/metrics";
|
||||||
|
import type { RuntimePermissionProfileSummary } from "../../domain/runtime/permissions";
|
||||||
import { type Diagnostics, diagnosticProbeError, diagnosticProbeOk } from "../../domain/server/diagnostics";
|
import { type Diagnostics, diagnosticProbeError, diagnosticProbeOk } from "../../domain/server/diagnostics";
|
||||||
import { accountRateLimitsSummaryFromResponse, rateLimitSnapshotFromAccountRateLimitsResponse } from "../protocol/runtime-metrics";
|
import { accountRateLimitsSummaryFromResponse, rateLimitSnapshotFromAccountRateLimitsResponse } from "../protocol/runtime-metrics";
|
||||||
import { listSkillCatalog } from "../services/catalog";
|
import { listPermissionProfiles, listSkillCatalog } from "../services/catalog";
|
||||||
import type { AppServerRequestClient } from "../services/request-client";
|
import type { AppServerRequestClient } from "../services/request-client";
|
||||||
import { readAccountRateLimits } from "../services/runtime-metadata";
|
import { readAccountRateLimits } from "../services/runtime-metadata";
|
||||||
|
|
||||||
|
|
@ -12,31 +13,31 @@ interface MetadataProbeResult<T, K extends keyof Diagnostics["probes"]> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SkillMetadataProbeResult = MetadataProbeResult<SkillMetadata[], "skills">;
|
export type SkillMetadataProbeResult = MetadataProbeResult<SkillMetadata[], "skills">;
|
||||||
|
export type PermissionProfileMetadataProbeResult = MetadataProbeResult<RuntimePermissionProfileSummary[], "permissionProfiles">;
|
||||||
export type RateLimitMetadataProbeResult = MetadataProbeResult<RateLimitSnapshot | null, "rateLimits">;
|
export type RateLimitMetadataProbeResult = MetadataProbeResult<RateLimitSnapshot | null, "rateLimits">;
|
||||||
|
|
||||||
export async function readSkillMetadataProbe(
|
export async function readSkillMetadataProbe(client: AppServerRequestClient, vaultPath: string): Promise<SkillMetadataProbeResult> {
|
||||||
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 {
|
try {
|
||||||
const catalog = await listSkillCatalog(client, vaultPath, { forceReload });
|
const catalog = await listSkillCatalog(client, vaultPath);
|
||||||
return { value: catalog.skills, probe: diagnosticProbeOk("skills", `${String(catalog.totalCount)} skills`, Date.now()) };
|
return { value: catalog.skills, probe: diagnosticProbeOk("skills", `${String(catalog.totalCount)} skills`, Date.now()) };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { value: [], probe: diagnosticProbeError("skills", error, Date.now()) };
|
return { value: [], probe: diagnosticProbeError("skills", error, Date.now()) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readRateLimitMetadataProbe(client: AppServerRequestClient | null): Promise<RateLimitMetadataProbeResult> {
|
export async function readPermissionProfileMetadataProbe(
|
||||||
if (!client) {
|
client: AppServerRequestClient,
|
||||||
return {
|
vaultPath: string,
|
||||||
value: null,
|
): Promise<PermissionProfileMetadataProbeResult> {
|
||||||
probe: diagnosticProbeError("rateLimits", 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()) };
|
||||||
|
} catch (error) {
|
||||||
|
return { value: [], probe: diagnosticProbeError("permissionProfiles", error, Date.now()) };
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readRateLimitMetadataProbe(client: AppServerRequestClient): Promise<RateLimitMetadataProbeResult> {
|
||||||
try {
|
try {
|
||||||
const response = await readAccountRateLimits(client);
|
const response = await readAccountRateLimits(client);
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,13 @@ export interface ObservedResult<T> {
|
||||||
|
|
||||||
export type ObservedResultListener<T> = (result: ObservedResult<T>) => void;
|
export type ObservedResultListener<T> = (result: ObservedResult<T>) => void;
|
||||||
|
|
||||||
export function observedValue<T>(result: ObservedResult<T>): T | null {
|
export interface ObservedPaginatedResult<T> extends ObservedResult<T> {
|
||||||
return result.value;
|
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 {
|
export function observedInitialLoading<T>(result: ObservedResult<T>, currentValue: T | null | undefined): boolean {
|
||||||
return currentValue == null && result.isFetching;
|
return currentValue == null && result.isFetching;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,193 +0,0 @@
|
||||||
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,
|
|
||||||
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 {
|
|
||||||
const context = this.context();
|
|
||||||
return `${context.codexPath}\u0000${context.vaultPath}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
activeThreadsSnapshot(): readonly Thread[] | null {
|
|
||||||
return this.options.cache.activeThreadsSnapshot(this.context());
|
|
||||||
}
|
|
||||||
|
|
||||||
archivedThreadsSnapshot(): readonly Thread[] | null {
|
|
||||||
return this.options.cache.archivedThreadsSnapshot(this.context());
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchActiveThreads(): Promise<readonly Thread[]> {
|
|
||||||
return this.runForCurrentContext((context) => this.options.cache.fetchActiveThreads(context));
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchArchivedThreads(): Promise<readonly Thread[]> {
|
|
||||||
return this.runForCurrentContext((context) => this.options.cache.fetchArchivedThreads(context));
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshActiveThreads(): Promise<readonly Thread[]> {
|
|
||||||
return this.runForCurrentContext((context) => this.options.cache.refreshActiveThreads(context));
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshArchivedThreads(): Promise<readonly Thread[]> {
|
|
||||||
return this.runForCurrentContext((context) => this.options.cache.refreshArchivedThreads(context));
|
|
||||||
}
|
|
||||||
|
|
||||||
setActiveThreads(threads: readonly Thread[]): void {
|
|
||||||
this.options.cache.setActiveThreads(this.context(), threads);
|
|
||||||
}
|
|
||||||
|
|
||||||
setArchivedThreads(threads: readonly Thread[]): void {
|
|
||||||
this.options.cache.setArchivedThreads(this.context(), threads);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateActiveThreads(updater: (threads: readonly Thread[] | null) => readonly Thread[] | null): readonly Thread[] | null {
|
|
||||||
return this.options.cache.updateActiveThreads(this.context(), updater);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateArchivedThreads(updater: (threads: readonly Thread[] | null) => readonly Thread[] | null): readonly Thread[] | null {
|
|
||||||
return this.options.cache.updateArchivedThreads(this.context(), updater);
|
|
||||||
}
|
|
||||||
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
|
|
||||||
updateAppServerMetadata(updater: (metadata: SharedServerMetadata | null) => SharedServerMetadata | null): SharedServerMetadata | null {
|
|
||||||
return this.options.cache.updateAppServerMetadata(this.context(), updater);
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshAppServerMetadata(options: { forceSkills?: boolean } = {}): Promise<SharedServerMetadata | null> {
|
|
||||||
return this.runForCurrentContext((context) => this.options.cache.refreshAppServerMetadata(context, options));
|
|
||||||
}
|
|
||||||
|
|
||||||
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?.();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { ModelMetadata } from "../../domain/catalog/metadata";
|
import type { ModelMetadata } from "../../domain/catalog/metadata";
|
||||||
import { cloneRuntimeConfigSnapshot } from "../../domain/runtime/config";
|
import { cloneRuntimeConfigSnapshot } from "../../domain/runtime/config";
|
||||||
import type { RateLimitSnapshot } from "../../domain/runtime/metrics";
|
import type { RateLimitSnapshot } from "../../domain/runtime/metrics";
|
||||||
import type { SharedServerMetadata } from "../../domain/server/metadata";
|
import type { SharedServerMetadata, SharedServerMetadataResource } from "../../domain/server/metadata";
|
||||||
import { cloneToolInventorySnapshot } from "../../domain/server/tool-inventory";
|
import { cloneToolInventorySnapshot } from "../../domain/server/tool-inventory";
|
||||||
import type { Thread } from "../../domain/threads/model";
|
import type { Thread } from "../../domain/threads/model";
|
||||||
|
|
||||||
|
|
@ -13,8 +13,8 @@ export function cloneModelMetadata(models: readonly ModelMetadata[]): ModelMetad
|
||||||
return models.map((model) => ({
|
return models.map((model) => ({
|
||||||
...model,
|
...model,
|
||||||
supportedReasoningEfforts: [...model.supportedReasoningEfforts],
|
supportedReasoningEfforts: [...model.supportedReasoningEfforts],
|
||||||
|
...(model.reasoningEffortOptions ? { reasoningEffortOptions: model.reasoningEffortOptions.map((option) => ({ ...option })) } : {}),
|
||||||
inputModalities: [...model.inputModalities],
|
inputModalities: [...model.inputModalities],
|
||||||
additionalSpeedTiers: [...model.additionalSpeedTiers],
|
|
||||||
serviceTiers: model.serviceTiers.map((tier) => ({ ...tier })),
|
serviceTiers: model.serviceTiers.map((tier) => ({ ...tier })),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
@ -24,8 +24,8 @@ export function cloneSharedServerMetadata(metadata: SharedServerMetadata): Share
|
||||||
...metadata,
|
...metadata,
|
||||||
runtimeConfig: metadata.runtimeConfig ? cloneRuntimeConfigSnapshot(metadata.runtimeConfig) : null,
|
runtimeConfig: metadata.runtimeConfig ? cloneRuntimeConfigSnapshot(metadata.runtimeConfig) : null,
|
||||||
rateLimit: metadata.rateLimit ? cloneRateLimitSnapshot(metadata.rateLimit) : null,
|
rateLimit: metadata.rateLimit ? cloneRateLimitSnapshot(metadata.rateLimit) : null,
|
||||||
availableModels: cloneModelMetadata(metadata.availableModels),
|
|
||||||
availableSkills: metadata.availableSkills.map((skill) => ({ ...skill })),
|
availableSkills: metadata.availableSkills.map((skill) => ({ ...skill })),
|
||||||
|
availablePermissionProfiles: metadata.availablePermissionProfiles.map((profile) => ({ ...profile })),
|
||||||
serverDiagnostics: {
|
serverDiagnostics: {
|
||||||
probes: { ...metadata.serverDiagnostics.probes },
|
probes: { ...metadata.serverDiagnostics.probes },
|
||||||
mcpServers: metadata.serverDiagnostics.mcpServers.map((server) => ({ ...server })),
|
mcpServers: metadata.serverDiagnostics.mcpServers.map((server) => ({ ...server })),
|
||||||
|
|
@ -34,6 +34,40 @@ 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 {
|
function cloneRateLimitSnapshot(snapshot: RateLimitSnapshot): RateLimitSnapshot {
|
||||||
return {
|
return {
|
||||||
...snapshot,
|
...snapshot,
|
||||||
|
|
|
||||||
31
src/app-server/query/thread-list-mutation.ts
Normal file
31
src/app-server/query/thread-list-mutation.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,26 +3,26 @@ export interface ActiveRouteScope {
|
||||||
activeTurnId: string | null;
|
activeTurnId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MessageScope {
|
export interface AppServerRouteScope {
|
||||||
threadId: string | null;
|
threadId: string | null;
|
||||||
turnId: string | null;
|
turnId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isMessageScopeInActiveRouteScope(messageScope: MessageScope, activeScope: ActiveRouteScope): boolean {
|
export function isAppServerRouteScopeInActiveRouteScope(routeScope: AppServerRouteScope, activeScope: ActiveRouteScope): boolean {
|
||||||
// Scope identifiers are filters only when both the message and the active
|
// Scope identifiers are filters only when both the app-server envelope and the active
|
||||||
// panel have one. Thread catalog and idle-thread notifications often omit
|
// panel have one. Thread catalog and idle-thread notifications often omit
|
||||||
// active turn scope, so missing ids stay eligible for the active route.
|
// active turn scope, so missing ids stay eligible for the active route.
|
||||||
if (messageScope.threadId && activeScope.activeThreadId && messageScope.threadId !== activeScope.activeThreadId) return false;
|
if (routeScope.threadId && activeScope.activeThreadId && routeScope.threadId !== activeScope.activeThreadId) return false;
|
||||||
if (messageScope.turnId && activeScope.activeTurnId && messageScope.turnId !== activeScope.activeTurnId) return false;
|
if (routeScope.turnId && activeScope.activeTurnId && routeScope.turnId !== activeScope.activeTurnId) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isTurnScopedMessageForIdleActiveThread(messageScope: MessageScope, activeScope: ActiveRouteScope): boolean {
|
export function isTurnScopedAppServerRouteForIdleActiveThread(routeScope: AppServerRouteScope, activeScope: ActiveRouteScope): boolean {
|
||||||
return activeScope.activeThreadId !== null && activeScope.activeTurnId === null && messageScope.turnId !== null;
|
return activeScope.activeThreadId !== null && activeScope.activeTurnId === null && routeScope.turnId !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fallbackMessageScope(message: { params?: unknown }): MessageScope {
|
export function fallbackAppServerRouteScope(envelope: { params?: unknown }): AppServerRouteScope {
|
||||||
const rawParams = message.params;
|
const rawParams = envelope.params;
|
||||||
const params =
|
const params =
|
||||||
rawParams !== null && typeof rawParams === "object" && !Array.isArray(rawParams) ? (rawParams as Record<string, unknown>) : null;
|
rawParams !== null && typeof rawParams === "object" && !Array.isArray(rawParams) ? (rawParams as Record<string, unknown>) : null;
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,10 @@ import {
|
||||||
} from "../protocol/server-requests";
|
} from "../protocol/server-requests";
|
||||||
import {
|
import {
|
||||||
type ActiveRouteScope,
|
type ActiveRouteScope,
|
||||||
fallbackMessageScope,
|
type AppServerRouteScope,
|
||||||
isMessageScopeInActiveRouteScope,
|
fallbackAppServerRouteScope,
|
||||||
isTurnScopedMessageForIdleActiveThread,
|
isAppServerRouteScopeInActiveRouteScope,
|
||||||
type MessageScope,
|
isTurnScopedAppServerRouteForIdleActiveThread,
|
||||||
} from "./scope";
|
} from "./scope";
|
||||||
|
|
||||||
export type ServerRequestRoute =
|
export type ServerRequestRoute =
|
||||||
|
|
@ -33,50 +33,43 @@ export type ServerRequestRoute =
|
||||||
| { kind: "inactive"; request: ServerRequest };
|
| { kind: "inactive"; request: ServerRequest };
|
||||||
|
|
||||||
type ServerRequestMethod = ServerRequest["method"];
|
type ServerRequestMethod = ServerRequest["method"];
|
||||||
type ServerRequestRouteKindByMethod = Record<ServerRequestMethod, Exclude<ServerRequestRoute["kind"], "inactive" | "unknown">>;
|
type ServerRequestDescriptorByMethod = {
|
||||||
type ServerRequestScopeExtractors = {
|
[Method in ServerRequestMethod]: {
|
||||||
[Method in ServerRequestMethod]: (request: Extract<ServerRequest, { method: Method }>) => MessageScope;
|
routeKind: Exclude<ServerRequestRoute["kind"], "inactive" | "unknown">;
|
||||||
|
scope: (request: Extract<ServerRequest, { method: Method }>) => AppServerRouteScope;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const SERVER_REQUEST_SCOPE_EXTRACTORS: ServerRequestScopeExtractors = {
|
const SERVER_REQUEST_DESCRIPTORS = {
|
||||||
"item/commandExecution/requestApproval": threadTurnRequestScope,
|
"item/commandExecution/requestApproval": { routeKind: "approval", scope: threadTurnRequestScope },
|
||||||
"item/fileChange/requestApproval": threadTurnRequestScope,
|
"item/fileChange/requestApproval": { routeKind: "approval", scope: threadTurnRequestScope },
|
||||||
"item/tool/requestUserInput": threadTurnRequestScope,
|
"item/permissions/requestApproval": { routeKind: "approval", scope: threadTurnRequestScope },
|
||||||
"mcpServer/elicitation/request": threadTurnRequestScope,
|
"item/tool/requestUserInput": { routeKind: "userInput", scope: threadTurnRequestScope },
|
||||||
"item/permissions/requestApproval": threadTurnRequestScope,
|
"mcpServer/elicitation/request": { routeKind: "mcpElicitation", scope: threadTurnRequestScope },
|
||||||
"item/tool/call": threadTurnRequestScope,
|
"item/tool/call": { routeKind: "unsupported", scope: threadTurnRequestScope },
|
||||||
"account/chatgptAuthTokens/refresh": unscopedRequestScope,
|
"account/chatgptAuthTokens/refresh": { routeKind: "unsupported", scope: unscopedRequestScope },
|
||||||
"attestation/generate": unscopedRequestScope,
|
"attestation/generate": { routeKind: "unsupported", scope: unscopedRequestScope },
|
||||||
"currentTime/read": threadOnlyRequestScope,
|
"currentTime/read": { routeKind: "currentTime", scope: threadOnlyRequestScope },
|
||||||
applyPatchApproval: unscopedRequestScope,
|
applyPatchApproval: { routeKind: "unsupported", scope: unscopedRequestScope },
|
||||||
execCommandApproval: unscopedRequestScope,
|
execCommandApproval: { routeKind: "unsupported", scope: unscopedRequestScope },
|
||||||
};
|
} satisfies ServerRequestDescriptorByMethod;
|
||||||
|
|
||||||
const SERVER_REQUEST_ROUTE_KIND_BY_METHOD: ServerRequestRouteKindByMethod = {
|
interface ServerRequestDescriptor {
|
||||||
"item/commandExecution/requestApproval": "approval",
|
readonly routeKind: Exclude<ServerRequestRoute["kind"], "inactive" | "unknown">;
|
||||||
"item/fileChange/requestApproval": "approval",
|
readonly scope: (request: ServerRequest) => AppServerRouteScope;
|
||||||
"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 {
|
export function routeServerRequest(request: ServerRequest, scope: ActiveRouteScope): ServerRequestRoute {
|
||||||
const messageScope = serverRequestScope(request);
|
const routeScope = serverRequestScope(request);
|
||||||
if (!isServerRequest(request)) {
|
if (!isServerRequest(request)) {
|
||||||
if (!isMessageScopeInActiveRouteScope(messageScope, scope)) return { kind: "inactive", request };
|
if (!isAppServerRouteScopeInActiveRouteScope(routeScope, scope)) return { kind: "inactive", request };
|
||||||
if (isTurnScopedMessageForIdleActiveThread(messageScope, scope)) return { kind: "inactive", request };
|
if (isTurnScopedAppServerRouteForIdleActiveThread(routeScope, scope)) return { kind: "inactive", request };
|
||||||
return { kind: "unknown", request };
|
return { kind: "unknown", request };
|
||||||
}
|
}
|
||||||
if (!isMessageScopeInActiveRouteScope(messageScope, scope)) return { kind: "inactive", request };
|
if (!isAppServerRouteScopeInActiveRouteScope(routeScope, scope)) return { kind: "inactive", request };
|
||||||
if (isTurnScopedMessageForIdleActiveThread(messageScope, scope)) return { kind: "inactive", request };
|
if (isTurnScopedAppServerRouteForIdleActiveThread(routeScope, scope)) return { kind: "inactive", request };
|
||||||
|
|
||||||
switch (SERVER_REQUEST_ROUTE_KIND_BY_METHOD[request.method]) {
|
switch (serverRequestDescriptor(request).routeKind) {
|
||||||
case "approval": {
|
case "approval": {
|
||||||
const approval = appServerApprovalRequest(request);
|
const approval = appServerApprovalRequest(request);
|
||||||
if (approval) return { kind: "approval", request, approval };
|
if (approval) return { kind: "approval", request, approval };
|
||||||
|
|
@ -118,24 +111,27 @@ export function serverRequestCurrentTimeResponse(currentTimeMs: number): unknown
|
||||||
return { currentTimeAt: Math.floor(currentTimeMs / 1000) };
|
return { currentTimeAt: Math.floor(currentTimeMs / 1000) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function serverRequestScope(request: ServerRequest): MessageScope {
|
function serverRequestScope(request: ServerRequest): AppServerRouteScope {
|
||||||
if (!isServerRequest(request)) return fallbackMessageScope(request);
|
if (!isServerRequest(request)) return fallbackAppServerRouteScope(request);
|
||||||
const extractor = SERVER_REQUEST_SCOPE_EXTRACTORS[request.method] as (request: ServerRequest) => MessageScope;
|
return serverRequestDescriptor(request).scope(request);
|
||||||
return extractor(request);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isServerRequest(request: ServerRequest): boolean {
|
function isServerRequest(request: ServerRequest): boolean {
|
||||||
return Object.hasOwn(SERVER_REQUEST_SCOPE_EXTRACTORS, request.method);
|
return Object.hasOwn(SERVER_REQUEST_DESCRIPTORS, request.method);
|
||||||
}
|
}
|
||||||
|
|
||||||
function threadTurnRequestScope(request: { params: { threadId: string; turnId: string | null } }): MessageScope {
|
function serverRequestDescriptor(request: ServerRequest): ServerRequestDescriptor {
|
||||||
|
return SERVER_REQUEST_DESCRIPTORS[request.method] as ServerRequestDescriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
function threadTurnRequestScope(request: { params: { threadId: string; turnId: string | null } }): AppServerRouteScope {
|
||||||
return { threadId: request.params.threadId, turnId: request.params.turnId };
|
return { threadId: request.params.threadId, turnId: request.params.turnId };
|
||||||
}
|
}
|
||||||
|
|
||||||
function threadOnlyRequestScope(request: { params: { threadId: string | null } }): MessageScope {
|
function threadOnlyRequestScope(request: { params: { threadId: string | null } }): AppServerRouteScope {
|
||||||
return { threadId: request.params.threadId, turnId: null };
|
return { threadId: request.params.threadId, turnId: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
function unscopedRequestScope(): MessageScope {
|
function unscopedRequestScope(): AppServerRouteScope {
|
||||||
return { threadId: null, turnId: null };
|
return { threadId: null, turnId: null };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import type { HookItem, ModelMetadata, SkillMetadata } from "../../domain/catalog/metadata";
|
import type { HookItem, ModelMetadata, SkillMetadata } from "../../domain/catalog/metadata";
|
||||||
|
import type { RuntimePermissionProfileSummary } from "../../domain/runtime/permissions";
|
||||||
|
import type { ClientResponseByMethod } from "../connection/client";
|
||||||
import type { ClientRequestParams } from "../connection/rpc-messages";
|
import type { ClientRequestParams } from "../connection/rpc-messages";
|
||||||
import {
|
import {
|
||||||
type AppServerHookOperation,
|
type AppServerHookOperation,
|
||||||
|
|
@ -20,11 +22,47 @@ export interface ModelMetadataClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listModelMetadata(client: ModelMetadataClient, options: { includeHidden?: boolean } = {}): Promise<ModelMetadata[]> {
|
export async function listModelMetadata(client: ModelMetadataClient, options: { includeHidden?: boolean } = {}): Promise<ModelMetadata[]> {
|
||||||
const response = await client.request("model/list", {
|
const models: ClientResponseByMethod["model/list"]["data"] = [];
|
||||||
includeHidden: options.includeHidden ?? false,
|
const seenCursors = new Set<string>();
|
||||||
limit: 100,
|
let cursor: string | null = null;
|
||||||
});
|
|
||||||
return modelMetadataFromCatalogModels(response.data);
|
for (;;) {
|
||||||
|
const response: ClientResponseByMethod["model/list"] = await client.request("model/list", {
|
||||||
|
includeHidden: options.includeHidden ?? false,
|
||||||
|
cursor,
|
||||||
|
limit: 100,
|
||||||
|
});
|
||||||
|
models.push(...response.data);
|
||||||
|
cursor = response.nextCursor ?? null;
|
||||||
|
if (!cursor) break;
|
||||||
|
if (seenCursors.has(cursor)) throw new Error("Codex app-server returned a repeated model list cursor.");
|
||||||
|
seenCursors.add(cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
return modelMetadataFromCatalogModels(models);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPermissionProfiles(client: AppServerRequestClient, cwd: string): Promise<RuntimePermissionProfileSummary[]> {
|
||||||
|
const profiles: RuntimePermissionProfileSummary[] = [];
|
||||||
|
const seenCursors = new Set<string>();
|
||||||
|
let cursor: string | null = null;
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const response: ClientResponseByMethod["permissionProfile/list"] = await client.request("permissionProfile/list", {
|
||||||
|
cwd,
|
||||||
|
cursor,
|
||||||
|
limit: 100,
|
||||||
|
});
|
||||||
|
profiles.push(...response.data.map((profile) => ({ ...profile })));
|
||||||
|
cursor = response.nextCursor ?? null;
|
||||||
|
if (!cursor) break;
|
||||||
|
if (seenCursors.has(cursor)) {
|
||||||
|
throw new Error("Codex app-server returned a repeated permission profile list cursor.");
|
||||||
|
}
|
||||||
|
seenCursors.add(cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
return profiles;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listSkillCatalog(
|
export async function listSkillCatalog(
|
||||||
|
|
@ -57,15 +95,15 @@ export async function listHookCatalog(client: AppServerRequestClient, cwd: strin
|
||||||
export async function trustHookItem(client: AppServerRequestClient, hook: HookItem): Promise<void> {
|
export async function trustHookItem(client: AppServerRequestClient, hook: HookItem): Promise<void> {
|
||||||
const operation = appServerHookOperationFromHookItem(hook);
|
const operation = appServerHookOperationFromHookItem(hook);
|
||||||
await writeHookState(client, operation.key, {
|
await writeHookState(client, operation.key, {
|
||||||
enabled: true,
|
|
||||||
trusted_hash: operation.currentHash,
|
trusted_hash: operation.currentHash,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setHookItemEnabled(client: AppServerRequestClient, hook: HookItem, enabled: boolean): Promise<void> {
|
export async function setHookItemEnabled(client: AppServerRequestClient, hook: HookItem, enabled: boolean): Promise<void> {
|
||||||
|
if (hook.isManaged) throw new Error("Managed hooks cannot be enabled or disabled here.");
|
||||||
const operation = appServerHookOperationFromHookItem(hook);
|
const operation = appServerHookOperationFromHookItem(hook);
|
||||||
const state: HookConfigState = operation.trustStatus === "trusted" ? { enabled, trusted_hash: operation.currentHash } : { enabled };
|
if (operation.trustStatus !== "trusted") throw new Error("Trust the current hook definition before enabling it.");
|
||||||
await writeHookState(client, operation.key, state);
|
await writeHookState(client, operation.key, { enabled });
|
||||||
}
|
}
|
||||||
|
|
||||||
type HookConfigState = Record<string, string | boolean | null>;
|
type HookConfigState = Record<string, string | boolean | null>;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import { listenAbortSignal } from "../../shared/runtime/abort-signal";
|
import { listenAbortSignal } from "../../shared/runtime/abort-signal";
|
||||||
import { AppServerClient, type AppServerClientHandlers } from "../connection/client";
|
import { AppServerClient, type AppServerClientHandlers } from "../connection/client";
|
||||||
import type { AppServerClientRequestPolicy } from "../connection/client-access";
|
import type { AppServerClientRequestPolicy } from "../connection/client-access";
|
||||||
|
import { codexPanelAppServerInitializeParams } from "../connection/client-profile";
|
||||||
import type { ServerNotification } from "../connection/rpc-messages";
|
import type { ServerNotification } from "../connection/rpc-messages";
|
||||||
import { lastAgentMessageTextFromTurnRecord, type TurnItem, type TurnRecord } from "../protocol/turn";
|
import { lastAgentMessageTextFromTurnRecord, type TurnItem, type TurnRecord } from "../protocol/turn";
|
||||||
import type { ModelMetadataClient } from "./catalog";
|
import type { ModelMetadataClient } from "./catalog";
|
||||||
import type { AppServerRequestClient } from "./request-client";
|
import type { AppServerRequestClient } from "./request-client";
|
||||||
|
import { type RuntimeOverrideSettings, resolvedRuntimeOverrideForClient } from "./runtime-overrides";
|
||||||
import { deleteThread, startEphemeralThread } from "./threads";
|
import { deleteThread, startEphemeralThread } from "./threads";
|
||||||
import { type AppServerStartStructuredTurnOptions, startStructuredTurn } from "./turns";
|
import { type AppServerStartStructuredTurnOptions, startStructuredTurn } from "./turns";
|
||||||
|
|
||||||
|
|
@ -36,7 +38,7 @@ export interface EphemeralStructuredTurnClient {
|
||||||
|
|
||||||
type EphemeralStructuredTurnRuntimeCapableClient = EphemeralStructuredTurnClient & ModelMetadataClient;
|
type EphemeralStructuredTurnRuntimeCapableClient = EphemeralStructuredTurnClient & ModelMetadataClient;
|
||||||
|
|
||||||
export type EphemeralStructuredTurnClientFactory = (
|
type EphemeralStructuredTurnClientFactory = (
|
||||||
codexPath: string,
|
codexPath: string,
|
||||||
cwd: string,
|
cwd: string,
|
||||||
handlers: AppServerClientHandlers,
|
handlers: AppServerClientHandlers,
|
||||||
|
|
@ -55,17 +57,31 @@ export interface RunEphemeralStructuredTurnOptions {
|
||||||
timedOutMessage: string;
|
timedOutMessage: string;
|
||||||
abortMessage?: string;
|
abortMessage?: string;
|
||||||
runtime?: StructuredTurnRuntimeOverride | undefined;
|
runtime?: StructuredTurnRuntimeOverride | undefined;
|
||||||
resolveRuntime?: ((client: ModelMetadataClient) => Promise<StructuredTurnRuntimeOverride>) | undefined;
|
runtimeSettings?: RuntimeOverrideSettings | undefined;
|
||||||
signal?: AbortSignal | undefined;
|
signal?: AbortSignal | undefined;
|
||||||
onProgress?: (event: StructuredTurnProgressEvent) => void;
|
onProgress?: (event: StructuredTurnProgressEvent) => void;
|
||||||
clientFactory?: EphemeralStructuredTurnClientFactory | undefined;
|
|
||||||
timers?: EphemeralStructuredTurnTimers | undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runEphemeralStructuredTurn(options: RunEphemeralStructuredTurnOptions): Promise<TurnRecord> {
|
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> {
|
||||||
throwIfAborted(options.signal, options.abortMessage);
|
throwIfAborted(options.signal, options.abortMessage);
|
||||||
let state = createEphemeralStructuredTurnState();
|
let state = createEphemeralStructuredTurnState();
|
||||||
const timers = options.timers ?? DEFAULT_EPHEMERAL_STRUCTURED_TURN_TIMERS;
|
const timers = dependencies.timers ?? DEFAULT_EPHEMERAL_STRUCTURED_TURN_TIMERS;
|
||||||
let handleNotification: (notification: ServerNotification) => void = () => undefined;
|
let handleNotification: (notification: ServerNotification) => void = () => undefined;
|
||||||
let operationAbortError: Error | null = null;
|
let operationAbortError: Error | null = null;
|
||||||
const operationAbort = new AbortController();
|
const operationAbort = new AbortController();
|
||||||
|
|
@ -96,7 +112,15 @@ export async function runEphemeralStructuredTurn(options: RunEphemeralStructured
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const clientFactory = options.clientFactory ?? ((codexPath, cwd, handlers) => new AppServerClient(codexPath, cwd, handlers));
|
const clientFactory =
|
||||||
|
dependencies.clientFactory ??
|
||||||
|
((codexPath, cwd, handlers) =>
|
||||||
|
new AppServerClient({
|
||||||
|
codexPath,
|
||||||
|
cwd,
|
||||||
|
handlers,
|
||||||
|
initializeParams: codexPanelAppServerInitializeParams(),
|
||||||
|
}));
|
||||||
let threadId: string | null = null;
|
let threadId: string | null = null;
|
||||||
const client = clientFactory(options.codexPath, options.cwd, {
|
const client = clientFactory(options.codexPath, options.cwd, {
|
||||||
onNotification: (notification) => {
|
onNotification: (notification) => {
|
||||||
|
|
@ -113,10 +137,13 @@ export async function runEphemeralStructuredTurn(options: RunEphemeralStructured
|
||||||
abortOperation(new Error(options.exitedMessage));
|
abortOperation(new Error(options.exitedMessage));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
dependencies.clientLifecycle?.created(client);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await runAbortable(client.connect());
|
await runAbortable(client.connect());
|
||||||
const runtime = options.resolveRuntime ? await runAbortable(options.resolveRuntime(client)) : (options.runtime ?? {});
|
const runtime = options.runtimeSettings
|
||||||
|
? await runAbortable(resolvedRuntimeOverrideForClient(client, options.runtimeSettings))
|
||||||
|
: (options.runtime ?? {});
|
||||||
const threadResponse = await runAbortable(
|
const threadResponse = await runAbortable(
|
||||||
startEphemeralThread(client, {
|
startEphemeralThread(client, {
|
||||||
cwd: options.cwd,
|
cwd: options.cwd,
|
||||||
|
|
@ -155,13 +182,20 @@ export async function runEphemeralStructuredTurn(options: RunEphemeralStructured
|
||||||
} finally {
|
} finally {
|
||||||
state = completeEphemeralStructuredTurnState(state);
|
state = completeEphemeralStructuredTurnState(state);
|
||||||
timers.clearTimeout(timeout);
|
timers.clearTimeout(timeout);
|
||||||
await deleteEphemeralStructuredTurnThread(client, threadId);
|
try {
|
||||||
client.disconnect();
|
await deleteEphemeralStructuredTurnThread(client, threadId);
|
||||||
|
} finally {
|
||||||
|
client.disconnect();
|
||||||
|
dependencies.clientLifecycle?.disposed(client);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runEphemeralStructuredTurnForLastAgentText(options: RunEphemeralStructuredTurnOptions): Promise<string | null> {
|
export async function runEphemeralStructuredTurnForLastAgentText(
|
||||||
const turn = await runEphemeralStructuredTurn(options);
|
options: RunEphemeralStructuredTurnOptions,
|
||||||
|
runner: EphemeralStructuredTurnRunner = runEphemeralStructuredTurn,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const turn = await runner(options);
|
||||||
return lastAgentMessageTextFromTurnRecord(turn);
|
return lastAgentMessageTextFromTurnRecord(turn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import type { ThreadTokenUsage, TokenUsageBreakdown } from "../../domain/runtime
|
||||||
const ROLLOUT_TOKEN_USAGE_READ_TIMEOUT_MS = 2_000;
|
const ROLLOUT_TOKEN_USAGE_READ_TIMEOUT_MS = 2_000;
|
||||||
const ROLLOUT_TOKEN_USAGE_MAX_BASE64_BYTES = 12 * 1024 * 1024;
|
const ROLLOUT_TOKEN_USAGE_MAX_BASE64_BYTES = 12 * 1024 * 1024;
|
||||||
|
|
||||||
export type RolloutReadFileBase64 = (path: string, options: { timeoutMs: number }) => Promise<string>;
|
export type RolloutReadFileBase64 = (path: string, options: { timeoutMs: number }) => Promise<string | null>;
|
||||||
|
|
||||||
export async function recoverRolloutTokenUsage(
|
export async function recoverRolloutTokenUsage(
|
||||||
path: string | null,
|
path: string | null,
|
||||||
|
|
@ -11,12 +11,13 @@ export async function recoverRolloutTokenUsage(
|
||||||
): Promise<ThreadTokenUsage | null> {
|
): Promise<ThreadTokenUsage | null> {
|
||||||
if (!path || !isAbsolutePath(path)) return null;
|
if (!path || !isAbsolutePath(path)) return null;
|
||||||
|
|
||||||
let dataBase64: string;
|
let dataBase64: string | null;
|
||||||
try {
|
try {
|
||||||
dataBase64 = await readFileBase64(path, { timeoutMs: ROLLOUT_TOKEN_USAGE_READ_TIMEOUT_MS });
|
dataBase64 = await readFileBase64(path, { timeoutMs: ROLLOUT_TOKEN_USAGE_READ_TIMEOUT_MS });
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (dataBase64 === null) return null;
|
||||||
if (dataBase64.length > ROLLOUT_TOKEN_USAGE_MAX_BASE64_BYTES) return null;
|
if (dataBase64.length > ROLLOUT_TOKEN_USAGE_MAX_BASE64_BYTES) return null;
|
||||||
|
|
||||||
const text = decodeBase64Text(dataBase64);
|
const text = decodeBase64Text(dataBase64);
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,12 @@ import {
|
||||||
threadTitleFromGeneratedText,
|
threadTitleFromGeneratedText,
|
||||||
threadTitlePrompt,
|
threadTitlePrompt,
|
||||||
} from "../../domain/threads/title-generation-model";
|
} from "../../domain/threads/title-generation-model";
|
||||||
import { conversationAssistantTextFromTurnRecord } from "../protocol/turn";
|
import { turnTranscriptAssistantTextFromTurnRecord } from "../protocol/turn";
|
||||||
import {
|
import {
|
||||||
type EphemeralStructuredTurnClientFactory,
|
type EphemeralStructuredTurnRunner,
|
||||||
runEphemeralStructuredTurn,
|
runEphemeralStructuredTurn,
|
||||||
type StructuredTurnOutputSchema,
|
type StructuredTurnOutputSchema,
|
||||||
} from "./ephemeral-structured-turn";
|
} from "./ephemeral-structured-turn";
|
||||||
import { resolvedRuntimeOverrideForClient } from "./runtime-overrides";
|
|
||||||
|
|
||||||
const THREAD_TITLE_SERVICE_NAME = "codex-panel-naming";
|
const THREAD_TITLE_SERVICE_NAME = "codex-panel-naming";
|
||||||
const THREAD_TITLE_TIMEOUT_MS = 60_000;
|
const THREAD_TITLE_TIMEOUT_MS = 60_000;
|
||||||
|
|
@ -41,14 +40,20 @@ export interface ThreadTitleRuntimeSettings {
|
||||||
threadNamingEffort: ReasoningEffort | null;
|
threadNamingEffort: ReasoningEffort | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GenerateThreadTitleWithCodexOptions {
|
||||||
|
runner?: EphemeralStructuredTurnRunner;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
export async function generateThreadTitleWithCodex(
|
export async function generateThreadTitleWithCodex(
|
||||||
codexPath: string,
|
codexPath: string,
|
||||||
cwd: string,
|
cwd: string,
|
||||||
context: ThreadTitleContext,
|
context: ThreadTitleContext,
|
||||||
runtimeSettings: ThreadTitleRuntimeSettings,
|
runtimeSettings: ThreadTitleRuntimeSettings,
|
||||||
clientFactory?: EphemeralStructuredTurnClientFactory,
|
options: GenerateThreadTitleWithCodexOptions = {},
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const turn = await runEphemeralStructuredTurn({
|
const runner = options.runner ?? runEphemeralStructuredTurn;
|
||||||
|
const turn = await runner({
|
||||||
codexPath,
|
codexPath,
|
||||||
cwd,
|
cwd,
|
||||||
serviceName: THREAD_TITLE_SERVICE_NAME,
|
serviceName: THREAD_TITLE_SERVICE_NAME,
|
||||||
|
|
@ -59,10 +64,10 @@ export async function generateThreadTitleWithCodex(
|
||||||
serverRequests: { kind: "reject", message: "Thread title generation does not handle server requests." },
|
serverRequests: { kind: "reject", message: "Thread title generation does not handle server requests." },
|
||||||
exitedMessage: "Codex title generation app-server exited.",
|
exitedMessage: "Codex title generation app-server exited.",
|
||||||
timedOutMessage: "Timed out while generating a Codex thread title.",
|
timedOutMessage: "Timed out while generating a Codex thread title.",
|
||||||
resolveRuntime: (client) =>
|
abortMessage: "Thread title generation cancelled.",
|
||||||
resolvedRuntimeOverrideForClient(client, { model: runtimeSettings.threadNamingModel, effort: runtimeSettings.threadNamingEffort }),
|
runtimeSettings: { model: runtimeSettings.threadNamingModel, effort: runtimeSettings.threadNamingEffort },
|
||||||
clientFactory,
|
signal: options.signal,
|
||||||
});
|
});
|
||||||
const response = conversationAssistantTextFromTurnRecord(turn);
|
const response = turnTranscriptAssistantTextFromTurnRecord(turn);
|
||||||
return response ? threadTitleFromGeneratedText(response) : null;
|
return response ? threadTitleFromGeneratedText(response) : null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,32 +6,26 @@ import type { RuntimeServiceTierRequest, RuntimeSettingsPatch } from "../../doma
|
||||||
import type { ThreadActivationSnapshot } from "../../domain/threads/activation";
|
import type { ThreadActivationSnapshot } from "../../domain/threads/activation";
|
||||||
import type { ArchiveThreadInput } from "../../domain/threads/archive-markdown";
|
import type { ArchiveThreadInput } from "../../domain/threads/archive-markdown";
|
||||||
import type { ThreadGoal, ThreadGoalUpdate } from "../../domain/threads/goal";
|
import type { ThreadGoal, ThreadGoalUpdate } from "../../domain/threads/goal";
|
||||||
import type { HistoricalTurn } from "../../domain/threads/history";
|
|
||||||
import type { Thread } from "../../domain/threads/model";
|
import type { Thread } from "../../domain/threads/model";
|
||||||
import { REFERENCED_THREAD_TURN_LIMIT } from "../../domain/threads/reference";
|
import { REFERENCED_THREAD_TURN_LIMIT } from "../../domain/threads/reference";
|
||||||
import type { ThreadConversationSummary } from "../../domain/threads/transcript";
|
import type { TurnTranscriptSummary } from "../../domain/threads/transcript";
|
||||||
import type { ClientResponseByMethod } from "../connection/client";
|
import type { ClientResponseByMethod } from "../connection/client";
|
||||||
import type { ClientRequestParams } from "../connection/rpc-messages";
|
import type { ClientRequestParams } from "../connection/rpc-messages";
|
||||||
|
import { appServerSideChatBoundaryItem, sideChatDeveloperInstructions } from "../protocol/side-chat";
|
||||||
import { type ThreadRecord, threadFromThreadRecord, threadsFromThreadRecords } from "../protocol/thread";
|
import { type ThreadRecord, threadFromThreadRecord, threadsFromThreadRecords } from "../protocol/thread";
|
||||||
import { appServerThreadGoalUpdate, appServerThreadGoalUserHistoryItem, threadGoalFromAppServerGoal } from "../protocol/thread-goal";
|
import { appServerThreadGoalUpdate, appServerThreadGoalUserHistoryItem, threadGoalFromAppServerGoal } from "../protocol/thread-goal";
|
||||||
import { appServerRuntimeSettingsPatch } from "../protocol/thread-settings";
|
import { appServerRuntimeSettingsPatch } from "../protocol/thread-settings";
|
||||||
import {
|
import {
|
||||||
chronologicalConversationSummariesFromTurnRecords,
|
chronologicalTurnTranscriptSummariesFromTurnRecords,
|
||||||
completedConversationSummariesFromTurnRecords,
|
completedTurnTranscriptSummariesFromTurnRecords,
|
||||||
transcriptEntriesFromTurnRecords,
|
transcriptEntriesFromTurnRecords,
|
||||||
} from "../protocol/turn";
|
} from "../protocol/turn";
|
||||||
import type { AppServerRequestClient } from "./request-client";
|
import type { AppServerRequestClient } from "./request-client";
|
||||||
|
|
||||||
const THREAD_LIST_PAGE_LIMIT = 100;
|
|
||||||
|
|
||||||
export type ThreadTurnSortDirection = "asc" | "desc";
|
export type ThreadTurnSortDirection = "asc" | "desc";
|
||||||
export type ThreadConversationSummaryClient = AppServerRequestClient;
|
|
||||||
export type ThreadForkClient = AppServerRequestClient;
|
|
||||||
export type ThreadRollbackClient = AppServerRequestClient;
|
|
||||||
export type ThreadCompactionClient = AppServerRequestClient;
|
|
||||||
|
|
||||||
interface ThreadConversationSummaryPage {
|
interface TurnTranscriptSummaryPage {
|
||||||
summaries: ThreadConversationSummary[];
|
summaries: TurnTranscriptSummary[];
|
||||||
nextCursor: string | null;
|
nextCursor: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -48,6 +42,7 @@ interface ThreadActivationResponse extends Partial<RuntimePermissionState> {
|
||||||
export interface AppServerStartThreadOptions {
|
export interface AppServerStartThreadOptions {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
serviceTier?: RuntimeServiceTierRequest;
|
serviceTier?: RuntimeServiceTierRequest;
|
||||||
|
permissions?: RuntimeSettingsPatch["permissions"];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppServerStartEphemeralThreadOptions {
|
export interface AppServerStartEphemeralThreadOptions {
|
||||||
|
|
@ -56,21 +51,28 @@ export interface AppServerStartEphemeralThreadOptions {
|
||||||
developerInstructions: string;
|
developerInstructions: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AppServerThreadListOptions {
|
export interface AppServerThreadListOptions {
|
||||||
archived?: boolean;
|
archived?: boolean;
|
||||||
cursor?: string | null;
|
cursor?: string | null;
|
||||||
limit?: number | null;
|
limit?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ThreadPage {
|
||||||
|
readonly threads: readonly Thread[];
|
||||||
|
readonly nextCursor: string | null;
|
||||||
|
readonly fetchedSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function startThread(
|
export function startThread(
|
||||||
client: AppServerRequestClient,
|
client: AppServerRequestClient,
|
||||||
options: AppServerStartThreadOptions,
|
options: AppServerStartThreadOptions,
|
||||||
): Promise<ClientResponseByMethod["thread/start"]> {
|
): Promise<ClientResponseByMethod["thread/start"]> {
|
||||||
const { cwd, serviceTier } = options;
|
const { cwd, serviceTier, permissions } = options;
|
||||||
return client.request("thread/start", {
|
return client.request("thread/start", {
|
||||||
cwd,
|
cwd,
|
||||||
serviceName: "codex-panel",
|
serviceName: "codex-panel",
|
||||||
...(serviceTier !== undefined ? { serviceTier } : {}),
|
...(serviceTier !== undefined ? { serviceTier } : {}),
|
||||||
|
...(permissions !== undefined ? { permissions } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,7 +88,6 @@ export function startEphemeralThread(
|
||||||
ephemeral: true,
|
ephemeral: true,
|
||||||
sandbox: "read-only",
|
sandbox: "read-only",
|
||||||
approvalPolicy: "never",
|
approvalPolicy: "never",
|
||||||
multiAgentMode: "none",
|
|
||||||
environments: [],
|
environments: [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -104,21 +105,25 @@ export function resumeThread(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listThreads(client: AppServerRequestClient, cwd: string, options: { archived?: boolean } = {}): Promise<Thread[]> {
|
export async function listThreads(
|
||||||
|
client: AppServerRequestClient,
|
||||||
|
cwd: string,
|
||||||
|
options: { archived?: boolean; signal?: AbortSignal } = {},
|
||||||
|
): Promise<Thread[]> {
|
||||||
const archived = options.archived ?? false;
|
const archived = options.archived ?? false;
|
||||||
const records: ThreadRecord[] = [];
|
const threads: Thread[] = [];
|
||||||
const seenCursors = new Set<string>();
|
const seenCursors = new Set<string>();
|
||||||
let cursor: string | null = null;
|
let cursor: string | null = null;
|
||||||
|
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const response = await listThreadPage(client, cwd, {
|
options.signal?.throwIfAborted();
|
||||||
|
const page = await readThreadPage(client, cwd, {
|
||||||
archived,
|
archived,
|
||||||
cursor,
|
cursor,
|
||||||
limit: THREAD_LIST_PAGE_LIMIT,
|
|
||||||
});
|
});
|
||||||
records.push(...response.data);
|
threads.push(...page.threads);
|
||||||
|
|
||||||
cursor = response.nextCursor ?? null;
|
cursor = page.nextCursor;
|
||||||
if (!cursor) break;
|
if (!cursor) break;
|
||||||
if (seenCursors.has(cursor)) {
|
if (seenCursors.has(cursor)) {
|
||||||
throw new Error("Codex app-server returned a repeated thread list cursor.");
|
throw new Error("Codex app-server returned a repeated thread list cursor.");
|
||||||
|
|
@ -126,7 +131,24 @@ export async function listThreads(client: AppServerRequestClient, cwd: string, o
|
||||||
seenCursors.add(cursor);
|
seenCursors.add(cursor);
|
||||||
}
|
}
|
||||||
|
|
||||||
return threadsFromThreadRecords(records, { archived });
|
return threads;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readThreadPage(
|
||||||
|
client: AppServerRequestClient,
|
||||||
|
cwd: string,
|
||||||
|
options: AppServerThreadListOptions = {},
|
||||||
|
): Promise<ThreadPage> {
|
||||||
|
const archived = options.archived ?? false;
|
||||||
|
const page = await readThreadRecordPage(client, cwd, {
|
||||||
|
...options,
|
||||||
|
archived,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
threads: threadsFromThreadRecords(page.data, { archived }),
|
||||||
|
nextCursor: page.nextCursor ?? null,
|
||||||
|
fetchedSize: page.data.length,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function threadFromAppServerRecord(thread: ThreadRecord, options: { archived?: boolean } = {}): Thread {
|
export function threadFromAppServerRecord(thread: ThreadRecord, options: { archived?: boolean } = {}): Thread {
|
||||||
|
|
@ -141,65 +163,136 @@ export async function readThreadForArchiveExport(client: AppServerRequestClient,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readCompletedConversationSummariesPage(
|
export async function readCompletedTurnTranscriptSummariesPage(
|
||||||
client: ThreadConversationSummaryClient,
|
client: AppServerRequestClient,
|
||||||
threadId: string,
|
threadId: string,
|
||||||
cursor: string | null,
|
cursor: string | null,
|
||||||
limit: number,
|
limit: number,
|
||||||
sortDirection: ThreadTurnSortDirection = "asc",
|
sortDirection: ThreadTurnSortDirection = "asc",
|
||||||
): Promise<ThreadConversationSummaryPage> {
|
): Promise<TurnTranscriptSummaryPage> {
|
||||||
const response = await listThreadTurns(client, threadId, cursor, limit, sortDirection);
|
const response = await listThreadTurns(client, threadId, cursor, limit, sortDirection);
|
||||||
return {
|
return {
|
||||||
summaries: completedConversationSummariesFromTurnRecords(response.data),
|
summaries: completedTurnTranscriptSummariesFromTurnRecords(response.data),
|
||||||
nextCursor: response.nextCursor,
|
nextCursor: response.nextCursor,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readReferencedThreadConversationSummaries(
|
export async function readReferencedThreadTurnTranscriptSummaries(
|
||||||
client: ThreadConversationSummaryClient,
|
client: AppServerRequestClient,
|
||||||
threadId: string,
|
threadId: string,
|
||||||
limit = REFERENCED_THREAD_TURN_LIMIT,
|
limit = REFERENCED_THREAD_TURN_LIMIT,
|
||||||
): Promise<ThreadConversationSummary[]> {
|
): Promise<TurnTranscriptSummary[]> {
|
||||||
const response = await listThreadTurns(client, threadId, null, limit);
|
const response = await listThreadTurns(client, threadId, null, limit);
|
||||||
return chronologicalConversationSummariesFromTurnRecords(response.data);
|
return chronologicalTurnTranscriptSummariesFromTurnRecords(response.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ThreadRollbackSnapshot {
|
type ThreadForkPosition =
|
||||||
thread: Thread;
|
| { readonly kind: "through-turn"; readonly turnId: string }
|
||||||
cwd: string;
|
| { readonly kind: "before-turn"; readonly turnId: string };
|
||||||
turns: readonly HistoricalTurn[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ThreadRollbackResult {
|
interface AppServerForkThreadOptions {
|
||||||
readonly thread: ThreadRecord & {
|
readonly position?: ThreadForkPosition;
|
||||||
readonly cwd: string;
|
readonly deferGoalContinuation?: boolean;
|
||||||
readonly turns: readonly HistoricalTurn[];
|
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"];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function threadRollbackSnapshotFromAppServerResponse(response: ThreadRollbackResult): ThreadRollbackSnapshot {
|
export async function forkThread(
|
||||||
return {
|
client: AppServerRequestClient,
|
||||||
thread: threadFromThreadRecord(response.thread),
|
threadId: string,
|
||||||
cwd: response.thread.cwd,
|
cwd: string,
|
||||||
turns: response.thread.turns,
|
options: AppServerForkThreadOptions = {},
|
||||||
};
|
): Promise<Thread> {
|
||||||
}
|
const position = options.position;
|
||||||
|
const runtime = options.runtime;
|
||||||
export async function rollbackThread(client: ThreadRollbackClient, threadId: string, numTurns?: number): Promise<ThreadRollbackSnapshot> {
|
const permissions = runtime?.permissions;
|
||||||
const response = await client.request("thread/rollback", { threadId, numTurns: numTurns ?? 1 });
|
const sandbox = permissions === undefined ? appServerSandboxMode(runtime?.sandboxPolicy) : undefined;
|
||||||
return threadRollbackSnapshotFromAppServerResponse(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function forkThread(client: ThreadForkClient, threadId: string, cwd: string): Promise<Thread> {
|
|
||||||
const response = await client.request("thread/fork", {
|
const response = await client.request("thread/fork", {
|
||||||
threadId,
|
threadId,
|
||||||
cwd,
|
cwd,
|
||||||
excludeTurns: true,
|
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 } } : {}),
|
||||||
});
|
});
|
||||||
return threadFromThreadRecord(response.thread);
|
return threadFromThreadRecord(response.thread);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function compactThread(client: ThreadCompactionClient, threadId: string): Promise<void> {
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EphemeralThreadCleanupRequiredError extends Error {
|
||||||
|
readonly cleanupError: unknown;
|
||||||
|
readonly threadId: string;
|
||||||
|
|
||||||
|
constructor(threadId: string, cause: unknown, cleanupError: unknown) {
|
||||||
|
super(`Could not prepare or unsubscribe side chat ${threadId}.`, { cause });
|
||||||
|
this.name = "EphemeralThreadCleanupRequiredError";
|
||||||
|
this.threadId = threadId;
|
||||||
|
this.cleanupError = cleanupError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function forkEphemeralThread(
|
||||||
|
client: AppServerRequestClient,
|
||||||
|
sourceThreadId: string,
|
||||||
|
vaultPath: string,
|
||||||
|
): Promise<EphemeralThreadForkSnapshot> {
|
||||||
|
const config = await client.request("config/read", { cwd: vaultPath });
|
||||||
|
const response = await client.request("thread/fork", {
|
||||||
|
threadId: sourceThreadId,
|
||||||
|
cwd: vaultPath,
|
||||||
|
ephemeral: true,
|
||||||
|
sandbox: "read-only",
|
||||||
|
approvalPolicy: "never",
|
||||||
|
developerInstructions: sideChatDeveloperInstructions(config.config.developer_instructions),
|
||||||
|
excludeTurns: true,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await client.request("thread/inject_items", {
|
||||||
|
threadId: response.thread.id,
|
||||||
|
items: [appServerSideChatBoundaryItem()],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
await unsubscribeThread(client, response.thread.id, { timeoutMs: 5_000 });
|
||||||
|
} catch (cleanupError) {
|
||||||
|
throw new EphemeralThreadCleanupRequiredError(response.thread.id, error, cleanupError);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
activation: threadActivationSnapshotFromAppServerResponse(response),
|
||||||
|
sourceThreadId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function compactThread(client: AppServerRequestClient, threadId: string): Promise<void> {
|
||||||
await client.request("thread/compact/start", { threadId });
|
await client.request("thread/compact/start", { threadId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -211,6 +304,14 @@ export async function deleteThread(client: AppServerRequestClient, threadId: str
|
||||||
await client.request("thread/delete", { threadId }, options);
|
await client.request("thread/delete", { threadId }, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function unsubscribeThread(
|
||||||
|
client: AppServerRequestClient,
|
||||||
|
threadId: string,
|
||||||
|
options: { timeoutMs?: number } = {},
|
||||||
|
): Promise<void> {
|
||||||
|
await client.request("thread/unsubscribe", { threadId }, options);
|
||||||
|
}
|
||||||
|
|
||||||
export async function restoreArchivedThread(client: AppServerRequestClient, threadId: string): Promise<Thread> {
|
export async function restoreArchivedThread(client: AppServerRequestClient, threadId: string): Promise<Thread> {
|
||||||
const response = await client.request("thread/unarchive", { threadId });
|
const response = await client.request("thread/unarchive", { threadId });
|
||||||
return threadFromThreadRecord(response.thread);
|
return threadFromThreadRecord(response.thread);
|
||||||
|
|
@ -224,7 +325,6 @@ export function threadActivationSnapshotFromAppServerResponse(response: ThreadAc
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
thread: threadFromThreadRecord(response.thread),
|
thread: threadFromThreadRecord(response.thread),
|
||||||
cwd: response.cwd,
|
|
||||||
approvalPolicyKnown: "approvalPolicy" in response,
|
approvalPolicyKnown: "approvalPolicy" in response,
|
||||||
sandboxPolicyKnown: "sandbox" in response || "sandboxPolicy" in response,
|
sandboxPolicyKnown: "sandbox" in response || "sandboxPolicy" in response,
|
||||||
permissionProfileKnown: "activePermissionProfile" in response,
|
permissionProfileKnown: "activePermissionProfile" in response,
|
||||||
|
|
@ -287,7 +387,7 @@ export function listThreadTurns(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function listThreadPage(client: AppServerRequestClient, cwd: string, options: AppServerThreadListOptions) {
|
function readThreadRecordPage(client: AppServerRequestClient, cwd: string, options: AppServerThreadListOptions) {
|
||||||
return client.request("thread/list", {
|
return client.request("thread/list", {
|
||||||
cwd,
|
cwd,
|
||||||
...(options.cursor ? { cursor: options.cursor } : {}),
|
...(options.cursor ? { cursor: options.cursor } : {}),
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,17 @@
|
||||||
import type { SkillMetadata } from "../../domain/catalog/metadata";
|
import type { SkillMetadata } from "../../domain/catalog/metadata";
|
||||||
import { type DiagnosticProbeResult, diagnosticProbeError, diagnosticProbeOk } from "../../domain/server/diagnostics";
|
import {
|
||||||
|
type DiagnosticProbeResult,
|
||||||
|
diagnosticProbeError,
|
||||||
|
diagnosticProbeOk,
|
||||||
|
shortDiagnosticErrorMessage,
|
||||||
|
} from "../../domain/server/diagnostics";
|
||||||
import {
|
import {
|
||||||
type McpServerDiagnostic,
|
type McpServerDiagnostic,
|
||||||
type McpServerStatusSummary,
|
type McpServerStatusSummary,
|
||||||
mcpServerStatusSummariesFromStatuses,
|
mcpServerStatusSummariesFromStatuses,
|
||||||
} from "../../domain/server/mcp-status";
|
} from "../../domain/server/mcp-status";
|
||||||
import type { ToolInventoryMarketplaceError, ToolInventoryPlugin, ToolInventorySnapshot } from "../../domain/server/tool-inventory";
|
import type { ToolInventoryMarketplaceError, ToolInventoryPlugin, ToolInventorySnapshot } from "../../domain/server/tool-inventory";
|
||||||
|
import type { ClientResponseByMethod } from "../connection/client";
|
||||||
import { toolInventoryPluginsFromInstalledResponse } from "../protocol/tool-inventory";
|
import { toolInventoryPluginsFromInstalledResponse } from "../protocol/tool-inventory";
|
||||||
import { listSkillCatalog } from "./catalog";
|
import { listSkillCatalog } from "./catalog";
|
||||||
import type { AppServerRequestClient } from "./request-client";
|
import type { AppServerRequestClient } from "./request-client";
|
||||||
|
|
@ -92,7 +98,7 @@ async function readPlugins(
|
||||||
return {
|
return {
|
||||||
items: null,
|
items: null,
|
||||||
marketplaceErrors: [],
|
marketplaceErrors: [],
|
||||||
error: shortErrorMessage(error),
|
error: shortDiagnosticErrorMessage(error),
|
||||||
probe: diagnosticProbeError("plugins", error, checkedAt),
|
probe: diagnosticProbeError("plugins", error, checkedAt),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -104,12 +110,22 @@ async function readMcpServers(
|
||||||
checkedAt: number,
|
checkedAt: number,
|
||||||
): Promise<{ items: McpServerStatusSummary[] | null; error: string | null; probe: DiagnosticProbeResult }> {
|
): Promise<{ items: McpServerStatusSummary[] | null; error: string | null; probe: DiagnosticProbeResult }> {
|
||||||
try {
|
try {
|
||||||
const response = await client.request("mcpServerStatus/list", {
|
const servers: McpServerStatusSummary[] = [];
|
||||||
detail: "toolsAndAuthOnly",
|
const seenCursors = new Set<string>();
|
||||||
limit: 100,
|
let cursor: string | null = null;
|
||||||
...(threadId ? { threadId } : {}),
|
for (;;) {
|
||||||
});
|
const response: ClientResponseByMethod["mcpServerStatus/list"] = await client.request("mcpServerStatus/list", {
|
||||||
const servers = mcpServerStatusSummariesFromStatuses(response.data);
|
detail: "toolsAndAuthOnly",
|
||||||
|
cursor,
|
||||||
|
limit: 100,
|
||||||
|
...(threadId ? { threadId } : {}),
|
||||||
|
});
|
||||||
|
servers.push(...mcpServerStatusSummariesFromStatuses(response.data));
|
||||||
|
cursor = response.nextCursor ?? null;
|
||||||
|
if (!cursor) break;
|
||||||
|
if (seenCursors.has(cursor)) throw new Error("Codex app-server returned a repeated MCP server status list cursor.");
|
||||||
|
seenCursors.add(cursor);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
items: servers,
|
items: servers,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
@ -118,7 +134,7 @@ async function readMcpServers(
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
items: null,
|
items: null,
|
||||||
error: shortErrorMessage(error),
|
error: shortDiagnosticErrorMessage(error),
|
||||||
probe: diagnosticProbeError("mcpServers", error, checkedAt),
|
probe: diagnosticProbeError("mcpServers", error, checkedAt),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -142,12 +158,6 @@ async function readSkills(
|
||||||
probe: diagnosticProbeOk("skills", `${String(catalog.totalCount)} skills`, checkedAt),
|
probe: diagnosticProbeOk("skills", `${String(catalog.totalCount)} skills`, checkedAt),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { items: null, error: shortErrorMessage(error), probe: diagnosticProbeError("skills", error, checkedAt) };
|
return { items: null, error: shortDiagnosticErrorMessage(error), probe: diagnosticProbeError("skills", error, checkedAt) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function shortErrorMessage(error: unknown, maxLength = 160): string {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
const compact = message.replace(/\s+/g, " ").trim() || "Codex app-server request failed.";
|
|
||||||
return compact.length > maxLength ? `${compact.slice(0, maxLength - 3)}...` : compact;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { CodexInput } from "../../domain/chat/input";
|
import type { CodexInput } from "../../domain/chat/input";
|
||||||
import type { ClientResponseByMethod } from "../connection/client";
|
import type { ClientResponseByMethod } from "../connection/client";
|
||||||
import type { ClientRequestParams } from "../connection/rpc-messages";
|
import type { ClientRequestParams } from "../connection/rpc-messages";
|
||||||
import { additionalContextFromCodexInput, toAppServerUserInput } from "../protocol/request-input";
|
import { appServerTurnInputFromCodexInput, toAppServerUserInput } from "../protocol/request-input";
|
||||||
import type { AppServerRequestClient } from "./request-client";
|
import type { AppServerRequestClient } from "./request-client";
|
||||||
|
|
||||||
type AppServerTurnRuntimeOverrides = Partial<AppServerTurnRuntimeParams>;
|
type AppServerTurnRuntimeOverrides = Partial<AppServerTurnRuntimeParams>;
|
||||||
|
|
@ -32,14 +32,14 @@ export function startTurn(
|
||||||
options: AppServerStartTurnOptions,
|
options: AppServerStartTurnOptions,
|
||||||
): Promise<ClientResponseByMethod["turn/start"]> {
|
): Promise<ClientResponseByMethod["turn/start"]> {
|
||||||
const { threadId, cwd, input, clientUserMessageId, runtime } = options;
|
const { threadId, cwd, input, clientUserMessageId, runtime } = options;
|
||||||
const additionalContext = toAdditionalContext(input);
|
const prepared = toTurnInput(input, contextSubmissionId(clientUserMessageId, "user"));
|
||||||
const params: ClientRequestParams<"turn/start"> = {
|
const params: ClientRequestParams<"turn/start"> = {
|
||||||
threadId,
|
threadId,
|
||||||
cwd,
|
cwd,
|
||||||
...(clientUserMessageId !== undefined ? { clientUserMessageId } : {}),
|
...(clientUserMessageId !== undefined ? { clientUserMessageId } : {}),
|
||||||
...(additionalContext !== undefined ? { additionalContext } : {}),
|
...(prepared.additionalContext !== undefined ? { additionalContext: prepared.additionalContext } : {}),
|
||||||
...appServerTurnRuntimeParams(runtime),
|
...appServerTurnRuntimeParams(runtime),
|
||||||
input: toUserInput(input),
|
input: prepared.input,
|
||||||
};
|
};
|
||||||
return client.request("turn/start", params);
|
return client.request("turn/start", params);
|
||||||
}
|
}
|
||||||
|
|
@ -72,13 +72,13 @@ export function steerTurn(
|
||||||
input: string | CodexInput,
|
input: string | CodexInput,
|
||||||
clientUserMessageId?: string | null,
|
clientUserMessageId?: string | null,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
const additionalContext = toAdditionalContext(input);
|
const prepared = toTurnInput(input, contextSubmissionId(clientUserMessageId, "steer"));
|
||||||
return client.request("turn/steer", {
|
return client.request("turn/steer", {
|
||||||
threadId,
|
threadId,
|
||||||
expectedTurnId,
|
expectedTurnId,
|
||||||
input: toUserInput(input),
|
input: prepared.input,
|
||||||
...(clientUserMessageId !== undefined ? { clientUserMessageId } : {}),
|
...(clientUserMessageId !== undefined ? { clientUserMessageId } : {}),
|
||||||
...(additionalContext !== undefined ? { additionalContext } : {}),
|
...(prepared.additionalContext !== undefined ? { additionalContext: prepared.additionalContext } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,14 +86,9 @@ export function interruptTurn(client: AppServerRequestClient, threadId: string,
|
||||||
return client.request("turn/interrupt", { threadId, turnId });
|
return client.request("turn/interrupt", { threadId, turnId });
|
||||||
}
|
}
|
||||||
|
|
||||||
function toUserInput(input: string | CodexInput): ClientRequestParams<"turn/start">["input"] {
|
function toTurnInput(input: string | CodexInput, submissionId: string) {
|
||||||
if (typeof input !== "string") return toAppServerUserInput(input);
|
if (typeof input !== "string") return appServerTurnInputFromCodexInput(input, submissionId);
|
||||||
return toAppServerUserInput([{ type: "text", text: input }]);
|
return { input: 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 {
|
function appServerTurnRuntimeParams(runtime: AppServerTurnRuntimeOverrides | undefined): AppServerTurnRuntimeParams {
|
||||||
|
|
@ -105,3 +100,11 @@ function appServerTurnRuntimeParams(runtime: AppServerTurnRuntimeOverrides | und
|
||||||
if (runtime?.approvalsReviewer !== undefined) params.approvalsReviewer = runtime.approvalsReviewer;
|
if (runtime?.approvalsReviewer !== undefined) params.approvalsReviewer = runtime.approvalsReviewer;
|
||||||
return params;
|
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`;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,11 @@ interface ModelServiceTier {
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ReasoningEffortMetadata {
|
||||||
|
readonly reasoningEffort: string;
|
||||||
|
readonly description: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ModelMetadata {
|
export interface ModelMetadata {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly model: string;
|
readonly model: string;
|
||||||
|
|
@ -10,9 +15,9 @@ export interface ModelMetadata {
|
||||||
readonly description: string;
|
readonly description: string;
|
||||||
readonly hidden: boolean;
|
readonly hidden: boolean;
|
||||||
readonly supportedReasoningEfforts: readonly string[];
|
readonly supportedReasoningEfforts: readonly string[];
|
||||||
|
readonly reasoningEffortOptions?: readonly ReasoningEffortMetadata[];
|
||||||
readonly defaultReasoningEffort: string | null;
|
readonly defaultReasoningEffort: string | null;
|
||||||
readonly inputModalities: readonly string[];
|
readonly inputModalities: readonly string[];
|
||||||
readonly additionalSpeedTiers: readonly string[];
|
|
||||||
readonly serviceTiers: readonly ModelServiceTier[];
|
readonly serviceTiers: readonly ModelServiceTier[];
|
||||||
readonly defaultServiceTier: string | null;
|
readonly defaultServiceTier: string | null;
|
||||||
readonly isDefault: boolean;
|
readonly isDefault: boolean;
|
||||||
|
|
@ -54,6 +59,14 @@ export function supportedEffortsForModelMetadata(model: ModelMetadata | null): R
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function reasoningEffortDescriptionForModelMetadata(model: ModelMetadata | null, effort: ReasoningEffort): string | null {
|
||||||
|
const normalizedEffort = normalizeReasoningEffort(effort);
|
||||||
|
if (!normalizedEffort) return null;
|
||||||
|
const option = model?.reasoningEffortOptions?.find((item) => normalizeReasoningEffort(item.reasoningEffort) === normalizedEffort);
|
||||||
|
const description = option?.description.trim();
|
||||||
|
return description ? description : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function sortedModelMetadata(models: readonly ModelMetadata[]): ModelMetadata[] {
|
export function sortedModelMetadata(models: readonly ModelMetadata[]): ModelMetadata[] {
|
||||||
return [...models]
|
return [...models]
|
||||||
.filter((model) => !model.hidden)
|
.filter((model) => !model.hidden)
|
||||||
|
|
|
||||||
53
src/domain/chat/context-budget.ts
Normal file
53
src/domain/chat/context-budget.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,15 @@
|
||||||
export interface RequestMention {
|
export interface VaultFileReference {
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SkillReference {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ACTIVE_FILE_REFERENCE_NAME = "<active>";
|
||||||
|
|
||||||
export interface RequestAdditionalContext {
|
export interface RequestAdditionalContext {
|
||||||
key: string;
|
key: string;
|
||||||
value: string;
|
value: string;
|
||||||
|
|
@ -14,21 +21,30 @@ export type CodexInputItem =
|
||||||
| { type: "image"; url: string; detail?: UserInputImageDetail }
|
| { type: "image"; url: string; detail?: UserInputImageDetail }
|
||||||
| { type: "localImage"; path: string; detail?: UserInputImageDetail }
|
| { type: "localImage"; path: string; detail?: UserInputImageDetail }
|
||||||
| { type: "skill"; name: string; path: string }
|
| { type: "skill"; name: string; path: string }
|
||||||
| { type: "mention"; name: string; path: string }
|
| { type: "fileReference"; name: string; path: string }
|
||||||
| { type: "additionalContext"; key: string; value: string; kind: RequestAdditionalContext["kind"] };
|
| {
|
||||||
|
type: "additionalContext";
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
kind: RequestAdditionalContext["kind"];
|
||||||
|
};
|
||||||
|
|
||||||
export type CodexInput = CodexInputItem[];
|
export type CodexInput = CodexInputItem[];
|
||||||
type UserInputImageDetail = "auto" | "low" | "high" | "original";
|
type UserInputImageDetail = "auto" | "low" | "high" | "original";
|
||||||
|
|
||||||
export function codexTextInputWithMentions(
|
export function codexTextInput(text: string): CodexInput {
|
||||||
|
return [{ type: "text", text }];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function codexTextInputWithReferences(
|
||||||
text: string,
|
text: string,
|
||||||
mentions: readonly RequestMention[],
|
fileReferences: readonly VaultFileReference[],
|
||||||
skills: readonly RequestMention[] = [],
|
skills: readonly SkillReference[] = [],
|
||||||
additionalContext: readonly RequestAdditionalContext[] = [],
|
additionalContext: readonly RequestAdditionalContext[] = [],
|
||||||
): CodexInput {
|
): CodexInput {
|
||||||
return [
|
return [
|
||||||
...codexTextInput(text),
|
...codexTextInput(text),
|
||||||
...mentions.map((mention) => ({ type: "mention" as const, name: mention.name, path: mention.path })),
|
...fileReferences.map((reference) => ({ type: "fileReference" as const, name: reference.name, path: reference.path })),
|
||||||
...skills.map((skill) => ({ type: "skill" as const, name: skill.name, path: skill.path })),
|
...skills.map((skill) => ({ type: "skill" as const, name: skill.name, path: skill.path })),
|
||||||
...additionalContext.map((context) => ({
|
...additionalContext.map((context) => ({
|
||||||
type: "additionalContext" as const,
|
type: "additionalContext" as const,
|
||||||
|
|
@ -42,7 +58,3 @@ export function codexTextInputWithMentions(
|
||||||
export function codexTextInputWithAttachments(text: string, input: readonly CodexInputItem[]): CodexInput {
|
export function codexTextInputWithAttachments(text: string, input: readonly CodexInputItem[]): CodexInput {
|
||||||
return [...codexTextInput(text), ...input.filter((item) => item.type !== "text")];
|
return [...codexTextInput(text), ...input.filter((item) => item.type !== "text")];
|
||||||
}
|
}
|
||||||
|
|
||||||
function codexTextInput(text: string): CodexInput {
|
|
||||||
return [{ type: "text", text }];
|
|
||||||
}
|
|
||||||
|
|
|
||||||
10
src/domain/chat/submission-id.ts
Normal file
10
src/domain/chat/submission-id.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
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";
|
||||||
|
}
|
||||||
19
src/domain/markdown/code-ranges.ts
Normal file
19
src/domain/markdown/code-ranges.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
@ -11,24 +11,10 @@ interface PendingRequestQueueSource {
|
||||||
readonly pendingMcpElicitations: readonly unknown[];
|
readonly pendingMcpElicitations: readonly unknown[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PendingRequestCountSource {
|
|
||||||
readonly pendingApprovals: number;
|
|
||||||
readonly pendingUserInputs: number;
|
|
||||||
readonly pendingMcpElicitations: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pendingRequestCountsFromQueues(source: PendingRequestQueueSource): PendingRequestCounts {
|
export function pendingRequestCountsFromQueues(source: PendingRequestQueueSource): PendingRequestCounts {
|
||||||
return pendingRequestCounts({
|
const approvals = source.approvals.length;
|
||||||
pendingApprovals: source.approvals.length,
|
const userInputs = source.pendingUserInputs.length;
|
||||||
pendingUserInputs: source.pendingUserInputs.length,
|
const mcpElicitations = source.pendingMcpElicitations.length;
|
||||||
pendingMcpElicitations: source.pendingMcpElicitations.length,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pendingRequestCounts(source: PendingRequestCountSource): PendingRequestCounts {
|
|
||||||
const approvals = source.pendingApprovals;
|
|
||||||
const userInputs = source.pendingUserInputs;
|
|
||||||
const mcpElicitations = source.pendingMcpElicitations;
|
|
||||||
return {
|
return {
|
||||||
approvals,
|
approvals,
|
||||||
userInputs,
|
userInputs,
|
||||||
|
|
|
||||||
|
|
@ -89,15 +89,10 @@ interface PendingMcpElicitationFieldBase {
|
||||||
export type PendingMcpElicitationField =
|
export type PendingMcpElicitationField =
|
||||||
| (PendingMcpElicitationFieldBase & {
|
| (PendingMcpElicitationFieldBase & {
|
||||||
type: "string";
|
type: "string";
|
||||||
format: string | null;
|
|
||||||
minLength: number | null;
|
|
||||||
maxLength: number | null;
|
|
||||||
defaultValue: string;
|
defaultValue: string;
|
||||||
})
|
})
|
||||||
| (PendingMcpElicitationFieldBase & {
|
| (PendingMcpElicitationFieldBase & {
|
||||||
type: "number" | "integer";
|
type: "number" | "integer";
|
||||||
minimum: number | null;
|
|
||||||
maximum: number | null;
|
|
||||||
defaultValue: number | null;
|
defaultValue: number | null;
|
||||||
})
|
})
|
||||||
| (PendingMcpElicitationFieldBase & {
|
| (PendingMcpElicitationFieldBase & {
|
||||||
|
|
@ -112,8 +107,6 @@ export type PendingMcpElicitationField =
|
||||||
| (PendingMcpElicitationFieldBase & {
|
| (PendingMcpElicitationFieldBase & {
|
||||||
type: "multi-select";
|
type: "multi-select";
|
||||||
options: readonly PendingMcpElicitationOption[];
|
options: readonly PendingMcpElicitationOption[];
|
||||||
minItems: number | null;
|
|
||||||
maxItems: number | null;
|
|
||||||
defaultValue: readonly string[];
|
defaultValue: readonly string[];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue