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 +37 -0
- package/dist/approvals.d.ts +9 -1
- package/dist/approvals.js +10 -2
- package/dist/catalog.d.ts +13 -2
- package/dist/catalog.js +88 -52
- package/dist/file-watch.d.ts +35 -0
- package/dist/file-watch.js +163 -0
- package/dist/misc-routes.js +12 -2
- package/dist/pi-event-translation.js +44 -2
- package/dist/route-services.d.ts +8 -1
- package/dist/routes-v2.js +251 -33
- package/dist/server.d.ts +16 -0
- package/dist/server.js +182 -6
- package/dist/session-commands.d.ts +3 -1
- package/dist/session-commands.js +13 -7
- package/dist/session-history.js +3 -0
- package/dist/session-store.d.ts +7 -0
- package/dist/session-store.js +28 -0
- package/dist/session-tree.d.ts +15 -1
- package/dist/sessions.d.ts +85 -1
- package/dist/sessions.js +238 -10
- package/dist/text-generation.d.ts +20 -0
- package/dist/text-generation.js +93 -0
- package/dist/types.d.ts +60 -0
- package/dist/v2.d.ts +8 -1
- package/dist/v2.js +34 -16
- package/extensions/omo-compat/package.json +1 -1
- package/extensions/omo-dag/package.json +1 -1
- package/extensions/omo-work/package.json +1 -1
- package/package.json +1 -1
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)
|
package/dist/approvals.d.ts
CHANGED
|
@@ -47,7 +47,15 @@ export declare function parsePermissionTitle(title: string): {
|
|
|
47
47
|
permission: string;
|
|
48
48
|
patterns: string[];
|
|
49
49
|
};
|
|
50
|
-
|
|
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",
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
48
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/misc-routes.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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("
|
|
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;
|
package/dist/route-services.d.ts
CHANGED
|
@@ -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;
|