chainlesschain 0.47.6 → 0.47.8

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 (107) hide show
  1. package/package.json +2 -2
  2. package/src/assets/web-panel/.build-hash +1 -1
  3. package/src/assets/web-panel/assets/Analytics-BFI7jbwM.css +1 -0
  4. package/src/assets/web-panel/assets/Analytics-DQ135mAd.js +3 -0
  5. package/src/assets/web-panel/assets/AppLayout-6SPt_8Y_.js +1 -0
  6. package/src/assets/web-panel/assets/AppLayout-BFJ-Fofn.css +1 -0
  7. package/src/assets/web-panel/assets/{Backup-Ba9UybpT.js → Backup-DbVRG5vE.js} +1 -1
  8. package/src/assets/web-panel/assets/{Chat-BwXskT21.js → Chat-wVhrFK9C.js} +1 -1
  9. package/src/assets/web-panel/assets/{Cowork-UmOe7qvE.js → Cowork-lOC25IW2.js} +1 -1
  10. package/src/assets/web-panel/assets/{Cron-JHS-rc-4.js → Cron-3P0eVLTV.js} +1 -1
  11. package/src/assets/web-panel/assets/{Dashboard-B95cMCO7.js → Dashboard-Br7kCwKJ.js} +1 -1
  12. package/src/assets/web-panel/assets/{Git-CSYO0_zk.js → Git-CrDCcBig.js} +2 -2
  13. package/src/assets/web-panel/assets/{Logs-Hxw_K0km.js → Logs-BfTE8urP.js} +1 -1
  14. package/src/assets/web-panel/assets/{McpTools-DIE75TrB.js → McpTools-CsGIijNe.js} +1 -1
  15. package/src/assets/web-panel/assets/{Memory-C4KVnLlp.js → Memory-BXX_yMKJ.js} +1 -1
  16. package/src/assets/web-panel/assets/{Notes-DuzrHMAk.js → Notes-DU6Vf2cL.js} +1 -1
  17. package/src/assets/web-panel/assets/{Organization-DTq6uF82.js → Organization-Bny6yOPV.js} +4 -4
  18. package/src/assets/web-panel/assets/{P2P-C0hjlhsR.js → P2P-BxFZ1Bit.js} +2 -2
  19. package/src/assets/web-panel/assets/{Permissions-Ec0NH-xC.js → Permissions-B1j3Mtms.js} +3 -3
  20. package/src/assets/web-panel/assets/{Projects-U8D0asCS.js → Projects-D-CGscDu.js} +1 -1
  21. package/src/assets/web-panel/assets/{Providers-BngtTLvJ.js → Providers-r6NaBYMf.js} +1 -1
  22. package/src/assets/web-panel/assets/{RssFeed-B9NbwCKM.js → RssFeed-D7b68C5q.js} +1 -1
  23. package/src/assets/web-panel/assets/{Security-BL5Rkr1T.js → Security-MJfKv0EJ.js} +3 -3
  24. package/src/assets/web-panel/assets/{Services-D4MJzLld.js → Services-Yb_Q1V3d.js} +1 -1
  25. package/src/assets/web-panel/assets/{Skills-CQTOMDwF.js → Skills-DLTHcH5T.js} +1 -1
  26. package/src/assets/web-panel/assets/{Tasks-DepbJMnL.js → Tasks-CqycpPjS.js} +1 -1
  27. package/src/assets/web-panel/assets/{Templates-C24PVZPu.js → Templates-y01u2Zis.js} +1 -1
  28. package/src/assets/web-panel/assets/VideoEditing-BA1N-5kq.css +1 -0
  29. package/src/assets/web-panel/assets/VideoEditing-B_nPKw6B.js +1 -0
  30. package/src/assets/web-panel/assets/{Wallet-PQoSpN_P.js → Wallet-CsRgnjJY.js} +1 -1
  31. package/src/assets/web-panel/assets/{WebAuthn-BcuyQ4Lr.js → WebAuthn-DWoR5ADp.js} +1 -1
  32. package/src/assets/web-panel/assets/{WorkflowEditor-C-SvXbHW.js → WorkflowEditor-DBJhFPMN.js} +1 -1
  33. package/src/assets/web-panel/assets/{antd-DEjZPGMj.js → antd-Dh2t0vGq.js} +84 -84
  34. package/src/assets/web-panel/assets/index-tN-8TosE.js +2 -0
  35. package/src/assets/web-panel/assets/{markdown-CusdXFxb.js → markdown-CBnGGMzE.js} +1 -1
  36. package/src/assets/web-panel/index.html +2 -2
  37. package/src/commands/agent.js +20 -0
  38. package/src/commands/mcp.js +86 -4
  39. package/src/commands/memory.js +85 -4
  40. package/src/commands/sandbox.js +80 -6
  41. package/src/commands/serve.js +10 -0
  42. package/src/commands/session.js +250 -0
  43. package/src/commands/stream.js +75 -0
  44. package/src/commands/video.js +363 -0
  45. package/src/gateways/http/envelope-http-server.js +194 -0
  46. package/src/gateways/ws/message-dispatcher.js +123 -0
  47. package/src/gateways/ws/session-core-protocol.js +427 -0
  48. package/src/gateways/ws/session-protocol.js +42 -1
  49. package/src/gateways/ws/video-protocol.js +230 -0
  50. package/src/gateways/ws/ws-server.js +72 -0
  51. package/src/gateways/ws/ws-session-gateway.js +7 -3
  52. package/src/harness/jsonl-session-store.js +17 -9
  53. package/src/index.js +8 -0
  54. package/src/lib/agent-stream.js +63 -0
  55. package/src/lib/chat-core.js +183 -6
  56. package/src/lib/cowork/ab-comparator-cli.js +44 -23
  57. package/src/lib/cowork/agent-group-runner.js +145 -0
  58. package/src/lib/cowork/debate-review-cli.js +47 -25
  59. package/src/lib/cowork/project-style-analyzer-cli.js +34 -7
  60. package/src/lib/interaction-adapter.js +59 -1
  61. package/src/lib/jsonl-session-store.js +2 -0
  62. package/src/lib/memory-injection.js +90 -0
  63. package/src/lib/provider-stream.js +120 -0
  64. package/src/lib/sandbox-v2.js +198 -3
  65. package/src/lib/session-consolidator.js +125 -0
  66. package/src/lib/session-core-singletons.js +56 -0
  67. package/src/lib/session-tail.js +128 -0
  68. package/src/lib/session-usage.js +166 -0
  69. package/src/lib/shell-approval.js +96 -0
  70. package/src/lib/ws-chat-handler.js +3 -0
  71. package/src/repl/agent-repl.js +294 -6
  72. package/src/repl/chat-repl.js +87 -100
  73. package/src/runtime/agent-core.js +98 -15
  74. package/src/runtime/agent-runtime.js +105 -3
  75. package/src/runtime/policies/agent-policy.js +10 -0
  76. package/src/skills/video-editing/SKILL.md +46 -0
  77. package/src/skills/video-editing/beat-snap.js +127 -0
  78. package/src/skills/video-editing/extractors/audio-extractor.js +212 -0
  79. package/src/skills/video-editing/extractors/subtitle-extractor.js +90 -0
  80. package/src/skills/video-editing/extractors/video-extractor.js +137 -0
  81. package/src/skills/video-editing/parallel-orchestrator.js +212 -0
  82. package/src/skills/video-editing/pipeline.js +480 -0
  83. package/src/skills/video-editing/prompts/aesthetic-analysis.md +21 -0
  84. package/src/skills/video-editing/prompts/audio-segment.md +15 -0
  85. package/src/skills/video-editing/prompts/character-identify.md +19 -0
  86. package/src/skills/video-editing/prompts/dense-caption.md +20 -0
  87. package/src/skills/video-editing/prompts/editor-system.md +29 -0
  88. package/src/skills/video-editing/prompts/hook-dialogue.md +17 -0
  89. package/src/skills/video-editing/prompts/protagonist-detect.md +20 -0
  90. package/src/skills/video-editing/prompts/scene-caption.md +16 -0
  91. package/src/skills/video-editing/prompts/shot-caption.md +25 -0
  92. package/src/skills/video-editing/prompts/shot-plan.md +28 -0
  93. package/src/skills/video-editing/prompts/structure-proposal.md +16 -0
  94. package/src/skills/video-editing/prompts/vlog-scene-caption.md +18 -0
  95. package/src/skills/video-editing/render/audio-mix.js +128 -0
  96. package/src/skills/video-editing/render/ffmpeg-concat.js +45 -0
  97. package/src/skills/video-editing/render/ffmpeg-extract.js +67 -0
  98. package/src/skills/video-editing/reviewer.js +161 -0
  99. package/src/skills/video-editing/tools/commit.js +108 -0
  100. package/src/skills/video-editing/tools/review-clip.js +46 -0
  101. package/src/skills/video-editing/tools/semantic-retrieval.js +56 -0
  102. package/src/skills/video-editing/tools/shot-trimming.js +73 -0
  103. package/src/assets/web-panel/assets/Analytics-B4OM8S8X.css +0 -1
  104. package/src/assets/web-panel/assets/Analytics-DgypYeUB.js +0 -3
  105. package/src/assets/web-panel/assets/AppLayout-Bzf3mSZI.js +0 -1
  106. package/src/assets/web-panel/assets/AppLayout-DQyDwGut.css +0 -1
  107. package/src/assets/web-panel/assets/index-CwvzTTw_.js +0 -2
@@ -34,6 +34,7 @@ import {
34
34
  appendUserMessage,
35
35
  appendAssistantMessage,
36
36
  appendCompactEvent,
37
+ appendTokenUsage,
37
38
  rebuildMessages,
38
39
  sessionExists,
39
40
  } from "../harness/jsonl-session-store.js";
@@ -70,18 +71,24 @@ import {
70
71
  */
71
72
  let _hookDb = null;
72
73
  let _compressor = null;
74
+ let _approvalGate = null;
73
75
 
74
76
  /**
75
77
  * Execute a tool call — delegates to agent-core with REPL's hookDb and cwd.
76
78
  */
77
79
  async function executeTool(name, args) {
78
- return coreExecuteTool(name, args, { hookDb: _hookDb, cwd: process.cwd() });
80
+ return coreExecuteTool(name, args, {
81
+ hookDb: _hookDb,
82
+ cwd: process.cwd(),
83
+ approvalGate: _approvalGate,
84
+ });
79
85
  }
80
86
 
81
87
  /**
82
88
  * Agentic loop — wraps agent-core's async generator with REPL display output.
83
89
  */
84
90
  async function agentLoop(messages, options) {
91
+ const usageEvents = [];
85
92
  for await (const event of coreAgentLoop(messages, options)) {
86
93
  if (event.type === "tool-executing") {
87
94
  process.stdout.write(
@@ -94,9 +101,34 @@ async function agentLoop(messages, options) {
94
101
  process.stdout.write(
95
102
  chalk.red(` Error: ${event.error || event.result?.error}\n`),
96
103
  );
104
+ // Parity with Desktop AIChatPage's `Switch to Trusted` button:
105
+ // when the deny came from ApprovalGate (not shell-policy), surface
106
+ // the exact CLI command the user can run to relax the per-session
107
+ // policy. The structured `approval` outcome is attached by
108
+ // `evaluateShellCommandWithApproval` in agent-core.js.
109
+ const approval = event.result?.approval;
110
+ if (approval?.decision === "deny" && approval?.via !== "shell-policy") {
111
+ const sid = options?.sessionId;
112
+ const policy = approval.policy || "strict";
113
+ if (sid && policy === "strict") {
114
+ process.stdout.write(
115
+ chalk.yellow(
116
+ ` Hint: relax policy with cc session policy ${sid} --set trusted\n`,
117
+ ),
118
+ );
119
+ } else if (sid) {
120
+ process.stdout.write(
121
+ chalk.yellow(
122
+ ` Hint: per-session policy is "${policy}" — see cc session policy ${sid}\n`,
123
+ ),
124
+ );
125
+ }
126
+ }
97
127
  } else if (event.result?.success) {
98
128
  process.stdout.write(chalk.green(` Done\n`));
99
129
  }
130
+ } else if (event.type === "token-usage") {
131
+ usageEvents.push(event);
100
132
  } else if (event.type === "iteration-warning") {
101
133
  process.stdout.write(chalk.yellow(`\n ${event.message}\n`));
102
134
  } else if (event.type === "iteration-budget-exhausted") {
@@ -104,10 +136,10 @@ async function agentLoop(messages, options) {
104
136
  chalk.red(`\n [Budget Exhausted] ${event.budget}\n`),
105
137
  );
106
138
  } else if (event.type === "response-complete") {
107
- return event.content;
139
+ return { content: event.content, usageEvents };
108
140
  }
109
141
  }
110
- return "";
142
+ return { content: "", usageEvents };
111
143
  }
112
144
 
113
145
  /**
@@ -155,6 +187,39 @@ export async function startAgentRepl(options = {}) {
155
187
  // Set hook DB reference for tool pipeline
156
188
  _hookDb = db;
157
189
 
190
+ // Wire the persistent ApprovalGate singleton (approval-policies.json) with
191
+ // a readline confirm prompt. agent-core's run_shell branch gates
192
+ // MEDIUM/HIGH-risk commands against the session's policy tier
193
+ // (strict / trusted / autopilot).
194
+ try {
195
+ const { getApprovalGate } =
196
+ await import("../lib/session-core-singletons.js");
197
+ _approvalGate = await getApprovalGate();
198
+ if (typeof _approvalGate.setConfirmer === "function") {
199
+ _approvalGate.setConfirmer(async ({ args, riskLevel }) => {
200
+ const rlConfirm = readline.createInterface({
201
+ input: process.stdin,
202
+ output: process.stdout,
203
+ });
204
+ const q = (p) => new Promise((res) => rlConfirm.question(p, res));
205
+ const cmd = args?.command ? ` ${args.command}` : "";
206
+ const ans = (
207
+ await q(
208
+ chalk.yellow(
209
+ `\n[ApprovalGate] ${riskLevel || "medium"} risk command:${cmd}\n Proceed? (y/N) `,
210
+ ),
211
+ )
212
+ )
213
+ .trim()
214
+ .toLowerCase();
215
+ rlConfirm.close();
216
+ return ans === "y" || ans === "yes";
217
+ });
218
+ }
219
+ } catch (_err) {
220
+ _approvalGate = null;
221
+ }
222
+
158
223
  // Resume existing session or create new one
159
224
  const useJsonl = feature("JSONL_SESSION");
160
225
 
@@ -200,10 +265,177 @@ export async function startAgentRepl(options = {}) {
200
265
  }
201
266
  }
202
267
 
268
+ // Phase H — register this session with session-core SessionManager so
269
+ // `cc session lifecycle / park / unpark / end` can see and control it.
270
+ // Resume a previously parked handle if --session points at one; otherwise
271
+ // create a fresh handle keyed by the JSONL sessionId.
272
+ let _sessionMgr = null;
273
+ let _sessionHandle = null;
274
+ try {
275
+ const { getSessionManager } =
276
+ await import("../lib/session-core-singletons.js");
277
+ _sessionMgr = getSessionManager();
278
+ if (sessionId) {
279
+ if (options.sessionId && !_sessionMgr.has(sessionId)) {
280
+ // Try unparking; no-op if nothing parked with that id
281
+ try {
282
+ await _sessionMgr.resume(sessionId);
283
+ } catch (_e) {
284
+ /* non-critical */
285
+ }
286
+ }
287
+ if (!_sessionMgr.has(sessionId)) {
288
+ _sessionHandle = _sessionMgr.create({
289
+ agentId: options.agentId || "cli-agent",
290
+ sessionId,
291
+ metadata: { provider, model },
292
+ });
293
+ } else {
294
+ _sessionHandle = _sessionMgr.get(sessionId);
295
+ }
296
+ }
297
+ } catch (_err) {
298
+ // Non-critical — SessionManager integration must not block startup
299
+ }
300
+
203
301
  const messages = [
204
302
  { role: "system", content: buildSystemPrompt(process.cwd()) },
205
303
  ];
206
304
 
305
+ // Deep Agents Deploy Phase 1 — load agent bundle if --bundle provided.
306
+ // Injects AGENTS.md as system prompt, seeds USER.md into MemoryStore,
307
+ // and applies bundle manifest metadata (model/provider override, agentId).
308
+ let _bundleResolved = null;
309
+ let _bundleMcpClient = null;
310
+ if (options.bundlePath) {
311
+ try {
312
+ const { loadBundle } =
313
+ await import("@chainlesschain/session-core/agent-bundle-loader");
314
+ const { resolveBundle } =
315
+ await import("@chainlesschain/session-core/agent-bundle-resolver");
316
+ const { getMemoryStore } =
317
+ await import("../lib/session-core-singletons.js");
318
+ const bundle = loadBundle(options.bundlePath);
319
+
320
+ const memoryStore = getMemoryStore();
321
+ _bundleResolved = resolveBundle(bundle, {
322
+ memoryStore,
323
+ seedOptions: {
324
+ userId: options.agentId || null,
325
+ },
326
+ });
327
+
328
+ if (_bundleResolved.systemPrompt) {
329
+ messages.push({
330
+ role: "system",
331
+ content: _bundleResolved.systemPrompt,
332
+ });
333
+ }
334
+
335
+ if (_bundleResolved.manifest) {
336
+ if (_bundleResolved.manifest.model && !options.model) {
337
+ model = _bundleResolved.manifest.model;
338
+ }
339
+ if (_bundleResolved.manifest.provider && !options.provider) {
340
+ provider = _bundleResolved.manifest.provider;
341
+ }
342
+ }
343
+
344
+ // Connect bundle MCP servers (stdio transport, local mode only).
345
+ const mcpServers = _bundleResolved.mcpConfig?.servers;
346
+ if (mcpServers && typeof mcpServers === "object") {
347
+ const serverEntries = Object.entries(mcpServers).filter(
348
+ ([, cfg]) => cfg && cfg.command,
349
+ );
350
+ if (serverEntries.length > 0) {
351
+ try {
352
+ const { MCPClient } = await import("../harness/mcp-client.js");
353
+ _bundleMcpClient = new MCPClient();
354
+ let connected = 0;
355
+ for (const [name, cfg] of serverEntries) {
356
+ try {
357
+ await _bundleMcpClient.connect(name, cfg);
358
+ connected += 1;
359
+ } catch (mcpErr) {
360
+ logger.log(
361
+ chalk.yellow(
362
+ `Bundle MCP: "${name}" connect failed — ${mcpErr.message}`,
363
+ ),
364
+ );
365
+ }
366
+ }
367
+ if (connected === 0) {
368
+ await _bundleMcpClient.disconnectAll().catch(() => undefined);
369
+ _bundleMcpClient = null;
370
+ }
371
+ } catch (mcpInitErr) {
372
+ logger.log(
373
+ chalk.yellow(`Bundle MCP: init failed — ${mcpInitErr.message}`),
374
+ );
375
+ _bundleMcpClient = null;
376
+ }
377
+ }
378
+ }
379
+
380
+ const seedInfo = _bundleResolved.seedResult;
381
+ const seedMsg =
382
+ seedInfo && seedInfo.seeded > 0
383
+ ? `, seeded ${seedInfo.seeded} user memories`
384
+ : "";
385
+ const mcpMsg = _bundleMcpClient
386
+ ? `, ${_bundleMcpClient.servers.size} MCP servers`
387
+ : "";
388
+ const warnMsg =
389
+ _bundleResolved.warnings.length > 0
390
+ ? ` (${_bundleResolved.warnings.length} warnings)`
391
+ : "";
392
+ logger.log(
393
+ chalk.gray(
394
+ `Bundle: loaded ${_bundleResolved.manifest?.id || path.basename(options.bundlePath)}${seedMsg}${mcpMsg}${warnMsg}`,
395
+ ),
396
+ );
397
+ } catch (err) {
398
+ logger.log(chalk.red(`Bundle: failed to load — ${err.message}`));
399
+ }
400
+ }
401
+
402
+ // Apply bundle approval policy to this session (after both gate and sessionId are ready)
403
+ if (_bundleResolved?.approvalPolicy?.default && _approvalGate && sessionId) {
404
+ try {
405
+ _approvalGate.setSessionPolicy(
406
+ sessionId,
407
+ _bundleResolved.approvalPolicy.default,
408
+ );
409
+ } catch (_err) {
410
+ // Non-critical — invalid policy value is silently ignored
411
+ }
412
+ }
413
+
414
+ // Phase G #5 — inject top-K memory recall into system prompt for new sessions
415
+ // Skip on resume (existing context already reflects prior work) and when
416
+ // --no-recall-memory is passed.
417
+ if (!options.sessionId && options.recallMemory !== false) {
418
+ try {
419
+ const { buildMemoryInjection } =
420
+ await import("../lib/memory-injection.js");
421
+ const injection = buildMemoryInjection({
422
+ agentId: options.agentId || null,
423
+ query: options.recallQuery || "",
424
+ limit: Number(options.recallLimit) || undefined,
425
+ });
426
+ if (injection) {
427
+ messages.push({ role: injection.role, content: injection.content });
428
+ logger.log(
429
+ chalk.gray(
430
+ `Context: recalled ${injection.count} memory entries into system prompt`,
431
+ ),
432
+ );
433
+ }
434
+ } catch (_err) {
435
+ // Non-critical — memory recall failure must not block REPL startup
436
+ }
437
+ }
438
+
207
439
  // Load resumed session messages
208
440
  if (options.sessionId && sessionId) {
209
441
  try {
@@ -1235,7 +1467,7 @@ export async function startAgentRepl(options = {}) {
1235
1467
  try {
1236
1468
  process.stdout.write("\n");
1237
1469
  const iterationBudget = new IterationBudget({ owner: sessionId });
1238
- const response = await agentLoop(messages, {
1470
+ const { content: response, usageEvents } = await agentLoop(messages, {
1239
1471
  provider,
1240
1472
  model: activeModel,
1241
1473
  baseUrl,
@@ -1245,8 +1477,24 @@ export async function startAgentRepl(options = {}) {
1245
1477
  sessionId,
1246
1478
  cwd: process.cwd(),
1247
1479
  prepareCall: defaultPrepareCall,
1480
+ approvalGate: _approvalGate,
1481
+ mcpClient: _bundleMcpClient || undefined,
1248
1482
  });
1249
1483
 
1484
+ if (sessionId && usageEvents?.length) {
1485
+ for (const ue of usageEvents) {
1486
+ try {
1487
+ appendTokenUsage(sessionId, {
1488
+ provider: ue.provider,
1489
+ model: ue.model,
1490
+ usage: ue.usage,
1491
+ });
1492
+ } catch (_e) {
1493
+ /* best-effort */
1494
+ }
1495
+ }
1496
+ }
1497
+
1250
1498
  // Fire AssistantResponse hook with rewrite/suppress support
1251
1499
  const responseDirective = await fireAssistantResponse(
1252
1500
  _hookDb,
@@ -1270,8 +1518,18 @@ export async function startAgentRepl(options = {}) {
1270
1518
  }
1271
1519
 
1272
1520
  if (effectiveResponse) {
1273
- process.stdout.write(`\n${effectiveResponse}\n\n`);
1274
- messages.push({ role: "assistant", content: effectiveResponse });
1521
+ // Phase G #2 — route through StreamRouter so REPL / WS / future
1522
+ // streaming providers share one StreamEvent protocol.
1523
+ const { streamAgentResponse } = await import("../lib/agent-stream.js");
1524
+ process.stdout.write("\n");
1525
+ const noStream = options.noStream === true;
1526
+ const streamResult = await streamAgentResponse(effectiveResponse, {
1527
+ noStream,
1528
+ writer: noStream ? null : (chunk) => process.stdout.write(chunk),
1529
+ });
1530
+ if (noStream) process.stdout.write(streamResult.text);
1531
+ process.stdout.write("\n\n");
1532
+ messages.push({ role: "assistant", content: streamResult.text });
1275
1533
  } else if (!responseDirective.suppress) {
1276
1534
  process.stdout.write("\n");
1277
1535
  }
@@ -1398,6 +1656,36 @@ export async function startAgentRepl(options = {}) {
1398
1656
  messageCount: messages.length,
1399
1657
  });
1400
1658
 
1659
+ // Phase H — park the SessionManager handle on clean exit so the session
1660
+ // can be resumed later via `cc session unpark <id>`. `--no-park-on-exit`
1661
+ // opts out; a SIGINT path (process-level) will force close instead.
1662
+ if (_sessionMgr && sessionId) {
1663
+ try {
1664
+ if (options.parkOnExit === false) {
1665
+ await _sessionMgr.close(sessionId);
1666
+ } else {
1667
+ _sessionMgr.markIdle(sessionId);
1668
+ await _sessionMgr.park(sessionId);
1669
+ logger.log(
1670
+ chalk.gray(
1671
+ `Session ${sessionId.slice(0, 12)} parked — resume with: cc session unpark ${sessionId}`,
1672
+ ),
1673
+ );
1674
+ }
1675
+ } catch (_e) {
1676
+ // Non-critical — parking failure must not block shutdown
1677
+ }
1678
+ }
1679
+
1680
+ // Disconnect bundle MCP servers
1681
+ if (_bundleMcpClient) {
1682
+ try {
1683
+ await _bundleMcpClient.disconnectAll();
1684
+ } catch (_e) {
1685
+ // Non-critical
1686
+ }
1687
+ }
1688
+
1401
1689
  // Shutdown runtime
1402
1690
  try {
1403
1691
  await shutdown();
@@ -12,6 +12,16 @@ import readline from "readline";
12
12
  import chalk from "chalk";
13
13
  import { logger } from "../lib/logger.js";
14
14
  import { BUILT_IN_PROVIDERS } from "../lib/llm-providers.js";
15
+ import {
16
+ streamOllama,
17
+ streamOpenAI,
18
+ streamAnthropic,
19
+ } from "../lib/chat-core.js";
20
+ import {
21
+ startSession,
22
+ appendTokenUsage,
23
+ appendEvent,
24
+ } from "../harness/jsonl-session-store.js";
15
25
 
16
26
  const SLASH_COMMANDS = {
17
27
  "/exit": "Exit the chat",
@@ -23,104 +33,6 @@ const SLASH_COMMANDS = {
23
33
  "/help": "Show available commands",
24
34
  };
25
35
 
26
- /**
27
- * Stream a response from Ollama
28
- */
29
- async function streamOllama(messages, model, baseUrl, onToken) {
30
- const response = await fetch(`${baseUrl}/api/chat`, {
31
- method: "POST",
32
- headers: { "Content-Type": "application/json" },
33
- body: JSON.stringify({
34
- model,
35
- messages,
36
- stream: true,
37
- }),
38
- });
39
-
40
- if (!response.ok) {
41
- throw new Error(`Ollama error: ${response.status} ${response.statusText}`);
42
- }
43
-
44
- const reader = response.body.getReader();
45
- const decoder = new TextDecoder();
46
- let fullResponse = "";
47
-
48
- while (true) {
49
- const { done, value } = await reader.read();
50
- if (done) break;
51
-
52
- const text = decoder.decode(value, { stream: true });
53
- const lines = text.split("\n").filter(Boolean);
54
-
55
- for (const line of lines) {
56
- try {
57
- const json = JSON.parse(line);
58
- if (json.message?.content) {
59
- fullResponse += json.message.content;
60
- onToken(json.message.content);
61
- }
62
- } catch {
63
- // Partial JSON, skip
64
- }
65
- }
66
- }
67
-
68
- return fullResponse;
69
- }
70
-
71
- /**
72
- * Stream a response from OpenAI-compatible API
73
- */
74
- async function streamOpenAI(messages, model, baseUrl, apiKey, onToken) {
75
- const response = await fetch(`${baseUrl}/chat/completions`, {
76
- method: "POST",
77
- headers: {
78
- "Content-Type": "application/json",
79
- Authorization: `Bearer ${apiKey}`,
80
- },
81
- body: JSON.stringify({
82
- model,
83
- messages,
84
- stream: true,
85
- }),
86
- });
87
-
88
- if (!response.ok) {
89
- throw new Error(`API error: ${response.status} ${response.statusText}`);
90
- }
91
-
92
- const reader = response.body.getReader();
93
- const decoder = new TextDecoder();
94
- let fullResponse = "";
95
-
96
- while (true) {
97
- const { done, value } = await reader.read();
98
- if (done) break;
99
-
100
- const text = decoder.decode(value, { stream: true });
101
- const lines = text.split("\n").filter(Boolean);
102
-
103
- for (const line of lines) {
104
- if (line.startsWith("data: ")) {
105
- const data = line.slice(6);
106
- if (data === "[DONE]") continue;
107
- try {
108
- const json = JSON.parse(data);
109
- const content = json.choices?.[0]?.delta?.content;
110
- if (content) {
111
- fullResponse += content;
112
- onToken(content);
113
- }
114
- } catch {
115
- // Partial data
116
- }
117
- }
118
- }
119
- }
120
-
121
- return fullResponse;
122
- }
123
-
124
36
  /**
125
37
  * Start the interactive chat REPL
126
38
  * @param {object} options
@@ -133,6 +45,21 @@ export async function startChatRepl(options = {}) {
133
45
 
134
46
  const messages = [];
135
47
 
48
+ // Phase J — attach chat REPL to a JSONL session so token_usage is recorded
49
+ // and `cc session usage` / `usage.*` WS routes show real numbers.
50
+ let sessionId = options.sessionId || null;
51
+ if (!sessionId && options.recordUsage !== false) {
52
+ try {
53
+ sessionId = startSession(null, {
54
+ title: "chat-repl",
55
+ provider,
56
+ model,
57
+ });
58
+ } catch {
59
+ sessionId = null;
60
+ }
61
+ }
62
+
136
63
  const rl = readline.createInterface({
137
64
  input: process.stdin,
138
65
  output: process.stdout,
@@ -142,6 +69,7 @@ export async function startChatRepl(options = {}) {
142
69
 
143
70
  logger.log(chalk.bold("\nChainlessChain AI Chat"));
144
71
  logger.log(chalk.gray(`Model: ${model} Provider: ${provider}`));
72
+ if (sessionId) logger.log(chalk.gray(`Session: ${sessionId}`));
145
73
  logger.log(chalk.gray("Type /help for commands, /exit to quit\n"));
146
74
 
147
75
  rl.prompt();
@@ -234,9 +162,43 @@ export async function startChatRepl(options = {}) {
234
162
  try {
235
163
  let response;
236
164
  const onToken = (token) => process.stdout.write(token);
165
+ let capturedUsage = null;
166
+ const onUsage = (u) => {
167
+ capturedUsage = u;
168
+ };
169
+
170
+ if (sessionId)
171
+ appendEvent(sessionId, "user_message", { content: trimmed });
237
172
 
238
173
  if (provider === "ollama") {
239
- response = await streamOllama(messages, model, baseUrl, onToken);
174
+ response = await streamOllama(
175
+ messages,
176
+ model,
177
+ baseUrl,
178
+ onToken,
179
+ onUsage,
180
+ );
181
+ } else if (provider === "anthropic") {
182
+ const providerDef = BUILT_IN_PROVIDERS.anthropic;
183
+ const url =
184
+ baseUrl !== "http://localhost:11434"
185
+ ? baseUrl
186
+ : providerDef?.baseUrl || "https://api.anthropic.com/v1";
187
+ const key =
188
+ apiKey ||
189
+ (providerDef?.apiKeyEnv ? process.env[providerDef.apiKeyEnv] : null);
190
+ if (!key)
191
+ throw new Error(
192
+ `API key required for anthropic (set ${providerDef?.apiKeyEnv || "ANTHROPIC_API_KEY"})`,
193
+ );
194
+ response = await streamAnthropic(
195
+ messages,
196
+ model,
197
+ url,
198
+ key,
199
+ onToken,
200
+ onUsage,
201
+ );
240
202
  } else {
241
203
  // OpenAI-compatible providers (openai, volcengine, deepseek, dashscope, mistral, gemini, anthropic-proxy)
242
204
  const providerDef = BUILT_IN_PROVIDERS[provider];
@@ -251,11 +213,36 @@ export async function startChatRepl(options = {}) {
251
213
  throw new Error(
252
214
  `API key required for ${provider} (set ${providerDef?.apiKeyEnv || "API key"})`,
253
215
  );
254
- response = await streamOpenAI(messages, model, url, key, onToken);
216
+ response = await streamOpenAI(
217
+ messages,
218
+ model,
219
+ url,
220
+ key,
221
+ onToken,
222
+ onUsage,
223
+ );
255
224
  }
256
225
 
257
226
  process.stdout.write("\n\n");
258
227
  messages.push({ role: "assistant", content: response });
228
+
229
+ if (sessionId) {
230
+ try {
231
+ appendEvent(sessionId, "assistant_message", { content: response });
232
+ if (capturedUsage) {
233
+ appendTokenUsage(sessionId, {
234
+ provider,
235
+ model,
236
+ usage: {
237
+ input_tokens: capturedUsage.inputTokens,
238
+ output_tokens: capturedUsage.outputTokens,
239
+ },
240
+ });
241
+ }
242
+ } catch {
243
+ /* best-effort — never break REPL */
244
+ }
245
+ }
259
246
  } catch (err) {
260
247
  process.stdout.write("\n");
261
248
  logger.error(`Error: ${err.message}`);