@jait/gateway 0.1.83 → 0.1.85

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.
@@ -1 +1 @@
1
- {"version":3,"file":"chat.d.ts","sourceRoot":"","sources":["../../src/routes/chat.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAC/C,OAAO,EAAE,KAAK,SAAS,EAAsB,MAAM,cAAc,CAAC;AAClE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAC;AAC7C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AAC9D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AACxD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AACzD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AACzD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AACxD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AACxD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAC;AAC5D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAC;AAE/D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAC/C,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAC;AACxE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAsTjE,MAAM,WAAW,aAAa;IAC5B,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,EAAE,CAAC,EAAE,cAAc,CAAC;IACpB,YAAY,CAAC,EAAE,mBAAmB,CAAC;IACnC,gBAAgB,CAAC,EAAE,gBAAgB,CAAC;IACpC,YAAY,CAAC,EAAE,CACb,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,OAAO,EACd,OAAO,EAAE,WAAW,EACpB,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,OAAO,CAAC;QAAC,gBAAgB,CAAC,EAAE,MAAM,CAAA;KAAE,KACtD,OAAO,CAAC,UAAU,CAAC,CAAC;CAC1B;AAED,wBAAgB,kBAAkB,CAChC,GAAG,EAAE,eAAe,EACpB,MAAM,EAAE,SAAS,EACjB,QAAQ,CAAC,EAAE,MAAM,GAAG,aAAa,EACjC,iBAAiB,CAAC,EAAE,cAAc,QAm1CnC"}
1
+ {"version":3,"file":"chat.d.ts","sourceRoot":"","sources":["../../src/routes/chat.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAC/C,OAAO,EAAE,KAAK,SAAS,EAAsB,MAAM,cAAc,CAAC;AAClE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAC;AAC7C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AAC9D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AACxD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AACzD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AACzD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AACxD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AACxD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAC;AAC5D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAC;AAE/D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAC/C,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAC;AACxE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAuZjE,MAAM,WAAW,aAAa;IAC5B,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,EAAE,CAAC,EAAE,cAAc,CAAC;IACpB,YAAY,CAAC,EAAE,mBAAmB,CAAC;IACnC,gBAAgB,CAAC,EAAE,gBAAgB,CAAC;IACpC,YAAY,CAAC,EAAE,CACb,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,OAAO,EACd,OAAO,EAAE,WAAW,EACpB,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,OAAO,CAAC;QAAC,gBAAgB,CAAC,EAAE,MAAM,CAAA;KAAE,KACtD,OAAO,CAAC,UAAU,CAAC,CAAC;CAC1B;AAED,wBAAgB,kBAAkB,CAChC,GAAG,EAAE,eAAe,EACpB,MAAM,EAAE,SAAS,EACjB,QAAQ,CAAC,EAAE,MAAM,GAAG,aAAa,EACjC,iBAAiB,CAAC,EAAE,cAAc,QAy4CnC"}
@@ -1,8 +1,9 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { inferContextWindow } from "../config.js";
3
3
  import { FileSystemSurface } from "../surfaces/filesystem.js";
4
- import { RemoteCliProvider } from "../providers/remote-cli-provider.js";
5
4
  import { resolveWorkspaceRoot } from "../tools/core/get-fs.js";
5
+ import { resolveRemoteNodeForSession, createRemoteToolExecutor } from "../tools/remote-executor.js";
6
+ import { WorkspaceSync } from "../services/workspace-sync.js";
6
7
  import { existsSync } from "node:fs";
7
8
  import { messages as messagesTable } from "../db/schema.js";
8
9
  import { eq } from "drizzle-orm";
@@ -15,6 +16,63 @@ import { buildSystemPrompt } from "../tools/prompts/index.js";
15
16
  const sessionHistory = new Map();
16
17
  const activeStreams = new Set();
17
18
  const sessionAbortControllers = new Map();
19
+ const sessionStreamingState = new Map();
20
+ function getOrCreateAccumulator(sessionId) {
21
+ let acc = sessionStreamingState.get(sessionId);
22
+ if (!acc) {
23
+ acc = { content: "", toolCalls: [], segments: [] };
24
+ sessionStreamingState.set(sessionId, acc);
25
+ }
26
+ return acc;
27
+ }
28
+ /** Append a text token to the streaming accumulator */
29
+ function accumulateToken(sessionId, token) {
30
+ const acc = getOrCreateAccumulator(sessionId);
31
+ acc.content += token;
32
+ const last = acc.segments[acc.segments.length - 1];
33
+ if (last?.type === "text") {
34
+ acc.segments[acc.segments.length - 1] = { type: "text", content: last.content + token };
35
+ }
36
+ else {
37
+ acc.segments.push({ type: "text", content: token });
38
+ }
39
+ }
40
+ /** Record a tool call start in the streaming accumulator */
41
+ function accumulateToolStart(sessionId, callId, tool, args) {
42
+ const acc = getOrCreateAccumulator(sessionId);
43
+ acc.toolCalls.push({ callId, tool, args, ok: true, message: "", startedAt: Date.now() });
44
+ const last = acc.segments[acc.segments.length - 1];
45
+ if (last?.type === "toolGroup") {
46
+ if (!last.callIds.includes(callId)) {
47
+ acc.segments[acc.segments.length - 1] = { type: "toolGroup", callIds: [...last.callIds, callId] };
48
+ }
49
+ }
50
+ else {
51
+ acc.segments.push({ type: "toolGroup", callIds: [callId] });
52
+ }
53
+ }
54
+ /** Record streaming output for a tool call */
55
+ function accumulateToolOutput(sessionId, callId, content) {
56
+ const acc = sessionStreamingState.get(sessionId);
57
+ if (!acc)
58
+ return;
59
+ const tc = acc.toolCalls.find(t => t.callId === callId);
60
+ if (tc)
61
+ tc.message = (tc.message || "") + content;
62
+ }
63
+ /** Record a tool call completion */
64
+ function accumulateToolResult(sessionId, callId, ok, message, data) {
65
+ const acc = sessionStreamingState.get(sessionId);
66
+ if (!acc)
67
+ return;
68
+ const tc = acc.toolCalls.find(t => t.callId === callId);
69
+ if (tc) {
70
+ tc.ok = ok;
71
+ tc.message = message;
72
+ tc.data = data;
73
+ tc.completedAt = Date.now();
74
+ }
75
+ }
18
76
  /** Persistent CLI provider sessions — kept alive across turns so the agent retains conversation context */
19
77
  const activeCliSessions = new Map();
20
78
  const sessionSubscribers = new Map();
@@ -165,13 +223,37 @@ function buildVisibleHistoryEntries(sessionId, history, options) {
165
223
  return out;
166
224
  }
167
225
  function buildVisibleHistoryMessages(sessionId, history, options) {
168
- return buildVisibleHistoryEntries(sessionId, history, options).map(({ id, role, content, toolCalls, segments }) => ({
226
+ const msgs = buildVisibleHistoryEntries(sessionId, history, options).map(({ id, role, content, toolCalls, segments }) => ({
169
227
  id,
170
228
  role,
171
229
  content,
172
230
  toolCalls,
173
231
  segments,
174
232
  }));
233
+ // If there is a live streaming accumulator for this session, inject a
234
+ // synthetic assistant message so that reconnecting clients see the partial
235
+ // content that has been streamed so far.
236
+ const acc = sessionStreamingState.get(sessionId);
237
+ if (acc && (acc.content || acc.toolCalls.length > 0)) {
238
+ const last = msgs[msgs.length - 1];
239
+ // Merge into the last assistant message if it exists and has no content yet
240
+ // (the snapshot builder may have emitted a stub). Otherwise append a new one.
241
+ if (last && last.role === "assistant" && !last.content && !last.toolCalls) {
242
+ last.content = acc.content;
243
+ last.toolCalls = acc.toolCalls.length > 0 ? mapPersistedToolCallsForUI(acc.toolCalls) : undefined;
244
+ last.segments = acc.segments.length > 0 ? acc.segments : undefined;
245
+ }
246
+ else {
247
+ msgs.push({
248
+ id: `${sessionId}-streaming`,
249
+ role: "assistant",
250
+ content: acc.content,
251
+ toolCalls: acc.toolCalls.length > 0 ? mapPersistedToolCallsForUI(acc.toolCalls) : undefined,
252
+ segments: acc.segments.length > 0 ? acc.segments : undefined,
253
+ });
254
+ }
255
+ }
256
+ return msgs;
175
257
  }
176
258
  // ── System prompt ────────────────────────────────────────────────────
177
259
  const SYSTEM_PROMPT = `You are Jait — Just Another Intelligent Tool. You are a capable AI assistant that can run shell commands, read/write files, and manage system surfaces.
@@ -329,48 +411,53 @@ export function registerChatRoutes(app, config, depsOrDb, sessionServiceArg) {
329
411
  }
330
412
  }
331
413
  // ── Tool execution helper ──────────────────────────────────────────
332
- async function executeTool(toolName, args, sessionId, auth, onOutputChunk, signal) {
333
- if (!toolRegistry) {
334
- return { ok: false, message: "Tool registry not available" };
335
- }
336
- if (signal?.aborted) {
337
- return { ok: false, message: "Cancelled" };
338
- }
339
- const context = {
340
- sessionId,
341
- actionId: uuidv7(),
342
- workspaceRoot: surfaceRegistry
343
- ? resolveWorkspaceRoot(surfaceRegistry, sessionId)
344
- : process.cwd(),
345
- requestedBy: "agent",
346
- userId: auth?.userId,
347
- apiKeys: auth?.apiKeys,
348
- onOutputChunk,
349
- signal,
350
- };
351
- try {
352
- const toolPromise = toolExecutor
353
- ? toolExecutor(toolName, args, context)
354
- : toolRegistry.execute(toolName, args, context, audit);
355
- // Race the tool execution against the abort signal so a stuck tool
356
- // (e.g. browser launch hanging) doesn't block the cancel flow forever.
357
- if (signal && !signal.aborted) {
358
- const abortPromise = new Promise((resolve) => {
359
- const onAbort = () => resolve({ ok: false, message: "Cancelled" });
360
- signal.addEventListener("abort", onAbort, { once: true });
361
- // Clean up if the tool finishes first
362
- toolPromise.finally(() => signal.removeEventListener("abort", onAbort));
363
- });
364
- return await Promise.race([toolPromise, abortPromise]);
414
+ function makeExecuteTool(executor) {
415
+ return async function executeTool(toolName, args, sessionId, auth, onOutputChunk, signal) {
416
+ if (!toolRegistry) {
417
+ return { ok: false, message: "Tool registry not available" };
365
418
  }
366
- return await toolPromise;
367
- }
368
- catch (err) {
369
- if (signal?.aborted)
419
+ if (signal?.aborted) {
370
420
  return { ok: false, message: "Cancelled" };
371
- return { ok: false, message: err instanceof Error ? err.message : String(err) };
372
- }
421
+ }
422
+ const context = {
423
+ sessionId,
424
+ actionId: uuidv7(),
425
+ workspaceRoot: surfaceRegistry
426
+ ? resolveWorkspaceRoot(surfaceRegistry, sessionId)
427
+ : process.cwd(),
428
+ requestedBy: "agent",
429
+ userId: auth?.userId,
430
+ apiKeys: auth?.apiKeys,
431
+ onOutputChunk,
432
+ signal,
433
+ };
434
+ try {
435
+ const effectiveExec = executor ?? toolExecutor;
436
+ const toolPromise = effectiveExec
437
+ ? effectiveExec(toolName, args, context)
438
+ : toolRegistry.execute(toolName, args, context, audit);
439
+ // Race the tool execution against the abort signal so a stuck tool
440
+ // (e.g. browser launch hanging) doesn't block the cancel flow forever.
441
+ if (signal && !signal.aborted) {
442
+ const abortPromise = new Promise((resolve) => {
443
+ const onAbort = () => resolve({ ok: false, message: "Cancelled" });
444
+ signal.addEventListener("abort", onAbort, { once: true });
445
+ // Clean up if the tool finishes first
446
+ toolPromise.finally(() => signal.removeEventListener("abort", onAbort));
447
+ });
448
+ return await Promise.race([toolPromise, abortPromise]);
449
+ }
450
+ return await toolPromise;
451
+ }
452
+ catch (err) {
453
+ if (signal?.aborted)
454
+ return { ok: false, message: "Cancelled" };
455
+ return { ok: false, message: err instanceof Error ? err.message : String(err) };
456
+ }
457
+ };
373
458
  }
459
+ /** Default executeTool using the module-level toolExecutor */
460
+ const executeTool = makeExecuteTool();
374
461
  // ══ POST /api/chat — Main chat endpoint with agentic tool loop ═════
375
462
  app.post("/api/chat", async (request, reply) => {
376
463
  const authUser = await requireAuth(request, reply, config.jwtSecret);
@@ -434,6 +521,12 @@ export function registerChatRoutes(app, config, depsOrDb, sessionServiceArg) {
434
521
  ? resolveWorkspaceRoot(surfaceRegistry, sessionId, sessionRecord?.workspacePath)
435
522
  : (sessionRecord?.workspacePath?.trim() || process.cwd());
436
523
  const promptCtx = { workspaceRoot: wsRoot };
524
+ // Detect if the workspace lives on a remote node and resolve the
525
+ // node ID so tool execution can be delegated there.
526
+ const remoteNodeId = ws ? resolveRemoteNodeForSession(ws, wsRoot) : null;
527
+ if (remoteNodeId) {
528
+ console.log(`[chat] session=${sessionId} workspace="${wsRoot}" → remote node ${remoteNodeId}`);
529
+ }
437
530
  if (!sessionHistory.has(sessionId)) {
438
531
  sessionHistory.set(sessionId, [
439
532
  { role: "system", content: buildSystemPrompt(chatMode, modelEndpoint, promptCtx) },
@@ -456,11 +549,19 @@ export function registerChatRoutes(app, config, depsOrDb, sessionServiceArg) {
456
549
  catch { /* session may not exist */ }
457
550
  const streamAbort = new AbortController();
458
551
  sessionAbortControllers.set(sessionId, streamAbort);
552
+ // Build a tool executor that delegates to the remote node when needed.
553
+ // For remote workspaces, tools like terminal.run and file.write will be
554
+ // proxied to the node via the tool.op-request/response protocol.
555
+ const effectiveToolExecutor = (remoteNodeId && ws && toolExecutor)
556
+ ? createRemoteToolExecutor({ ws, localExecutor: toolExecutor }, remoteNodeId)
557
+ : toolExecutor;
459
558
  let fullContent = "";
460
559
  let partialToolCalls = [];
461
560
  let resultSegmentsJson;
462
561
  let hitMaxRounds = false;
463
562
  activeStreams.add(sessionId);
563
+ // Reset streaming accumulator for this turn so reload snapshots start fresh
564
+ sessionStreamingState.delete(sessionId);
464
565
  let clientDisconnected = false;
465
566
  reply.raw.on("close", () => { clientDisconnected = true; });
466
567
  const safeWrite = (data) => {
@@ -487,32 +588,31 @@ export function registerChatRoutes(app, config, depsOrDb, sessionServiceArg) {
487
588
  const cliWsRoot = surfaceRegistry
488
589
  ? resolveWorkspaceRoot(surfaceRegistry, sessionId, sessionRecord?.workspacePath)
489
590
  : (sessionRecord?.workspacePath?.trim() || process.cwd());
490
- // Detect if the workspace lives on a remote node (e.g. Windows
491
- // desktop) and route the CLI provider session there instead of
492
- // trying to spawn codex/claude-code locally on the gateway.
591
+ // CLI providers always run on the gateway. If the workspace path
592
+ // is on a remote node, sync the files locally first.
493
593
  const pathExistsLocally = existsSync(cliWsRoot);
494
- let cliProvider = null;
495
- let isRemote = false;
594
+ let effectiveCwd = cliWsRoot;
595
+ let workspaceSync = null;
496
596
  if (!pathExistsLocally && ws) {
497
- // Match path platform to connected FsNodes
498
- const isWindowsPath = /^[A-Za-z]:[\\\/]/.test(cliWsRoot);
499
- const expectedPlatform = isWindowsPath ? "windows" : null;
500
- for (const node of ws.getFsNodes()) {
501
- if (node.isGateway)
502
- continue;
503
- if (expectedPlatform && node.platform !== expectedPlatform)
504
- continue;
505
- if (!node.providers?.includes(requestProvider))
506
- continue;
507
- cliProvider = new RemoteCliProvider(ws, node.id, requestProvider);
508
- isRemote = true;
509
- break;
597
+ // Find a connected remote node that owns this path
598
+ const nodeId = resolveRemoteNodeForSession(ws, cliWsRoot);
599
+ if (nodeId) {
600
+ workspaceSync = new WorkspaceSync({ ws, nodeId, remotePath: cliWsRoot, sessionId });
601
+ try {
602
+ safeWrite(`data: ${JSON.stringify({ type: "token", content: "Syncing workspace from remote node...\n" })}\n\n`);
603
+ await workspaceSync.pull();
604
+ effectiveCwd = workspaceSync.localDir;
605
+ console.log(`[chat/cli] Synced remote workspace to ${effectiveCwd}`);
606
+ }
607
+ catch (err) {
608
+ safeWrite(`data: ${JSON.stringify({ type: "error", message: `Failed to sync remote workspace: ${err instanceof Error ? err.message : String(err)}` })}\n\n`);
609
+ reply.raw.end();
610
+ return;
611
+ }
510
612
  }
511
613
  }
512
- // Fall back to the local provider if path exists on the gateway
513
- if (!cliProvider) {
514
- cliProvider = providerRegistry.get(requestProvider) ?? null;
515
- }
614
+ // Always use the local provider CLI agents run on the gateway
615
+ let cliProvider = providerRegistry.get(requestProvider) ?? null;
516
616
  if (!cliProvider) {
517
617
  safeWrite(`data: ${JSON.stringify({ type: "error", message: `Unknown provider: ${requestProvider}` })}\n\n`);
518
618
  reply.raw.end();
@@ -520,11 +620,11 @@ export function registerChatRoutes(app, config, depsOrDb, sessionServiceArg) {
520
620
  }
521
621
  const available = await cliProvider.checkAvailability();
522
622
  if (!available) {
523
- safeWrite(`data: ${JSON.stringify({ type: "error", message: `Provider ${requestProvider} is not available${isRemote ? " on the remote node" : ""}: ${cliProvider.info.unavailableReason ?? "CLI not found"}` })}\n\n`);
623
+ safeWrite(`data: ${JSON.stringify({ type: "error", message: `Provider ${requestProvider} is not available: ${cliProvider.info.unavailableReason ?? "CLI not found"}` })}\n\n`);
524
624
  reply.raw.end();
525
625
  return;
526
626
  }
527
- console.log(`[chat/cli] session=${sessionId} wsRoot="${cliWsRoot}" session.workspacePath="${sessionRecord?.workspacePath}" surfaces=${surfaceRegistry?.getBySession(sessionId)?.length ?? 0}`);
627
+ console.log(`[chat/cli] session=${sessionId} wsRoot="${cliWsRoot}" effectiveCwd="${effectiveCwd}" session.workspacePath="${sessionRecord?.workspacePath}" surfaces=${surfaceRegistry?.getBySession(sessionId)?.length ?? 0}`);
528
628
  // Ensure a FileSystemSurface exists for this session so we can
529
629
  // back up files before CLI providers (Codex/Claude) write them,
530
630
  // enabling the keep/discard (undo) flow.
@@ -542,7 +642,7 @@ export function registerChatRoutes(app, config, depsOrDb, sessionServiceArg) {
542
642
  surfaceRegistry.onSurfaceStarted = null;
543
643
  const started = await surfaceRegistry.startSurface("filesystem", fsId, {
544
644
  sessionId,
545
- workspaceRoot: cliWsRoot,
645
+ workspaceRoot: effectiveCwd,
546
646
  });
547
647
  surfaceRegistry.onSurfaceStarted = prevHandler;
548
648
  cliFsSurface = started;
@@ -550,8 +650,8 @@ export function registerChatRoutes(app, config, depsOrDb, sessionServiceArg) {
550
650
  catch { /* best effort */ }
551
651
  }
552
652
  }
553
- // Remote providers can't reach the gateway's MCP endpoint
554
- const mcpServers = isRemote ? [] : [providerRegistry.buildJaitMcpServerRef(config)];
653
+ // CLI providers run locally on the gateway MCP always available
654
+ const mcpServers = [providerRegistry.buildJaitMcpServerRef(config)];
555
655
  // ── Reuse an existing CLI session if one is alive for this Jait session ──
556
656
  const cachedCliSession = activeCliSessions.get(sessionId);
557
657
  let providerSessionId;
@@ -572,14 +672,14 @@ export function registerChatRoutes(app, config, depsOrDb, sessionServiceArg) {
572
672
  }
573
673
  const session = await cliProvider.startSession({
574
674
  threadId: sessionId,
575
- workingDirectory: cliWsRoot,
675
+ workingDirectory: effectiveCwd,
576
676
  mode: "full-access",
577
677
  model: typeof body["model"] === "string" ? body["model"] : undefined,
578
678
  mcpServers,
579
679
  });
580
680
  providerSessionId = session.id;
581
681
  activeCliSessions.set(sessionId, { providerId: requestProvider, providerSessionId, provider: cliProvider });
582
- console.log(`[chat/cli] Started new ${requestProvider}${isRemote ? " (remote)" : ""} session ${providerSessionId} for ${sessionId}`);
682
+ console.log(`[chat/cli] Started new ${requestProvider} session ${providerSessionId} for ${sessionId}`);
583
683
  }
584
684
  // Collect full content from CLI provider events
585
685
  const contentChunks = [];
@@ -629,6 +729,7 @@ export function registerChatRoutes(app, config, depsOrDb, sessionServiceArg) {
629
729
  contentChunks.push(event.content);
630
730
  tokenBytesThisBlock += event.content.length;
631
731
  lastSegmentWasText = false; // new text arrived
732
+ accumulateToken(sessionId, event.content);
632
733
  safeWrite(`data: ${JSON.stringify({ type: "token", content: event.content })}\n\n`);
633
734
  emitToSubscribers(sessionId, { type: "token", content: event.content });
634
735
  break;
@@ -657,6 +758,7 @@ export function registerChatRoutes(app, config, depsOrDb, sessionServiceArg) {
657
758
  }
658
759
  safeWrite(`data: ${JSON.stringify({ type: "tool_start", call_id: callId, tool: event.tool, args: event.args })}\n\n`);
659
760
  emitToSubscribers(sessionId, { type: "tool_start", call_id: callId, tool: event.tool, args: event.args });
761
+ accumulateToolStart(sessionId, callId, event.tool, event.args ?? {});
660
762
  break;
661
763
  }
662
764
  case "tool.output": {
@@ -665,6 +767,7 @@ export function registerChatRoutes(app, config, depsOrDb, sessionServiceArg) {
665
767
  if (tc) {
666
768
  tc.message = (tc.message || "") + event.content;
667
769
  }
770
+ accumulateToolOutput(sessionId, event.callId ?? "", event.content);
668
771
  safeWrite(`data: ${JSON.stringify({ type: "tool_output", call_id: event.callId, content: event.content })}\n\n`);
669
772
  break;
670
773
  }
@@ -680,6 +783,7 @@ export function registerChatRoutes(app, config, depsOrDb, sessionServiceArg) {
680
783
  }
681
784
  safeWrite(`data: ${JSON.stringify({ type: "tool_result", call_id: resultCallId, tool: event.tool, ok: event.ok, message: event.message, data: event.data })}\n\n`);
682
785
  emitToSubscribers(sessionId, { type: "tool_result", call_id: resultCallId, tool: event.tool, ok: event.ok, message: event.message });
786
+ accumulateToolResult(sessionId, resultCallId, event.ok, event.message || "", event.data);
683
787
  // Emit file_changed for successful edits → drives the keep/discard UI
684
788
  if (event.ok && event.tool === "edit") {
685
789
  const editPath = String(tc?.args ? tc.args.path ?? "" : "");
@@ -744,7 +848,7 @@ export function registerChatRoutes(app, config, depsOrDb, sessionServiceArg) {
744
848
  activeCliSessions.delete(sessionId);
745
849
  const freshSession = await cliProvider.startSession({
746
850
  threadId: sessionId,
747
- workingDirectory: cliWsRoot,
851
+ workingDirectory: effectiveCwd,
748
852
  mode: "full-access",
749
853
  model: typeof body["model"] === "string" ? body["model"] : undefined,
750
854
  mcpServers,
@@ -789,6 +893,18 @@ export function registerChatRoutes(app, config, depsOrDb, sessionServiceArg) {
789
893
  // Persist assistant message with tool calls and segments
790
894
  history.push({ role: "assistant", content: fullContent, uiToolCalls: cliToolCalls.length > 0 ? cliToolCalls : undefined });
791
895
  persistMessage(sessionId, "assistant", fullContent, cliTcJson, cliSegJson);
896
+ // Push changed files back to the remote node if workspace was synced
897
+ if (workspaceSync) {
898
+ try {
899
+ const { pushed } = await workspaceSync.push();
900
+ if (pushed > 0) {
901
+ console.log(`[chat/cli] Pushed ${pushed} changed file(s) back to remote node`);
902
+ }
903
+ }
904
+ catch (err) {
905
+ console.error(`[chat/cli] Failed to push changes back:`, err);
906
+ }
907
+ }
792
908
  // Session stays alive for the next turn — do NOT stop it.
793
909
  // It will be cleaned up on session error, provider switch, or server shutdown.
794
910
  }
@@ -805,6 +921,15 @@ export function registerChatRoutes(app, config, depsOrDb, sessionServiceArg) {
805
921
  const onEvent = (event) => {
806
922
  emitToSubscribers(sessionId, event);
807
923
  safeWrite(`data: ${JSON.stringify(event)}\n\n`);
924
+ // ── Update streaming accumulator so reload snapshots include partial content ──
925
+ if (event.type === "token")
926
+ accumulateToken(sessionId, event.content);
927
+ else if (event.type === "tool_start")
928
+ accumulateToolStart(sessionId, event.call_id, event.tool, event.args);
929
+ else if (event.type === "tool_output")
930
+ accumulateToolOutput(sessionId, event.call_id, event.content);
931
+ else if (event.type === "tool_result")
932
+ accumulateToolResult(sessionId, event.call_id, event.ok, event.message, event.data);
808
933
  // ── Cross-client sync: persist & broadcast state changes ──
809
934
  const ev = event;
810
935
  // Broadcast todo list updates to all session clients and persist to DB
@@ -864,7 +989,11 @@ export function registerChatRoutes(app, config, depsOrDb, sessionServiceArg) {
864
989
  onEvent,
865
990
  onPersist: (sid, role, content, tc, seg) => persistMessage(sid, role, content, tc, seg),
866
991
  log: app.log,
867
- }, executeTool, steering);
992
+ },
993
+ // Use remote-aware executor when workspace is on a remote node
994
+ effectiveToolExecutor !== toolExecutor
995
+ ? makeExecuteTool(effectiveToolExecutor)
996
+ : executeTool, steering);
868
997
  fullContent = result.content;
869
998
  partialToolCalls = result.executedToolCalls;
870
999
  resultSegmentsJson = result.segments.length > 0 ? JSON.stringify(result.segments) : undefined;
@@ -913,6 +1042,7 @@ export function registerChatRoutes(app, config, depsOrDb, sessionServiceArg) {
913
1042
  activeStreams.delete(sessionId);
914
1043
  sessionAbortControllers.delete(sessionId);
915
1044
  sessionSteeringControllers.delete(sessionId);
1045
+ sessionStreamingState.delete(sessionId);
916
1046
  // Clean up in-memory history: remove any dangling assistant tool_calls
917
1047
  // messages that never got a text response (e.g. cancelled mid-tool-call).
918
1048
  // This prevents them from showing as "running" on reload.