@shipers-dev/multi 0.5.0 → 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.5.0";
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",
@@ -5833,29 +5844,71 @@ async function handleRunTask(apiUrl, deviceId, task, detected) {
5833
5844
  } catch {}
5834
5845
  };
5835
5846
  const turn = {
5836
- text: "",
5837
- toolOrder: [],
5847
+ blocks: [],
5838
5848
  tools: new Map,
5839
5849
  plans: [],
5840
5850
  progress: [],
5841
5851
  result: null,
5842
5852
  error: null
5843
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
+ };
5844
5894
  const render = () => {
5845
5895
  const parts = [];
5846
- if (turn.text)
5847
- parts.push(turn.text);
5848
- for (const id of turn.toolOrder) {
5849
- 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);
5850
5903
  if (!t)
5851
5904
  continue;
5852
5905
  const icon = statusIcon(t.status);
5853
- const cleanTool = stripMd(t.tool);
5854
- 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}` : ""}`;
5855
5908
  const body = [];
5856
5909
  if (t.input !== undefined && t.input !== null) {
5857
5910
  const inputStr = typeof t.input === "object" ? JSON.stringify(t.input, null, 2) : String(t.input);
5858
- body.push("```json\n" + inputStr + "\n```");
5911
+ body.push("```json\n" + inputStr.slice(0, 2000) + "\n```");
5859
5912
  }
5860
5913
  if (t.results.length) {
5861
5914
  const joined = t.results.join(`
@@ -5917,7 +5970,7 @@ _${bits.join(" \xB7 ")}_`);
5917
5970
  }
5918
5971
  case "assistant_text": {
5919
5972
  await ensureLiveComment();
5920
- turn.text += p.text;
5973
+ appendText(p.text);
5921
5974
  hasAssistantText = true;
5922
5975
  schedulePatch();
5923
5976
  break;
@@ -5925,8 +5978,8 @@ _${bits.join(" \xB7 ")}_`);
5925
5978
  case "stdout": {
5926
5979
  if (p.line) {
5927
5980
  await ensureLiveComment();
5928
- turn.text += (turn.text ? `
5929
- ` : "") + p.line;
5981
+ appendText((turn.blocks.length ? `
5982
+ ` : "") + p.line);
5930
5983
  hasAssistantText = true;
5931
5984
  schedulePatch();
5932
5985
  }
@@ -5934,36 +5987,24 @@ _${bits.join(" \xB7 ")}_`);
5934
5987
  }
5935
5988
  case "tool_call": {
5936
5989
  await ensureLiveComment();
5937
- const id = p.id || `anon-${turn.toolOrder.length}`;
5938
- const existing = turn.tools.get(id);
5939
- if (existing) {
5940
- if (p.tool)
5941
- existing.tool = p.tool;
5942
- if (p.kind)
5943
- existing.kind = p.kind;
5944
- if (p.status)
5945
- existing.status = p.status;
5946
- if (p.input !== undefined)
5947
- existing.input = p.input;
5948
- } else {
5949
- turn.toolOrder.push(id);
5950
- turn.tools.set(id, { id, tool: p.tool || "tool", kind: p.kind, status: p.status, input: p.input, results: [] });
5951
- }
5990
+ const id = p.id || `anon-${turn.tools.size}`;
5991
+ upsertTool(id, { tool: p.tool, kind: p.kind, status: p.status, input: p.input });
5952
5992
  schedulePatch();
5953
5993
  break;
5954
5994
  }
5955
5995
  case "tool_result": {
5956
5996
  await ensureLiveComment();
5957
- 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;
5958
5999
  const entry = id ? turn.tools.get(id) : undefined;
5959
6000
  const content = String(p.content ?? "").trim();
5960
6001
  if (entry && content) {
5961
6002
  entry.results.push(content);
5962
6003
  schedulePatch();
5963
6004
  } else if (content) {
5964
- const pid = `result-${turn.toolOrder.length}`;
5965
- turn.toolOrder.push(pid);
5966
- 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);
5967
6008
  schedulePatch();
5968
6009
  }
5969
6010
  break;
@@ -5973,7 +6014,7 @@ _${bits.join(" \xB7 ")}_`);
5973
6014
  if (p.is_error)
5974
6015
  hadError = true;
5975
6016
  if (!hasAssistantText && p.result) {
5976
- turn.text = p.result;
6017
+ appendText(p.result);
5977
6018
  hasAssistantText = true;
5978
6019
  }
5979
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.5.0",
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.5.0';
19
+ const VERSION = '0.5.1';
20
20
 
21
21
  const COMMANDS = {
22
22
  setup: 'Register this device with a workspace',
@@ -448,9 +448,9 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
448
448
  };
449
449
 
450
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 };
451
452
  const turn = {
452
- text: '' as string,
453
- toolOrder: [] as string[],
453
+ blocks: [] as Block[],
454
454
  tools: new Map<string, ToolEntry>(),
455
455
  plans: [] as string[],
456
456
  progress: [] as string[],
@@ -458,20 +458,54 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
458
458
  error: null as null | string,
459
459
  };
460
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
+
461
493
  const render = (): string => {
462
494
  const parts: string[] = [];
463
- if (turn.text) parts.push(turn.text);
464
-
465
- for (const id of turn.toolOrder) {
466
- 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);
467
501
  if (!t) continue;
468
502
  const icon = statusIcon(t.status);
469
- const cleanTool = stripMd(t.tool);
470
- 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}` : ''}`;
471
505
  const body: string[] = [];
472
506
  if (t.input !== undefined && t.input !== null) {
473
507
  const inputStr = typeof t.input === 'object' ? JSON.stringify(t.input, null, 2) : String(t.input);
474
- body.push('```json\n' + inputStr + '\n```');
508
+ body.push('```json\n' + inputStr.slice(0, 2000) + '\n```');
475
509
  }
476
510
  if (t.results.length) {
477
511
  const joined = t.results.join('\n').slice(-2000);
@@ -481,7 +515,6 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
481
515
  }
482
516
 
483
517
  if (turn.plans.length) parts.push('**Plan**\n\n' + turn.plans[turn.plans.length - 1]);
484
-
485
518
  if (turn.error) parts.push(`> ❌ ${turn.error}`);
486
519
 
487
520
  if (turn.result) {
@@ -517,16 +550,15 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
517
550
  }
518
551
  case 'assistant_text': {
519
552
  await ensureLiveComment();
520
- turn.text += p.text;
553
+ appendText(p.text);
521
554
  hasAssistantText = true;
522
555
  schedulePatch();
523
556
  break;
524
557
  }
525
558
  case 'stdout': {
526
- // Legacy (non-ACP) runners emit stdout — route to live comment too.
527
559
  if (p.line) {
528
560
  await ensureLiveComment();
529
- turn.text += (turn.text ? '\n' : '') + p.line;
561
+ appendText((turn.blocks.length ? '\n' : '') + p.line);
530
562
  hasAssistantText = true;
531
563
  schedulePatch();
532
564
  }
@@ -534,33 +566,24 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
534
566
  }
535
567
  case 'tool_call': {
536
568
  await ensureLiveComment();
537
- const id = p.id || `anon-${turn.toolOrder.length}`;
538
- const existing = turn.tools.get(id);
539
- if (existing) {
540
- if (p.tool) existing.tool = p.tool;
541
- if (p.kind) existing.kind = p.kind;
542
- if (p.status) existing.status = p.status;
543
- if (p.input !== undefined) existing.input = p.input;
544
- } else {
545
- turn.toolOrder.push(id);
546
- turn.tools.set(id, { id, tool: p.tool || 'tool', kind: p.kind, status: p.status, input: p.input, results: [] });
547
- }
569
+ const id = p.id || `anon-${turn.tools.size}`;
570
+ upsertTool(id, { tool: p.tool, kind: p.kind, status: p.status, input: p.input });
548
571
  schedulePatch();
549
572
  break;
550
573
  }
551
574
  case 'tool_result': {
552
575
  await ensureLiveComment();
553
- 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;
554
578
  const entry = id ? turn.tools.get(id) : undefined;
555
579
  const content = String(p.content ?? '').trim();
556
580
  if (entry && content) {
557
581
  entry.results.push(content);
558
582
  schedulePatch();
559
583
  } else if (content) {
560
- // orphan result — create a pseudo tool
561
- const pid = `result-${turn.toolOrder.length}`;
562
- turn.toolOrder.push(pid);
563
- 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);
564
587
  schedulePatch();
565
588
  }
566
589
  break;
@@ -569,7 +592,7 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
569
592
  await ensureLiveComment();
570
593
  if (p.is_error) hadError = true;
571
594
  if (!hasAssistantText && p.result) {
572
- turn.text = p.result;
595
+ appendText(p.result);
573
596
  hasAssistantText = true;
574
597
  }
575
598
  turn.result = { duration_ms: p.duration_ms, total_cost_usd: p.total_cost_usd, stopReason: p.stopReason, is_error: p.is_error };