@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.
- package/dist/cli/agent-server-stdio.js +26 -1
- package/dist/cli/agent-server-tcp.js +58 -7
- package/dist/engine/engine.d.ts +8 -0
- package/dist/engine/engine.js +16 -12
- package/dist/engine/model-facade.d.ts +3 -0
- package/dist/engine/model-facade.js +2 -0
- package/dist/engine/run-tooling.js +8 -8
- package/dist/engine/streaming-tool-queue.d.ts +4 -1
- package/dist/engine/streaming-tool-queue.js +17 -2
- package/dist/engine/turn-loop.d.ts +14 -5
- package/dist/engine/turn-loop.js +92 -37
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/llm/prompt-cache.d.ts +48 -0
- package/dist/llm/prompt-cache.js +100 -0
- package/dist/llm/providers/anthropic.d.ts +3 -0
- package/dist/llm/providers/anthropic.js +77 -53
- package/dist/llm/providers/openai.d.ts +7 -27
- package/dist/llm/providers/openai.js +120 -68
- package/dist/llm/types.d.ts +3 -0
- package/dist/onboarding.js +72 -49
- package/dist/panel-apps/manifest.d.ts +12 -12
- package/dist/profile/types.d.ts +26 -26
- 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/session/session-manager.js +30 -1
- package/dist/tool-system/builtin/agent-notifications.d.ts +25 -1
- package/dist/tool-system/builtin/agent-notifications.js +334 -2
- package/dist/tool-system/context.d.ts +9 -4
- package/dist/tool-system/external-tool-exposure.js +11 -10
- package/package.json +2 -1
- package/dist/services/notifier.d.ts +0 -33
- 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
|
-
/**
|
|
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
|
|
224
|
-
|
|
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
|
|
66
|
-
//
|
|
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
|
|
82
|
-
"
|
|
83
|
-
"
|
|
84
|
-
"bridge, which bounds nesting at one level; the outer call
|
|
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
|
|
92
|
-
"
|
|
93
|
-
"
|
|
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.
|
|
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
|
-
}
|