@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.
@@ -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({ llm: llmConfig, cwd, settingsScope: "full" });
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
- listenTcp({ port, host }, (transport, socket) => {
120
- const server = new AgentServer({
153
+ const goalDiskManager = new SessionManager(dataSessionsDir);
154
+ function createTcpAgentServer(transport, connectionId, ownsBackgroundWakeups) {
155
+ return new AgentServer({
121
156
  chatManager,
122
157
  transport,
123
- connectionId: randomUUID(),
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.2";
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.2";
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) ─────────────
@@ -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 { mkdirSync, writeFileSync, readFileSync, existsSync, renameSync, rmSync } from "node:fs";
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 { /* ignore */ }
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
- mkdirSync(dir, { recursive: true });
301
- let existing = {};
302
- if (existsSync(file)) {
303
- try {
304
- existing = JSON.parse(readFileSync(file, "utf-8"));
305
- }
306
- catch { /* corrupt → replace */ }
307
- }
308
- const creds = Array.isArray(existing.credentials)
309
- ? [...existing.credentials] : [];
310
- const conns = Array.isArray(existing.modelConnections)
311
- ? [...existing.modelConnections] : [];
312
- for (const m of opts.models) {
313
- const catalogId = catalogIdForKind(m.kind);
314
- const credId = `${m.instanceId}-key`;
315
- if (!creds.some((c) => c?.id === credId)) {
316
- creds.push({ id: credId, catalogId, apiKey: m.apiKey, baseUrl: m.baseUrl });
317
- }
318
- if (!conns.some((c) => c?.id === m.instanceId)) {
319
- conns.push({
320
- id: m.instanceId, catalogId, tag, model: m.model, credentialId: credId,
321
- ...(m.baseUrl ? { baseUrl: m.baseUrl } : {}),
322
- });
323
- }
324
- }
325
- const existingDefaults = (typeof existing.defaults === "object" && existing.defaults)
326
- ? existing.defaults : {};
327
- const updated = {
328
- ...existing,
329
- credentials: creds,
330
- modelConnections: conns,
331
- defaults: { ...existingDefaults, [tag]: opts.activeId },
332
- };
333
- // Atomic write: tmp file in the same dir, then rename (atomic on POSIX).
334
- // mode 0o600 — settings.json holds plaintext API keys, must be owner-only.
335
- const tmp = `${file}.${process.pid}.tmp`;
336
- writeFileSync(tmp, JSON.stringify(updated, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
337
- try {
338
- renameSync(tmp, file);
339
- }
340
- catch {
341
- // Fallback: best-effort direct write if rename fails (e.g. cross-device),
342
- // then remove the orphaned temp file the failed rename left behind.
343
- writeFileSync(file, JSON.stringify(updated, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
344
- rmSync(tmp, { force: true });
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: notificationQueue.getSnapshot(sessionId).length,
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 = notificationQueue.drainAll(sessionId);
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 = notificationQueue.restoreResults(sessionId, pending);
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 = notificationQueue.restoreResults(sessionId, pending);
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 —
@@ -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 && notificationQueue.getSnapshot(sessionId).length > 0) {
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 = notificationQueue.getSnapshot(sessionId);
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;
@@ -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";
@@ -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";
@@ -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, writeFileSync, readdirSync, renameSync, statSync, } from "node:fs";
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
- writeFileSync(filePath, content, "utf-8");
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
- declare class NotificationQueue {
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@cjhyy/code-shell-core",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
4
4
  "description": "Core engine for code-shell — agent orchestration, tool execution, hooks, protocol. UI-agnostic.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -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
- }