antechamber 1.0.39 → 1.0.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,43 @@ and this project uses [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.0.41] - 2026-11-09
11
+
12
+ ### Fixed
13
+
14
+ OpenChamber 2.6.1 against OpenCode 2.2.5 was captured and replayed against antechamber; these close the differences
15
+ that OpenChamber relies on.
16
+
17
+ - Permission cards on `/api/event` now use OpenCode 2.x's fields (`action`, `resources`, `save`, `source`), and
18
+ "Allow always" advertises the scope OmO saves for reads, edits, lists, globs, greps and eval. The pending-form list
19
+ only shows questions from the current project.
20
+ - Session lists honor `parentID=null` and `archived`, so message search indexes existing conversations. A metadata
21
+ update no longer sends a rename and an agent switch; renames, agent switches, archive and unarchive are announced
22
+ once, when they really happen, and a new session announces its model.
23
+ - OpenChamber's per-session guidance, checkout info, memory, pinned messages and response style now reach the agent:
24
+ instruction entries are stored and sent to OmO before the next prompt, removals are announced, and compaction
25
+ re-sends them. Unread state is kept (`/view` no longer fails), and the inbox lists inputs still waiting for the agent.
26
+ - A reloaded transcript keeps the turn boundaries and compaction records the live one had. `/compact` answers as soon
27
+ as the compaction starts instead of waiting for it, and slash commands keep their attachments and delivery mode.
28
+ - Rename with AI and other small-model actions work: `/api/experimental/generate/text` runs a one-off OmO turn with
29
+ no tools and no session. The image-model picker only lists image models, directory search finds folders, open
30
+ files reload when they change on disk, and project, provider, model, command and Git branch changes reach an open
31
+ OpenChamber without a reload. Nothing polls: refreshes ride on finished turns, client reads and a watch on Git's
32
+ HEAD. Usage limits and the debug location list answer like OpenCode instead of 404.
33
+ - Text generation picks its model at agent start, so Rename with AI never changes OmO's saved default model; it is
34
+ bounded by one deadline and two concurrent runs, and stops when the caller disconnects or the server stops.
35
+ - File watches are 24-hour leases that OpenChamber's renewals extend, so closed editors no longer leave watchers
36
+ behind; a reverted, forked or compacted conversation gets back the instruction entries it lost; and a turn still
37
+ running tools no longer shows as finished in a reloaded transcript.
38
+
39
+ ## [1.0.40] - 2026-11-06
40
+
41
+ ### Fixed
42
+
43
+ - Keep the conversation in order after a `!` shell command. The history refresh that follows a shell command sent
44
+ every earlier reply to OpenChamber again with the current time as its creation time, so OpenChamber moved those
45
+ replies after the prompts they answered and placed the shell card among them. Replies now keep their own times.
46
+
10
47
  ## [1.0.39] - 2026-11-04
11
48
 
12
49
  - fix(v2): give each task batch card its own tool id (#90)
@@ -47,7 +47,15 @@ export declare function parsePermissionTitle(title: string): {
47
47
  permission: string;
48
48
  patterns: string[];
49
49
  };
50
- export declare function toV2Permission(card: PermissionRequest): PermissionV2Request;
50
+ /** OpenCode 2.2.5 names the tool source `{type, messageID, id}` (packages/schema/src/permission.ts); the SDK types predate it. */
51
+ type V2PermissionCard = Omit<PermissionV2Request, "source"> & {
52
+ source?: {
53
+ type: "tool";
54
+ messageID: string;
55
+ id: string;
56
+ };
57
+ };
58
+ export declare function toV2Permission(card: PermissionRequest): V2PermissionCard;
51
59
  /** Session-scoped dialog lifecycle; RPC select replies are labels, never indices. */
52
60
  export declare class ApprovalBridge {
53
61
  private sessionID;
package/dist/approvals.js CHANGED
@@ -91,7 +91,7 @@ export function toV2Permission(card) {
91
91
  resources: card.patterns,
92
92
  save: card.always,
93
93
  metadata: card.metadata,
94
- ...(card.tool ? { source: { type: "tool", ...card.tool } } : {}),
94
+ ...(card.tool ? { source: { type: "tool", messageID: card.tool.messageID, id: card.tool.callID } } : {}),
95
95
  };
96
96
  }
97
97
  /** Session-scoped dialog lifecycle; RPC select replies are labels, never indices. */
@@ -339,7 +339,15 @@ export class ApprovalBridge {
339
339
  id: request.id,
340
340
  sessionID: context.sessionID,
341
341
  ...parsed,
342
- always: [],
342
+ // The UI request carries display patterns, not the native `always` array. Only advertise
343
+ // scopes whose native parser saves exactly these patterns (or a known blanket scope).
344
+ always: request.options.includes("Allow always")
345
+ ? ["read", "edit", "list", "glob"].includes(parsed.permission)
346
+ ? parsed.patterns
347
+ : ["grep", "eval"].includes(parsed.permission)
348
+ ? ["*"]
349
+ : []
350
+ : [],
343
351
  metadata: { title: request.title },
344
352
  ...(callID !== undefined ? { tool: { messageID: context.messageID, callID } } : {}),
345
353
  };
package/dist/catalog.d.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Model/provider catalog: obtained from a throwaway `pi --mode rpc` process
3
3
  * (`get_available_models` + `get_state`), translated into OpenCode v2
4
- * Provider/Model shapes, cached for the adapter lifetime.
4
+ * Provider/Model shapes, refreshed from live agents or bounded cold reads.
5
5
  */
6
+ import { PiRpcClient } from "./pi-rpc.js";
6
7
  interface OCv2Model {
7
8
  id: string;
8
9
  providerID: string;
@@ -84,5 +85,15 @@ export declare function agentDefaults(cat: Catalog): {
84
85
  };
85
86
  variant?: string;
86
87
  };
87
- export declare function loadCatalog(directory: string): Promise<Catalog>;
88
+ /**
89
+ * The configured default plus the provider/model inventory in a fixed order, so a reordering alone is not
90
+ * reported as a catalog change but a new default is.
91
+ */
92
+ export declare function catalogFingerprint(catalog: Catalog): string;
93
+ /**
94
+ * `configured` is the previous snapshot when `client` is a live session's agent: that agent's `get_state.model`
95
+ * is the session's own selection, not the configured default, so only the model inventory is re-read and the
96
+ * snapshot's default is kept. Only a fresh `--no-session` agent defines the default.
97
+ */
98
+ export declare function loadCatalog(directory: string, client?: PiRpcClient, configured?: Catalog): Promise<Catalog>;
88
99
  export {};
package/dist/catalog.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Model/provider catalog: obtained from a throwaway `pi --mode rpc` process
3
3
  * (`get_available_models` + `get_state`), translated into OpenCode v2
4
- * Provider/Model shapes, cached for the adapter lifetime.
4
+ * Provider/Model shapes, refreshed from live agents or bounded cold reads.
5
5
  */
6
6
  import { PiRpcClient } from "./pi-rpc.js";
7
7
  import { thinkingVariants } from "./thinking-levels.js";
@@ -44,24 +44,95 @@ function toOCModel(m) {
44
44
  ...(variants !== undefined ? { variants } : {}),
45
45
  };
46
46
  }
47
- export async function loadCatalog(directory) {
48
- const pi = new PiRpcClient({ cwd: directory, args: ["--no-session"] });
47
+ function inventory(res) {
48
+ const providers = new Map();
49
+ if (res.success && res.data) {
50
+ for (const m of res.data.models) {
51
+ let p = providers.get(m.provider);
52
+ if (!p) {
53
+ p = { id: m.provider, name: m.provider, source: "config", env: [], options: {}, models: {} };
54
+ providers.set(m.provider, p);
55
+ }
56
+ p.models[m.id] = toOCModel(m);
57
+ }
58
+ }
59
+ return providers;
60
+ }
61
+ /**
62
+ * Apply the default to the inventory: list it even if get_available_models omitted it (the client drops a
63
+ * selection it cannot find), and put it first, because OpenChamber's picker defaults to the first listed
64
+ * provider/model. `active` is a model from a fresh agent's get_state, or the previous snapshot's default.
65
+ */
66
+ function withDefault(providers, active, thinkingLevel) {
67
+ const defaults = {};
68
+ if (active !== null) {
69
+ defaults[active.providerID] = active.modelID;
70
+ let p = providers.get(active.providerID);
71
+ if (p === undefined) {
72
+ p = { id: active.providerID, name: active.providerID, source: "config", env: [], options: {}, models: {} };
73
+ providers.set(active.providerID, p);
74
+ }
75
+ if (p.models[active.modelID] === undefined)
76
+ p.models[active.modelID] = active.model;
77
+ }
78
+ const sorted = [...providers.values()].sort((a, b) => {
79
+ const aDef = defaults[a.id] !== undefined ? 0 : 1;
80
+ const bDef = defaults[b.id] !== undefined ? 0 : 1;
81
+ return aDef - bDef;
82
+ });
83
+ for (const p of sorted) {
84
+ const def = defaults[p.id];
85
+ if (!def)
86
+ continue;
87
+ const entries = Object.entries(p.models).sort(([a], [b]) => (a === def ? -1 : b === def ? 1 : 0));
88
+ p.models = Object.fromEntries(entries);
89
+ }
90
+ // exactOptionalPropertyTypes: omit `default` rather than set it undefined.
91
+ if (active === null)
92
+ return { providers: sorted, defaults };
93
+ const resolvedDefault = { providerID: active.providerID, modelID: active.modelID };
94
+ // Only a level the catalog offers as a variant is selectable in the picker.
95
+ const offered = sorted.find((p) => p.id === active.providerID)?.models[active.modelID]?.variants;
96
+ return thinkingLevel !== undefined && offered?.[thinkingLevel] !== undefined
97
+ ? { providers: sorted, defaults, default: resolvedDefault, defaultThinkingLevel: thinkingLevel }
98
+ : { providers: sorted, defaults, default: resolvedDefault };
99
+ }
100
+ /**
101
+ * The configured default plus the provider/model inventory in a fixed order, so a reordering alone is not
102
+ * reported as a catalog change but a new default is.
103
+ */
104
+ export function catalogFingerprint(catalog) {
105
+ return JSON.stringify([
106
+ catalog.default ?? null,
107
+ catalog.defaultThinkingLevel ?? null,
108
+ [...catalog.providers]
109
+ .sort((a, b) => a.id.localeCompare(b.id))
110
+ .map((p) => [
111
+ p.id,
112
+ Object.keys(p.models)
113
+ .sort()
114
+ .map((id) => [id, p.models[id]]),
115
+ ]),
116
+ ]);
117
+ }
118
+ /**
119
+ * `configured` is the previous snapshot when `client` is a live session's agent: that agent's `get_state.model`
120
+ * is the session's own selection, not the configured default, so only the model inventory is re-read and the
121
+ * snapshot's default is kept. Only a fresh `--no-session` agent defines the default.
122
+ */
123
+ export async function loadCatalog(directory, client, configured) {
124
+ const pi = client ?? new PiRpcClient({ cwd: directory, args: ["--no-session"] });
49
125
  try {
126
+ if (configured !== undefined) {
127
+ const modelsRes = await pi.command({ type: "get_available_models" });
128
+ const def = configured.default;
129
+ const model = def && configured.providers.find((p) => p.id === def.providerID)?.models[def.modelID];
130
+ return withDefault(inventory(modelsRes), def && model ? { ...def, model } : null, configured.defaultThinkingLevel);
131
+ }
50
132
  const [modelsRes, firstState] = await Promise.all([
51
133
  pi.command({ type: "get_available_models" }),
52
134
  pi.command({ type: "get_state" }),
53
135
  ]);
54
- const providers = new Map();
55
- if (modelsRes.success && modelsRes.data) {
56
- for (const m of modelsRes.data.models) {
57
- let p = providers.get(m.provider);
58
- if (!p) {
59
- p = { id: m.provider, name: m.provider, source: "config", env: [], options: {}, models: {} };
60
- providers.set(m.provider, p);
61
- }
62
- p.models[m.id] = toOCModel(m);
63
- }
64
- }
65
136
  // get_state occasionally races pi's own startup. One retry beats resolving
66
137
  // no active model, which is precisely what leaves the UI picker guessing.
67
138
  let active = firstState.success ? (firstState.data?.model ?? null) : null;
@@ -74,45 +145,10 @@ export async function loadCatalog(directory) {
74
145
  if (active === null) {
75
146
  console.error("[catalog] warning: pi reported no active model — /api/model/default will be null and the UI picker falls back to its own default");
76
147
  }
77
- const defaults = {};
78
- let resolvedDefault;
79
- if (active !== null) {
80
- defaults[active.provider] = active.id;
81
- resolvedDefault = { providerID: active.provider, modelID: active.id };
82
- // The client drops any selection it cannot find in this catalog and picks
83
- // something else instead, so a model get_available_models omitted still
84
- // has to be listed here — otherwise the active model is unselectable.
85
- let p = providers.get(active.provider);
86
- if (p === undefined) {
87
- p = { id: active.provider, name: active.provider, source: "config", env: [], options: {}, models: {} };
88
- providers.set(active.provider, p);
89
- }
90
- if (p.models[active.id] === undefined)
91
- p.models[active.id] = toOCModel(active);
92
- }
93
- // OpenChamber's picker defaults to the first listed provider/model — put pi's active config there.
94
- const sorted = [...providers.values()].sort((a, b) => {
95
- const aDef = defaults[a.id] !== undefined ? 0 : 1;
96
- const bDef = defaults[b.id] !== undefined ? 0 : 1;
97
- return aDef - bDef;
98
- });
99
- for (const p of sorted) {
100
- const def = defaults[p.id];
101
- if (!def)
102
- continue;
103
- const entries = Object.entries(p.models).sort(([a], [b]) => (a === def ? -1 : b === def ? 1 : 0));
104
- p.models = Object.fromEntries(entries);
105
- }
106
- // exactOptionalPropertyTypes: omit `default` rather than set it undefined.
107
- if (resolvedDefault === undefined)
108
- return { providers: sorted, defaults };
109
- // Only a level the catalog offers as a variant is selectable in the picker.
110
- const offered = sorted.find((p) => p.id === resolvedDefault.providerID)?.models[resolvedDefault.modelID]?.variants;
111
- return thinkingLevel !== undefined && offered?.[thinkingLevel] !== undefined
112
- ? { providers: sorted, defaults, default: resolvedDefault, defaultThinkingLevel: thinkingLevel }
113
- : { providers: sorted, defaults, default: resolvedDefault };
148
+ return withDefault(inventory(modelsRes), active === null ? null : { providerID: active.provider, modelID: active.id, model: toOCModel(active) }, thinkingLevel);
114
149
  }
115
150
  finally {
116
- await pi.dispose();
151
+ if (client === undefined)
152
+ await pi.dispose();
117
153
  }
118
154
  }
@@ -0,0 +1,35 @@
1
+ import type { OCEvent } from "./types.js";
2
+ export declare class FileWatchError extends Error {
3
+ readonly status: number;
4
+ readonly kind: string;
5
+ constructor(status: number, kind: string, message: string);
6
+ }
7
+ export declare class FileWatchRegistry {
8
+ private emit;
9
+ private watches;
10
+ constructor(emit: (directory: string, event: OCEvent) => void);
11
+ file(directory: string, path: string, required: boolean): Promise<string>;
12
+ watch(directory: string, file: string, reportedFile: string): void;
13
+ /**
14
+ * Watch the file itself, not its directory: on macOS a directory watch goes through FSEvents, which starts
15
+ * asynchronously and under load missed a write made right after the 204, and missed a delete-and-recreate for
16
+ * seconds (reproduced: every run failed with eight test processes in parallel). A file watch (kqueue there,
17
+ * inotify on Linux) starts within a tick. An editor's atomic save replaces the inode and ends the watch with
18
+ * "rename"; the replacement is watched. A removed file keeps its lease, as OpenCode's does: a stat watch looks for
19
+ * it until it exists again, then the native watch resumes.
20
+ */
21
+ private armFile;
22
+ /**
23
+ * Check for the missing file on a timer. Not fs.watchFile: it takes its baseline stat asynchronously and never
24
+ * reports a file that came back before that stat finished.
25
+ */
26
+ private awaitFile;
27
+ /**
28
+ * Runs inside a watch listener, where a throw would end the process: fs.watch throws synchronously for a
29
+ * replacement it cannot open (EACCES, a file gone again, ENOSPC/EMFILE). Such a lease is released instead.
30
+ */
31
+ private rearm;
32
+ private release;
33
+ unwatch(directory: string, file: string): void;
34
+ close(): void;
35
+ }
@@ -0,0 +1,163 @@
1
+ import { existsSync, watch } from "node:fs";
2
+ import { realpath, stat } from "node:fs/promises";
3
+ import { isAbsolute, relative, resolve, sep } from "node:path";
4
+ export class FileWatchError extends Error {
5
+ status;
6
+ kind;
7
+ constructor(status, kind, message) {
8
+ super(message);
9
+ this.status = status;
10
+ this.kind = kind;
11
+ }
12
+ }
13
+ const LEASE_MS = 24 * 60 * 60 * 1000;
14
+ const MAX_PER_DIRECTORY = 256;
15
+ /** How often a missing watched file is checked for (existsSync, nothing spawned). */
16
+ const MISSING_POLL_MS = 1000;
17
+ export class FileWatchRegistry {
18
+ emit;
19
+ watches = new Map();
20
+ constructor(emit) {
21
+ this.emit = emit;
22
+ }
23
+ async file(directory, path, required) {
24
+ const root = await realpath(directory);
25
+ const target = resolve(root, path);
26
+ const inside = relative(root, target);
27
+ if (inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside))
28
+ throw new FileWatchError(400, "BadRequestError", "Path must remain inside the selected directory");
29
+ // Removal uses the registered path even when the file or symlink no longer exists.
30
+ if (!required)
31
+ return resolve(directory, path);
32
+ try {
33
+ const canonical = await realpath(target);
34
+ const rel = relative(root, canonical);
35
+ if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
36
+ throw new FileWatchError(400, "BadRequestError", "Path must remain inside the selected directory");
37
+ if (!(await stat(canonical)).isFile())
38
+ throw new FileWatchError(404, "FileNotFoundError", `File not found: ${path}`);
39
+ return canonical;
40
+ }
41
+ catch (error) {
42
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
43
+ throw new FileWatchError(404, "FileNotFoundError", `File not found: ${path}`);
44
+ throw error;
45
+ }
46
+ }
47
+ watch(directory, file, reportedFile) {
48
+ const files = this.watches.get(directory) ?? new Map();
49
+ const existing = files.get(reportedFile);
50
+ if (existing !== undefined) {
51
+ existing.expires = Date.now() + LEASE_MS;
52
+ clearTimeout(existing.timer);
53
+ existing.timer = setTimeout(() => this.unwatch(directory, reportedFile), LEASE_MS);
54
+ existing.timer.unref();
55
+ return;
56
+ }
57
+ if (files.size >= MAX_PER_DIRECTORY) {
58
+ const [soonest] = [...files].reduce((a, b) => (b[1].expires < a[1].expires ? b : a));
59
+ this.unwatch(directory, soonest);
60
+ }
61
+ const timer = setTimeout(() => this.unwatch(directory, reportedFile), LEASE_MS);
62
+ timer.unref();
63
+ const lease = { watcher: null, missing: null, expires: Date.now() + LEASE_MS, timer };
64
+ files.set(reportedFile, lease);
65
+ this.watches.set(directory, files);
66
+ lease.watcher = this.armFile(directory, file, reportedFile);
67
+ }
68
+ /**
69
+ * Watch the file itself, not its directory: on macOS a directory watch goes through FSEvents, which starts
70
+ * asynchronously and under load missed a write made right after the 204, and missed a delete-and-recreate for
71
+ * seconds (reproduced: every run failed with eight test processes in parallel). A file watch (kqueue there,
72
+ * inotify on Linux) starts within a tick. An editor's atomic save replaces the inode and ends the watch with
73
+ * "rename"; the replacement is watched. A removed file keeps its lease, as OpenCode's does: a stat watch looks for
74
+ * it until it exists again, then the native watch resumes.
75
+ */
76
+ armFile(directory, file, reportedFile) {
77
+ const watcher = watch(file, (event) => {
78
+ const exists = existsSync(file);
79
+ this.emit(directory, {
80
+ type: "filesystem.changed",
81
+ properties: { file: reportedFile, event: exists ? "change" : "unlink" },
82
+ });
83
+ const lease = this.watches.get(directory)?.get(reportedFile);
84
+ // A rename replaced the inode; an absent file ended the watch, whatever the event (macOS reports a write and
85
+ // a delete arriving together as one "change").
86
+ if (lease?.watcher !== watcher || (exists && event !== "rename"))
87
+ return;
88
+ watcher.close();
89
+ lease.watcher = null;
90
+ if (exists)
91
+ this.rearm(directory, file, reportedFile, lease);
92
+ else
93
+ this.awaitFile(directory, file, reportedFile, lease);
94
+ });
95
+ watcher.on("error", () => {
96
+ if (this.watches.get(directory)?.get(reportedFile)?.watcher === watcher)
97
+ this.unwatch(directory, reportedFile);
98
+ else
99
+ watcher.close();
100
+ });
101
+ return watcher;
102
+ }
103
+ /**
104
+ * Check for the missing file on a timer. Not fs.watchFile: it takes its baseline stat asynchronously and never
105
+ * reports a file that came back before that stat finished.
106
+ */
107
+ awaitFile(directory, file, reportedFile, lease) {
108
+ const check = () => {
109
+ if (!existsSync(file) || this.watches.get(directory)?.get(reportedFile) !== lease)
110
+ return;
111
+ if (lease.missing !== null)
112
+ clearInterval(lease.missing);
113
+ lease.missing = null;
114
+ this.emit(directory, { type: "filesystem.changed", properties: { file: reportedFile, event: "change" } });
115
+ this.rearm(directory, file, reportedFile, lease);
116
+ };
117
+ lease.missing = setInterval(check, MISSING_POLL_MS);
118
+ lease.missing.unref();
119
+ check();
120
+ }
121
+ /**
122
+ * Runs inside a watch listener, where a throw would end the process: fs.watch throws synchronously for a
123
+ * replacement it cannot open (EACCES, a file gone again, ENOSPC/EMFILE). Such a lease is released instead.
124
+ */
125
+ rearm(directory, file, reportedFile, lease) {
126
+ try {
127
+ lease.watcher = this.armFile(directory, file, reportedFile);
128
+ }
129
+ catch (error) {
130
+ // Gone again between the check and the watch: keep the lease and wait for it once more.
131
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
132
+ this.awaitFile(directory, file, reportedFile, lease);
133
+ return;
134
+ }
135
+ console.error(`[file-watch] stopped watching ${reportedFile}:`, error);
136
+ this.unwatch(directory, reportedFile);
137
+ }
138
+ }
139
+ release(lease) {
140
+ clearTimeout(lease.timer);
141
+ lease.watcher?.close();
142
+ if (lease.missing !== null)
143
+ clearInterval(lease.missing);
144
+ }
145
+ unwatch(directory, file) {
146
+ const files = this.watches.get(directory);
147
+ const entry = files?.get(file);
148
+ if (entry === undefined)
149
+ return;
150
+ this.release(entry);
151
+ files?.delete(file);
152
+ if (files?.size === 0)
153
+ this.watches.delete(directory);
154
+ }
155
+ close() {
156
+ for (const files of this.watches.values()) {
157
+ for (const entry of files.values()) {
158
+ this.release(entry);
159
+ }
160
+ }
161
+ this.watches.clear();
162
+ }
163
+ }
@@ -93,6 +93,7 @@ export async function miscRoute(method, path, url, directory) {
93
93
  const dirs = url.searchParams.get("dirs") !== "false" && url.searchParams.get("type") !== "file";
94
94
  const onlyDirs = url.searchParams.get("type") === "directory";
95
95
  const files = [];
96
+ const matchedDirs = new Set();
96
97
  let visited = 0;
97
98
  const lowerQuery = query.toLowerCase();
98
99
  async function visit(directory, depth) {
@@ -107,12 +108,21 @@ export async function miscRoute(method, path, url, directory) {
107
108
  continue;
108
109
  const absolute = resolve(directory, entry.name);
109
110
  const path = relative(root, absolute).split(sep).join("/");
111
+ if (onlyDirs && entry.isFile() && path.toLowerCase().includes(lowerQuery)) {
112
+ matchedDirs.add("/");
113
+ const segments = path.split("/");
114
+ for (let i = 1; i < segments.length; i++)
115
+ matchedDirs.add(`${segments.slice(0, i).join("/")}/`);
116
+ }
110
117
  if (((entry.isFile() && !onlyDirs) || (entry.isDirectory() && dirs)) && path.toLowerCase().includes(lowerQuery))
111
- files.push(path);
118
+ if (onlyDirs)
119
+ matchedDirs.add(`${path}/`);
120
+ else
121
+ files.push(path);
112
122
  if (entry.isDirectory() && entry.name !== "node_modules")
113
123
  await visit(absolute, depth + 1);
114
124
  }
115
125
  }
116
126
  await visit(root, 0);
117
- return { status: 200, body: files.sort() };
127
+ return { status: 200, body: onlyDirs ? [...matchedDirs].sort().slice(0, limit) : files.sort() };
118
128
  }
@@ -202,6 +202,24 @@ export class PiEventTranslator {
202
202
  }
203
203
  delete s.compactingFromIdle;
204
204
  delete s.info.time.compacting;
205
+ // A compaction the run never finished must not stay "running": a later Compact would join it and report
206
+ // success for work that will never happen.
207
+ if (s.compactionID !== undefined) {
208
+ const inputID = s.compactionID;
209
+ const reason = s.compactionReason ?? "manual";
210
+ delete s.compactionID;
211
+ delete s.compactionReason;
212
+ delete s.heldCompactionEnd;
213
+ this.services.emit(s.info.directory, {
214
+ type: "session.compaction.failed",
215
+ properties: {
216
+ sessionID: s.info.id,
217
+ inputID,
218
+ reason,
219
+ error: { name: "CompactionError", data: { message: error.data.message } },
220
+ },
221
+ });
222
+ }
205
223
  }
206
224
  setStatus(s, status) {
207
225
  s.status = status;
@@ -211,6 +229,14 @@ export class PiEventTranslator {
211
229
  });
212
230
  if (status.type === "idle") {
213
231
  s.info.time.idle = Date.now();
232
+ // Restored as time.idle after a restart, so /view can still accept the watermark a client saw.
233
+ s.tree.lastIdle = s.info.time.idle;
234
+ try {
235
+ this.services.persistMeta(s);
236
+ }
237
+ catch (error) {
238
+ console.error(`[session] could not save the idle time of ${s.info.id}:`, error);
239
+ }
214
240
  const last = s.messages?.findLast((message) => message.info.role === "assistant" && !message.info.id.startsWith("msg_background_"));
215
241
  s.info.outcome =
216
242
  s.interruptRequested || (last?.info.role === "assistant" && last.info.error?.name === "MessageAbortedError")
@@ -323,6 +349,7 @@ export class PiEventTranslator {
323
349
  this.onMessageEvent(s, ev);
324
350
  break;
325
351
  case "compaction_start":
352
+ case "compaction_progress":
326
353
  case "compaction_end":
327
354
  this.onCompactionEvent(s, ev);
328
355
  break;
@@ -641,7 +668,8 @@ export class PiEventTranslator {
641
668
  }
642
669
  // An auto-compaction can fall between steps; its summary is a new step.
643
670
  this.completeAwaitingTools(s);
644
- s.compactionID = id("compaction");
671
+ s.compactionID ??= id("msg");
672
+ s.compactionReason = typeof ev.reason === "string" ? ev.reason : "manual";
645
673
  this.services.emit(s.info.directory, {
646
674
  type: "session.compaction.started",
647
675
  properties: {
@@ -658,6 +686,15 @@ export class PiEventTranslator {
658
686
  this.setStatus(s, { type: "busy" });
659
687
  }
660
688
  return;
689
+ case "compaction_progress":
690
+ // Extension compaction feedback carries actual partial summary fragments
691
+ // (senpi/dist/core/agent-session.js _updateExtensionCompactionFeedback).
692
+ if (s.compactionID !== undefined && typeof ev.delta === "string" && ev.delta !== "")
693
+ this.services.emit(s.info.directory, {
694
+ type: "session.compaction.delta",
695
+ properties: { sessionID: s.info.id, text: ev.delta },
696
+ });
697
+ return;
661
698
  case "compaction_end": {
662
699
  const retry = s.compactRetry;
663
700
  if (ev.aborted === true && retry !== undefined && retry.left > 0 && (s.abortRequests ?? 0) === retry.aborts) {
@@ -666,8 +703,9 @@ export class PiEventTranslator {
666
703
  }
667
704
  delete s.info.time.compacting;
668
705
  const result = ev.result;
669
- const inputID = s.compactionID ?? id("compaction");
706
+ const inputID = s.compactionID ?? id("msg");
670
707
  delete s.compactionID;
708
+ delete s.compactionReason;
671
709
  this.services.emit(s.info.directory, ev.aborted === true || typeof result?.summary !== "string"
672
710
  ? {
673
711
  type: "session.compaction.failed",
@@ -702,6 +740,10 @@ export class PiEventTranslator {
702
740
  key = entry.id;
703
741
  }
704
742
  }
743
+ if (key !== undefined) {
744
+ s.tree.messageEntries[inputID] ??= key;
745
+ (s.tree.compactionReasons ??= {})[key] = ev.reason === "manual" ? "manual" : "auto";
746
+ }
705
747
  for (const message of compactionMessages(s, key ?? id("compaction"), result.summary, Date.now(), s.model, s.thinkingLevel)) {
706
748
  if (messages.some((item) => item.info.id === message.info.id))
707
749
  continue;
@@ -6,14 +6,21 @@ import type { ModelSelection } from "./thinking-levels.js";
6
6
  import type { OCEvent, OCMessageWithParts, OCPromptPart } from "./types.js";
7
7
  import type { TodoUpdated } from "./todo-bridge.js";
8
8
  import type { SelfUpdateResult } from "./self-update.js";
9
+ import type { FileWatchRegistry } from "./file-watch.js";
10
+ import type { getCommands } from "./session-commands.js";
11
+ import type { TextGeneration } from "./text-generation.js";
9
12
  export interface RouteServices {
10
13
  readonly sessions: SessionManager;
11
14
  readonly defaultDirectory: string;
12
15
  readonly url: string;
13
16
  readonly body: (req: IncomingMessage) => Promise<unknown>;
14
- readonly catalog: () => Promise<Catalog>;
17
+ readonly catalog: (directory?: string, refresh?: boolean) => Promise<Catalog>;
15
18
  readonly broadcast: (directory: string, event: OCEvent | TodoUpdated) => void;
16
19
  readonly selfUpdate: () => Promise<SelfUpdateResult>;
20
+ readonly fileWatches: FileWatchRegistry;
21
+ readonly commands: (directory: string) => ReturnType<typeof getCommands>;
22
+ readonly vcsBranch: (directory: string) => Promise<string | undefined>;
23
+ readonly textGeneration: TextGeneration;
17
24
  }
18
25
  export declare const OPENCODE_COMPAT_VERSION: string | null;
19
26
  export declare function numberParam(value: string | null): number | undefined;