@phnx-labs/agents-cli 1.20.33 → 1.20.35

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 (159) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +32 -2
  3. package/dist/commands/computer-actions.d.ts +15 -0
  4. package/dist/commands/computer-actions.js +120 -22
  5. package/dist/commands/computer.d.ts +23 -0
  6. package/dist/commands/computer.js +45 -3
  7. package/dist/commands/cost.js +17 -12
  8. package/dist/commands/doctor.d.ts +11 -0
  9. package/dist/commands/doctor.js +108 -14
  10. package/dist/commands/events.d.ts +16 -0
  11. package/dist/commands/events.js +173 -0
  12. package/dist/commands/exec.js +6 -1
  13. package/dist/commands/hosts.js +28 -24
  14. package/dist/commands/import.js +1 -1
  15. package/dist/commands/inspect.d.ts +2 -0
  16. package/dist/commands/inspect.js +54 -7
  17. package/dist/commands/logs.d.ts +17 -0
  18. package/dist/commands/logs.js +139 -0
  19. package/dist/commands/models.d.ts +3 -0
  20. package/dist/commands/models.js +27 -8
  21. package/dist/commands/permissions.js +9 -2
  22. package/dist/commands/repo.d.ts +34 -0
  23. package/dist/commands/repo.js +243 -65
  24. package/dist/commands/resource-view.d.ts +20 -0
  25. package/dist/commands/resource-view.js +90 -28
  26. package/dist/commands/rules.js +1 -1
  27. package/dist/commands/secrets-migrate.js +23 -11
  28. package/dist/commands/secrets.d.ts +20 -0
  29. package/dist/commands/secrets.js +93 -17
  30. package/dist/commands/sessions-resume.d.ts +2 -0
  31. package/dist/commands/sessions-resume.js +209 -0
  32. package/dist/commands/sessions-tail.d.ts +10 -0
  33. package/dist/commands/sessions-tail.js +11 -0
  34. package/dist/commands/sessions.d.ts +73 -1
  35. package/dist/commands/sessions.js +273 -53
  36. package/dist/commands/status.d.ts +12 -0
  37. package/dist/commands/status.js +81 -0
  38. package/dist/commands/teams.js +70 -6
  39. package/dist/commands/versions.js +2 -1
  40. package/dist/commands/view.d.ts +42 -0
  41. package/dist/commands/view.js +213 -83
  42. package/dist/commands/wallet.d.ts +6 -0
  43. package/dist/commands/wallet.js +22 -5
  44. package/dist/index.js +61 -32
  45. package/dist/lib/acp/harnesses.d.ts +1 -1
  46. package/dist/lib/acp/harnesses.js +2 -2
  47. package/dist/lib/agents.d.ts +12 -0
  48. package/dist/lib/agents.js +145 -41
  49. package/dist/lib/browser/cdp.js +5 -1
  50. package/dist/lib/browser/chrome.js +20 -0
  51. package/dist/lib/browser/drivers/ssh.d.ts +27 -0
  52. package/dist/lib/browser/drivers/ssh.js +94 -19
  53. package/dist/lib/browser/ipc.d.ts +3 -0
  54. package/dist/lib/browser/ipc.js +13 -9
  55. package/dist/lib/browser/service.js +71 -15
  56. package/dist/lib/daemon.d.ts +35 -0
  57. package/dist/lib/daemon.js +33 -5
  58. package/dist/lib/devices/connect.d.ts +3 -2
  59. package/dist/lib/devices/connect.js +5 -3
  60. package/dist/lib/doctor-diff.js +29 -2
  61. package/dist/lib/drift-sync.d.ts +43 -0
  62. package/dist/lib/drift-sync.js +179 -0
  63. package/dist/lib/events.d.ts +9 -2
  64. package/dist/lib/events.js +70 -11
  65. package/dist/lib/exec.d.ts +15 -0
  66. package/dist/lib/exec.js +43 -11
  67. package/dist/lib/hooks.js +8 -2
  68. package/dist/lib/hosts/dispatch.js +6 -7
  69. package/dist/lib/hosts/logs.d.ts +16 -0
  70. package/dist/lib/hosts/logs.js +45 -0
  71. package/dist/lib/hosts/progress.d.ts +66 -0
  72. package/dist/lib/hosts/progress.js +125 -17
  73. package/dist/lib/hosts/ready.d.ts +23 -2
  74. package/dist/lib/hosts/ready.js +35 -13
  75. package/dist/lib/hosts/reconcile.d.ts +53 -0
  76. package/dist/lib/hosts/reconcile.js +81 -0
  77. package/dist/lib/hosts/tasks.d.ts +8 -0
  78. package/dist/lib/hosts/tasks.js +14 -0
  79. package/dist/lib/permissions.d.ts +4 -0
  80. package/dist/lib/permissions.js +35 -0
  81. package/dist/lib/picker.d.ts +24 -0
  82. package/dist/lib/picker.js +124 -0
  83. package/dist/lib/platform/winpath.d.ts +31 -2
  84. package/dist/lib/platform/winpath.js +133 -24
  85. package/dist/lib/plugin-marketplace.d.ts +30 -0
  86. package/dist/lib/plugin-marketplace.js +215 -2
  87. package/dist/lib/plugins.d.ts +5 -0
  88. package/dist/lib/plugins.js +45 -4
  89. package/dist/lib/pwsh.d.ts +11 -0
  90. package/dist/lib/pwsh.js +13 -0
  91. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  92. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  93. package/dist/lib/secrets/agent.d.ts +42 -1
  94. package/dist/lib/secrets/agent.js +89 -11
  95. package/dist/lib/secrets/bundles.js +40 -9
  96. package/dist/lib/secrets/filestore.js +31 -1
  97. package/dist/lib/secrets/index.d.ts +33 -1
  98. package/dist/lib/secrets/index.js +90 -9
  99. package/dist/lib/secrets/windows.d.ts +74 -0
  100. package/dist/lib/secrets/windows.js +440 -0
  101. package/dist/lib/session/active.d.ts +7 -0
  102. package/dist/lib/session/active.js +11 -3
  103. package/dist/lib/session/discover.js +94 -0
  104. package/dist/lib/session/parse.d.ts +6 -0
  105. package/dist/lib/session/parse.js +283 -2
  106. package/dist/lib/session/pid-registry.d.ts +21 -0
  107. package/dist/lib/session/pid-registry.js +80 -0
  108. package/dist/lib/session/remote-active.d.ts +26 -0
  109. package/dist/lib/session/remote-active.js +141 -0
  110. package/dist/lib/session/remote.js +2 -6
  111. package/dist/lib/session/render.js +1 -1
  112. package/dist/lib/session/sync/config.d.ts +8 -0
  113. package/dist/lib/session/sync/config.js +11 -2
  114. package/dist/lib/session/types.d.ts +1 -1
  115. package/dist/lib/session/types.js +1 -1
  116. package/dist/lib/shims.d.ts +30 -2
  117. package/dist/lib/shims.js +107 -50
  118. package/dist/lib/ssh-exec.d.ts +26 -3
  119. package/dist/lib/ssh-exec.js +45 -3
  120. package/dist/lib/ssh-tunnel.d.ts +24 -5
  121. package/dist/lib/ssh-tunnel.js +60 -62
  122. package/dist/lib/staleness/writers/hooks.js +1 -1
  123. package/dist/lib/startup/command-registry.d.ts +3 -0
  124. package/dist/lib/startup/command-registry.js +6 -0
  125. package/dist/lib/sync-status.d.ts +102 -0
  126. package/dist/lib/sync-status.js +135 -0
  127. package/dist/lib/teams/agents.d.ts +24 -0
  128. package/dist/lib/teams/agents.js +30 -1
  129. package/dist/lib/teams/registry.js +25 -9
  130. package/dist/lib/terminal/backends/ghostty.d.ts +6 -0
  131. package/dist/lib/terminal/backends/ghostty.js +69 -0
  132. package/dist/lib/terminal/backends/index.d.ts +18 -0
  133. package/dist/lib/terminal/backends/index.js +29 -0
  134. package/dist/lib/terminal/backends/iterm.d.ts +6 -0
  135. package/dist/lib/terminal/backends/iterm.js +62 -0
  136. package/dist/lib/terminal/backends/tmux.d.ts +14 -0
  137. package/dist/lib/terminal/backends/tmux.js +23 -0
  138. package/dist/lib/terminal/engine.d.ts +39 -0
  139. package/dist/lib/terminal/engine.js +54 -0
  140. package/dist/lib/terminal/index.d.ts +14 -0
  141. package/dist/lib/terminal/index.js +5 -0
  142. package/dist/lib/terminal/policy.d.ts +11 -0
  143. package/dist/lib/terminal/policy.js +11 -0
  144. package/dist/lib/terminal/quote.d.ts +11 -0
  145. package/dist/lib/terminal/quote.js +13 -0
  146. package/dist/lib/terminal/shell.d.ts +6 -0
  147. package/dist/lib/terminal/shell.js +23 -0
  148. package/dist/lib/terminal/transport.d.ts +15 -0
  149. package/dist/lib/terminal/transport.js +40 -0
  150. package/dist/lib/terminal/types.d.ts +59 -0
  151. package/dist/lib/terminal/types.js +13 -0
  152. package/dist/lib/types.d.ts +27 -1
  153. package/dist/lib/usage.d.ts +30 -0
  154. package/dist/lib/usage.js +159 -2
  155. package/dist/lib/versions.d.ts +7 -3
  156. package/dist/lib/versions.js +10 -5
  157. package/dist/lib/whats-new.d.ts +9 -0
  158. package/dist/lib/whats-new.js +35 -0
  159. package/package.json +1 -1
@@ -96,6 +96,9 @@ export function parseSession(filePath, agent) {
96
96
  case 'gemini':
97
97
  events = parseGemini(filePath);
98
98
  break;
99
+ case 'antigravity':
100
+ events = parseAntigravity(filePath);
101
+ break;
99
102
  case 'opencode':
100
103
  events = parseOpenCode(filePath);
101
104
  break;
@@ -131,6 +134,12 @@ export function detectAgent(filePath) {
131
134
  return 'claude';
132
135
  if (filePath.includes('/.codex/') || filePath.includes('\\.codex\\'))
133
136
  return 'codex';
137
+ // Antigravity lives under ~/.gemini/antigravity-cli/conversations/<uuid>.db, so
138
+ // it must be matched BEFORE the generic /.gemini/ check below or it would be
139
+ // misdetected as Gemini.
140
+ if ((filePath.includes('/antigravity-cli/conversations/') || filePath.includes('\\antigravity-cli\\conversations\\'))
141
+ && filePath.endsWith('.db'))
142
+ return 'antigravity';
134
143
  if (filePath.includes('/.gemini/') || filePath.includes('\\.gemini\\'))
135
144
  return 'gemini';
136
145
  if (filePath.includes('/.grok/') || filePath.includes('\\.grok\\'))
@@ -178,6 +187,11 @@ export function summarizeToolUse(tool, args) {
178
187
  case 'WebSearch':
179
188
  case 'WebFetch':
180
189
  return `${tool}: ${truncate(args.query || args.url || '', 80)}`;
190
+ // Codex plan tool: arrives as a function_call with {plan:[{step,status}]}.
191
+ case 'update_plan': {
192
+ const steps = Array.isArray(args.plan) ? args.plan.length : 0;
193
+ return `Plan: ${steps} step${steps === 1 ? '' : 's'}`;
194
+ }
181
195
  // Codex tools
182
196
  case 'exec_command':
183
197
  return `Bash: ${truncate(String(args.command || args.cmd || '').replace(/\n/g, ' ').trim(), 120)}`;
@@ -418,6 +432,16 @@ export function parseClaudeContent(content) {
418
432
  export function parseCodex(filePath) {
419
433
  return parseCodexContent(safeReadSessionFile(filePath));
420
434
  }
435
+ /**
436
+ * Extract the target file path from a Codex apply_patch envelope. The patch body
437
+ * opens with `*** Begin Patch` and carries one or more file ops of the form
438
+ * `*** Update File: <path>` / `*** Add File: <path>` / `*** Delete File: <path>`.
439
+ * Returns the first file path found, or undefined for an unparseable body.
440
+ */
441
+ function applyPatchTargetPath(input) {
442
+ const m = input.match(/^\*\*\* (?:Update|Add|Delete) File: (.+)$/m);
443
+ return m ? m[1].trim() : undefined;
444
+ }
421
445
  /**
422
446
  * Parse Codex JSONL *content* (already read into a string) into normalized
423
447
  * events. Split from `parseCodex` so the tail reader can parse just the last
@@ -449,6 +473,23 @@ export function parseCodexContent(content) {
449
473
  });
450
474
  continue;
451
475
  }
476
+ if (lineType === 'event_msg') {
477
+ // Web search is reported out-of-band as event_msg (not response_item).
478
+ // The paired begin (`web_search_call`) has no query; only `web_search_end`
479
+ // carries the resolved query string, so we emit exactly one tool_use per
480
+ // search off the end event and ignore the begin to avoid duplicates.
481
+ if (payload.type === 'web_search_end') {
482
+ const query = typeof payload.query === 'string' ? payload.query : '';
483
+ events.push({
484
+ type: 'tool_use',
485
+ agent: 'codex',
486
+ timestamp,
487
+ tool: 'WebSearch',
488
+ args: { query },
489
+ });
490
+ }
491
+ continue;
492
+ }
452
493
  if (lineType === 'response_item') {
453
494
  const ptype = payload.type;
454
495
  if (ptype === 'message') {
@@ -523,6 +564,45 @@ export function parseCodexContent(content) {
523
564
  if (callId)
524
565
  callMap.delete(callId);
525
566
  }
567
+ else if (ptype === 'custom_tool_call') {
568
+ // Codex edits arrive as custom_tool_call (apply_patch), NOT function_call.
569
+ const rawName = payload.name || 'unknown';
570
+ const input = typeof payload.input === 'string' ? payload.input : '';
571
+ const isApplyPatch = rawName === 'apply_patch';
572
+ const patchPath = isApplyPatch ? applyPatchTargetPath(input) : undefined;
573
+ // Normalize apply_patch to the shared Edit tool so it flows through
574
+ // artifact discovery (WRITE_TOOLS) and the Edit summarizer.
575
+ const tool = isApplyPatch ? 'Edit' : rawName;
576
+ const args = { input: input.length > 500 ? input.slice(0, 497) + '...' : input };
577
+ if (patchPath)
578
+ args.file_path = patchPath;
579
+ const callId = payload.call_id || payload.id;
580
+ if (callId)
581
+ callMap.set(callId, { name: tool, args });
582
+ events.push({
583
+ type: 'tool_use',
584
+ agent: 'codex',
585
+ timestamp,
586
+ tool,
587
+ args,
588
+ path: patchPath,
589
+ });
590
+ }
591
+ else if (ptype === 'custom_tool_call_output') {
592
+ const callId = payload.call_id || payload.id;
593
+ const callInfo = callId ? callMap.get(callId) : undefined;
594
+ const output = String(payload.output || '');
595
+ events.push({
596
+ type: 'tool_result',
597
+ agent: 'codex',
598
+ timestamp,
599
+ tool: callInfo?.name,
600
+ success: true,
601
+ output: output.length > 500 ? output.slice(0, 497) + '...' : output,
602
+ });
603
+ if (callId)
604
+ callMap.delete(callId);
605
+ }
526
606
  else if (ptype === 'reasoning') {
527
607
  // Codex reasoning -- try to get the readable summary
528
608
  const summaries = payload.summary || [];
@@ -670,6 +750,204 @@ function extractGeminiContent(content) {
670
750
  }
671
751
  return '';
672
752
  }
753
+ /** Read a base-128 varint from `b` at offset `i`; returns [value, nextOffset]. */
754
+ function readVarint(b, i) {
755
+ let shift = 0;
756
+ let val = 0;
757
+ for (;;) {
758
+ const byte = b[i++];
759
+ val += (byte & 0x7f) * 2 ** shift;
760
+ if ((byte & 0x80) === 0)
761
+ break;
762
+ shift += 7;
763
+ }
764
+ return [val, i];
765
+ }
766
+ /** Decode a protobuf message into a flat list of fields (one nesting level). */
767
+ function decodeProtoMessage(b) {
768
+ const out = [];
769
+ let i = 0;
770
+ while (i < b.length) {
771
+ let tag;
772
+ [tag, i] = readVarint(b, i);
773
+ const field = tag >>> 3;
774
+ const wire = tag & 7;
775
+ if (wire === 0) {
776
+ let v;
777
+ [v, i] = readVarint(b, i);
778
+ out.push({ field, wire, value: v });
779
+ }
780
+ else if (wire === 2) {
781
+ let len;
782
+ [len, i] = readVarint(b, i);
783
+ out.push({ field, wire, value: b.subarray(i, i + len) });
784
+ i += len;
785
+ }
786
+ else if (wire === 5) {
787
+ i += 4; // fixed32 — skipped
788
+ out.push({ field, wire, value: 0 });
789
+ }
790
+ else if (wire === 1) {
791
+ i += 8; // fixed64 — skipped
792
+ out.push({ field, wire, value: 0 });
793
+ }
794
+ else {
795
+ break; // unknown wire type — stop to avoid runaway reads
796
+ }
797
+ }
798
+ return out;
799
+ }
800
+ const ANTIGRAVITY_TEXT_DECODER = new TextDecoder('utf-8', { fatal: false });
801
+ /**
802
+ * Recursively locate the tool-call node: the first sub-message that carries an
803
+ * f2 string tool-name AND an f3 JSON-args string. Also captures the f1 call id
804
+ * (used to dedupe the request + completion steps) and the f30/f31 human labels.
805
+ */
806
+ function findAntigravityToolCall(fields) {
807
+ let id;
808
+ let name;
809
+ let argsJson;
810
+ let summary;
811
+ let action;
812
+ const subs = [];
813
+ for (const f of fields) {
814
+ if (f.wire !== 2)
815
+ continue;
816
+ const bytes = f.value;
817
+ const s = ANTIGRAVITY_TEXT_DECODER.decode(bytes);
818
+ if (f.field === 1 && !id && /^[a-z0-9]{4,16}$/.test(s))
819
+ id = s;
820
+ else if (f.field === 2 && !name && /^[a-z][a-z_]{2,30}$/.test(s))
821
+ name = s;
822
+ else if (f.field === 3 && !argsJson && s.startsWith('{') && s.includes('"toolAction"'))
823
+ argsJson = s;
824
+ else if (f.field === 30 && !summary)
825
+ summary = s;
826
+ else if (f.field === 31 && !action)
827
+ action = s;
828
+ else {
829
+ try {
830
+ subs.push(decodeProtoMessage(bytes));
831
+ }
832
+ catch {
833
+ /* not a nested message */
834
+ }
835
+ }
836
+ }
837
+ if (name && argsJson) {
838
+ let args = {};
839
+ try {
840
+ args = JSON.parse(argsJson);
841
+ }
842
+ catch {
843
+ args = { _raw: argsJson };
844
+ }
845
+ return { id, name, args, summary, action };
846
+ }
847
+ for (const sub of subs) {
848
+ const hit = findAntigravityToolCall(sub);
849
+ if (hit)
850
+ return hit;
851
+ }
852
+ return null;
853
+ }
854
+ /**
855
+ * Map an Antigravity tool name onto the shared normalized vocabulary that the
856
+ * renderer already handles (so no render.ts changes are needed). Unknown tools
857
+ * pass through untouched (with their JSON args) so future tools are captured.
858
+ */
859
+ const ANTIGRAVITY_TOOL_MAP = {
860
+ run_command: 'Bash',
861
+ view_file: 'Read',
862
+ read_file: 'Read',
863
+ list_dir: 'LS',
864
+ grep_search: 'Grep',
865
+ replace_file_content: 'Edit',
866
+ write_to_file: 'Write',
867
+ // Web tools surface identically once observed:
868
+ search_web: 'WebSearch',
869
+ read_url: 'WebFetch',
870
+ execute_url: 'WebFetch',
871
+ };
872
+ /**
873
+ * Parse an Antigravity conversation SQLite DB into normalized tool_use events.
874
+ * Deduped by the tool-call id so each tool appears once (Antigravity writes a
875
+ * request step and a completion step that share the id).
876
+ */
877
+ export function parseAntigravity(dbPath) {
878
+ let rows;
879
+ try {
880
+ rows = execFileSync('sqlite3', ['-separator', '\t', dbPath, 'SELECT idx, step_type, quote(step_payload) FROM steps ORDER BY idx;'], {
881
+ encoding: 'utf-8',
882
+ timeout: 10000,
883
+ maxBuffer: 64 * 1024 * 1024,
884
+ stdio: ['ignore', 'pipe', 'ignore'],
885
+ });
886
+ }
887
+ catch {
888
+ /* DB not accessible, sqlite3 missing, or query failed */
889
+ return [];
890
+ }
891
+ // Single timestamp for the whole session: the steps table carries no per-step
892
+ // time column, so fall back to the DB file's mtime for a stable, sortable value.
893
+ let timestamp = new Date().toISOString();
894
+ try {
895
+ timestamp = fs.statSync(dbPath).mtime.toISOString();
896
+ }
897
+ catch {
898
+ /* file vanished between query and stat — keep now() */
899
+ }
900
+ const events = [];
901
+ const seenCallIds = new Set();
902
+ for (const line of rows.split('\n')) {
903
+ if (!line.trim())
904
+ continue;
905
+ const tab1 = line.indexOf('\t');
906
+ if (tab1 === -1)
907
+ continue;
908
+ const tab2 = line.indexOf('\t', tab1 + 1);
909
+ if (tab2 === -1)
910
+ continue;
911
+ const hex = line.slice(tab2 + 1).trim();
912
+ // sqlite3 quote() renders a BLOB as X'...'; anything else (NULL) is skipped.
913
+ if (!hex.startsWith("X'") || !hex.endsWith("'"))
914
+ continue;
915
+ const bytes = Uint8Array.from(Buffer.from(hex.slice(2, -1), 'hex'));
916
+ let fields;
917
+ try {
918
+ fields = decodeProtoMessage(bytes);
919
+ }
920
+ catch {
921
+ continue;
922
+ }
923
+ const call = findAntigravityToolCall(fields);
924
+ if (!call)
925
+ continue;
926
+ // Dedupe: the request + completion steps of one tool share the f1 call id.
927
+ if (call.id) {
928
+ if (seenCallIds.has(call.id))
929
+ continue;
930
+ seenCallIds.add(call.id);
931
+ }
932
+ const norm = ANTIGRAVITY_TOOL_MAP[call.name] || call.name;
933
+ const a = call.args || {};
934
+ events.push({
935
+ type: 'tool_use',
936
+ agent: 'antigravity',
937
+ timestamp,
938
+ tool: norm,
939
+ args: a,
940
+ command: norm === 'Bash' ? a.CommandLine : undefined,
941
+ // Antigravity uses PascalCase arg keys; probe the known path-bearing ones.
942
+ path: a.AbsolutePath || a.TargetFile || a.DirectoryPath || a.SearchPath || undefined,
943
+ // Antigravity's own short human label — free, high quality. It surfaces
944
+ // either as the f30 string or (more reliably) as `toolSummary` inside the
945
+ // f3 JSON args.
946
+ content: call.summary || (typeof a.toolSummary === 'string' ? a.toolSummary : undefined) || undefined,
947
+ });
948
+ }
949
+ return events;
950
+ }
673
951
  // ---------------------------------------------------------------------------
674
952
  // OpenCode parser
675
953
  // ---------------------------------------------------------------------------
@@ -1086,7 +1364,10 @@ export function parseKimi(filePath) {
1086
1364
  const fn = event.function || {};
1087
1365
  const toolName = typeof event.name === 'string' ? event.name : (fn.name || 'unknown');
1088
1366
  let args = {};
1089
- if (typeof fn.arguments === 'string') {
1367
+ if (event.args && typeof event.args === 'object') {
1368
+ args = event.args;
1369
+ }
1370
+ else if (typeof fn.arguments === 'string') {
1090
1371
  try {
1091
1372
  args = JSON.parse(fn.arguments);
1092
1373
  }
@@ -1212,7 +1493,7 @@ export function parseDroid(filePath) {
1212
1493
  tool: toolName,
1213
1494
  args: toolInput,
1214
1495
  path: toolInput.file_path || toolInput.path || undefined,
1215
- command: toolName === 'Bash' ? toolInput.command : undefined,
1496
+ command: (toolName === 'Bash' || toolName === 'Execute') ? toolInput.command : undefined,
1216
1497
  });
1217
1498
  }
1218
1499
  else if (block.type === 'tool_result') {
@@ -0,0 +1,21 @@
1
+ export interface PidSessionEntry {
2
+ pid: number;
3
+ agent: string;
4
+ /** The launch session id. Present for agents launched with a known id (Claude). */
5
+ sessionId?: string;
6
+ cwd?: string;
7
+ /**
8
+ * `$TMUX_PANE` at launch — stored for diagnostics and possible future
9
+ * disambiguation. NOT currently consulted on read: the listing path keys
10
+ * purely on pid (stale entries are pruned when the pid dies), so this is
11
+ * metadata, not an anti-collision key.
12
+ */
13
+ tmuxPane?: string;
14
+ startedAtMs: number;
15
+ }
16
+ /** Record a launched agent process. Never throws — the registry is an optimization. */
17
+ export declare function writePidSessionEntry(entry: PidSessionEntry): void;
18
+ /** Look up a live pid's recorded session. Returns undefined if absent/corrupt. */
19
+ export declare function readPidSessionEntry(pid: number): PidSessionEntry | undefined;
20
+ /** Remove entries whose pid is no longer alive. Best-effort housekeeping. */
21
+ export declare function prunePidSessionRegistry(isAlive: (pid: number) => boolean): void;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Per-PID session registry — the headless equivalent of the swarmify VS Code
3
+ * extension's `live-terminals.json`.
4
+ *
5
+ * On a machine with no terminal extension (a bare SSH/tmux host), the only way
6
+ * `ag sessions --active` can attribute a `ps`-discovered agent process to a
7
+ * session is to guess "newest .jsonl in the cwd" — which collapses N agents
8
+ * sharing one repo onto a single session (all rows show the same topic/id).
9
+ *
10
+ * `ag run` closes that gap by recording, at spawn time, one file per launched
11
+ * agent process keyed by its OS pid: the exact session id it was launched with
12
+ * (Claude is started with `--session-id <uuid>`, so the launcher knows it),
13
+ * plus agent/cwd/tmux pane. The active-sessions headless path reads it back for
14
+ * an exact pid -> session match instead of a heuristic.
15
+ *
16
+ * Best-effort throughout: a failed write or a corrupt file degrades to the old
17
+ * heuristic, never throws into the launch or the listing path.
18
+ */
19
+ import fs from 'fs';
20
+ import path from 'path';
21
+ import { getTerminalsDir } from '../state.js';
22
+ function pidRegistryDir() {
23
+ return path.join(getTerminalsDir(), 'by-pid');
24
+ }
25
+ function entryPath(pid) {
26
+ return path.join(pidRegistryDir(), `${pid}.json`);
27
+ }
28
+ /** Record a launched agent process. Never throws — the registry is an optimization. */
29
+ export function writePidSessionEntry(entry) {
30
+ if (!entry.pid || entry.pid < 1)
31
+ return;
32
+ try {
33
+ fs.mkdirSync(pidRegistryDir(), { recursive: true });
34
+ fs.writeFileSync(entryPath(entry.pid), JSON.stringify(entry), 'utf8');
35
+ }
36
+ catch {
37
+ /* degrade to the newest-jsonl heuristic */
38
+ }
39
+ }
40
+ /** Look up a live pid's recorded session. Returns undefined if absent/corrupt. */
41
+ export function readPidSessionEntry(pid) {
42
+ let raw;
43
+ try {
44
+ raw = fs.readFileSync(entryPath(pid), 'utf8');
45
+ }
46
+ catch {
47
+ return undefined;
48
+ }
49
+ try {
50
+ const parsed = JSON.parse(raw);
51
+ if (parsed && typeof parsed === 'object' && typeof parsed.pid === 'number') {
52
+ return parsed;
53
+ }
54
+ }
55
+ catch {
56
+ /* unparseable */
57
+ }
58
+ return undefined;
59
+ }
60
+ /** Remove entries whose pid is no longer alive. Best-effort housekeeping. */
61
+ export function prunePidSessionRegistry(isAlive) {
62
+ let files;
63
+ try {
64
+ files = fs.readdirSync(pidRegistryDir()).filter(f => f.endsWith('.json'));
65
+ }
66
+ catch {
67
+ return;
68
+ }
69
+ for (const f of files) {
70
+ const pid = Number(f.slice(0, -'.json'.length));
71
+ if (!Number.isInteger(pid) || isAlive(pid))
72
+ continue;
73
+ try {
74
+ fs.unlinkSync(path.join(pidRegistryDir(), f));
75
+ }
76
+ catch {
77
+ /* raced with another writer/pruner */
78
+ }
79
+ }
80
+ }
@@ -0,0 +1,26 @@
1
+ import type { ActiveSession } from './active.js';
2
+ /**
3
+ * Recursion guard, passed as an env var (not a CLI flag) so an OLDER remote
4
+ * `agents` that predates this feature ignores it harmlessly instead of erroring
5
+ * on an unknown option. A remote new enough to fan out reads it and stays local.
6
+ */
7
+ export declare const NO_FANOUT_ENV = "AGENTS_SESSIONS_LOCAL";
8
+ /**
9
+ * Parse a peer's `--active --json` stdout into active sessions, tagging each
10
+ * with `machine`. Defensive against version skew / partial output: non-JSON or
11
+ * a non-array yields `[]`, and non-object entries are dropped rather than
12
+ * throwing. Exported for unit testing without a live tailnet.
13
+ */
14
+ export declare function parseRemoteActive(stdout: string, machine: string): ActiveSession[];
15
+ export interface RemoteActiveResult {
16
+ sessions: ActiveSession[];
17
+ /** How many peer machines we attempted to reach (drives the empty-fleet tip). */
18
+ deviceCount: number;
19
+ }
20
+ /**
21
+ * Gather active sessions from other machines. With an explicit `hosts` list
22
+ * (from `--host`), fan out to exactly those. Otherwise sweep the registered,
23
+ * online devices from `ag devices`, excluding this machine and any without an
24
+ * address. Results from all peers run in parallel and are flattened.
25
+ */
26
+ export declare function gatherRemoteActive(hosts?: string[]): Promise<RemoteActiveResult>;
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Cross-machine fan-out for `agents sessions --active`.
3
+ *
4
+ * A single `getActiveSessions()` only sees the local machine. To show the whole
5
+ * fleet in one view, we run `agents sessions --active --json --local` on each
6
+ * peer over SSH and merge the parsed results, tagging every row with the machine
7
+ * it came from so the renderer can bucket by computer.
8
+ *
9
+ * Peers are the registered, online devices from `ag devices` (or an explicit
10
+ * `--host` list). `--local` on the remote invocation is critical: it stops the
11
+ * peer from fanning out to *its* devices, so the sweep never recurses.
12
+ *
13
+ * A dead or slow host is skipped with a stderr note, never fatal — one asleep
14
+ * laptop must not blank the whole view. SSH runs are async + parallel (a fresh
15
+ * `spawn`, not the sync `sshExec`) so N peers cost one round-trip, not N.
16
+ */
17
+ import { spawn } from 'child_process';
18
+ import chalk from 'chalk';
19
+ import { SSH_OPTS, controlOpts, assertValidSshTarget, shellQuote } from '../ssh-exec.js';
20
+ import { sshTargetFor } from '../devices/connect.js';
21
+ import { loadDevices } from '../devices/registry.js';
22
+ import { machineId, normalizeHost } from './sync/config.js';
23
+ /** Per-host SSH budget. Slightly above SSH_OPTS' ConnectTimeout=10 so a
24
+ * reachable-but-slow remote still answers before we give up. */
25
+ const REMOTE_TIMEOUT_MS = 12_000;
26
+ /**
27
+ * Recursion guard, passed as an env var (not a CLI flag) so an OLDER remote
28
+ * `agents` that predates this feature ignores it harmlessly instead of erroring
29
+ * on an unknown option. A remote new enough to fan out reads it and stays local.
30
+ */
31
+ export const NO_FANOUT_ENV = 'AGENTS_SESSIONS_LOCAL';
32
+ /** The command run on each peer: answer for itself, as JSON, without recursing. */
33
+ function remoteActiveCommand() {
34
+ const inner = `${NO_FANOUT_ENV}=1 agents sessions --active --json`;
35
+ return `bash -lc ${shellQuote(inner)}`;
36
+ }
37
+ /**
38
+ * Parse a peer's `--active --json` stdout into active sessions, tagging each
39
+ * with `machine`. Defensive against version skew / partial output: non-JSON or
40
+ * a non-array yields `[]`, and non-object entries are dropped rather than
41
+ * throwing. Exported for unit testing without a live tailnet.
42
+ */
43
+ export function parseRemoteActive(stdout, machine) {
44
+ let parsed;
45
+ try {
46
+ parsed = JSON.parse(stdout);
47
+ }
48
+ catch {
49
+ return [];
50
+ }
51
+ if (!Array.isArray(parsed))
52
+ return [];
53
+ const out = [];
54
+ for (const x of parsed) {
55
+ if (x && typeof x === 'object' && !Array.isArray(x)) {
56
+ out.push({ ...x, machine });
57
+ }
58
+ }
59
+ return out;
60
+ }
61
+ /** Run one remote `agents sessions --active --json --local` and capture stdout.
62
+ * Resolves `{ code: null }` on spawn error or timeout (host treated as dead). */
63
+ function sshCapture(target, remoteCmd, timeoutMs) {
64
+ assertValidSshTarget(target);
65
+ return new Promise((resolve) => {
66
+ const args = [...SSH_OPTS, ...controlOpts(), target, remoteCmd];
67
+ const child = spawn('ssh', args, { stdio: ['ignore', 'pipe', 'ignore'] });
68
+ let stdout = '';
69
+ let settled = false;
70
+ const done = (code) => {
71
+ if (settled)
72
+ return;
73
+ settled = true;
74
+ clearTimeout(timer);
75
+ resolve({ code, stdout });
76
+ };
77
+ const timer = setTimeout(() => { child.kill('SIGKILL'); done(null); }, timeoutMs);
78
+ child.stdout.on('data', (d) => { stdout += d.toString(); });
79
+ child.on('error', () => done(null));
80
+ child.on('close', (code) => done(code));
81
+ });
82
+ }
83
+ async function fetchByTarget(target, machine, display) {
84
+ const { code, stdout } = await sshCapture(target, remoteActiveCommand(), REMOTE_TIMEOUT_MS);
85
+ if (code !== 0) {
86
+ process.stderr.write(chalk.gray(` ${display}: unreachable or no agents CLI — skipped\n`));
87
+ return [];
88
+ }
89
+ return parseRemoteActive(stdout, machine);
90
+ }
91
+ /**
92
+ * Gather active sessions from other machines. With an explicit `hosts` list
93
+ * (from `--host`), fan out to exactly those. Otherwise sweep the registered,
94
+ * online devices from `ag devices`, excluding this machine and any without an
95
+ * address. Results from all peers run in parallel and are flattened.
96
+ */
97
+ export async function gatherRemoteActive(hosts) {
98
+ const self = machineId();
99
+ const targets = [];
100
+ if (hosts && hosts.length > 0) {
101
+ for (const h of hosts) {
102
+ try {
103
+ assertValidSshTarget(h);
104
+ }
105
+ catch {
106
+ process.stderr.write(chalk.gray(` ${h}: not a valid ssh target — skipped\n`));
107
+ continue;
108
+ }
109
+ const bareHost = h.split('@').pop() || h;
110
+ targets.push({ target: h, machine: normalizeHost(bareHost), name: h });
111
+ }
112
+ }
113
+ else {
114
+ let reg;
115
+ try {
116
+ reg = await loadDevices();
117
+ }
118
+ catch {
119
+ return { sessions: [], deviceCount: 0 };
120
+ }
121
+ for (const d of Object.values(reg)) {
122
+ if (d.tailscale?.online !== true)
123
+ continue;
124
+ if (normalizeHost(d.name) === self)
125
+ continue;
126
+ // Only machines that can actually run the CLI. iOS/tablet nodes register as
127
+ // `unknown` platform and can never answer, so skip them rather than burn a
128
+ // full ConnectTimeout on each.
129
+ if (d.platform !== 'windows' && d.platform !== 'linux' && d.platform !== 'macos')
130
+ continue;
131
+ try {
132
+ targets.push({ target: sshTargetFor(d), machine: normalizeHost(d.name), name: d.name });
133
+ }
134
+ catch {
135
+ // No address on the profile — nothing to dial; skip silently.
136
+ }
137
+ }
138
+ }
139
+ const results = await Promise.all(targets.map((t) => fetchByTarget(t.target, t.machine, t.name)));
140
+ return { sessions: results.flat(), deviceCount: targets.length };
141
+ }
@@ -27,6 +27,7 @@ import { join } from 'path';
27
27
  import { createHash } from 'crypto';
28
28
  import chalk from 'chalk';
29
29
  import { getCacheDir } from '../state.js';
30
+ import { SSH_OPTS, controlOpts } from '../ssh-exec.js';
30
31
  import { formatRelativeTime } from './relative-time.js';
31
32
  import { terminalWidth } from './width.js';
32
33
  /**
@@ -96,11 +97,6 @@ export function buildRemoteCommand(forwardedArgs, columns) {
96
97
  const withCols = columns && columns > 0 ? `COLUMNS=${columns} ${inner}` : inner;
97
98
  return `bash -lc ${shellQuote(withCols)}`;
98
99
  }
99
- const SSH_OPTS = [
100
- '-o', 'BatchMode=yes',
101
- '-o', 'StrictHostKeyChecking=accept-new',
102
- '-o', 'ConnectTimeout=10',
103
- ];
104
100
  /**
105
101
  * Classify an ssh `spawnSync` result. ssh(1) reserves exit 255 for its own
106
102
  * connection-layer failures (host down, timeout, refused, auth, changed host
@@ -186,7 +182,7 @@ export function runRemoteSessions(hosts, argv = process.argv) {
186
182
  for (const host of hosts) {
187
183
  if (multi)
188
184
  process.stdout.write(chalk.cyan(`\n── ${host} ──\n`));
189
- const res = spawnSync('ssh', [...SSH_OPTS, host, remoteCmd], {
185
+ const res = spawnSync('ssh', [...SSH_OPTS, ...controlOpts(), host, remoteCmd], {
190
186
  encoding: 'utf8',
191
187
  maxBuffer: 64 * 1024 * 1024,
192
188
  });
@@ -541,7 +541,7 @@ export function renderSummary(events, cwd) {
541
541
  if (p)
542
542
  filesReadAbs.add(p);
543
543
  }
544
- else if (['Write', 'Edit', 'write_file', 'edit_file', 'create_file', 'replace', 'patch'].includes(tool)) {
544
+ else if (['Write', 'Edit', 'Create', 'write_file', 'edit_file', 'create_file', 'replace', 'patch'].includes(tool)) {
545
545
  if (p) {
546
546
  if (p.includes('.claude/plans/') && p.endsWith('.md')) {
547
547
  planFilePath = p;
@@ -33,6 +33,14 @@ export declare function loadR2Config(): R2Config;
33
33
  * does not re-storm every cycle. `now` is injectable for tests.
34
34
  */
35
35
  export declare function isSyncConfigured(now?: number): boolean;
36
+ /**
37
+ * Normalize a raw hostname into a stable device id: first label only,
38
+ * lowercased, non-alphanumerics collapsed to hyphens. `zion.tail…ts.net` and
39
+ * `ZION` both become `zion`. The single source for this transform — machineId()
40
+ * and cross-machine session grouping must agree or the local machine won't
41
+ * match its own registry key.
42
+ */
43
+ export declare function normalizeHost(raw: string): string;
36
44
  /**
37
45
  * This machine's stable, human-readable id, used as its R2 prefix and mirror
38
46
  * directory name. Tailnet hostnames (zion, yosemite-s0, mac-mini) are already