@cjhyy/code-shell-core 0.9.2 → 0.9.4

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.
Files changed (38) hide show
  1. package/dist/cli/agent-server-stdio.js +26 -1
  2. package/dist/cli/agent-server-tcp.js +58 -7
  3. package/dist/engine/engine.d.ts +8 -0
  4. package/dist/engine/engine.js +16 -12
  5. package/dist/engine/model-facade.d.ts +3 -0
  6. package/dist/engine/model-facade.js +2 -0
  7. package/dist/engine/run-tooling.js +8 -8
  8. package/dist/engine/streaming-tool-queue.d.ts +4 -1
  9. package/dist/engine/streaming-tool-queue.js +17 -2
  10. package/dist/engine/turn-loop.d.ts +14 -5
  11. package/dist/engine/turn-loop.js +92 -37
  12. package/dist/index.d.ts +1 -1
  13. package/dist/index.js +1 -1
  14. package/dist/llm/prompt-cache.d.ts +48 -0
  15. package/dist/llm/prompt-cache.js +100 -0
  16. package/dist/llm/providers/anthropic.d.ts +3 -0
  17. package/dist/llm/providers/anthropic.js +77 -53
  18. package/dist/llm/providers/openai.d.ts +7 -27
  19. package/dist/llm/providers/openai.js +120 -68
  20. package/dist/llm/types.d.ts +3 -0
  21. package/dist/onboarding.js +72 -49
  22. package/dist/panel-apps/manifest.d.ts +12 -12
  23. package/dist/profile/types.d.ts +26 -26
  24. package/dist/protocol/background-result-wakeup.d.ts +3 -1
  25. package/dist/protocol/background-result-wakeup.js +5 -5
  26. package/dist/protocol/server.d.ts +13 -0
  27. package/dist/protocol/server.js +22 -3
  28. package/dist/services/index.d.ts +0 -1
  29. package/dist/services/index.js +0 -1
  30. package/dist/session/memory.js +2 -2
  31. package/dist/session/session-manager.js +30 -1
  32. package/dist/tool-system/builtin/agent-notifications.d.ts +25 -1
  33. package/dist/tool-system/builtin/agent-notifications.js +334 -2
  34. package/dist/tool-system/context.d.ts +9 -4
  35. package/dist/tool-system/external-tool-exposure.js +11 -10
  36. package/package.json +2 -1
  37. package/dist/services/notifier.d.ts +0 -33
  38. package/dist/services/notifier.js +0 -83
@@ -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 {
@@ -215,13 +215,18 @@ export interface ExternalFileChangesRecord {
215
215
  changedFiles: string[];
216
216
  originClientMessageId?: string;
217
217
  }
218
- export type ToolRunYieldReason = "background_notification";
219
- /** Run-scoped handoff from a trusted tool to the owning turn loop. */
218
+ export type ToolRunYieldReason = "background_notification" | "reply_committed";
219
+ /**
220
+ * Run-scoped handoff from a trusted tool to the owning turn loop. Distinct
221
+ * reasons accumulate independently: one batch may both commit a host reply
222
+ * and launch background work, and the loop decides boundary precedence.
223
+ */
220
224
  export interface ToolRunYieldController {
221
225
  request(reason: ToolRunYieldReason): void;
222
226
  /** Inspect without clearing so the loop can prioritize an accepted steer. */
223
- peek?(): ToolRunYieldReason | undefined;
224
- consume(): ToolRunYieldReason | undefined;
227
+ peek(reason: ToolRunYieldReason): boolean;
228
+ /** Clear one pending reason; true if it was pending. */
229
+ consume(reason: ToolRunYieldReason): boolean;
225
230
  }
226
231
  export interface ToolContext {
227
232
  /** Active working directory for this Engine. */
@@ -62,8 +62,9 @@ export const FIRST_PHASE_EXPOSURE_RATIONALE = [
62
62
  },
63
63
  // ── Delegation and state-machine exceptions ──────────────────────
64
64
  // Agent and the two plan-state tools remain structurally excluded. DriveAgent
65
- // is the reviewed exception because the external host forces it into a
66
- // foreground, one-level handoff with an observable result.
65
+ // is the reviewed exception because the child does not inherit the host
66
+ // bridge and background work is allowed only when the host guarantees an
67
+ // observable completion handoff.
67
68
  {
68
69
  tool: "Agent",
69
70
  kind: "self-contained",
@@ -78,19 +79,19 @@ export const FIRST_PHASE_EXPOSURE_RATIONALE = [
78
79
  kind: "self-contained",
79
80
  status: "exposed",
80
81
  reason: "Delegates one bounded task to an installed Codex/Claude CLI. External " +
81
- "sessions force foreground execution and disable automatic background " +
82
- "handoff, so the parent turn receives the result instead of losing a wake-up " +
83
- "inside the native Engine queue. The child CLI does not inherit this host " +
84
- "bridge, which bounds nesting at one level; the outer call still requires " +
85
- "the normal DriveAgent approval.",
82
+ "sessions may detach it only when the Desktop host promises to drain the " +
83
+ "completion queue and inject a continuation into the same Session; other " +
84
+ "hosts fail closed and require foreground execution. The child CLI does not " +
85
+ "inherit this host bridge, which bounds nesting at one level; the outer call " +
86
+ "still requires the normal DriveAgent approval.",
86
87
  },
87
88
  {
88
89
  tool: "DriveAgentJobs",
89
90
  kind: "self-contained",
90
91
  status: "exposed",
91
- reason: "Lets the runtime inspect or cancel retained DriveAgent jobs. New external " +
92
- "delegations run in the foreground, but retained jobs from the same Session " +
93
- "still need an observable cleanup surface.",
92
+ reason: "Lets the runtime inspect or cancel retained DriveAgent jobs, including " +
93
+ "Desktop-backed background delegations whose completion is delivered through " +
94
+ "the owning Session.",
94
95
  },
95
96
  {
96
97
  tool: "EnterPlanMode",
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.4",
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",
@@ -38,6 +38,7 @@
38
38
  ],
39
39
  "scripts": {
40
40
  "build": "bun run clean && tsc -p tsconfig.json && bun run copy-assets",
41
+ "test": "bun test --timeout 30000 src",
41
42
  "typecheck": "tsc -p tsconfig.json --noEmit",
42
43
  "copy-assets": "node ../../scripts/copy-assets.mjs dist/prompt/sections src/prompt/sections/*.md && node ../../scripts/copy-assets.mjs dist/data src/data/*.json",
43
44
  "dev": "bun run copy-assets && tsc -p tsconfig.json --watch --preserveWatchOutput",
@@ -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
- }