@shipers-dev/multi 0.4.3 → 0.5.1

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/index.js CHANGED
@@ -5198,6 +5198,7 @@ async function runAcp(opts) {
5198
5198
  const stream2 = ndJsonStream(output, input);
5199
5199
  let activeSessionId = opts.sessionId || null;
5200
5200
  let recording = false;
5201
+ const alwaysAllow = new Set;
5201
5202
  const client = {
5202
5203
  async sessionUpdate(params) {
5203
5204
  if (!recording)
@@ -5205,7 +5206,7 @@ async function runAcp(opts) {
5205
5206
  await handleSessionUpdate(params, opts);
5206
5207
  },
5207
5208
  async requestPermission(params) {
5208
- return await handleRequestPermission(params, opts);
5209
+ return await handleRequestPermission(params, opts, alwaysAllow);
5209
5210
  },
5210
5211
  async readTextFile(params) {
5211
5212
  try {
@@ -5325,8 +5326,14 @@ ${entries}` } });
5325
5326
  break;
5326
5327
  }
5327
5328
  }
5328
- async function handleRequestPermission(params, o) {
5329
+ async function handleRequestPermission(params, o, allowCache) {
5329
5330
  const tc = params.toolCall || {};
5331
+ const toolKey = `${tc.toolName || tc.title || ""}|${tc.kind || ""}`.toLowerCase();
5332
+ if (toolKey && allowCache.has(toolKey)) {
5333
+ const allowOpt = params.options.find((op) => /allow/i.test(op.kind || "") || /allow/i.test(op.name || ""));
5334
+ if (allowOpt)
5335
+ return { outcome: { outcome: "selected", optionId: allowOpt.optionId } };
5336
+ }
5330
5337
  const created = await apiClient.post(`${o.apiUrl}/api/permissions`, {
5331
5338
  issue_id: o.issueId,
5332
5339
  device_id: o.deviceId,
@@ -5346,6 +5353,10 @@ ${entries}` } });
5346
5353
  if (!row)
5347
5354
  break;
5348
5355
  if (row.status === "resolved" && row.chosen) {
5356
+ const chosenOpt = params.options.find((op) => op.optionId === row.chosen);
5357
+ const isAlways = chosenOpt && (/always/i.test(chosenOpt.name || "") || /allow_always/i.test(chosenOpt.kind || ""));
5358
+ if (isAlways && toolKey)
5359
+ allowCache.add(toolKey);
5349
5360
  return { outcome: { outcome: "selected", optionId: row.chosen } };
5350
5361
  }
5351
5362
  if (row.status === "cancelled") {
@@ -5381,7 +5392,7 @@ var LOG_PATH = join(MULTI_DIR, "logs", "agent.log");
5381
5392
  var SKILLS_DIR = join(MULTI_DIR, "skills");
5382
5393
  var STOP_PATH = join(MULTI_DIR, "stop.flag");
5383
5394
  var TASKS_DB_PATH = join(MULTI_DIR, "tasks.db");
5384
- var VERSION = "0.4.3";
5395
+ var VERSION = "0.5.1";
5385
5396
  var COMMANDS = {
5386
5397
  setup: "Register this device with a workspace",
5387
5398
  connect: "Connect device to realtime hub and execute assigned tasks",
@@ -5414,6 +5425,7 @@ async function main() {
5414
5425
  options: {
5415
5426
  help: { type: "boolean", default: false, short: "h" },
5416
5427
  version: { type: "boolean", default: false },
5428
+ detach: { type: "boolean", default: false, short: "d" },
5417
5429
  name: { type: "string" },
5418
5430
  workspace: { type: "string" },
5419
5431
  agent: { type: "string" },
@@ -5437,7 +5449,11 @@ async function main() {
5437
5449
  break;
5438
5450
  case "connect":
5439
5451
  case "start":
5440
- await cmdConnect(apiUrl, config);
5452
+ if (args.values.detach) {
5453
+ await cmdConnectDetached(apiUrl);
5454
+ } else {
5455
+ await cmdConnect(apiUrl, config);
5456
+ }
5441
5457
  break;
5442
5458
  case "link":
5443
5459
  await cmdLink(apiUrl, config, args.values.agent);
@@ -5473,10 +5489,11 @@ Commands:
5473
5489
 
5474
5490
  Options:
5475
5491
  --name <name> Device name
5476
- --workspace <id> Workspace ID
5477
5492
  --agent <id> Agent ID (for link)
5478
- --api <url> API URL (default: http://localhost:8787)
5479
- --help Show this help
5493
+ --api <url> API URL
5494
+ -d, --detach Run connect in background (daemon)
5495
+ -v, --version Print version
5496
+ -h, --help Show this help
5480
5497
 
5481
5498
  Examples:
5482
5499
  multi-agent setup --name "My Mac" --workspace ws_xxx
@@ -5719,6 +5736,28 @@ async function cmdConnect(apiUrl, config) {
5719
5736
  }
5720
5737
  }
5721
5738
  }
5739
+ async function cmdConnectDetached(apiUrl) {
5740
+ if (existsSync2(PID_PATH)) {
5741
+ const pid = Number(readFileSync2(PID_PATH, "utf8").trim());
5742
+ if (pid && isRunning(pid)) {
5743
+ console.log(`\u274C Daemon already running (pid ${pid}).`);
5744
+ process.exit(1);
5745
+ }
5746
+ }
5747
+ ensureDirs();
5748
+ const logFd = Bun.file(LOG_PATH);
5749
+ const args = Bun.argv.slice(1).filter((a) => a !== "-d" && a !== "--detach");
5750
+ const proc = Bun.spawn([process.execPath, ...args, "--api", apiUrl], {
5751
+ stdio: ["ignore", "ignore", "ignore"],
5752
+ env: { ...process.env, MULTI_DETACHED: "1" }
5753
+ });
5754
+ proc.unref?.();
5755
+ await sleep(500);
5756
+ console.log(`\u2705 Daemon started in background (pid ${proc.pid}).`);
5757
+ console.log(` Tail logs: multi-agent logs`);
5758
+ console.log(` Stop: multi-agent stop`);
5759
+ process.exit(0);
5760
+ }
5722
5761
  async function pickFreePort() {
5723
5762
  for (let i = 0;i < 10; i++) {
5724
5763
  const p = 40000 + Math.floor(Math.random() * 20000);
@@ -5805,29 +5844,71 @@ async function handleRunTask(apiUrl, deviceId, task, detected) {
5805
5844
  } catch {}
5806
5845
  };
5807
5846
  const turn = {
5808
- text: "",
5809
- toolOrder: [],
5847
+ blocks: [],
5810
5848
  tools: new Map,
5811
5849
  plans: [],
5812
5850
  progress: [],
5813
5851
  result: null,
5814
5852
  error: null
5815
5853
  };
5854
+ const appendText = (text) => {
5855
+ const last = turn.blocks[turn.blocks.length - 1];
5856
+ if (last && last.kind === "text")
5857
+ last.text += text;
5858
+ else
5859
+ turn.blocks.push({ kind: "text", text });
5860
+ };
5861
+ const upsertTool = (id, patch) => {
5862
+ const existing = turn.tools.get(id);
5863
+ if (existing) {
5864
+ if (patch.tool && patch.tool !== "tool")
5865
+ existing.tool = patch.tool;
5866
+ if (patch.kind)
5867
+ existing.kind = patch.kind;
5868
+ if (patch.status)
5869
+ existing.status = patch.status;
5870
+ if (patch.input !== undefined)
5871
+ existing.input = patch.input;
5872
+ } else {
5873
+ turn.tools.set(id, { id, tool: patch.tool || "tool", kind: patch.kind, status: patch.status, input: patch.input, results: [] });
5874
+ turn.blocks.push({ kind: "tool", id });
5875
+ }
5876
+ };
5877
+ const toolLabel = (t) => {
5878
+ const clean = stripMd(t.tool);
5879
+ if (clean && clean !== "tool")
5880
+ return clean;
5881
+ if (t.input && typeof t.input === "object") {
5882
+ if (t.input.command)
5883
+ return `Bash: ${String(t.input.command).split(`
5884
+ `)[0].slice(0, 60)}`;
5885
+ if (t.input.file_path)
5886
+ return `${t.kind === "edit" ? "Edit" : "Read"}: ${t.input.file_path}`;
5887
+ if (t.input.pattern)
5888
+ return `Grep: ${t.input.pattern}`;
5889
+ if (t.input.url)
5890
+ return `Fetch: ${t.input.url}`;
5891
+ }
5892
+ return t.kind ? `${t.kind}` : "tool";
5893
+ };
5816
5894
  const render = () => {
5817
5895
  const parts = [];
5818
- if (turn.text)
5819
- parts.push(turn.text);
5820
- for (const id of turn.toolOrder) {
5821
- const t = turn.tools.get(id);
5896
+ for (const b of turn.blocks) {
5897
+ if (b.kind === "text") {
5898
+ if (b.text)
5899
+ parts.push(b.text);
5900
+ continue;
5901
+ }
5902
+ const t = turn.tools.get(b.id);
5822
5903
  if (!t)
5823
5904
  continue;
5824
5905
  const icon = statusIcon(t.status);
5825
- const cleanTool = stripMd(t.tool);
5826
- const head = `${icon} ${cleanTool}${t.kind ? ` \xB7 ${t.kind}` : ""}${t.status && t.status !== "completed" ? ` \xB7 ${t.status}` : ""}`;
5906
+ const label = toolLabel(t);
5907
+ const head = `${icon} ${label}${t.status && t.status !== "completed" ? ` \xB7 ${t.status}` : ""}`;
5827
5908
  const body = [];
5828
5909
  if (t.input !== undefined && t.input !== null) {
5829
5910
  const inputStr = typeof t.input === "object" ? JSON.stringify(t.input, null, 2) : String(t.input);
5830
- body.push("```json\n" + inputStr + "\n```");
5911
+ body.push("```json\n" + inputStr.slice(0, 2000) + "\n```");
5831
5912
  }
5832
5913
  if (t.results.length) {
5833
5914
  const joined = t.results.join(`
@@ -5889,7 +5970,7 @@ _${bits.join(" \xB7 ")}_`);
5889
5970
  }
5890
5971
  case "assistant_text": {
5891
5972
  await ensureLiveComment();
5892
- turn.text += p.text;
5973
+ appendText(p.text);
5893
5974
  hasAssistantText = true;
5894
5975
  schedulePatch();
5895
5976
  break;
@@ -5897,8 +5978,8 @@ _${bits.join(" \xB7 ")}_`);
5897
5978
  case "stdout": {
5898
5979
  if (p.line) {
5899
5980
  await ensureLiveComment();
5900
- turn.text += (turn.text ? `
5901
- ` : "") + p.line;
5981
+ appendText((turn.blocks.length ? `
5982
+ ` : "") + p.line);
5902
5983
  hasAssistantText = true;
5903
5984
  schedulePatch();
5904
5985
  }
@@ -5906,36 +5987,24 @@ _${bits.join(" \xB7 ")}_`);
5906
5987
  }
5907
5988
  case "tool_call": {
5908
5989
  await ensureLiveComment();
5909
- const id = p.id || `anon-${turn.toolOrder.length}`;
5910
- const existing = turn.tools.get(id);
5911
- if (existing) {
5912
- if (p.tool)
5913
- existing.tool = p.tool;
5914
- if (p.kind)
5915
- existing.kind = p.kind;
5916
- if (p.status)
5917
- existing.status = p.status;
5918
- if (p.input !== undefined)
5919
- existing.input = p.input;
5920
- } else {
5921
- turn.toolOrder.push(id);
5922
- turn.tools.set(id, { id, tool: p.tool || "tool", kind: p.kind, status: p.status, input: p.input, results: [] });
5923
- }
5990
+ const id = p.id || `anon-${turn.tools.size}`;
5991
+ upsertTool(id, { tool: p.tool, kind: p.kind, status: p.status, input: p.input });
5924
5992
  schedulePatch();
5925
5993
  break;
5926
5994
  }
5927
5995
  case "tool_result": {
5928
5996
  await ensureLiveComment();
5929
- const id = p.tool_use_id || turn.toolOrder[turn.toolOrder.length - 1];
5997
+ const lastToolId = [...turn.blocks].reverse().find((b) => b.kind === "tool")?.id;
5998
+ const id = p.tool_use_id || lastToolId;
5930
5999
  const entry = id ? turn.tools.get(id) : undefined;
5931
6000
  const content = String(p.content ?? "").trim();
5932
6001
  if (entry && content) {
5933
6002
  entry.results.push(content);
5934
6003
  schedulePatch();
5935
6004
  } else if (content) {
5936
- const pid = `result-${turn.toolOrder.length}`;
5937
- turn.toolOrder.push(pid);
5938
- turn.tools.set(pid, { id: pid, tool: "tool result", results: [content] });
6005
+ const pid = `result-${turn.tools.size}`;
6006
+ upsertTool(pid, { tool: "tool result" });
6007
+ turn.tools.get(pid).results.push(content);
5939
6008
  schedulePatch();
5940
6009
  }
5941
6010
  break;
@@ -5945,7 +6014,7 @@ _${bits.join(" \xB7 ")}_`);
5945
6014
  if (p.is_error)
5946
6015
  hadError = true;
5947
6016
  if (!hasAssistantText && p.result) {
5948
- turn.text = p.result;
6017
+ appendText(p.result);
5949
6018
  hasAssistantText = true;
5950
6019
  }
5951
6020
  turn.result = { duration_ms: p.duration_ms, total_cost_usd: p.total_cost_usd, stopReason: p.stopReason, is_error: p.is_error };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipers-dev/multi",
3
- "version": "0.4.3",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "multi-agent": "./dist/index.js"
package/src/acp-runner.ts CHANGED
@@ -54,6 +54,7 @@ export async function runAcp(opts: AcpRunOpts): Promise<{ stopReason: string; se
54
54
 
55
55
  let activeSessionId: string | null = opts.sessionId || null;
56
56
  let recording = false; // only forward events after prompt() starts
57
+ const alwaysAllow = new Set<string>(); // keys: toolName|kind that user said "always allow"
57
58
 
58
59
  const client: Client = {
59
60
  async sessionUpdate(params: SessionNotification): Promise<void> {
@@ -61,7 +62,7 @@ export async function runAcp(opts: AcpRunOpts): Promise<{ stopReason: string; se
61
62
  await handleSessionUpdate(params, opts);
62
63
  },
63
64
  async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
64
- return await handleRequestPermission(params, opts);
65
+ return await handleRequestPermission(params, opts, alwaysAllow);
65
66
  },
66
67
  async readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
67
68
  try {
@@ -170,8 +171,16 @@ export async function runAcp(opts: AcpRunOpts): Promise<{ stopReason: string; se
170
171
  }
171
172
  }
172
173
 
173
- async function handleRequestPermission(params: RequestPermissionRequest, o: AcpRunOpts): Promise<RequestPermissionResponse> {
174
+ async function handleRequestPermission(params: RequestPermissionRequest, o: AcpRunOpts, allowCache: Set<string>): Promise<RequestPermissionResponse> {
174
175
  const tc: any = params.toolCall || {};
176
+ const toolKey = `${tc.toolName || tc.title || ''}|${tc.kind || ''}`.toLowerCase();
177
+
178
+ // Auto-approve if user previously chose "always allow" for same tool/kind
179
+ if (toolKey && allowCache.has(toolKey)) {
180
+ const allowOpt = (params.options as any[]).find(op => /allow/i.test(op.kind || '') || /allow/i.test(op.name || ''));
181
+ if (allowOpt) return { outcome: { outcome: 'selected', optionId: allowOpt.optionId } as any };
182
+ }
183
+
175
184
  const created = await apiClient.post<any>(`${o.apiUrl}/api/permissions`, {
176
185
  issue_id: o.issueId,
177
186
  device_id: o.deviceId,
@@ -183,7 +192,6 @@ export async function runAcp(opts: AcpRunOpts): Promise<{ stopReason: string; se
183
192
  const permId = created.data?.id;
184
193
  if (!permId) return { outcome: { outcome: 'cancelled' } as any };
185
194
 
186
- // Poll every 500ms up to 5 min
187
195
  const deadline = Date.now() + 5 * 60 * 1000;
188
196
  while (Date.now() < deadline) {
189
197
  await new Promise(r => setTimeout(r, 500));
@@ -191,6 +199,10 @@ export async function runAcp(opts: AcpRunOpts): Promise<{ stopReason: string; se
191
199
  const row = got.data;
192
200
  if (!row) break;
193
201
  if (row.status === 'resolved' && row.chosen) {
202
+ // If the chosen option is an "always" variant, cache it
203
+ const chosenOpt = (params.options as any[]).find(op => op.optionId === row.chosen);
204
+ const isAlways = chosenOpt && (/always/i.test(chosenOpt.name || '') || /allow_always/i.test(chosenOpt.kind || ''));
205
+ if (isAlways && toolKey) allowCache.add(toolKey);
194
206
  return { outcome: { outcome: 'selected', optionId: row.chosen } as any };
195
207
  }
196
208
  if (row.status === 'cancelled') {
package/src/index.ts CHANGED
@@ -16,7 +16,7 @@ const LOG_PATH = join(MULTI_DIR, 'logs', 'agent.log');
16
16
  const SKILLS_DIR = join(MULTI_DIR, 'skills');
17
17
  const STOP_PATH = join(MULTI_DIR, 'stop.flag');
18
18
  const TASKS_DB_PATH = join(MULTI_DIR, 'tasks.db');
19
- const VERSION = '0.4.3';
19
+ const VERSION = '0.5.1';
20
20
 
21
21
  const COMMANDS = {
22
22
  setup: 'Register this device with a workspace',
@@ -54,6 +54,7 @@ async function main() {
54
54
  options: {
55
55
  help: { type: 'boolean', default: false, short: 'h' },
56
56
  version: { type: 'boolean', default: false },
57
+ detach: { type: 'boolean', default: false, short: 'd' },
57
58
  name: { type: 'string' },
58
59
  workspace: { type: 'string' },
59
60
  agent: { type: 'string' },
@@ -80,7 +81,11 @@ async function main() {
80
81
  break;
81
82
  case 'connect':
82
83
  case 'start':
83
- await cmdConnect(apiUrl, config);
84
+ if (args.values.detach) {
85
+ await cmdConnectDetached(apiUrl);
86
+ } else {
87
+ await cmdConnect(apiUrl, config);
88
+ }
84
89
  break;
85
90
  case 'link':
86
91
  await cmdLink(apiUrl, config, args.values.agent);
@@ -117,10 +122,11 @@ Commands:
117
122
 
118
123
  Options:
119
124
  --name <name> Device name
120
- --workspace <id> Workspace ID
121
125
  --agent <id> Agent ID (for link)
122
- --api <url> API URL (default: http://localhost:8787)
123
- --help Show this help
126
+ --api <url> API URL
127
+ -d, --detach Run connect in background (daemon)
128
+ -v, --version Print version
129
+ -h, --help Show this help
124
130
 
125
131
  Examples:
126
132
  multi-agent setup --name "My Mac" --workspace ws_xxx
@@ -342,6 +348,31 @@ async function cmdConnect(apiUrl: string, config: Config) {
342
348
  }
343
349
  }
344
350
 
351
+ async function cmdConnectDetached(apiUrl: string) {
352
+ if (existsSync(PID_PATH)) {
353
+ const pid = Number(readFileSync(PID_PATH, 'utf8').trim());
354
+ if (pid && isRunning(pid)) {
355
+ console.log(`❌ Daemon already running (pid ${pid}).`);
356
+ process.exit(1);
357
+ }
358
+ }
359
+ ensureDirs();
360
+ const logFd = Bun.file(LOG_PATH);
361
+ // Re-exec ourselves with same args minus --detach
362
+ const args = Bun.argv.slice(1).filter(a => a !== '-d' && a !== '--detach');
363
+ const proc = Bun.spawn([process.execPath, ...args, '--api', apiUrl], {
364
+ stdio: ['ignore', 'ignore', 'ignore'],
365
+ env: { ...process.env, MULTI_DETACHED: '1' },
366
+ });
367
+ (proc as any).unref?.();
368
+ // Give daemon a moment to write pidfile
369
+ await sleep(500);
370
+ console.log(`✅ Daemon started in background (pid ${proc.pid}).`);
371
+ console.log(` Tail logs: multi-agent logs`);
372
+ console.log(` Stop: multi-agent stop`);
373
+ process.exit(0);
374
+ }
375
+
345
376
  async function pickFreePort(): Promise<number> {
346
377
  // Bind to 0, read assigned port, close immediately.
347
378
  for (let i = 0; i < 10; i++) {
@@ -417,9 +448,9 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
417
448
  };
418
449
 
419
450
  type ToolEntry = { id: string; tool: string; kind?: string; status?: string; input?: any; results: string[] };
451
+ type Block = { kind: 'text'; text: string } | { kind: 'tool'; id: string };
420
452
  const turn = {
421
- text: '' as string,
422
- toolOrder: [] as string[],
453
+ blocks: [] as Block[],
423
454
  tools: new Map<string, ToolEntry>(),
424
455
  plans: [] as string[],
425
456
  progress: [] as string[],
@@ -427,20 +458,54 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
427
458
  error: null as null | string,
428
459
  };
429
460
 
461
+ const appendText = (text: string) => {
462
+ const last = turn.blocks[turn.blocks.length - 1];
463
+ if (last && last.kind === 'text') last.text += text;
464
+ else turn.blocks.push({ kind: 'text', text });
465
+ };
466
+
467
+ const upsertTool = (id: string, patch: Partial<ToolEntry>) => {
468
+ const existing = turn.tools.get(id);
469
+ if (existing) {
470
+ if (patch.tool && patch.tool !== 'tool') existing.tool = patch.tool;
471
+ if (patch.kind) existing.kind = patch.kind;
472
+ if (patch.status) existing.status = patch.status;
473
+ if (patch.input !== undefined) existing.input = patch.input;
474
+ } else {
475
+ turn.tools.set(id, { id, tool: patch.tool || 'tool', kind: patch.kind, status: patch.status, input: patch.input, results: [] });
476
+ turn.blocks.push({ kind: 'tool', id });
477
+ }
478
+ };
479
+
480
+ const toolLabel = (t: ToolEntry): string => {
481
+ const clean = stripMd(t.tool);
482
+ if (clean && clean !== 'tool') return clean;
483
+ // Fallback from input shape
484
+ if (t.input && typeof t.input === 'object') {
485
+ if (t.input.command) return `Bash: ${String(t.input.command).split('\n')[0].slice(0, 60)}`;
486
+ if (t.input.file_path) return `${t.kind === 'edit' ? 'Edit' : 'Read'}: ${t.input.file_path}`;
487
+ if (t.input.pattern) return `Grep: ${t.input.pattern}`;
488
+ if (t.input.url) return `Fetch: ${t.input.url}`;
489
+ }
490
+ return (t.kind ? `${t.kind}` : 'tool');
491
+ };
492
+
430
493
  const render = (): string => {
431
494
  const parts: string[] = [];
432
- if (turn.text) parts.push(turn.text);
433
-
434
- for (const id of turn.toolOrder) {
435
- const t = turn.tools.get(id);
495
+ for (const b of turn.blocks) {
496
+ if (b.kind === 'text') {
497
+ if (b.text) parts.push(b.text);
498
+ continue;
499
+ }
500
+ const t = turn.tools.get(b.id);
436
501
  if (!t) continue;
437
502
  const icon = statusIcon(t.status);
438
- const cleanTool = stripMd(t.tool);
439
- const head = `${icon} ${cleanTool}${t.kind ? ` · ${t.kind}` : ''}${t.status && t.status !== 'completed' ? ` · ${t.status}` : ''}`;
503
+ const label = toolLabel(t);
504
+ const head = `${icon} ${label}${t.status && t.status !== 'completed' ? ` · ${t.status}` : ''}`;
440
505
  const body: string[] = [];
441
506
  if (t.input !== undefined && t.input !== null) {
442
507
  const inputStr = typeof t.input === 'object' ? JSON.stringify(t.input, null, 2) : String(t.input);
443
- body.push('```json\n' + inputStr + '\n```');
508
+ body.push('```json\n' + inputStr.slice(0, 2000) + '\n```');
444
509
  }
445
510
  if (t.results.length) {
446
511
  const joined = t.results.join('\n').slice(-2000);
@@ -450,7 +515,6 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
450
515
  }
451
516
 
452
517
  if (turn.plans.length) parts.push('**Plan**\n\n' + turn.plans[turn.plans.length - 1]);
453
-
454
518
  if (turn.error) parts.push(`> ❌ ${turn.error}`);
455
519
 
456
520
  if (turn.result) {
@@ -486,16 +550,15 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
486
550
  }
487
551
  case 'assistant_text': {
488
552
  await ensureLiveComment();
489
- turn.text += p.text;
553
+ appendText(p.text);
490
554
  hasAssistantText = true;
491
555
  schedulePatch();
492
556
  break;
493
557
  }
494
558
  case 'stdout': {
495
- // Legacy (non-ACP) runners emit stdout — route to live comment too.
496
559
  if (p.line) {
497
560
  await ensureLiveComment();
498
- turn.text += (turn.text ? '\n' : '') + p.line;
561
+ appendText((turn.blocks.length ? '\n' : '') + p.line);
499
562
  hasAssistantText = true;
500
563
  schedulePatch();
501
564
  }
@@ -503,33 +566,24 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
503
566
  }
504
567
  case 'tool_call': {
505
568
  await ensureLiveComment();
506
- const id = p.id || `anon-${turn.toolOrder.length}`;
507
- const existing = turn.tools.get(id);
508
- if (existing) {
509
- if (p.tool) existing.tool = p.tool;
510
- if (p.kind) existing.kind = p.kind;
511
- if (p.status) existing.status = p.status;
512
- if (p.input !== undefined) existing.input = p.input;
513
- } else {
514
- turn.toolOrder.push(id);
515
- turn.tools.set(id, { id, tool: p.tool || 'tool', kind: p.kind, status: p.status, input: p.input, results: [] });
516
- }
569
+ const id = p.id || `anon-${turn.tools.size}`;
570
+ upsertTool(id, { tool: p.tool, kind: p.kind, status: p.status, input: p.input });
517
571
  schedulePatch();
518
572
  break;
519
573
  }
520
574
  case 'tool_result': {
521
575
  await ensureLiveComment();
522
- const id = p.tool_use_id || turn.toolOrder[turn.toolOrder.length - 1];
576
+ const lastToolId = [...turn.blocks].reverse().find(b => b.kind === 'tool')?.id;
577
+ const id = p.tool_use_id || lastToolId;
523
578
  const entry = id ? turn.tools.get(id) : undefined;
524
579
  const content = String(p.content ?? '').trim();
525
580
  if (entry && content) {
526
581
  entry.results.push(content);
527
582
  schedulePatch();
528
583
  } else if (content) {
529
- // orphan result — create a pseudo tool
530
- const pid = `result-${turn.toolOrder.length}`;
531
- turn.toolOrder.push(pid);
532
- turn.tools.set(pid, { id: pid, tool: 'tool result', results: [content] });
584
+ const pid = `result-${turn.tools.size}`;
585
+ upsertTool(pid, { tool: 'tool result' });
586
+ turn.tools.get(pid)!.results.push(content);
533
587
  schedulePatch();
534
588
  }
535
589
  break;
@@ -538,7 +592,7 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
538
592
  await ensureLiveComment();
539
593
  if (p.is_error) hadError = true;
540
594
  if (!hasAssistantText && p.result) {
541
- turn.text = p.result;
595
+ appendText(p.result);
542
596
  hasAssistantText = true;
543
597
  }
544
598
  turn.result = { duration_ms: p.duration_ms, total_cost_usd: p.total_cost_usd, stopReason: p.stopReason, is_error: p.is_error };