@tea-agent/loop-agent 0.23.1 → 0.24.0

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 (53) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/README.md +1 -1
  3. package/bin/agent-worker.js +0 -0
  4. package/dist/executors/shell-executor.js +20 -7
  5. package/dist/shared/operator/capabilities.js +475 -2
  6. package/dist/worker/console/app-data.js +2 -0
  7. package/dist/worker/console/chat/artifact-card.js +23 -0
  8. package/dist/worker/console/chat/chat-event-store.js +495 -0
  9. package/dist/worker/console/chat/chat-ui-policy.js +25 -0
  10. package/dist/worker/console/chat/composer-draft-store.js +45 -0
  11. package/dist/worker/console/chat/context-panel.js +54 -0
  12. package/dist/worker/console/chat/contract-apply-receipt-store.js +174 -0
  13. package/dist/worker/console/chat/explore-tools.js +299 -0
  14. package/dist/worker/console/chat/human-gate-card.js +37 -0
  15. package/dist/worker/console/chat/interview-adapter.js +136 -0
  16. package/dist/worker/console/chat/operation-card.js +23 -0
  17. package/dist/worker/console/chat/pi-console-config.js +158 -0
  18. package/dist/worker/console/chat/pi-runtime.js +581 -43
  19. package/dist/worker/console/chat/repo-browser.js +140 -0
  20. package/dist/worker/console/chat/repo-walk.js +116 -0
  21. package/dist/worker/console/chat/resource-loader.js +18 -17
  22. package/dist/worker/console/chat/routes.js +1354 -65
  23. package/dist/worker/console/chat/runtime-context.js +24 -0
  24. package/dist/worker/console/chat/runtime-selection.js +37 -0
  25. package/dist/worker/console/chat/session-store.js +210 -11
  26. package/dist/worker/console/chat/shortcuts.js +15 -0
  27. package/dist/worker/console/chat/tool-adapter.js +81 -194
  28. package/dist/worker/console/chat/tools.js +72 -48
  29. package/dist/worker/console/chat/usage.js +37 -0
  30. package/dist/worker/console/chat/workspace-landing.js +56 -0
  31. package/dist/worker/console/dag-confirmation.js +42 -8
  32. package/dist/worker/console/human-gate-token.js +130 -0
  33. package/dist/worker/console/mutation-gate-receipt-store.js +184 -0
  34. package/dist/worker/console/operation-runner.js +6 -2
  35. package/dist/worker/console/operation-sse.js +26 -0
  36. package/dist/worker/console/operator-actions.js +420 -7
  37. package/dist/worker/console/server.js +14 -2
  38. package/dist/worker/console/static/assets/index-BTbrEHnO.css +1 -0
  39. package/dist/worker/console/static/assets/index-D9qLevoP.js +27 -0
  40. package/dist/worker/console/static/index.html +2 -2
  41. package/dist/workflows/dag/backend-test-markdown-workflow.js +9 -5
  42. package/dist/workflows/dag/backend-test-result-contract.js +229 -0
  43. package/dist/workflows/dag/frontend-lint-baseline.js +4 -4
  44. package/dist/workflows/dag/init-hybrid.js +2 -1
  45. package/docs/README.md +1 -1
  46. package/docs/architecture/README.md +5 -5
  47. package/docs/architecture/evolution.md +4 -4
  48. package/docs/architecture/worker-and-feature.md +1 -1
  49. package/docs/templates/backend-test-dag.json +2 -2
  50. package/harness.json +1 -1
  51. package/package.json +1 -1
  52. package/dist/worker/console/static/assets/index-DVl7Jxt5.js +0 -25
  53. package/dist/worker/console/static/assets/index-lVcIr9Ju.css +0 -1
@@ -41,6 +41,7 @@ export function openConsoleAppData(options) {
41
41
  interviews: path.join(repoNamespace, "interviews"),
42
42
  chats: path.join(repoNamespace, "chats"),
43
43
  chatSessions: path.join(repoNamespace, "chat-sessions"),
44
+ chatEvents: path.join(repoNamespace, "chat-events"),
44
45
  };
45
46
  for (const dir of [
46
47
  paths.drafts,
@@ -51,6 +52,7 @@ export function openConsoleAppData(options) {
51
52
  paths.interviews,
52
53
  paths.chats,
53
54
  paths.chatSessions,
55
+ paths.chatEvents,
54
56
  ]) {
55
57
  ensureSecureDir(dir);
56
58
  }
@@ -0,0 +1,23 @@
1
+ export const ARTIFACT_KINDS = ["operation", "contract", "dag", "report", "html"];
2
+ export function artifactFromEventPayload(payload) {
3
+ const raw = payload.artifact;
4
+ if (!raw || typeof raw !== "object")
5
+ return undefined;
6
+ const candidate = raw;
7
+ if (candidate.schemaVersion !== 1 ||
8
+ typeof candidate.artifactId !== "string" || !candidate.artifactId ||
9
+ typeof candidate.kind !== "string" || !ARTIFACT_KINDS.includes(candidate.kind) ||
10
+ typeof candidate.previewHash !== "string")
11
+ return undefined;
12
+ for (const field of ["title", "summary", "htmlPreview", "reportText", "inspectHref"]) {
13
+ if (candidate[field] !== undefined && typeof candidate[field] !== "string")
14
+ return undefined;
15
+ }
16
+ return candidate;
17
+ }
18
+ export function mergeArtifactCardState(current, incoming) {
19
+ const index = current.findIndex((item) => item.artifactId === incoming.artifactId);
20
+ if (index < 0)
21
+ return [...current, incoming];
22
+ return current.map((item, candidate) => candidate === index ? { ...item, ...incoming } : item);
23
+ }
@@ -0,0 +1,495 @@
1
+ /**
2
+ * Operator Chat — persisted event ring + reconnect support (roadmap M1-T03/T04/T07).
3
+ *
4
+ * Each Chat session owns a bounded event ring persisted to app-data. Every
5
+ * event gets a monotonic `seq` and an `eventId = "<sessionId>:<seq>"`, so a
6
+ * browser that refreshes mid-stream (or reconnects after a network blip) can
7
+ * resume via the SSE `Last-Event-ID` header.
8
+ *
9
+ * Live turn streaming: while a turn is in flight, in-memory subscriber
10
+ * callbacks are notified so an open `GET /events` connection (re)plays the
11
+ * retained tail and then follows the live stream. When no turn is active the
12
+ * endpoint simply replays the ring and closes (idempotent snapshot).
13
+ *
14
+ * Stale-turn fence (T07): each persisted event records its `turnId`. A
15
+ * reconnecting client that lost its `turnId` mid-stream compares the ring's
16
+ * latest `turnId` against the one it last saw; a mismatch is surfaced as a
17
+ * `reconcile` signal so the UI does not render stale events under a new turn.
18
+ */
19
+ import { createHash } from "node:crypto";
20
+ import { existsSync, readFileSync, rmSync } from "node:fs";
21
+ import path from "node:path";
22
+ import { ensureSecureDir, writeSecureJsonSync, } from "../app-data.js";
23
+ import { isTerminalOperationState, } from "../operation-store.js";
24
+ import { scrubSecrets } from "./explore-tools.js";
25
+ const OPERATION_PREVIEW_MAX_CHARS = 1200;
26
+ function boundedRedactedText(value, maxChars = OPERATION_PREVIEW_MAX_CHARS) {
27
+ if (typeof value !== "string" || !value.trim())
28
+ return undefined;
29
+ const { scrubbed } = scrubSecrets(value);
30
+ return scrubbed.length <= maxChars
31
+ ? scrubbed
32
+ : `${scrubbed.slice(0, maxChars)}…[truncated]`;
33
+ }
34
+ function safeIdentity(value) {
35
+ return boundedRedactedText(value, 240);
36
+ }
37
+ function controllerSummary(operation) {
38
+ const identity = operation.controllerIdentity;
39
+ if (!identity || typeof identity !== "object")
40
+ return undefined;
41
+ const record = identity;
42
+ const fingerprint = typeof record.fingerprint === "string"
43
+ ? record.fingerprint
44
+ : typeof record.packageFingerprint?.value === "string"
45
+ ? String(record.packageFingerprint.value)
46
+ : undefined;
47
+ const label = [record.packageName, record.packageVersion, fingerprint]
48
+ .filter((part) => typeof part === "string" && part.length > 0)
49
+ .join("@");
50
+ return safeIdentity(label);
51
+ }
52
+ /** Chat/browser-safe projection. Never includes actionParams, CLI args, paths or raw results. */
53
+ export function projectOperationForChat(operation) {
54
+ const params = operation.actionParams ?? {};
55
+ const stdoutPreview = boundedRedactedText(operation.stdoutPreview);
56
+ const stderrPreview = boundedRedactedText(operation.stderrPreview);
57
+ const summaryPreview = boundedRedactedText(operation.errorMessage);
58
+ const taskId = safeIdentity(operation.taskId ?? params.taskId);
59
+ const dagRunId = safeIdentity(operation.dagRunId ?? params.dagRunId ?? params.runId);
60
+ const featureId = safeIdentity(params.featureId);
61
+ const workerRunId = safeIdentity(params.workerRunId);
62
+ const controller = controllerSummary(operation);
63
+ const previewHash = createHash("sha256")
64
+ .update(JSON.stringify({ stdoutPreview, stderrPreview, summaryPreview, state: operation.state }))
65
+ .digest("hex");
66
+ return {
67
+ schemaVersion: 1,
68
+ operationId: operation.operationId,
69
+ action: operation.action,
70
+ state: operation.state,
71
+ createdAt: operation.createdAt,
72
+ updatedAt: operation.finishedAt ?? operation.startedAt ?? operation.createdAt,
73
+ inspectHref: `/inspect/?operationId=${encodeURIComponent(operation.operationId)}#/`,
74
+ ...(taskId ? { taskId } : {}),
75
+ ...(dagRunId ? { dagRunId } : {}),
76
+ ...(featureId ? { featureId } : {}),
77
+ ...(workerRunId ? { workerRunId } : {}),
78
+ ...(controller ? { controller } : {}),
79
+ ...(stdoutPreview ? { stdoutPreview } : {}),
80
+ ...(stderrPreview ? { stderrPreview } : {}),
81
+ ...(summaryPreview ? { summaryPreview } : {}),
82
+ ...(operation.errorCode ? { errorCode: safeIdentity(operation.errorCode) } : {}),
83
+ previewHash,
84
+ };
85
+ }
86
+ export function projectCompactSnapshot(value) {
87
+ const record = value && typeof value === "object" ? value : {};
88
+ const summary = boundedRedactedText(record.summary, 12_000) ?? "Context compacted.";
89
+ const firstKeptEntryId = safeIdentity(record.firstKeptEntryId);
90
+ const tokensBefore = typeof record.tokensBefore === "number" && Number.isFinite(record.tokensBefore)
91
+ ? Math.max(0, Math.floor(record.tokensBefore))
92
+ : undefined;
93
+ const details = record.details === undefined ? undefined : boundedRedactedJson(record.details).preview;
94
+ return {
95
+ schemaVersion: 1,
96
+ summary,
97
+ ...(firstKeptEntryId ? { firstKeptEntryId } : {}),
98
+ ...(tokensBefore !== undefined ? { tokensBefore } : {}),
99
+ ...(details ? { details } : {}),
100
+ previewHash: createHash("sha256").update(JSON.stringify({ summary, firstKeptEntryId, tokensBefore, details })).digest("hex"),
101
+ };
102
+ }
103
+ function boundedRedactedJson(value) {
104
+ let raw;
105
+ try {
106
+ raw = JSON.stringify(value ?? {});
107
+ }
108
+ catch {
109
+ raw = String(value);
110
+ }
111
+ const preview = boundedRedactedText(raw) ?? "{}";
112
+ return {
113
+ preview,
114
+ previewHash: createHash("sha256").update(preview).digest("hex"),
115
+ };
116
+ }
117
+ export function projectChatToolEventPayload(input) {
118
+ const { preview, previewHash } = boundedRedactedJson(input.value);
119
+ if (input.kind === "tool_call") {
120
+ return {
121
+ toolCallId: input.toolCallId,
122
+ toolName: input.toolName,
123
+ argsPreview: preview,
124
+ previewHash,
125
+ };
126
+ }
127
+ return {
128
+ toolCallId: input.toolCallId,
129
+ toolName: input.toolName,
130
+ resultPreview: preview,
131
+ previewHash,
132
+ isError: Boolean(input.isError),
133
+ };
134
+ }
135
+ export function projectOperationEventSummary(event) {
136
+ const messagePreview = boundedRedactedText(event.message, 600);
137
+ const dataPreview = event.data === undefined
138
+ ? undefined
139
+ : boundedRedactedText(JSON.stringify(event.data), 600);
140
+ return {
141
+ seq: event.seq,
142
+ at: event.at,
143
+ kind: event.kind,
144
+ ...(event.state ? { state: event.state } : {}),
145
+ ...(messagePreview ? { messagePreview } : {}),
146
+ ...(dataPreview ? { dataPreview } : {}),
147
+ previewHash: createHash("sha256")
148
+ .update(JSON.stringify({ messagePreview, dataPreview, state: event.state, kind: event.kind }))
149
+ .digest("hex"),
150
+ };
151
+ }
152
+ const DEFAULT_MAX_EVENTS = 2000;
153
+ /** Make a sessionId safe to use as a filename component. */
154
+ function safeSessionId(sessionId) {
155
+ return sessionId.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 180);
156
+ }
157
+ /**
158
+ * Link durable Chat refs to the existing canonical operation store/event ring.
159
+ * This owns no operation state and schedules no work; it only projects facts.
160
+ */
161
+ export function createChatOperationLinker(options) {
162
+ const followers = new Map();
163
+ const keyFor = (sessionId, operationId) => `${sessionId}\u0000${operationId}`;
164
+ function hasRef(input) {
165
+ return options.chatEvents.snapshot(input.sessionId).some((event) => {
166
+ if (event.kind !== "operation-ref")
167
+ return false;
168
+ const data = event.data;
169
+ return event.turnId === input.turnId &&
170
+ data.operationId === input.operationId &&
171
+ data.toolCallId === input.toolCallId;
172
+ });
173
+ }
174
+ async function syncFact(input) {
175
+ const operation = await options.operations.get(input.operationId);
176
+ if (!operation)
177
+ return;
178
+ const focused = isTerminalOperationState(operation.state) || operation.state === "needs-reconcile";
179
+ if (!focused)
180
+ return;
181
+ const projection = projectOperationForChat(operation);
182
+ const closureKind = operation.state === "needs-reconcile"
183
+ ? "needs-reconcile"
184
+ : operation.state === "failed" || operation.state === "timed-out"
185
+ ? "failed"
186
+ : "terminal";
187
+ const duplicate = options.chatEvents.snapshot(input.sessionId).some((event) => {
188
+ if (event.kind !== "operation-status")
189
+ return false;
190
+ const data = event.data;
191
+ return data.operationId === input.operationId &&
192
+ data.closureKind === closureKind &&
193
+ data.operation?.state === projection.state &&
194
+ data.operation?.previewHash === projection.previewHash;
195
+ });
196
+ if (!duplicate) {
197
+ options.chatEvents.append(input.sessionId, input.turnId, {
198
+ kind: "operation-status",
199
+ data: {
200
+ operationId: input.operationId,
201
+ closureKind,
202
+ operation: projection,
203
+ },
204
+ });
205
+ }
206
+ if (isTerminalOperationState(operation.state)) {
207
+ const key = keyFor(input.sessionId, input.operationId);
208
+ followers.get(key)?.();
209
+ followers.delete(key);
210
+ }
211
+ }
212
+ async function link(input) {
213
+ const operation = await options.operations.get(input.operationId);
214
+ if (!operation)
215
+ throw new Error(`operation not found: ${input.operationId}`);
216
+ if (!hasRef(input)) {
217
+ options.chatEvents.append(input.sessionId, input.turnId, {
218
+ kind: "operation-ref",
219
+ data: {
220
+ operationId: input.operationId,
221
+ toolCallId: input.toolCallId,
222
+ operation: projectOperationForChat(operation),
223
+ },
224
+ });
225
+ }
226
+ const key = keyFor(input.sessionId, input.operationId);
227
+ if (!followers.has(key) && !isTerminalOperationState(operation.state)) {
228
+ const unsubscribe = options.operationEvents.subscribe(input.operationId, () => {
229
+ void syncFact(input).catch(() => {
230
+ // Recovery reads will retry from durable refs; never break operation append.
231
+ });
232
+ });
233
+ followers.set(key, unsubscribe);
234
+ }
235
+ await syncFact(input);
236
+ }
237
+ return {
238
+ link,
239
+ async recoverSession(sessionId) {
240
+ const refs = options.chatEvents.snapshot(sessionId).filter((event) => event.kind === "operation-ref");
241
+ for (const event of refs) {
242
+ const data = event.data;
243
+ if (!data.operationId || !data.toolCallId)
244
+ continue;
245
+ await link({
246
+ sessionId,
247
+ turnId: event.turnId,
248
+ toolCallId: data.toolCallId,
249
+ operationId: data.operationId,
250
+ }).catch(() => undefined);
251
+ }
252
+ },
253
+ clearSession(sessionId) {
254
+ for (const [key, unsubscribe] of followers) {
255
+ if (!key.startsWith(`${sessionId}\u0000`))
256
+ continue;
257
+ unsubscribe();
258
+ followers.delete(key);
259
+ }
260
+ },
261
+ dispose() {
262
+ for (const unsubscribe of followers.values())
263
+ unsubscribe();
264
+ followers.clear();
265
+ },
266
+ };
267
+ }
268
+ export function createChatEventStore(options) {
269
+ const maxEvents = options?.maxEventsPerSession ?? DEFAULT_MAX_EVENTS;
270
+ const persistenceDir = options?.appData?.chatEvents
271
+ ? path.resolve(options.appData.chatEvents)
272
+ : undefined;
273
+ if (persistenceDir)
274
+ ensureSecureDir(persistenceDir);
275
+ const rings = new Map();
276
+ /** Monotonic turn ordinal per session (persisted alongside the ring). */
277
+ const turnCounts = new Map();
278
+ /** Live subscribers per session. */
279
+ const subscribers = new Map();
280
+ function persistedPath(sessionId) {
281
+ return persistenceDir
282
+ ? path.join(persistenceDir, `${safeSessionId(sessionId)}.json`)
283
+ : undefined;
284
+ }
285
+ function loadRing(sessionId) {
286
+ const file = persistedPath(sessionId);
287
+ if (!file || !existsSync(file))
288
+ return undefined;
289
+ try {
290
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
291
+ if (parsed.schemaVersion !== 1 ||
292
+ parsed.sessionId !== sessionId ||
293
+ !Array.isArray(parsed.events)) {
294
+ throw new Error("invalid persisted chat event ring");
295
+ }
296
+ const events = parsed.events.slice(-maxEvents);
297
+ const turns = Array.isArray(parsed.turns) ? parsed.turns : [];
298
+ turnCounts.set(sessionId, parsed.turnCount ?? 0);
299
+ return {
300
+ events,
301
+ turns,
302
+ nextSeq: Math.max(parsed.nextSeq, (events[events.length - 1]?.seq ?? 0) + 1),
303
+ minSeq: events[0]?.seq ?? parsed.minSeq ?? 1,
304
+ maxEvents,
305
+ };
306
+ }
307
+ catch (error) {
308
+ throw new Error(`invalid persisted chat events ${file}: ${error instanceof Error ? error.message : String(error)}`);
309
+ }
310
+ }
311
+ function persist(sessionId, ring) {
312
+ const file = persistedPath(sessionId);
313
+ if (!file)
314
+ return;
315
+ writeSecureJsonSync(file, {
316
+ schemaVersion: 1,
317
+ sessionId,
318
+ turnCount: turnCounts.get(sessionId) ?? 0,
319
+ turns: ring.turns,
320
+ events: ring.events,
321
+ nextSeq: ring.nextSeq,
322
+ minSeq: ring.minSeq,
323
+ });
324
+ }
325
+ function ringFor(sessionId) {
326
+ let ring = rings.get(sessionId);
327
+ if (!ring) {
328
+ ring = loadRing(sessionId) ?? {
329
+ events: [],
330
+ turns: [],
331
+ nextSeq: 1,
332
+ minSeq: 1,
333
+ maxEvents,
334
+ };
335
+ rings.set(sessionId, ring);
336
+ }
337
+ return ring;
338
+ }
339
+ return {
340
+ forkContextRefs(input) {
341
+ const allowed = new Set([
342
+ "operation-ref", "operation-status", "draft", "assessment",
343
+ "taskKind-confirmed", "human-gate-card", "compact", "artifact-ref",
344
+ ]);
345
+ let copiedCount = 0;
346
+ for (const event of this.snapshot(input.sourceSessionId)) {
347
+ if (!allowed.has(event.kind))
348
+ continue;
349
+ const data = structuredClone(event.data);
350
+ if (event.kind === "human-gate-card") {
351
+ delete data.humanGateToken;
352
+ delete data.dagBytesPath;
353
+ }
354
+ this.append(input.targetSessionId, input.targetTurnId, { kind: event.kind, data: data });
355
+ copiedCount += 1;
356
+ }
357
+ return { copiedCount };
358
+ },
359
+ createTurn(sessionId) {
360
+ const ring = ringFor(sessionId);
361
+ const active = ring.turns.find((turn) => turn.state === "queued" || turn.state === "running");
362
+ if (active)
363
+ return { ok: false, code: "TURN_ACTIVE", activeTurn: active };
364
+ const ordinal = (turnCounts.get(sessionId) ?? 0) + 1;
365
+ turnCounts.set(sessionId, ordinal);
366
+ const now = new Date().toISOString();
367
+ const turn = {
368
+ schemaVersion: 1,
369
+ turnId: `${sessionId}:turn-${ordinal}`,
370
+ sessionId,
371
+ ordinal,
372
+ state: "queued",
373
+ createdAt: now,
374
+ };
375
+ ring.turns.push(turn);
376
+ persist(sessionId, ring);
377
+ return { ok: true, turn };
378
+ },
379
+ setTurnState(sessionId, turnId, state, error) {
380
+ const ring = ringFor(sessionId);
381
+ const turn = ring.turns.find((candidate) => candidate.turnId === turnId);
382
+ if (!turn)
383
+ return undefined;
384
+ turn.state = state;
385
+ if (state === "running" && !turn.startedAt)
386
+ turn.startedAt = new Date().toISOString();
387
+ if (state === "settled" || state === "aborted" || state === "failed") {
388
+ turn.finishedAt = new Date().toISOString();
389
+ }
390
+ if (error)
391
+ turn.error = error;
392
+ persist(sessionId, ring);
393
+ return { ...turn };
394
+ },
395
+ getTurn(sessionId, turnId) {
396
+ const ring = ringFor(sessionId);
397
+ const turn = ring.turns.find((candidate) => candidate.turnId === turnId);
398
+ return turn ? { ...turn } : undefined;
399
+ },
400
+ getActiveTurn(sessionId) {
401
+ const ring = ringFor(sessionId);
402
+ const turn = ring.turns.find((candidate) => candidate.state === "queued" || candidate.state === "running");
403
+ return turn ? { ...turn } : undefined;
404
+ },
405
+ append(sessionId, turnId, partial) {
406
+ const ring = ringFor(sessionId);
407
+ const seq = ring.nextSeq++;
408
+ const event = {
409
+ schemaVersion: 1,
410
+ eventId: `${sessionId}:${seq}`,
411
+ sessionId,
412
+ seq,
413
+ turnId,
414
+ at: partial.at ?? new Date().toISOString(),
415
+ kind: partial.kind,
416
+ data: partial.data,
417
+ };
418
+ ring.events.push(event);
419
+ while (ring.events.length > ring.maxEvents) {
420
+ const dropped = ring.events.shift();
421
+ if (dropped)
422
+ ring.minSeq = dropped.seq + 1;
423
+ }
424
+ if (ring.events.length > 0) {
425
+ ring.minSeq = ring.events[0].seq;
426
+ }
427
+ persist(sessionId, ring);
428
+ const subs = subscribers.get(sessionId);
429
+ if (subs) {
430
+ for (const fn of subs) {
431
+ try {
432
+ fn(event);
433
+ }
434
+ catch {
435
+ // a subscriber throwing must never break the append path
436
+ }
437
+ }
438
+ }
439
+ return event;
440
+ },
441
+ listFrom(sessionId, afterSeq) {
442
+ const ring = rings.get(sessionId) ?? loadRing(sessionId);
443
+ if (!ring)
444
+ return { events: [] };
445
+ rings.set(sessionId, ring);
446
+ if (ring.events.length > 0 && afterSeq + 1 < ring.minSeq) {
447
+ return { error: "CURSOR_EXPIRED", minSeq: ring.minSeq };
448
+ }
449
+ return { events: ring.events.filter((e) => e.seq > afterSeq) };
450
+ },
451
+ snapshot(sessionId) {
452
+ const ring = rings.get(sessionId) ?? loadRing(sessionId);
453
+ if (ring)
454
+ rings.set(sessionId, ring);
455
+ return ring?.events.slice() ?? [];
456
+ },
457
+ nextTurnOrdinal(sessionId) {
458
+ // Load the ring FIRST so a fresh store instance hydrates turnCounts from
459
+ // the persisted file before we read it (otherwise a restart resets the
460
+ // ordinal to 1 and collides with prior turns).
461
+ const ring = ringFor(sessionId);
462
+ const next = (turnCounts.get(sessionId) ?? 0) + 1;
463
+ turnCounts.set(sessionId, next);
464
+ persist(sessionId, ring);
465
+ return next;
466
+ },
467
+ latestTurnId(sessionId) {
468
+ const ring = rings.get(sessionId) ?? loadRing(sessionId);
469
+ if (ring)
470
+ rings.set(sessionId, ring);
471
+ return ring?.events.at(-1)?.turnId;
472
+ },
473
+ subscribe(sessionId, listener) {
474
+ let subs = subscribers.get(sessionId);
475
+ if (!subs) {
476
+ subs = new Set();
477
+ subscribers.set(sessionId, subs);
478
+ }
479
+ subs.add(listener);
480
+ return () => {
481
+ subs?.delete(listener);
482
+ if (subs && subs.size === 0)
483
+ subscribers.delete(sessionId);
484
+ };
485
+ },
486
+ clear(sessionId) {
487
+ rings.delete(sessionId);
488
+ turnCounts.delete(sessionId);
489
+ subscribers.delete(sessionId);
490
+ const file = persistedPath(sessionId);
491
+ if (file)
492
+ rmSync(file, { force: true });
493
+ },
494
+ };
495
+ }
@@ -0,0 +1,25 @@
1
+ export function advanceIndicator(state, event) {
2
+ if (event.eventId && event.eventId === state.lastEventId)
3
+ return state;
4
+ if (event.kind === "visible")
5
+ return { ...state, unread: 0 };
6
+ const running = event.kind === "agent_start"
7
+ ? true
8
+ : event.kind === "agent_settled" || event.kind === "error"
9
+ ? false
10
+ : state.running;
11
+ const unread = event.hidden && (event.kind === "message_end" || event.kind === "agent_settled")
12
+ ? state.unread + 1
13
+ : state.unread;
14
+ return { running, unread, ...(event.eventId ? { lastEventId: event.eventId } : { lastEventId: state.lastEventId }) };
15
+ }
16
+ export function shouldPlayCompletionSound(input) {
17
+ return input.enabled && input.kind === "agent_settled" && !input.replayed;
18
+ }
19
+ export function copyTextForMessage(input) {
20
+ return input.code ?? input.text;
21
+ }
22
+ /** Empty sandbox means scripts, same-origin access, navigation and popups are denied. */
23
+ export function previewSandbox() {
24
+ return { sandbox: "" };
25
+ }
@@ -0,0 +1,45 @@
1
+ import path from "node:path";
2
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ const MAX_DRAFT_BYTES = 64 * 1024;
4
+ function safeSessionId(sessionId) {
5
+ if (!/^[A-Za-z0-9_-]+$/.test(sessionId))
6
+ throw new Error("invalid chat session id");
7
+ return sessionId;
8
+ }
9
+ export class ComposerDraftStore {
10
+ chains = new Map();
11
+ dir;
12
+ constructor(appData) {
13
+ this.dir = path.join(appData.chats, ".composer-drafts");
14
+ }
15
+ file(sessionId) {
16
+ return path.join(this.dir, `${safeSessionId(sessionId)}.txt`);
17
+ }
18
+ serialize(sessionId, op) {
19
+ const previous = this.chains.get(sessionId) ?? Promise.resolve();
20
+ const next = previous.then(op, op);
21
+ this.chains.set(sessionId, next.then(() => undefined, () => undefined));
22
+ return next;
23
+ }
24
+ async get(sessionId) {
25
+ try {
26
+ return await readFile(this.file(sessionId), "utf8");
27
+ }
28
+ catch {
29
+ return "";
30
+ }
31
+ }
32
+ async save(sessionId, text) {
33
+ if (Buffer.byteLength(text, "utf8") > MAX_DRAFT_BYTES)
34
+ throw new Error(`composer draft exceeds ${MAX_DRAFT_BYTES} bytes`);
35
+ await this.serialize(sessionId, async () => {
36
+ if (!text) {
37
+ await rm(this.file(sessionId), { force: true });
38
+ return;
39
+ }
40
+ await mkdir(this.dir, { recursive: true, mode: 0o700 });
41
+ await writeFile(this.file(sessionId), text, { encoding: "utf8", mode: 0o600 });
42
+ });
43
+ }
44
+ async remove(sessionId) { await this.save(sessionId, ""); }
45
+ }
@@ -0,0 +1,54 @@
1
+ import { humanGateCardFromEventPayload } from "./human-gate-card.js";
2
+ import { mergeOperationCardState, operationFromEventPayload } from "./operation-card.js";
3
+ function taskIdOf(event) {
4
+ if (!["interview-turn", "draft", "assessment", "taskKind-confirmed", "human-gate-card"].includes(event.kind))
5
+ return undefined;
6
+ const value = event.data.taskId;
7
+ return typeof value === "string" && value ? value : undefined;
8
+ }
9
+ function safeGate(card) {
10
+ const { humanGateToken: _token, ...safe } = card;
11
+ return safe;
12
+ }
13
+ export function projectTaskContext(input) {
14
+ const events = [...input].sort((a, b) => a.seq - b.seq);
15
+ const latest = events.at(-1);
16
+ let activeTaskId;
17
+ for (const event of events)
18
+ activeTaskId = taskIdOf(event) ?? activeTaskId;
19
+ const view = { schemaVersion: 1, operations: [], ...(latest ? { sessionId: latest.sessionId, lastEventId: latest.eventId, updatedAt: latest.at } : {}), ...(activeTaskId ? { taskId: activeTaskId } : {}) };
20
+ const gates = new Map();
21
+ for (const event of events) {
22
+ const eventTaskId = taskIdOf(event);
23
+ if (eventTaskId && activeTaskId && eventTaskId !== activeTaskId)
24
+ continue;
25
+ const data = event.data;
26
+ if (event.kind === "interview-turn")
27
+ view.interview = { interviewSessionId: typeof data.interviewSessionId === "string" ? data.interviewSessionId : undefined, state: typeof data.state === "string" ? data.state : undefined, question: data.question && typeof data.question === "object" ? data.question : undefined };
28
+ else if (event.kind === "draft" && typeof data.draftSha256 === "string" && data.draft && typeof data.draft === "object")
29
+ view.draft = { value: data.draft, draftSha256: data.draftSha256, ...(typeof data.taskKindRecommendation === "string" ? { taskKindRecommendation: data.taskKindRecommendation } : {}) };
30
+ else if (event.kind === "assessment" && typeof data.outcome === "string")
31
+ view.assessment = { outcome: data.outcome, fresh: data.fresh === true, missingFields: Array.isArray(data.missingFields) ? data.missingFields.map(String) : [], ...(typeof data.reason === "string" ? { reason: data.reason } : {}), ...(typeof data.assessmentId === "string" ? { assessmentId: data.assessmentId } : {}) };
32
+ else if (event.kind === "taskKind-confirmed" && typeof data.taskKind === "string" && typeof data.confirmedAt === "string")
33
+ view.taskKind = { value: data.taskKind, confirmedAt: data.confirmedAt, confirmedBy: "human" };
34
+ else if (event.kind === "human-gate-card") {
35
+ const card = humanGateCardFromEventPayload(data);
36
+ if (!card)
37
+ continue;
38
+ const previous = gates.get(card.cardId);
39
+ gates.set(card.cardId, safeGate({ ...card, ...(previous?.dagSpine && !card.dagSpine ? { dagSpine: previous.dagSpine } : {}) }));
40
+ }
41
+ else if (event.kind === "operation-ref" || event.kind === "operation-status") {
42
+ const operation = operationFromEventPayload(data);
43
+ if (operation && (!activeTaskId || !operation.taskId || operation.taskId === activeTaskId))
44
+ view.operations = mergeOperationCardState(view.operations, operation);
45
+ }
46
+ }
47
+ for (const gate of gates.values()) {
48
+ if (gate.gateType === "contract-apply")
49
+ view.contract = { diffSummary: gate.contractDiffSummary, revision: gate.contractRevision, confirmationState: gate.state, gate };
50
+ else
51
+ view.dag = { spine: gate.dagSpine, confirmationState: gate.state, gate };
52
+ }
53
+ return view;
54
+ }