@cjhyy/code-shell-core 0.9.2 → 0.9.3
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/dist/cli/agent-server-stdio.js +26 -1
- package/dist/cli/agent-server-tcp.js +58 -7
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/onboarding.js +72 -49
- package/dist/protocol/background-result-wakeup.d.ts +3 -1
- package/dist/protocol/background-result-wakeup.js +5 -5
- package/dist/protocol/server.d.ts +13 -0
- package/dist/protocol/server.js +22 -3
- package/dist/services/index.d.ts +0 -1
- package/dist/services/index.js +0 -1
- package/dist/session/memory.js +2 -2
- package/dist/tool-system/builtin/agent-notifications.d.ts +25 -1
- package/dist/tool-system/builtin/agent-notifications.js +334 -2
- package/package.json +1 -1
- package/dist/services/notifier.d.ts +0 -33
- package/dist/services/notifier.js +0 -83
|
@@ -30,10 +30,11 @@
|
|
|
30
30
|
* bootstrap is invisible to them.
|
|
31
31
|
*/
|
|
32
32
|
import { join } from "node:path";
|
|
33
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
33
34
|
import { Engine } from "../engine/engine.js";
|
|
34
35
|
import { EngineRuntime } from "../engine/runtime.js";
|
|
35
36
|
import { ChatSessionManager } from "../protocol/chat-session-manager.js";
|
|
36
|
-
import { SessionManager } from "../session/session-manager.js";
|
|
37
|
+
import { assertSafeSessionId, SessionManager, sessionsRoot } from "../session/session-manager.js";
|
|
37
38
|
import { validateSettings } from "../settings/schema.js";
|
|
38
39
|
import { AgentServer } from "../protocol/server.js";
|
|
39
40
|
import { StdioTransport } from "../protocol/transport.js";
|
|
@@ -122,6 +123,29 @@ const composition = compileComposition({ modules: await loadConfiguredAgentModul
|
|
|
122
123
|
// byte-for-byte.
|
|
123
124
|
const dataRoot = process.env.CODE_SHELL_DATA_ROOT?.trim() || undefined;
|
|
124
125
|
const dataSessionsDir = dataRoot ? join(dataRoot, "sessions") : undefined;
|
|
126
|
+
const notificationSessionsDir = dataSessionsDir ?? sessionsRoot();
|
|
127
|
+
const notificationPersistence = {
|
|
128
|
+
fileForSession(sessionId) {
|
|
129
|
+
try {
|
|
130
|
+
assertSafeSessionId(sessionId);
|
|
131
|
+
return join(notificationSessionsDir, sessionId, "pending-notifications.json");
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
listSessionIds() {
|
|
138
|
+
try {
|
|
139
|
+
return readdirSync(notificationSessionsDir, { withFileTypes: true })
|
|
140
|
+
.filter((entry) => entry.isDirectory() &&
|
|
141
|
+
existsSync(join(notificationSessionsDir, entry.name, "pending-notifications.json")))
|
|
142
|
+
.map((entry) => entry.name);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
};
|
|
125
149
|
// Load settings once to derive llm config for the seed engine.
|
|
126
150
|
// Desktop is a host application: read the full disk hierarchy (incl. the
|
|
127
151
|
// user's ~/.code-shell). The SDK default 'project' would skip user config.
|
|
@@ -364,6 +388,7 @@ const agentServer = new AgentServer({
|
|
|
364
388
|
// Cold background-wakeup rehydrate must read the same sessions store the
|
|
365
389
|
// engines write when the data root is relocated (undefined → default root).
|
|
366
390
|
sessionDiskRoot: dataSessionsDir,
|
|
391
|
+
notificationPersistence,
|
|
367
392
|
// Config hot-reload (layer 2) reads disk through the SAME closure the
|
|
368
393
|
// engineFactory uses for new sessions, so a reloaded running session and a
|
|
369
394
|
// newly-created session converge on identical disk config (no divergence).
|
|
@@ -34,11 +34,39 @@ import { startAutomation } from "../automation/index.js";
|
|
|
34
34
|
import { CronStore, defaultCronStorePath } from "../automation/store.js";
|
|
35
35
|
import { resolveLLMConfigForTag } from "../engine/resolve-llm-config.js";
|
|
36
36
|
import { randomUUID } from "node:crypto";
|
|
37
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
38
|
+
import { join } from "node:path";
|
|
37
39
|
import { getApprovalRouter } from "../tool-system/permission.js";
|
|
38
|
-
import { SessionManager } from "../session/session-manager.js";
|
|
40
|
+
import { assertSafeSessionId, SessionManager, sessionsRoot } from "../session/session-manager.js";
|
|
41
|
+
import { notificationQueue } from "../tool-system/builtin/agent-notifications.js";
|
|
39
42
|
const cwd = process.env.AGENT_CWD ?? process.cwd();
|
|
40
43
|
const port = Number(process.env.AGENT_TCP_PORT ?? "4321");
|
|
41
44
|
const host = process.env.AGENT_TCP_HOST ?? "127.0.0.1";
|
|
45
|
+
const dataRoot = process.env.CODE_SHELL_DATA_ROOT?.trim() || undefined;
|
|
46
|
+
const dataSessionsDir = dataRoot ? join(dataRoot, "sessions") : undefined;
|
|
47
|
+
const notificationSessionsDir = dataSessionsDir ?? sessionsRoot();
|
|
48
|
+
notificationQueue.attachPersistence({
|
|
49
|
+
fileForSession(sessionId) {
|
|
50
|
+
try {
|
|
51
|
+
assertSafeSessionId(sessionId);
|
|
52
|
+
return join(notificationSessionsDir, sessionId, "pending-notifications.json");
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
listSessionIds() {
|
|
59
|
+
try {
|
|
60
|
+
return readdirSync(notificationSessionsDir, { withFileTypes: true })
|
|
61
|
+
.filter((entry) => entry.isDirectory() &&
|
|
62
|
+
existsSync(join(notificationSessionsDir, entry.name, "pending-notifications.json")))
|
|
63
|
+
.map((entry) => entry.name);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
});
|
|
42
70
|
const settingsManager = new SettingsManager(cwd, "full");
|
|
43
71
|
// Read once at startup and reuse for every session (see engineFactory below).
|
|
44
72
|
// This is intentional for the TCP host: it's a headless long-running server,
|
|
@@ -53,7 +81,12 @@ if (!seedLlm) {
|
|
|
53
81
|
}
|
|
54
82
|
const llmConfig = seedLlm;
|
|
55
83
|
// ── Shared runtime (same bootstrap as stdio) ─────────────────────
|
|
56
|
-
const seedEngine = new Engine({
|
|
84
|
+
const seedEngine = new Engine({
|
|
85
|
+
llm: llmConfig,
|
|
86
|
+
cwd,
|
|
87
|
+
settingsScope: "full",
|
|
88
|
+
sessionStorageDir: dataSessionsDir,
|
|
89
|
+
});
|
|
57
90
|
const modelPool = seedEngine.getModelPool();
|
|
58
91
|
const toolRegistry = seedEngine.getRuntimeToolRegistry();
|
|
59
92
|
const resolvedLlmConfig = seedEngine.getConfig().llm;
|
|
@@ -92,11 +125,13 @@ const chatManager = new ChatSessionManager({
|
|
|
92
125
|
...personalizationFrom(settings.agent),
|
|
93
126
|
maxTurns: slice.maxTurns,
|
|
94
127
|
maxContextTokens: slice.maxContextTokens,
|
|
128
|
+
sessionStorageDir: slice.sessionStorageDir ?? dataSessionsDir,
|
|
95
129
|
...(slice.cwd ? { cwd: slice.cwd } : {}),
|
|
96
130
|
});
|
|
97
131
|
},
|
|
98
132
|
maxSessions: 16,
|
|
99
133
|
idleTtlMs: 30 * 60 * 1000,
|
|
134
|
+
...(dataRoot ? { dataRoot } : {}),
|
|
100
135
|
});
|
|
101
136
|
chatManager.startIdleSweeper();
|
|
102
137
|
// ── Automation (same module the desktop loads) ──────────────────
|
|
@@ -109,23 +144,38 @@ const automationRunManager = createRunManager({
|
|
|
109
144
|
approvalBackend: new HeadlessApprovalBackend("approve-read-only"),
|
|
110
145
|
});
|
|
111
146
|
const automation = startAutomation({
|
|
112
|
-
store: new CronStore(defaultCronStorePath()),
|
|
147
|
+
store: new CronStore(defaultCronStorePath(dataRoot)),
|
|
113
148
|
runManager: automationRunManager,
|
|
114
149
|
});
|
|
115
150
|
// ── Serve over TCP ──────────────────────────────────────────────
|
|
116
151
|
// One AgentServer per accepted connection, all sharing the same chatManager.
|
|
117
152
|
const servers = new Set();
|
|
118
|
-
const goalDiskManager = new SessionManager();
|
|
119
|
-
|
|
120
|
-
|
|
153
|
+
const goalDiskManager = new SessionManager(dataSessionsDir);
|
|
154
|
+
function createTcpAgentServer(transport, connectionId, ownsBackgroundWakeups) {
|
|
155
|
+
return new AgentServer({
|
|
121
156
|
chatManager,
|
|
122
157
|
transport,
|
|
123
|
-
connectionId
|
|
158
|
+
connectionId,
|
|
124
159
|
approvalRouter: getApprovalRouter(),
|
|
160
|
+
sessionDiskRoot: dataSessionsDir,
|
|
161
|
+
ownsBackgroundWakeups,
|
|
125
162
|
readActiveGoalFromDisk: (sessionId) => goalDiskManager.readActiveGoal(sessionId),
|
|
126
163
|
updateActiveGoalOnDisk: (sessionId, patch) => goalDiskManager.updateActiveGoal(sessionId, patch)?.goal,
|
|
127
164
|
clearActiveGoalOnDisk: (sessionId, expected) => goalDiskManager.clearActiveGoal(sessionId, expected),
|
|
128
165
|
});
|
|
166
|
+
}
|
|
167
|
+
// Own restoration for the whole TCP process, not for the first client. This
|
|
168
|
+
// wakes pending chats immediately after a headless restart even if nobody has
|
|
169
|
+
// connected a UI yet. Per-connection servers still forward live observations
|
|
170
|
+
// but do not race this owner for mailbox consumption.
|
|
171
|
+
const backgroundWakeTransport = {
|
|
172
|
+
send() { },
|
|
173
|
+
onMessage() { },
|
|
174
|
+
close() { },
|
|
175
|
+
};
|
|
176
|
+
const backgroundWakeServer = createTcpAgentServer(backgroundWakeTransport, "tcp-background-wakeup", true);
|
|
177
|
+
listenTcp({ port, host }, (transport, socket) => {
|
|
178
|
+
const server = createTcpAgentServer(transport, randomUUID(), false);
|
|
129
179
|
servers.add(server);
|
|
130
180
|
socket.once("close", () => {
|
|
131
181
|
server.disconnect();
|
|
@@ -138,6 +188,7 @@ listenTcp({ port, host }, (transport, socket) => {
|
|
|
138
188
|
automation.stop();
|
|
139
189
|
for (const s of servers)
|
|
140
190
|
s.close();
|
|
191
|
+
backgroundWakeServer.close();
|
|
141
192
|
void listener.close().then(() => process.exit(0));
|
|
142
193
|
};
|
|
143
194
|
process.on("SIGTERM", shutdown);
|
package/dist/index.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Public API exports.
|
|
5
5
|
*/
|
|
6
|
-
export declare const VERSION = "0.9.
|
|
6
|
+
export declare const VERSION = "0.9.3";
|
|
7
7
|
export type { Message, ContentBlock, ToolDefinition, ToolCall, ToolResult, RegisteredTool, TranscriptEvent, TranscriptEventType, SessionState, SessionKind, SessionWorkspace, SessionProjectBinding, SessionForkLineage, ContextUsageAnchor, SessionStatus, TokenUsage, CompiledInput, PermissionDecision, PermissionMode, PermissionRule, TurnPhase, TurnResult, TerminalReason, TurnCompletionKind, StreamEvent, StreamCallback, LLMConfig, ClientDefaults, LLMResponse, Settings, MCPServerConfig, } from "./types.js";
|
|
8
8
|
export type { GoalConfig, GoalLifecycleConfig, GoalLifecyclePhase, GoalLifecycleTerminalReason, GoalLifecycleV1, } from "./goal/lifecycle.js";
|
|
9
9
|
export { FrameworkError, LLMError, LLMRateLimitError, ContextLimitError, ToolError, ToolNotFoundError, ToolExecutionError, ToolTimeoutError, PermissionDeniedError, SessionError, TranscriptError, ConfigError, CompositionError, SandboxUnavailableError, } from "./exceptions.js";
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Public API exports.
|
|
5
5
|
*/
|
|
6
|
-
export const VERSION = "0.9.
|
|
6
|
+
export const VERSION = "0.9.3";
|
|
7
7
|
// ─── Exceptions ──────────────────────────────────────────────────
|
|
8
8
|
export { FrameworkError, LLMError, LLMRateLimitError, ContextLimitError, ToolError, ToolNotFoundError, ToolExecutionError, ToolTimeoutError, PermissionDeniedError, SessionError, TranscriptError, ConfigError, CompositionError, SandboxUnavailableError, } from "./exceptions.js";
|
|
9
9
|
// ─── Composition (AgentModule / ResolvedComposition) ─────────────
|
package/dist/onboarding.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* (see src/ui/components/OnboardingPrompt.tsx); this module only exposes
|
|
5
5
|
* pure(-ish) helpers it consumes, so there's a single input stack.
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
7
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import { userHome } from "./settings/manager.js";
|
|
10
10
|
import { getOpenRouterModels } from "./data/openrouter-models.js";
|
|
@@ -14,6 +14,7 @@ import { KNOWN_MAX_OUTPUT, KNOWN_CONTEXT_WINDOWS, OPENROUTER_VENDORS, PROVIDERS,
|
|
|
14
14
|
export { PROVIDERS };
|
|
15
15
|
import { sanitizeApiKey } from "./llm/api-key-sanitize.js";
|
|
16
16
|
import { getMergedCatalog } from "./model-catalog/index.js";
|
|
17
|
+
import { mutateJsonFile } from "./utils/file-mutex.js";
|
|
17
18
|
// ProviderDef 类型 + PROVIDERS 目录已外移到 data/model-metadata.json
|
|
18
19
|
// (loader: data/model-metadata.ts),并在文件顶部 re-export。core 只读目录数据。
|
|
19
20
|
// ─── Dynamic model list (OpenRouter snapshot) ────────────────────
|
|
@@ -115,7 +116,7 @@ export async function validateApiKey(baseUrl, apiKey) {
|
|
|
115
116
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
116
117
|
signal: AbortSignal.timeout(10_000),
|
|
117
118
|
});
|
|
118
|
-
const data = await res.json();
|
|
119
|
+
const data = (await res.json());
|
|
119
120
|
return !data.error;
|
|
120
121
|
}
|
|
121
122
|
const url = baseUrl.replace(/\/$/, "") + "/models";
|
|
@@ -162,6 +163,8 @@ export function hasApiKey() {
|
|
|
162
163
|
// Reads ~/.code-shell/ only — ~/.claude/ compat was dropped because Claude
|
|
163
164
|
// Code's settings schema diverges and merging broke boot.
|
|
164
165
|
const p = join(userHome(), ".code-shell", "settings.json");
|
|
166
|
+
// This is only a boot-time hint. A slightly stale read can show onboarding
|
|
167
|
+
// once; all mutations below still re-read under the shared file lock.
|
|
165
168
|
if (existsSync(p)) {
|
|
166
169
|
try {
|
|
167
170
|
const data = JSON.parse(readFileSync(p, "utf-8"));
|
|
@@ -170,7 +173,9 @@ export function hasApiKey() {
|
|
|
170
173
|
if (Array.isArray(data?.modelConnections) && data.modelConnections.length > 0)
|
|
171
174
|
return true;
|
|
172
175
|
}
|
|
173
|
-
catch {
|
|
176
|
+
catch {
|
|
177
|
+
/* ignore */
|
|
178
|
+
}
|
|
174
179
|
}
|
|
175
180
|
return false;
|
|
176
181
|
}
|
|
@@ -297,50 +302,68 @@ export function appendOnboardingResult(opts) {
|
|
|
297
302
|
const tag = opts.tag ?? "text";
|
|
298
303
|
const dir = join(userHome(), ".code-shell");
|
|
299
304
|
const file = join(dir, "settings.json");
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
305
|
+
mutateJsonFile(file, {
|
|
306
|
+
parse: (raw) => {
|
|
307
|
+
if (raw === undefined)
|
|
308
|
+
return {};
|
|
309
|
+
let parsed;
|
|
310
|
+
try {
|
|
311
|
+
parsed = JSON.parse(raw);
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
throw new Error("settings.json is unreadable; onboarding did not overwrite it", {
|
|
315
|
+
cause: error,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
319
|
+
throw new Error("settings.json must contain a JSON object");
|
|
320
|
+
}
|
|
321
|
+
return parsed;
|
|
322
|
+
},
|
|
323
|
+
serialize: (value) => `${JSON.stringify(value, null, 2)}\n`,
|
|
324
|
+
mutation: (existing) => {
|
|
325
|
+
const creds = Array.isArray(existing.credentials)
|
|
326
|
+
? [...existing.credentials]
|
|
327
|
+
: [];
|
|
328
|
+
const conns = Array.isArray(existing.modelConnections)
|
|
329
|
+
? [...existing.modelConnections]
|
|
330
|
+
: [];
|
|
331
|
+
for (const model of opts.models) {
|
|
332
|
+
const catalogId = catalogIdForKind(model.kind);
|
|
333
|
+
const credentialId = `${model.instanceId}-key`;
|
|
334
|
+
if (!creds.some((credential) => credential?.id === credentialId)) {
|
|
335
|
+
creds.push({
|
|
336
|
+
id: credentialId,
|
|
337
|
+
catalogId,
|
|
338
|
+
apiKey: model.apiKey,
|
|
339
|
+
baseUrl: model.baseUrl,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
if (!conns.some((connection) => connection?.id === model.instanceId)) {
|
|
343
|
+
conns.push({
|
|
344
|
+
id: model.instanceId,
|
|
345
|
+
catalogId,
|
|
346
|
+
tag,
|
|
347
|
+
model: model.model,
|
|
348
|
+
credentialId,
|
|
349
|
+
...(model.baseUrl ? { baseUrl: model.baseUrl } : {}),
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
const existingDefaults = existing.defaults &&
|
|
354
|
+
typeof existing.defaults === "object" &&
|
|
355
|
+
!Array.isArray(existing.defaults)
|
|
356
|
+
? existing.defaults
|
|
357
|
+
: {};
|
|
358
|
+
return {
|
|
359
|
+
value: {
|
|
360
|
+
...existing,
|
|
361
|
+
credentials: creds,
|
|
362
|
+
modelConnections: conns,
|
|
363
|
+
defaults: { ...existingDefaults, [tag]: opts.activeId },
|
|
364
|
+
},
|
|
365
|
+
};
|
|
366
|
+
},
|
|
367
|
+
mode: 0o600,
|
|
368
|
+
});
|
|
346
369
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { StreamEvent } from "../types.js";
|
|
2
|
+
import { type NotificationQueue } from "../tool-system/builtin/agent-notifications.js";
|
|
2
3
|
import type { ApprovalRouter } from "../tool-system/permission.js";
|
|
3
4
|
import type { ChatSession } from "./chat-session.js";
|
|
4
5
|
import type { ChatSessionManager } from "./chat-session-manager.js";
|
|
@@ -8,11 +9,12 @@ interface BackgroundResultWakeOptions {
|
|
|
8
9
|
rehydrate(sessionId: string): Promise<ChatSession | null>;
|
|
9
10
|
approvalRouter: ApprovalRouter;
|
|
10
11
|
onStream(event: StreamEvent): void;
|
|
12
|
+
notificationMailbox?: NotificationQueue;
|
|
11
13
|
}
|
|
12
14
|
/**
|
|
13
15
|
* Drain pending background results into exactly one synthetic continuation.
|
|
14
16
|
* Busy sessions are awaited so a completion cannot fall into the gap between
|
|
15
17
|
* the notification bus callback and the interactive run-boundary re-check.
|
|
16
18
|
*/
|
|
17
|
-
export declare function wakeSessionForBackgroundResults({ sessionId, manager, rehydrate, approvalRouter, onStream, }: BackgroundResultWakeOptions): Promise<boolean>;
|
|
19
|
+
export declare function wakeSessionForBackgroundResults({ sessionId, manager, rehydrate, approvalRouter, onStream, notificationMailbox, }: BackgroundResultWakeOptions): Promise<boolean>;
|
|
18
20
|
export {};
|
|
@@ -5,7 +5,7 @@ import { buildNotificationMessage, notificationQueue, } from "../tool-system/bui
|
|
|
5
5
|
* Busy sessions are awaited so a completion cannot fall into the gap between
|
|
6
6
|
* the notification bus callback and the interactive run-boundary re-check.
|
|
7
7
|
*/
|
|
8
|
-
export async function wakeSessionForBackgroundResults({ sessionId, manager, rehydrate, approvalRouter, onStream, }) {
|
|
8
|
+
export async function wakeSessionForBackgroundResults({ sessionId, manager, rehydrate, approvalRouter, onStream, notificationMailbox = notificationQueue, }) {
|
|
9
9
|
if (!manager) {
|
|
10
10
|
logger.debug("bg_wakeup.skipped", { sessionId, reason: "no_chat_manager" });
|
|
11
11
|
return false;
|
|
@@ -22,7 +22,7 @@ export async function wakeSessionForBackgroundResults({ sessionId, manager, rehy
|
|
|
22
22
|
while (session.isBusy()) {
|
|
23
23
|
logger.debug("bg_wakeup.waiting_for_idle", {
|
|
24
24
|
sessionId,
|
|
25
|
-
pendingCount:
|
|
25
|
+
pendingCount: notificationMailbox.getSnapshot(sessionId).length,
|
|
26
26
|
});
|
|
27
27
|
await session.settled;
|
|
28
28
|
if (manager.isUnavailable(sessionId)) {
|
|
@@ -63,7 +63,7 @@ export async function wakeSessionForBackgroundResults({ sessionId, manager, rehy
|
|
|
63
63
|
});
|
|
64
64
|
return false;
|
|
65
65
|
}
|
|
66
|
-
const pending =
|
|
66
|
+
const pending = notificationMailbox.drainAll(sessionId);
|
|
67
67
|
if (pending.length === 0) {
|
|
68
68
|
logger.debug("bg_wakeup.skipped", { sessionId, reason: "no_pending_results" });
|
|
69
69
|
return false;
|
|
@@ -77,7 +77,7 @@ export async function wakeSessionForBackgroundResults({ sessionId, manager, rehy
|
|
|
77
77
|
approvalRouter,
|
|
78
78
|
});
|
|
79
79
|
if (result.turnCount === 0) {
|
|
80
|
-
const restored =
|
|
80
|
+
const restored = notificationMailbox.restoreResults(sessionId, pending);
|
|
81
81
|
logger.warn("bg_wakeup.turn_not_started", {
|
|
82
82
|
sessionId,
|
|
83
83
|
restored,
|
|
@@ -89,7 +89,7 @@ export async function wakeSessionForBackgroundResults({ sessionId, manager, rehy
|
|
|
89
89
|
}
|
|
90
90
|
catch (error) {
|
|
91
91
|
const message = error instanceof Error ? error.message : String(error);
|
|
92
|
-
const restored =
|
|
92
|
+
const restored = notificationMailbox.restoreResults(sessionId, pending);
|
|
93
93
|
logger.warn("bg_wakeup.turn_failed", { sessionId, error: message, restored });
|
|
94
94
|
// A setup failure can occur before the turn loop emits its own terminal
|
|
95
95
|
// event. Emit an error so every renderer clears its busy state, but keep
|
|
@@ -17,6 +17,7 @@ import type { Transport } from "./transport.js";
|
|
|
17
17
|
import type { Engine } from "../engine/engine.js";
|
|
18
18
|
import type { ValidatedSettings } from "../settings/schema.js";
|
|
19
19
|
import { type ApprovalRouter } from "../tool-system/permission.js";
|
|
20
|
+
import { type NotificationQueue, type NotificationQueuePersistence } from "../tool-system/builtin/agent-notifications.js";
|
|
20
21
|
import type { ChatSessionManager } from "./chat-session-manager.js";
|
|
21
22
|
import type { ResolvedComposition } from "../composition/types.js";
|
|
22
23
|
/**
|
|
@@ -106,6 +107,16 @@ export interface AgentServerOptions {
|
|
|
106
107
|
* write.
|
|
107
108
|
*/
|
|
108
109
|
sessionDiskRoot?: string;
|
|
110
|
+
/** Durable per-session result mailbox. Omitted keeps the in-memory behavior. */
|
|
111
|
+
notificationPersistence?: NotificationQueuePersistence;
|
|
112
|
+
/** Test/host seam; production uses the process singleton. */
|
|
113
|
+
notificationMailbox?: NotificationQueue;
|
|
114
|
+
/**
|
|
115
|
+
* Whether this server instance owns durable result restoration and idle
|
|
116
|
+
* wakeups. Defaults to true. Multi-connection hosts set one process-lifetime
|
|
117
|
+
* owner and leave connection servers as observation-only forwarders.
|
|
118
|
+
*/
|
|
119
|
+
ownsBackgroundWakeups?: boolean;
|
|
109
120
|
/** Shared owner router; injectable for hosts/tests, process singleton by default. */
|
|
110
121
|
approvalRouter?: ApprovalRouter;
|
|
111
122
|
/**
|
|
@@ -197,8 +208,10 @@ export declare class AgentServer {
|
|
|
197
208
|
* itself outlives the server (it's a process-local singleton).
|
|
198
209
|
*/
|
|
199
210
|
private bgAgentBusUnsubscribe;
|
|
211
|
+
private readonly ownsBackgroundWakeups;
|
|
200
212
|
private readonly wakeupsInFlight;
|
|
201
213
|
private readonly sessionWorkspaceRpc;
|
|
214
|
+
private readonly notificationMailbox;
|
|
202
215
|
/**
|
|
203
216
|
* Effective session manager for the connection this server serves. Without
|
|
204
217
|
* a resolveIdentity hook this is exactly the host-supplied manager —
|
package/dist/protocol/server.js
CHANGED
|
@@ -381,8 +381,10 @@ export class AgentServer {
|
|
|
381
381
|
* itself outlives the server (it's a process-local singleton).
|
|
382
382
|
*/
|
|
383
383
|
bgAgentBusUnsubscribe = null;
|
|
384
|
+
ownsBackgroundWakeups;
|
|
384
385
|
wakeupsInFlight = new Set();
|
|
385
386
|
sessionWorkspaceRpc;
|
|
387
|
+
notificationMailbox;
|
|
386
388
|
/**
|
|
387
389
|
* Effective session manager for the connection this server serves. Without
|
|
388
390
|
* a resolveIdentity hook this is exactly the host-supplied manager —
|
|
@@ -407,6 +409,11 @@ export class AgentServer {
|
|
|
407
409
|
return manager;
|
|
408
410
|
}
|
|
409
411
|
constructor(options) {
|
|
412
|
+
this.notificationMailbox = options.notificationMailbox ?? notificationQueue;
|
|
413
|
+
this.ownsBackgroundWakeups = options.ownsBackgroundWakeups !== false;
|
|
414
|
+
if (options.notificationPersistence) {
|
|
415
|
+
this.notificationMailbox.attachPersistence(options.notificationPersistence);
|
|
416
|
+
}
|
|
410
417
|
this.baseChatManager = options.chatManager ?? null;
|
|
411
418
|
this.resolveIdentity = options.resolveIdentity ?? null;
|
|
412
419
|
this.sessionDiskRoot = options.sessionDiskRoot;
|
|
@@ -509,8 +516,9 @@ export class AgentServer {
|
|
|
509
516
|
// legacy UI event is only an observation path. A renderer transport
|
|
510
517
|
// failure after accepting the event must not strand the result in the
|
|
511
518
|
// queue, so schedule the wake from finally.
|
|
512
|
-
if (envelope.kind === "result")
|
|
519
|
+
if (this.ownsBackgroundWakeups && envelope.kind === "result") {
|
|
513
520
|
this.maybeWakeIdleSession(sessionId);
|
|
521
|
+
}
|
|
514
522
|
}
|
|
515
523
|
// Background work that finishes while the session is idle (a
|
|
516
524
|
// run_in_background Bash like a download, a background sub-agent, or a
|
|
@@ -523,6 +531,14 @@ export class AgentServer {
|
|
|
523
531
|
// continuation. A never-exiting dev server emits no completion, so it
|
|
524
532
|
// never wakes anything (no task/service classification needed).
|
|
525
533
|
});
|
|
534
|
+
// Results restored from disk are deliberately not republished on the
|
|
535
|
+
// observation bus: their required destination is the original chat. Feed
|
|
536
|
+
// them into the same guarded wake path as live completions instead.
|
|
537
|
+
if (this.ownsBackgroundWakeups) {
|
|
538
|
+
for (const sessionId of this.notificationMailbox.restorePersistedSessions()) {
|
|
539
|
+
this.maybeWakeIdleSession(sessionId);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
526
542
|
// Notify client we're ready
|
|
527
543
|
this.notify(Methods.Status, { status: "ready" });
|
|
528
544
|
}
|
|
@@ -549,13 +565,15 @@ export class AgentServer {
|
|
|
549
565
|
* wakeup turns while getOrCreate waits for a closing generation to settle.
|
|
550
566
|
*/
|
|
551
567
|
maybeWakeIdleSession(sessionId) {
|
|
568
|
+
if (!this.ownsBackgroundWakeups)
|
|
569
|
+
return;
|
|
552
570
|
if (this.wakeupsInFlight.has(sessionId))
|
|
553
571
|
return;
|
|
554
572
|
this.wakeupsInFlight.add(sessionId);
|
|
555
573
|
void this.wakeIdleSession(sessionId)
|
|
556
574
|
.then((ranTurn) => {
|
|
557
575
|
this.wakeupsInFlight.delete(sessionId);
|
|
558
|
-
if (ranTurn &&
|
|
576
|
+
if (ranTurn && this.notificationMailbox.getSnapshot(sessionId).length > 0) {
|
|
559
577
|
this.maybeWakeIdleSession(sessionId);
|
|
560
578
|
}
|
|
561
579
|
})
|
|
@@ -574,6 +592,7 @@ export class AgentServer {
|
|
|
574
592
|
rehydrate: (id) => this.rehydrateSessionForWake(id),
|
|
575
593
|
approvalRouter: this.approvalRouter,
|
|
576
594
|
onStream: (event) => this.notify(Methods.StreamEvent, { sessionId, event }),
|
|
595
|
+
notificationMailbox: this.notificationMailbox,
|
|
577
596
|
});
|
|
578
597
|
}
|
|
579
598
|
async rehydrateSessionForWake(sessionId) {
|
|
@@ -582,7 +601,7 @@ export class AgentServer {
|
|
|
582
601
|
if (this.chatManager.isUnavailable(sessionId))
|
|
583
602
|
return null;
|
|
584
603
|
try {
|
|
585
|
-
const pending =
|
|
604
|
+
const pending = this.notificationMailbox.getSnapshot(sessionId);
|
|
586
605
|
if (pending.length === 0) {
|
|
587
606
|
logger.debug("bg_wakeup.rehydrate_skipped_no_pending", { sessionId });
|
|
588
607
|
return null;
|
package/dist/services/index.d.ts
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export { analytics, trackEvent } from "./analytics.js";
|
|
5
5
|
export { authorize, refreshToken, generatePKCE, createHardenedOAuthFetch, type OAuthConfig, type OAuthTokens, type OAuthAuthorizeOptions, type OAuthRefreshOptions, type HardenedOAuthFetchOptions, } from "./oauth.js";
|
|
6
|
-
export { notify, notifyComplete, notifyError } from "./notifier.js";
|
|
7
6
|
export { diagnostics } from "./diagnostics.js";
|
|
8
7
|
export { buildExtractionPrompt, parseExtractionResponse, type ExtractedMemory, } from "./extract-memories.js";
|
|
9
8
|
export { shouldAutoDream, recordSession, recordDreamComplete, buildDreamSystemPrompt, buildDreamUserPrompt, } from "./auto-dream.js";
|
package/dist/services/index.js
CHANGED
|
@@ -9,7 +9,6 @@ export { analytics, trackEvent } from "./analytics.js";
|
|
|
9
9
|
// to the tool_result-based microcompact in ContextManager). Removed to end
|
|
10
10
|
// the "which microcompact?" ambiguity — the source of truth is ContextManager.
|
|
11
11
|
export { authorize, refreshToken, generatePKCE, createHardenedOAuthFetch, } from "./oauth.js";
|
|
12
|
-
export { notify, notifyComplete, notifyError } from "./notifier.js";
|
|
13
12
|
export { diagnostics } from "./diagnostics.js";
|
|
14
13
|
export { buildExtractionPrompt, parseExtractionResponse, } from "./extract-memories.js";
|
|
15
14
|
export { shouldAutoDream, recordSession, recordDreamComplete, buildDreamSystemPrompt, buildDreamUserPrompt, } from "./auto-dream.js";
|
package/dist/session/memory.js
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* Deletes are SOFT — files are moved to <baseDir>/memory-trash/<ISO>/<scope>/
|
|
24
24
|
* rather than removed, so accidental deletions are recoverable.
|
|
25
25
|
*/
|
|
26
|
-
import { mkdirSync, existsSync, readFileSync,
|
|
26
|
+
import { mkdirSync, existsSync, readFileSync, readdirSync, renameSync, statSync } from "node:fs";
|
|
27
27
|
import { join } from "node:path";
|
|
28
28
|
import { homedir } from "node:os";
|
|
29
29
|
import { createHash, randomUUID } from "node:crypto";
|
|
@@ -174,7 +174,7 @@ export class MemoryManager {
|
|
|
174
174
|
frontmatterLine("updateCount", updateCount) +
|
|
175
175
|
`---\n\n` +
|
|
176
176
|
`${entry.content}\n`;
|
|
177
|
-
|
|
177
|
+
writeFileAtomic(filePath, content, 0o600);
|
|
178
178
|
// Incremental index update. Initialize the cache on first
|
|
179
179
|
// save by paying one loadAll() — every save after that just
|
|
180
180
|
// mutates the cache and rewrites MEMORY.md.
|
|
@@ -119,12 +119,23 @@ export type NotificationItem = {
|
|
|
119
119
|
enqueuedAt: number;
|
|
120
120
|
};
|
|
121
121
|
type Listener = () => void;
|
|
122
|
-
|
|
122
|
+
export interface NotificationQueuePersistence {
|
|
123
|
+
fileForSession(sessionId: string): string | null;
|
|
124
|
+
/** Optional startup inventory. Lazy per-session restore works without it. */
|
|
125
|
+
listSessionIds?(): readonly string[];
|
|
126
|
+
}
|
|
127
|
+
export declare class NotificationQueue {
|
|
123
128
|
private buckets;
|
|
124
129
|
private listeners;
|
|
125
130
|
private sequences;
|
|
126
131
|
private sequenceRoutes;
|
|
132
|
+
private persistence;
|
|
133
|
+
private restoredSessions;
|
|
127
134
|
private readonly maxSequenceRoutes;
|
|
135
|
+
attachPersistence(persistence: NotificationQueuePersistence | null): void;
|
|
136
|
+
/** Restore every persisted mailbox discovered by the host during startup. */
|
|
137
|
+
restorePersistedSessions(): string[];
|
|
138
|
+
restorePersistedSession(sessionId: string): number;
|
|
128
139
|
enqueue(draft: NotificationEnvelopeDraft): NotificationEnvelope | undefined;
|
|
129
140
|
enqueue(item: NotificationItem, sessionId: string): ResultEnvelope | undefined;
|
|
130
141
|
subscribe: (listener: Listener) => (() => void);
|
|
@@ -143,6 +154,19 @@ declare class NotificationQueue {
|
|
|
143
154
|
clearProgress(sessionId: string, agentId: string, runtimeGeneration?: number): boolean;
|
|
144
155
|
clearDirections(sessionId: string, runtimeGeneration: number): boolean;
|
|
145
156
|
reset(sessionId?: string): void;
|
|
157
|
+
private resultSnapshot;
|
|
158
|
+
private persistAddedResults;
|
|
159
|
+
private persistRemovedResults;
|
|
160
|
+
private replacePersistedResults;
|
|
161
|
+
/**
|
|
162
|
+
* Cross-process mailbox mutation. The directory lock exists before the JSON
|
|
163
|
+
* file does, and the current contents are re-read inside that lock. This
|
|
164
|
+
* avoids both the old lock-outside seed race and stale-snapshot overwrite.
|
|
165
|
+
*/
|
|
166
|
+
private mutatePersistedResults;
|
|
167
|
+
private parsePersistedResultsForMutation;
|
|
168
|
+
private quarantineCorruptFile;
|
|
169
|
+
private reseedSequence;
|
|
146
170
|
private notify;
|
|
147
171
|
}
|
|
148
172
|
type EnvelopeBusHandler = (envelope: NotificationEnvelope) => void;
|
|
@@ -1,9 +1,78 @@
|
|
|
1
1
|
import { nanoid } from "nanoid";
|
|
2
|
+
import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readFileSync, renameSync, } from "node:fs";
|
|
2
3
|
import { logger } from "../../logging/logger.js";
|
|
4
|
+
import { mutateJsonFile } from "../../utils/file-mutex.js";
|
|
3
5
|
const EMPTY = Object.freeze([]);
|
|
6
|
+
const PERSISTENCE_SCHEMA_VERSION = 1;
|
|
7
|
+
const MAX_PERSISTED_BYTES = 16 * 1024 * 1024;
|
|
4
8
|
function isValidSessionId(value) {
|
|
5
9
|
return typeof value === "string" && value.length > 0;
|
|
6
10
|
}
|
|
11
|
+
function isRecord(value) {
|
|
12
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
13
|
+
}
|
|
14
|
+
function isNotificationAuthority(value) {
|
|
15
|
+
return value === "user" || value === "agent" || value === "system" || value === "policy";
|
|
16
|
+
}
|
|
17
|
+
function isEndpoint(value) {
|
|
18
|
+
if (!isRecord(value) || !isValidSessionId(value.sessionId))
|
|
19
|
+
return false;
|
|
20
|
+
if (value.agentId !== undefined && typeof value.agentId !== "string")
|
|
21
|
+
return false;
|
|
22
|
+
return isNotificationAuthority(value.authority);
|
|
23
|
+
}
|
|
24
|
+
function isOptionalString(value) {
|
|
25
|
+
return value === undefined || typeof value === "string";
|
|
26
|
+
}
|
|
27
|
+
function isResultPayload(value) {
|
|
28
|
+
if (!isRecord(value))
|
|
29
|
+
return false;
|
|
30
|
+
if (!isValidSessionId(value.workId) || typeof value.description !== "string")
|
|
31
|
+
return false;
|
|
32
|
+
if (value.status !== "completed" && value.status !== "failed" && value.status !== "cancelled") {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
if (value.workKind !== "agent" &&
|
|
36
|
+
value.workKind !== "shell" &&
|
|
37
|
+
value.workKind !== "video" &&
|
|
38
|
+
value.workKind !== "cc") {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
if (!Number.isFinite(value.finishedAt))
|
|
42
|
+
return false;
|
|
43
|
+
if (!isOptionalString(value.name) ||
|
|
44
|
+
!isOptionalString(value.finalText) ||
|
|
45
|
+
!isOptionalString(value.error) ||
|
|
46
|
+
!isOptionalString(value.command) ||
|
|
47
|
+
!isOptionalString(value.ccSessionId) ||
|
|
48
|
+
!isOptionalString(value.cwd) ||
|
|
49
|
+
!isOptionalString(value.originClientMessageId)) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
return (value.changedFiles === undefined ||
|
|
53
|
+
(Array.isArray(value.changedFiles) &&
|
|
54
|
+
value.changedFiles.every((item) => typeof item === "string")));
|
|
55
|
+
}
|
|
56
|
+
function isPersistedResultEnvelope(value) {
|
|
57
|
+
if (!isRecord(value))
|
|
58
|
+
return false;
|
|
59
|
+
if (value.schemaVersion !== 1 ||
|
|
60
|
+
value.kind !== "result" ||
|
|
61
|
+
value.delivery !== "idle-drain" ||
|
|
62
|
+
!isValidSessionId(value.id) ||
|
|
63
|
+
!isEndpoint(value.from) ||
|
|
64
|
+
!isEndpoint(value.to) ||
|
|
65
|
+
!Number.isSafeInteger(value.sequence) ||
|
|
66
|
+
value.sequence < 1 ||
|
|
67
|
+
!Number.isFinite(value.createdAt) ||
|
|
68
|
+
!isResultPayload(value.payload)) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
if (value.teamId !== undefined || !isOptionalString(value.correlationId))
|
|
72
|
+
return false;
|
|
73
|
+
return (value.runtimeGeneration === undefined ||
|
|
74
|
+
(Number.isSafeInteger(value.runtimeGeneration) && value.runtimeGeneration > 0));
|
|
75
|
+
}
|
|
7
76
|
function routeSequenceKey(draft) {
|
|
8
77
|
return [
|
|
9
78
|
draft.teamId ?? "tree",
|
|
@@ -83,12 +152,122 @@ function installLegacyResultAliases(envelope) {
|
|
|
83
152
|
Object.defineProperty(envelope, name, { configurable: false, enumerable: false, get });
|
|
84
153
|
}
|
|
85
154
|
}
|
|
86
|
-
class NotificationQueue {
|
|
155
|
+
export class NotificationQueue {
|
|
87
156
|
buckets = new Map();
|
|
88
157
|
listeners = new Set();
|
|
89
158
|
sequences = new Map();
|
|
90
159
|
sequenceRoutes = new Map();
|
|
160
|
+
persistence = null;
|
|
161
|
+
restoredSessions = new Set();
|
|
91
162
|
maxSequenceRoutes = 4_096;
|
|
163
|
+
attachPersistence(persistence) {
|
|
164
|
+
this.persistence = persistence;
|
|
165
|
+
this.restoredSessions.clear();
|
|
166
|
+
}
|
|
167
|
+
/** Restore every persisted mailbox discovered by the host during startup. */
|
|
168
|
+
restorePersistedSessions() {
|
|
169
|
+
const restored = [];
|
|
170
|
+
for (const sessionId of this.persistence?.listSessionIds?.() ?? []) {
|
|
171
|
+
if (!isValidSessionId(sessionId) || this.restoredSessions.has(sessionId))
|
|
172
|
+
continue;
|
|
173
|
+
this.restorePersistedSession(sessionId);
|
|
174
|
+
if (this.resultSnapshot(sessionId).length > 0)
|
|
175
|
+
restored.push(sessionId);
|
|
176
|
+
}
|
|
177
|
+
return restored;
|
|
178
|
+
}
|
|
179
|
+
restorePersistedSession(sessionId) {
|
|
180
|
+
if (!isValidSessionId(sessionId) || this.restoredSessions.has(sessionId))
|
|
181
|
+
return 0;
|
|
182
|
+
this.restoredSessions.add(sessionId);
|
|
183
|
+
const file = this.persistence?.fileForSession(sessionId) ?? null;
|
|
184
|
+
if (!file || !existsSync(file))
|
|
185
|
+
return 0;
|
|
186
|
+
let pathInfo;
|
|
187
|
+
try {
|
|
188
|
+
pathInfo = lstatSync(file);
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
if (error.code === "ENOENT")
|
|
192
|
+
return 0;
|
|
193
|
+
this.restoredSessions.delete(sessionId);
|
|
194
|
+
logger.warn("notification_queue.persistence_read_failed", {
|
|
195
|
+
file,
|
|
196
|
+
error: error instanceof Error ? error.message : String(error),
|
|
197
|
+
});
|
|
198
|
+
return 0;
|
|
199
|
+
}
|
|
200
|
+
if (pathInfo.isSymbolicLink() || !pathInfo.isFile() || pathInfo.size > MAX_PERSISTED_BYTES) {
|
|
201
|
+
this.quarantineCorruptFile(file, new Error("pending notification file is not a bounded regular file"));
|
|
202
|
+
return 0;
|
|
203
|
+
}
|
|
204
|
+
let raw;
|
|
205
|
+
try {
|
|
206
|
+
const descriptor = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
207
|
+
try {
|
|
208
|
+
const opened = fstatSync(descriptor);
|
|
209
|
+
if (!opened.isFile() || opened.size > MAX_PERSISTED_BYTES) {
|
|
210
|
+
throw new Error("pending notification file is not a bounded regular file");
|
|
211
|
+
}
|
|
212
|
+
raw = readFileSync(descriptor, "utf8");
|
|
213
|
+
}
|
|
214
|
+
finally {
|
|
215
|
+
closeSync(descriptor);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
this.restoredSessions.delete(sessionId);
|
|
220
|
+
logger.warn("notification_queue.persistence_read_failed", {
|
|
221
|
+
file,
|
|
222
|
+
error: error instanceof Error ? error.message : String(error),
|
|
223
|
+
});
|
|
224
|
+
return 0;
|
|
225
|
+
}
|
|
226
|
+
let parsed;
|
|
227
|
+
try {
|
|
228
|
+
parsed = JSON.parse(raw);
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
this.quarantineCorruptFile(file, error);
|
|
232
|
+
return 0;
|
|
233
|
+
}
|
|
234
|
+
if (!isRecord(parsed) ||
|
|
235
|
+
parsed.schemaVersion !== PERSISTENCE_SCHEMA_VERSION ||
|
|
236
|
+
!Array.isArray(parsed.results)) {
|
|
237
|
+
this.quarantineCorruptFile(file, new Error("invalid pending notification schema"));
|
|
238
|
+
return 0;
|
|
239
|
+
}
|
|
240
|
+
const valid = [];
|
|
241
|
+
const invalid = [];
|
|
242
|
+
for (const candidate of parsed.results) {
|
|
243
|
+
if (isPersistedResultEnvelope(candidate) && candidate.to.sessionId === sessionId) {
|
|
244
|
+
installLegacyResultAliases(candidate);
|
|
245
|
+
valid.push(candidate);
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
invalid.push(candidate);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (invalid.length > 0) {
|
|
252
|
+
this.quarantineCorruptFile(file, new Error(`${invalid.length} invalid pending notification entries`));
|
|
253
|
+
// Merge the salvaged rows back under the directory lock. A concurrent
|
|
254
|
+
// writer may already have recreated the active path after quarantine;
|
|
255
|
+
// replacing it with this earlier snapshot would lose that new result.
|
|
256
|
+
this.persistAddedResults(sessionId, valid);
|
|
257
|
+
}
|
|
258
|
+
if (valid.length === 0)
|
|
259
|
+
return 0;
|
|
260
|
+
const bucket = this.buckets.get(sessionId) ?? [];
|
|
261
|
+
const ids = new Set(bucket.map((item) => item.id));
|
|
262
|
+
const restored = valid.filter((item) => !ids.has(item.id));
|
|
263
|
+
if (restored.length === 0)
|
|
264
|
+
return 0;
|
|
265
|
+
this.buckets.set(sessionId, [...restored, ...bucket]);
|
|
266
|
+
for (const envelope of restored)
|
|
267
|
+
this.reseedSequence(envelope);
|
|
268
|
+
this.notify();
|
|
269
|
+
return restored.length;
|
|
270
|
+
}
|
|
92
271
|
enqueue(draftOrItem, legacySessionId) {
|
|
93
272
|
const draft = legacySessionId !== undefined || !("kind" in draftOrItem)
|
|
94
273
|
? legacyItemToDraft(draftOrItem, legacySessionId)
|
|
@@ -112,6 +291,7 @@ class NotificationQueue {
|
|
|
112
291
|
logger.warn("notification_queue.invalid_direction_draft");
|
|
113
292
|
return undefined;
|
|
114
293
|
}
|
|
294
|
+
this.restorePersistedSession(draft.to.sessionId);
|
|
115
295
|
const sequenceKey = routeSequenceKey(draft);
|
|
116
296
|
const sequence = (this.sequences.get(sequenceKey) ?? 0) + 1;
|
|
117
297
|
const id = nanoid();
|
|
@@ -154,6 +334,9 @@ class NotificationQueue {
|
|
|
154
334
|
this.sequences.delete(oldest);
|
|
155
335
|
}
|
|
156
336
|
this.buckets.set(envelope.to.sessionId, [...next, envelope]);
|
|
337
|
+
if (envelope.kind === "result") {
|
|
338
|
+
this.persistAddedResults(envelope.to.sessionId, [envelope]);
|
|
339
|
+
}
|
|
157
340
|
this.notify();
|
|
158
341
|
agentNotificationBus.publish(envelope);
|
|
159
342
|
return envelope;
|
|
@@ -165,11 +348,13 @@ class NotificationQueue {
|
|
|
165
348
|
getSnapshot = (sessionId) => {
|
|
166
349
|
if (!isValidSessionId(sessionId))
|
|
167
350
|
return EMPTY;
|
|
351
|
+
this.restorePersistedSession(sessionId);
|
|
168
352
|
return this.buckets.get(sessionId) ?? EMPTY;
|
|
169
353
|
};
|
|
170
354
|
drain(sessionId, predicate) {
|
|
171
355
|
if (!isValidSessionId(sessionId))
|
|
172
356
|
return [];
|
|
357
|
+
this.restorePersistedSession(sessionId);
|
|
173
358
|
const bucket = this.buckets.get(sessionId);
|
|
174
359
|
if (!bucket?.length)
|
|
175
360
|
return [];
|
|
@@ -184,6 +369,9 @@ class NotificationQueue {
|
|
|
184
369
|
this.buckets.set(sessionId, retained);
|
|
185
370
|
else
|
|
186
371
|
this.buckets.delete(sessionId);
|
|
372
|
+
this.persistRemovedResults(sessionId, drained
|
|
373
|
+
.filter((item) => item.kind === "result")
|
|
374
|
+
.map((item) => item.id));
|
|
187
375
|
this.notify();
|
|
188
376
|
return drained;
|
|
189
377
|
}
|
|
@@ -201,12 +389,16 @@ class NotificationQueue {
|
|
|
201
389
|
restoreResults(sessionId, envelopes) {
|
|
202
390
|
if (!isValidSessionId(sessionId) || envelopes.length === 0)
|
|
203
391
|
return 0;
|
|
392
|
+
this.restorePersistedSession(sessionId);
|
|
204
393
|
const bucket = this.buckets.get(sessionId) ?? [];
|
|
205
394
|
const ids = new Set(bucket.map((item) => item.id));
|
|
206
395
|
const restored = envelopes.filter((item) => item.kind === "result" && item.to.sessionId === sessionId && !ids.has(item.id));
|
|
207
396
|
if (restored.length === 0)
|
|
208
397
|
return 0;
|
|
209
398
|
this.buckets.set(sessionId, [...restored, ...bucket]);
|
|
399
|
+
for (const envelope of restored)
|
|
400
|
+
this.reseedSequence(envelope);
|
|
401
|
+
this.persistAddedResults(sessionId, restored);
|
|
210
402
|
this.notify();
|
|
211
403
|
return restored.length;
|
|
212
404
|
}
|
|
@@ -242,13 +434,23 @@ class NotificationQueue {
|
|
|
242
434
|
}
|
|
243
435
|
reset(sessionId) {
|
|
244
436
|
if (sessionId === undefined) {
|
|
245
|
-
if (this.buckets.size === 0 && this.sequences.size === 0)
|
|
437
|
+
if (this.buckets.size === 0 && this.sequences.size === 0) {
|
|
438
|
+
this.restoredSessions.clear();
|
|
246
439
|
return;
|
|
440
|
+
}
|
|
441
|
+
const persistedSessions = [...this.buckets.keys()];
|
|
247
442
|
this.buckets.clear();
|
|
248
443
|
this.sequences.clear();
|
|
249
444
|
this.sequenceRoutes.clear();
|
|
445
|
+
for (const persistedSession of persistedSessions) {
|
|
446
|
+
const file = this.persistence?.fileForSession(persistedSession) ?? null;
|
|
447
|
+
if (file)
|
|
448
|
+
this.replacePersistedResults(file, []);
|
|
449
|
+
}
|
|
450
|
+
this.restoredSessions.clear();
|
|
250
451
|
}
|
|
251
452
|
else {
|
|
453
|
+
this.restorePersistedSession(sessionId);
|
|
252
454
|
const hadBucket = this.buckets.delete(sessionId);
|
|
253
455
|
let clearedRoute = false;
|
|
254
456
|
for (const [key, route] of this.sequenceRoutes) {
|
|
@@ -260,9 +462,139 @@ class NotificationQueue {
|
|
|
260
462
|
}
|
|
261
463
|
if (!hadBucket && !clearedRoute)
|
|
262
464
|
return;
|
|
465
|
+
const file = this.persistence?.fileForSession(sessionId) ?? null;
|
|
466
|
+
if (file)
|
|
467
|
+
this.replacePersistedResults(file, []);
|
|
468
|
+
this.restoredSessions.delete(sessionId);
|
|
263
469
|
}
|
|
264
470
|
this.notify();
|
|
265
471
|
}
|
|
472
|
+
resultSnapshot(sessionId) {
|
|
473
|
+
return (this.buckets.get(sessionId) ?? []).filter((item) => item.kind === "result");
|
|
474
|
+
}
|
|
475
|
+
persistAddedResults(sessionId, results) {
|
|
476
|
+
if (results.length === 0)
|
|
477
|
+
return;
|
|
478
|
+
const file = this.persistence?.fileForSession(sessionId) ?? null;
|
|
479
|
+
if (!file)
|
|
480
|
+
return;
|
|
481
|
+
try {
|
|
482
|
+
this.mutatePersistedResults(file, (current) => {
|
|
483
|
+
const merged = [...current.results];
|
|
484
|
+
const ids = new Set(merged.map((item) => item.id));
|
|
485
|
+
for (const result of results) {
|
|
486
|
+
if (ids.has(result.id))
|
|
487
|
+
continue;
|
|
488
|
+
ids.add(result.id);
|
|
489
|
+
merged.push(result);
|
|
490
|
+
}
|
|
491
|
+
// Always return the validated state: parse may have quarantined a
|
|
492
|
+
// mixed-validity file, in which case even a duplicate add must reseed
|
|
493
|
+
// the active path with the valid rows.
|
|
494
|
+
return merged;
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
catch (error) {
|
|
498
|
+
logger.error("notification_queue.persistence_write_failed", {
|
|
499
|
+
sessionId,
|
|
500
|
+
error: error instanceof Error ? error.message : String(error),
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
persistRemovedResults(sessionId, resultIds) {
|
|
505
|
+
if (resultIds.length === 0)
|
|
506
|
+
return;
|
|
507
|
+
const file = this.persistence?.fileForSession(sessionId) ?? null;
|
|
508
|
+
if (!file)
|
|
509
|
+
return;
|
|
510
|
+
try {
|
|
511
|
+
const removed = new Set(resultIds);
|
|
512
|
+
this.mutatePersistedResults(file, (current) => {
|
|
513
|
+
const retained = current.results.filter((item) => !removed.has(item.id));
|
|
514
|
+
return retained;
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
catch (error) {
|
|
518
|
+
logger.error("notification_queue.persistence_write_failed", {
|
|
519
|
+
sessionId,
|
|
520
|
+
error: error instanceof Error ? error.message : String(error),
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
replacePersistedResults(file, results) {
|
|
525
|
+
this.mutatePersistedResults(file, () => [...results]);
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Cross-process mailbox mutation. The directory lock exists before the JSON
|
|
529
|
+
* file does, and the current contents are re-read inside that lock. This
|
|
530
|
+
* avoids both the old lock-outside seed race and stale-snapshot overwrite.
|
|
531
|
+
*/
|
|
532
|
+
mutatePersistedResults(file, mutation) {
|
|
533
|
+
mutateJsonFile(file, {
|
|
534
|
+
parse: (raw) => this.parsePersistedResultsForMutation(file, raw),
|
|
535
|
+
serialize: (value) => `${JSON.stringify(value, null, 2)}\n`,
|
|
536
|
+
mutation: (current) => {
|
|
537
|
+
const results = mutation(current);
|
|
538
|
+
return results === undefined
|
|
539
|
+
? {}
|
|
540
|
+
: {
|
|
541
|
+
value: {
|
|
542
|
+
schemaVersion: PERSISTENCE_SCHEMA_VERSION,
|
|
543
|
+
results,
|
|
544
|
+
},
|
|
545
|
+
};
|
|
546
|
+
},
|
|
547
|
+
mode: 0o600,
|
|
548
|
+
maxBytes: MAX_PERSISTED_BYTES,
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
parsePersistedResultsForMutation(file, raw) {
|
|
552
|
+
if (raw === undefined) {
|
|
553
|
+
return { schemaVersion: PERSISTENCE_SCHEMA_VERSION, results: [] };
|
|
554
|
+
}
|
|
555
|
+
let parsed;
|
|
556
|
+
try {
|
|
557
|
+
parsed = JSON.parse(raw);
|
|
558
|
+
}
|
|
559
|
+
catch (error) {
|
|
560
|
+
this.quarantineCorruptFile(file, error);
|
|
561
|
+
return { schemaVersion: PERSISTENCE_SCHEMA_VERSION, results: [] };
|
|
562
|
+
}
|
|
563
|
+
if (!isRecord(parsed) ||
|
|
564
|
+
parsed.schemaVersion !== PERSISTENCE_SCHEMA_VERSION ||
|
|
565
|
+
!Array.isArray(parsed.results)) {
|
|
566
|
+
this.quarantineCorruptFile(file, new Error("invalid pending notification schema"));
|
|
567
|
+
return { schemaVersion: PERSISTENCE_SCHEMA_VERSION, results: [] };
|
|
568
|
+
}
|
|
569
|
+
const valid = parsed.results.filter(isPersistedResultEnvelope);
|
|
570
|
+
if (valid.length !== parsed.results.length) {
|
|
571
|
+
this.quarantineCorruptFile(file, new Error(`${parsed.results.length - valid.length} invalid pending notification entries`));
|
|
572
|
+
}
|
|
573
|
+
return { schemaVersion: PERSISTENCE_SCHEMA_VERSION, results: valid };
|
|
574
|
+
}
|
|
575
|
+
quarantineCorruptFile(file, error) {
|
|
576
|
+
const corruptFile = `${file}.${Date.now()}.${nanoid(6)}.corrupt`;
|
|
577
|
+
try {
|
|
578
|
+
renameSync(file, corruptFile);
|
|
579
|
+
logger.warn("notification_queue.persistence_quarantined", {
|
|
580
|
+
file,
|
|
581
|
+
corruptFile,
|
|
582
|
+
error: error instanceof Error ? error.message : String(error),
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
catch (quarantineError) {
|
|
586
|
+
logger.warn("notification_queue.persistence_quarantine_failed", {
|
|
587
|
+
file,
|
|
588
|
+
error: quarantineError instanceof Error ? quarantineError.message : String(quarantineError),
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
reseedSequence(envelope) {
|
|
593
|
+
const key = routeSequenceKey(envelope);
|
|
594
|
+
this.sequences.set(key, Math.max(this.sequences.get(key) ?? 0, envelope.sequence));
|
|
595
|
+
this.sequenceRoutes.delete(key);
|
|
596
|
+
this.sequenceRoutes.set(key, { from: envelope.from.sessionId, to: envelope.to.sessionId });
|
|
597
|
+
}
|
|
266
598
|
notify() {
|
|
267
599
|
for (const listener of this.listeners) {
|
|
268
600
|
try {
|
package/package.json
CHANGED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Notifier service — desktop/system notifications.
|
|
3
|
-
*
|
|
4
|
-
* Sends notifications when tasks complete, agents finish, or errors occur.
|
|
5
|
-
* Falls back gracefully when no notification system is available.
|
|
6
|
-
*/
|
|
7
|
-
export interface NotificationOptions {
|
|
8
|
-
title: string;
|
|
9
|
-
message: string;
|
|
10
|
-
sound?: boolean;
|
|
11
|
-
/** Urgency level: low, normal, critical */
|
|
12
|
-
urgency?: "low" | "normal" | "critical";
|
|
13
|
-
}
|
|
14
|
-
/**
|
|
15
|
-
* Send a desktop notification.
|
|
16
|
-
*/
|
|
17
|
-
export declare function notify(options: NotificationOptions): void;
|
|
18
|
-
/** Escape a string for embedding inside an AppleScript double-quoted literal. */
|
|
19
|
-
export declare function escapeAppleScriptString(str: string): string;
|
|
20
|
-
/** Build the osascript argv (a single `-e <script>` pair). */
|
|
21
|
-
export declare function buildOsascriptArgs(title: string, message: string, sound: boolean): string[];
|
|
22
|
-
/** Build the notify-send argv with title/message as separate tokens. */
|
|
23
|
-
export declare function buildNotifySendArgs(title: string, message: string, urgency: "low" | "normal" | "critical"): string[];
|
|
24
|
-
/** Build the powershell.exe argv (a single `-Command <script>` element). */
|
|
25
|
-
export declare function buildPowershellArgs(title: string, message: string): string[];
|
|
26
|
-
/**
|
|
27
|
-
* Send a notification that a task/agent has completed.
|
|
28
|
-
*/
|
|
29
|
-
export declare function notifyComplete(taskName: string, duration?: number): void;
|
|
30
|
-
/**
|
|
31
|
-
* Send an error notification.
|
|
32
|
-
*/
|
|
33
|
-
export declare function notifyError(context: string, error: string): void;
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Notifier service — desktop/system notifications.
|
|
3
|
-
*
|
|
4
|
-
* Sends notifications when tasks complete, agents finish, or errors occur.
|
|
5
|
-
* Falls back gracefully when no notification system is available.
|
|
6
|
-
*/
|
|
7
|
-
import { execFileSync } from "node:child_process";
|
|
8
|
-
/**
|
|
9
|
-
* Send a desktop notification.
|
|
10
|
-
*/
|
|
11
|
-
export function notify(options) {
|
|
12
|
-
const { title, message, sound = false, urgency = "normal" } = options;
|
|
13
|
-
try {
|
|
14
|
-
if (process.platform === "darwin") {
|
|
15
|
-
// macOS: osascript. Pass the script as a single -e argv element via
|
|
16
|
-
// execFileSync (no shell), so title/message are never seen by the shell.
|
|
17
|
-
execFileSync("osascript", buildOsascriptArgs(title, message, sound), { timeout: 5000 });
|
|
18
|
-
}
|
|
19
|
-
else if (process.platform === "linux") {
|
|
20
|
-
// Linux: notify-send. argv keeps title/message as separate tokens.
|
|
21
|
-
execFileSync("notify-send", buildNotifySendArgs(title, message, urgency), { timeout: 5000 });
|
|
22
|
-
}
|
|
23
|
-
else if (process.platform === "win32") {
|
|
24
|
-
// Windows: PowerShell toast. The script is one -Command argv element
|
|
25
|
-
// (no outer shell); title/message are escaped for PowerShell single
|
|
26
|
-
// quotes (' → '').
|
|
27
|
-
execFileSync("powershell.exe", buildPowershellArgs(title, message), { timeout: 5000 });
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
catch {
|
|
31
|
-
// Silently fail — notifications are best-effort
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
/** Escape a string for embedding inside an AppleScript double-quoted literal. */
|
|
35
|
-
export function escapeAppleScriptString(str) {
|
|
36
|
-
return str.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, " ");
|
|
37
|
-
}
|
|
38
|
-
/** Build the osascript argv (a single `-e <script>` pair). */
|
|
39
|
-
export function buildOsascriptArgs(title, message, sound) {
|
|
40
|
-
const soundClause = sound ? ' sound name "default"' : "";
|
|
41
|
-
const script = `display notification "${escapeAppleScriptString(message)}"` +
|
|
42
|
-
` with title "${escapeAppleScriptString(title)}"${soundClause}`;
|
|
43
|
-
return ["-e", script];
|
|
44
|
-
}
|
|
45
|
-
/** Build the notify-send argv with title/message as separate tokens. */
|
|
46
|
-
export function buildNotifySendArgs(title, message, urgency) {
|
|
47
|
-
return ["-u", urgency, title, message];
|
|
48
|
-
}
|
|
49
|
-
/** Build the powershell.exe argv (a single `-Command <script>` element). */
|
|
50
|
-
export function buildPowershellArgs(title, message) {
|
|
51
|
-
const esc = (s) => s.replace(/'/g, "''").replace(/\n/g, " ");
|
|
52
|
-
const ps = [
|
|
53
|
-
"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null",
|
|
54
|
-
"$xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)",
|
|
55
|
-
"$text = $xml.GetElementsByTagName('text')",
|
|
56
|
-
`$text[0].AppendChild($xml.CreateTextNode('${esc(title)}')) | Out-Null`,
|
|
57
|
-
`$text[1].AppendChild($xml.CreateTextNode('${esc(message)}')) | Out-Null`,
|
|
58
|
-
"$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)",
|
|
59
|
-
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('CodeShell').Show($toast)",
|
|
60
|
-
].join("; ");
|
|
61
|
-
return ["-NoProfile", "-Command", ps];
|
|
62
|
-
}
|
|
63
|
-
/**
|
|
64
|
-
* Send a notification that a task/agent has completed.
|
|
65
|
-
*/
|
|
66
|
-
export function notifyComplete(taskName, duration) {
|
|
67
|
-
const durationStr = duration ? ` (${(duration / 1000).toFixed(1)}s)` : "";
|
|
68
|
-
notify({
|
|
69
|
-
title: "Code Shell",
|
|
70
|
-
message: `✓ ${taskName} completed${durationStr}`,
|
|
71
|
-
sound: true,
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
/**
|
|
75
|
-
* Send an error notification.
|
|
76
|
-
*/
|
|
77
|
-
export function notifyError(context, error) {
|
|
78
|
-
notify({
|
|
79
|
-
title: "Code Shell Error",
|
|
80
|
-
message: `✗ ${context}: ${error.slice(0, 100)}`,
|
|
81
|
-
urgency: "critical",
|
|
82
|
-
});
|
|
83
|
-
}
|