@phnx-labs/agents-cli 1.22.24 → 1.22.26

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 (143) hide show
  1. package/CHANGELOG.md +350 -1
  2. package/README.md +31 -6
  3. package/dist/bin/agents +0 -0
  4. package/dist/browser.js +14 -4
  5. package/dist/commands/apply.js +52 -8
  6. package/dist/commands/browser.js +35 -0
  7. package/dist/commands/cloud.js +9 -5
  8. package/dist/commands/doctor.d.ts +24 -0
  9. package/dist/commands/doctor.js +108 -9
  10. package/dist/commands/exec.js +19 -16
  11. package/dist/commands/feed.d.ts +5 -0
  12. package/dist/commands/feed.js +21 -1
  13. package/dist/commands/focus.js +2 -2
  14. package/dist/commands/insights.d.ts +25 -19
  15. package/dist/commands/insights.js +107 -33
  16. package/dist/commands/menubar.js +8 -0
  17. package/dist/commands/reconnect.d.ts +46 -0
  18. package/dist/commands/reconnect.js +109 -0
  19. package/dist/commands/routines.d.ts +3 -0
  20. package/dist/commands/routines.js +72 -49
  21. package/dist/commands/run-cloud.js +1 -1
  22. package/dist/commands/secrets.d.ts +2 -8
  23. package/dist/commands/secrets.js +29 -105
  24. package/dist/commands/sessions-browser.d.ts +1 -1
  25. package/dist/commands/sessions-browser.js +27 -7
  26. package/dist/commands/sessions-resume.js +3 -2
  27. package/dist/commands/sessions.d.ts +13 -1
  28. package/dist/commands/sessions.js +28 -2
  29. package/dist/commands/setup-secrets.d.ts +1 -0
  30. package/dist/commands/setup-secrets.js +1 -1
  31. package/dist/commands/setup-watchdog.js +5 -10
  32. package/dist/commands/setup.d.ts +26 -3
  33. package/dist/commands/setup.js +106 -47
  34. package/dist/commands/teams.d.ts +6 -0
  35. package/dist/commands/teams.js +46 -2
  36. package/dist/commands/trends.d.ts +8 -0
  37. package/dist/commands/trends.js +10 -156
  38. package/dist/commands/watchdog.d.ts +3 -4
  39. package/dist/commands/watchdog.js +26 -66
  40. package/dist/index.js +37 -2
  41. package/dist/lib/agents.d.ts +11 -0
  42. package/dist/lib/agents.js +155 -23
  43. package/dist/lib/analytics/dashboard.d.ts +10 -6
  44. package/dist/lib/analytics/dashboard.js +6 -4
  45. package/dist/lib/analytics/mix-commands.d.ts +53 -0
  46. package/dist/lib/analytics/mix-commands.js +229 -0
  47. package/dist/lib/analytics/recipes.d.ts +19 -14
  48. package/dist/lib/analytics/recipes.js +4 -2
  49. package/dist/lib/browser/ipc.d.ts +26 -0
  50. package/dist/lib/browser/ipc.js +139 -24
  51. package/dist/lib/browser/profiles.d.ts +11 -0
  52. package/dist/lib/browser/profiles.js +1 -1
  53. package/dist/lib/browser/stream.d.ts +14 -0
  54. package/dist/lib/browser/stream.js +71 -0
  55. package/dist/lib/channels/owner-sink.d.ts +27 -0
  56. package/dist/lib/channels/owner-sink.js +93 -0
  57. package/dist/lib/cloud/cursor.d.ts +79 -0
  58. package/dist/lib/cloud/cursor.js +228 -0
  59. package/dist/lib/cloud/registry.js +2 -0
  60. package/dist/lib/cloud/types.d.ts +7 -2
  61. package/dist/lib/cloud/types.js +14 -0
  62. package/dist/lib/crabbox/cli.d.ts +2 -2
  63. package/dist/lib/crabbox/config.d.ts +7 -8
  64. package/dist/lib/crabbox/config.js +14 -14
  65. package/dist/lib/crabbox/lease.d.ts +11 -4
  66. package/dist/lib/crabbox/lease.js +40 -8
  67. package/dist/lib/crabbox/setup-copy.d.ts +5 -0
  68. package/dist/lib/crabbox/setup-copy.js +17 -1
  69. package/dist/lib/daemon.js +27 -1
  70. package/dist/lib/device-config.js +7 -0
  71. package/dist/lib/devices/doctor-findings.d.ts +13 -1
  72. package/dist/lib/devices/doctor-findings.js +73 -2
  73. package/dist/lib/events.d.ts +9 -0
  74. package/dist/lib/events.js +58 -0
  75. package/dist/lib/exec.d.ts +3 -3
  76. package/dist/lib/exec.js +24 -10
  77. package/dist/lib/feed-outcome.d.ts +3 -0
  78. package/dist/lib/feed-outcome.js +18 -10
  79. package/dist/lib/feed.d.ts +4 -0
  80. package/dist/lib/fleet/apply.d.ts +59 -3
  81. package/dist/lib/fleet/apply.js +183 -6
  82. package/dist/lib/fleet/types.d.ts +21 -2
  83. package/dist/lib/hooks/cache.js +15 -0
  84. package/dist/lib/hosts/passthrough.d.ts +44 -0
  85. package/dist/lib/hosts/passthrough.js +84 -12
  86. package/dist/lib/hosts/ready.d.ts +2 -0
  87. package/dist/lib/hosts/ready.js +10 -1
  88. package/dist/lib/hosts/reconnect.d.ts +14 -12
  89. package/dist/lib/hosts/reconnect.js +41 -40
  90. package/dist/lib/mcp.js +5 -1
  91. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  92. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  93. package/dist/lib/menubar/snapshot.d.ts +15 -0
  94. package/dist/lib/menubar/snapshot.js +40 -0
  95. package/dist/lib/plugins.js +13 -1
  96. package/dist/lib/resources/mcp.js +3 -0
  97. package/dist/lib/routine-process-cleanup.d.ts +9 -0
  98. package/dist/lib/routine-process-cleanup.js +73 -0
  99. package/dist/lib/routines.js +14 -2
  100. package/dist/lib/runner.d.ts +0 -3
  101. package/dist/lib/runner.js +6 -19
  102. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  103. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  104. package/dist/lib/secrets/bundles.js +1 -20
  105. package/dist/lib/secrets/filestore.d.ts +2 -0
  106. package/dist/lib/secrets/filestore.js +13 -0
  107. package/dist/lib/secrets/push.d.ts +94 -0
  108. package/dist/lib/secrets/push.js +145 -0
  109. package/dist/lib/secrets/rc-hygiene.d.ts +14 -0
  110. package/dist/lib/secrets/rc-hygiene.js +14 -1
  111. package/dist/lib/secrets/reaper.d.ts +15 -1
  112. package/dist/lib/secrets/reaper.js +30 -3
  113. package/dist/lib/session/active.d.ts +4 -0
  114. package/dist/lib/session/db.d.ts +21 -3
  115. package/dist/lib/session/db.js +229 -15
  116. package/dist/lib/session/discover.d.ts +1 -0
  117. package/dist/lib/session/discover.js +115 -19
  118. package/dist/lib/session/insights.d.ts +18 -0
  119. package/dist/lib/session/insights.js +143 -1
  120. package/dist/lib/session/remote-list.d.ts +2 -0
  121. package/dist/lib/session/remote-list.js +1 -0
  122. package/dist/lib/session/session-cache.d.ts +4 -4
  123. package/dist/lib/session/session-cache.js +4 -4
  124. package/dist/lib/session/tool-index.js +133 -22
  125. package/dist/lib/session/tool-store.d.ts +26 -2
  126. package/dist/lib/session/tool-store.js +36 -17
  127. package/dist/lib/shims.js +21 -1
  128. package/dist/lib/signin-badge.js +2 -0
  129. package/dist/lib/ssh-exec.js +8 -2
  130. package/dist/lib/startup/command-registry.d.ts +16 -0
  131. package/dist/lib/startup/command-registry.js +46 -0
  132. package/dist/lib/teams/agents.d.ts +13 -0
  133. package/dist/lib/teams/agents.js +76 -8
  134. package/dist/lib/teams/parsers.d.ts +1 -1
  135. package/dist/lib/teams/placement-probe.d.ts +21 -0
  136. package/dist/lib/teams/placement-probe.js +135 -0
  137. package/dist/lib/teams/scheduler.d.ts +74 -1
  138. package/dist/lib/teams/scheduler.js +187 -10
  139. package/dist/lib/types.d.ts +1 -1
  140. package/dist/lib/versions.js +16 -1
  141. package/dist/lib/watchdog/service.d.ts +17 -0
  142. package/dist/lib/watchdog/service.js +39 -0
  143. package/package.json +1 -1
@@ -28,11 +28,17 @@ export function toolEvidenceSourcePath(filePath, agent) {
28
28
  return filePath;
29
29
  }
30
30
  function deleteSessionCalls(db, sessionId) {
31
- const keys = db.prepare(`SELECT call_key FROM tool_calls WHERE session_id = ?`).all(sessionId);
32
- for (const { call_key } of keys) {
33
- db.prepare(`DELETE FROM tool_call_programs WHERE call_key = ?`).run(call_key);
34
- db.prepare(`DELETE FROM tool_program_occurrences WHERE call_key = ?`).run(call_key);
35
- db.prepare(`DELETE FROM tool_call_text WHERE call_key = ?`).run(call_key);
31
+ const rows = db.prepare(`SELECT rowid, call_key FROM tool_calls WHERE session_id = ?`)
32
+ .all(sessionId);
33
+ const deletePrograms = db.prepare(`DELETE FROM tool_call_programs WHERE call_key = ?`);
34
+ const deleteOccurrences = db.prepare(`DELETE FROM tool_program_occurrences WHERE call_key = ?`);
35
+ // Addressed by rowid, never by the UNINDEXED call_key see the tool_call_text
36
+ // schema comment in db.ts. A call_key predicate scans the whole FTS index.
37
+ const deleteText = db.prepare(`DELETE FROM tool_call_text WHERE rowid = ?`);
38
+ for (const { rowid, call_key } of rows) {
39
+ deletePrograms.run(call_key);
40
+ deleteOccurrences.run(call_key);
41
+ deleteText.run(rowid);
36
42
  }
37
43
  db.prepare(`DELETE FROM tool_calls WHERE session_id = ?`).run(sessionId);
38
44
  }
@@ -61,8 +67,11 @@ export function purgeMissingToolCallsInDirectory(db, dirPath, currentFilePaths)
61
67
  }
62
68
  return purged;
63
69
  }
64
- /** Persist one parser batch and its file stamp atomically. */
65
- export function persistToolCalls(db, session, calls, sourceStamp, mode = 'replace', maxSessionBytes = TOOL_SESSION_EVIDENCE_MAX_BYTES) {
70
+ /** Persist one parser batch, its file stamp, and its resume point atomically. */
71
+ export function persistToolCalls(db, session, calls, sourceStamp, options = {}) {
72
+ const mode = options.mode ?? 'replace';
73
+ const maxSessionBytes = options.maxSessionBytes ?? TOOL_SESSION_EVIDENCE_MAX_BYTES;
74
+ const resume = options.resume ?? null;
66
75
  const sourcePath = toolEvidenceSourcePath(session.filePath, session.agent);
67
76
  const insertCall = db.prepare(`
68
77
  INSERT INTO tool_calls (
@@ -89,16 +98,19 @@ export function persistToolCalls(db, session, calls, sourceStamp, mode = 'replac
89
98
  INSERT INTO tool_program_occurrences (call_key, occurrence_ordinal, program, role)
90
99
  VALUES (?, ?, ?, ?)
91
100
  `);
92
- const insertText = db.prepare(`INSERT INTO tool_call_text (call_key, tool, input, output, error) VALUES (?, ?, ?, ?, ?)`);
101
+ // tool_call_text rows are addressed by the rowid of the tool_calls row they
102
+ // describe (db.ts schema comment): its UNINDEXED call_key cannot be seeked.
103
+ const insertText = db.prepare(`INSERT INTO tool_call_text (rowid, call_key, tool, input, output, error) VALUES (?, ?, ?, ?, ?, ?)`);
104
+ const callRowid = db.prepare(`SELECT rowid FROM tool_calls WHERE call_key = ?`);
93
105
  const deletePrograms = db.prepare(`DELETE FROM tool_call_programs WHERE call_key = ?`);
94
106
  const deleteOccurrences = db.prepare(`DELETE FROM tool_program_occurrences WHERE call_key = ?`);
95
- const deleteText = db.prepare(`DELETE FROM tool_call_text WHERE call_key = ?`);
107
+ const deleteText = db.prepare(`DELETE FROM tool_call_text WHERE rowid = ?`);
96
108
  const deleteCall = db.prepare(`DELETE FROM tool_calls WHERE call_key = ?`);
97
109
  const writeLedger = db.prepare(`
98
110
  INSERT INTO tool_scan_ledger (
99
111
  session_id, file_path, file_mtime_ms, file_size, extractor_version, indexed_at, call_count,
100
- evidence_bytes
101
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
112
+ evidence_bytes, parser_state, parsed_offset
113
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
102
114
  ON CONFLICT(session_id) DO UPDATE SET
103
115
  file_path = excluded.file_path,
104
116
  file_mtime_ms = excluded.file_mtime_ms,
@@ -106,7 +118,9 @@ export function persistToolCalls(db, session, calls, sourceStamp, mode = 'replac
106
118
  extractor_version = excluded.extractor_version,
107
119
  indexed_at = excluded.indexed_at,
108
120
  call_count = excluded.call_count,
109
- evidence_bytes = excluded.evidence_bytes
121
+ evidence_bytes = excluded.evidence_bytes,
122
+ parser_state = excluded.parser_state,
123
+ parsed_offset = excluded.parsed_offset
110
124
  `);
111
125
  const txn = db.transaction(() => {
112
126
  if (mode === 'replace')
@@ -119,7 +133,7 @@ export function persistToolCalls(db, session, calls, sourceStamp, mode = 'replac
119
133
  throw new Error('Append tool evidence requires an existing tool ledger row.');
120
134
  }
121
135
  if (mode === 'append' && calls.length === 0) {
122
- writeLedger.run(session.id, ledgerPath, sourceStamp.fileMtimeMs, sourceStamp.fileSize, TOOL_INDEX_VERSION, Date.now(), priorLedger.call_count, priorLedger.evidence_bytes);
136
+ writeLedger.run(session.id, ledgerPath, sourceStamp.fileMtimeMs, sourceStamp.fileSize, TOOL_INDEX_VERSION, Date.now(), priorLedger.call_count, priorLedger.evidence_bytes, resume?.parserState ?? null, resume?.parsedOffset ?? null);
123
137
  return;
124
138
  }
125
139
  const limitKey = toolCallKey(session.id, TOOL_INDEX_LIMIT_ORDINAL);
@@ -129,9 +143,11 @@ export function persistToolCalls(db, session, calls, sourceStamp, mode = 'replac
129
143
  const priorLimit = mode === 'append'
130
144
  ? existingSize.get(session.id, TOOL_INDEX_LIMIT_ORDINAL)
131
145
  : undefined;
146
+ const limitRow = callRowid.get(limitKey);
132
147
  deletePrograms.run(limitKey);
133
148
  deleteOccurrences.run(limitKey);
134
- deleteText.run(limitKey);
149
+ if (limitRow)
150
+ deleteText.run(limitRow.rowid);
135
151
  deleteCall.run(limitKey);
136
152
  const existingRows = mode === 'append'
137
153
  ? calls.map((call) => ({
@@ -182,17 +198,20 @@ export function persistToolCalls(db, session, calls, sourceStamp, mode = 'replac
182
198
  for (const call of accepted) {
183
199
  const key = toolCallKey(session.id, call.ordinal);
184
200
  insertCall.run(key, session.id, call.ordinal, call.sourceCallId ?? null, call.timestamp, call.tool, call.input, call.outcome, call.exitCode ?? null, call.statusCode ?? null, call.errorCode ?? null, call.output ?? null, call.error ?? null, call.parseError ?? null, toolCallEvidenceBytes(call));
201
+ // The upsert above preserves the rowid of a call it updated, so this is
202
+ // the same rowid the existing text row (if any) was written under.
203
+ const { rowid } = callRowid.get(key);
185
204
  deletePrograms.run(key);
186
205
  deleteOccurrences.run(key);
187
- deleteText.run(key);
206
+ deleteText.run(rowid);
188
207
  for (const program of call.programs)
189
208
  insertProgram.run(key, program);
190
209
  call.programOccurrences.forEach((occurrence, occurrenceOrdinal) => {
191
210
  insertOccurrence.run(key, occurrenceOrdinal, occurrence.program, occurrence.role);
192
211
  });
193
- insertText.run(key, call.tool, call.input, call.output ?? '', call.error ?? '');
212
+ insertText.run(rowid, key, call.tool, call.input, call.output ?? '', call.error ?? '');
194
213
  }
195
- writeLedger.run(session.id, ledgerPath, sourceStamp.fileMtimeMs, sourceStamp.fileSize, TOOL_INDEX_VERSION, Date.now(), count, storedBytes);
214
+ writeLedger.run(session.id, ledgerPath, sourceStamp.fileMtimeMs, sourceStamp.fileSize, TOOL_INDEX_VERSION, Date.now(), count, storedBytes, resume?.parserState ?? null, resume?.parsedOffset ?? null);
196
215
  });
197
216
  txn();
198
217
  }
package/dist/lib/shims.js CHANGED
@@ -584,6 +584,18 @@ elif [ "$AGENT" = "muse" ]; then
584
584
  esac
585
585
  fi
586
586
  fi
587
+ elif [ "$AGENT" = "warp" ]; then
588
+ # Warp Agent CLI installs a global, self-updating oz binary (brew cask on
589
+ # macOS, the oz-stable apt|yum|pacman package on Linux) -- a platform/package
590
+ # specific location, not ~/.local/bin -- so resolve it from PATH with the same
591
+ # shims-dir re-exec guard as droid/muse.
592
+ BINARY=$(adopted_original_bin || echo "")
593
+ if [ -z "$BINARY" ]; then
594
+ BINARY=$(command -v oz 2>/dev/null || echo "")
595
+ case "$(readlink -f "$BINARY" 2>/dev/null)" in
596
+ "$AGENTS_USER_DIR/.cache/shims/"*) BINARY="" ;;
597
+ esac
598
+ fi
587
599
  else
588
600
  BINARY="$VERSION_DIR/node_modules/.bin/$CLI_COMMAND"
589
601
  fi
@@ -1041,7 +1053,15 @@ else
1041
1053
  "$HOME/.agents/.cache/shims/"*) BINARY="" ;;
1042
1054
  esac
1043
1055
  fi`
1044
- : `BINARY="${versionDir}/node_modules/.bin/${agentConfig.cliCommand}"`;
1056
+ : agent === 'warp'
1057
+ ? `# Warp Agent CLI installs a global self-updating \`oz\` binary (brew cask on
1058
+ # macOS, oz-stable apt|yum|pacman on Linux) — a platform-specific location, not
1059
+ # ~/.local/bin — so resolve it from PATH, refusing anything under our shims dir.
1060
+ BINARY=$(command -v oz 2>/dev/null || echo "")
1061
+ case "$BINARY" in
1062
+ "$HOME/.agents/.cache/shims/"*) BINARY="" ;;
1063
+ esac`
1064
+ : `BINARY="${versionDir}/node_modules/.bin/${agentConfig.cliCommand}"`;
1045
1065
  return `#!/bin/bash
1046
1066
  # Auto-generated by agents-cli - do not edit
1047
1067
  # ${VERSIONED_ALIAS_VERSION_MARKER} ${VERSIONED_ALIAS_SCHEMA_VERSION}
@@ -24,6 +24,8 @@ export function loginHint(agentId) {
24
24
  return `${cli}, then /login`;
25
25
  case 'codex':
26
26
  case 'grok':
27
+ // Warp Agent CLI: `oz login` opens a browser sign-in (or set WARP_API_KEY).
28
+ case 'warp':
27
29
  return `${cli} login`;
28
30
  case 'opencode':
29
31
  return `${cli} auth login`;
@@ -117,7 +117,11 @@ export function sshExec(target, remoteCmd, opts = {}) {
117
117
  stdio: ['pipe', 'pipe', 'pipe'],
118
118
  windowsHide: true,
119
119
  });
120
- const timedOut = !!(res.error && res.error.code === 'ETIMEDOUT');
120
+ // Node's spawnSync with a `timeout` option kills the child via SIGTERM and sets
121
+ // res.signal (e.g. 'SIGTERM') — it does NOT set res.error.code = 'ETIMEDOUT'.
122
+ // Check both so the detection fires whether the timeout is signalled or errored.
123
+ const timedOut = !!(res.error && res.error.code === 'ETIMEDOUT') ||
124
+ (!!opts.timeoutMs && res.signal !== null);
121
125
  return {
122
126
  code: typeof res.status === 'number' ? res.status : null,
123
127
  stdout: res.stdout ?? '',
@@ -206,7 +210,9 @@ export function sshExecRaw(target, remoteCmd, opts = {}) {
206
210
  stdio: ['pipe', 'pipe', 'pipe'],
207
211
  windowsHide: true,
208
212
  });
209
- const timedOut = !!(res.error && res.error.code === 'ETIMEDOUT');
213
+ // Same fix as sshExec: spawnSync sets res.signal, not error.code = 'ETIMEDOUT'.
214
+ const timedOut = !!(res.error && res.error.code === 'ETIMEDOUT') ||
215
+ (!!opts.timeoutMs && res.signal !== null);
210
216
  return {
211
217
  code: typeof res.status === 'number' ? res.status : null,
212
218
  stdout: res.stdout ?? Buffer.alloc(0),
@@ -47,6 +47,7 @@ export declare const loadMonitors: ModuleLoader;
47
47
  export declare const loadProjects: ModuleLoader;
48
48
  export declare const loadRun: ModuleLoader;
49
49
  export declare const loadResume: ModuleLoader;
50
+ export declare const loadReconnect: ModuleLoader;
50
51
  export declare const loadFork: ModuleLoader;
51
52
  export declare const loadDefaults: ModuleLoader;
52
53
  export declare const loadSet: ModuleLoader;
@@ -131,3 +132,18 @@ export declare const LAZY_COMMAND_NAMES: ReadonlySet<string>;
131
132
  * are handled directly in src/index.ts.
132
133
  */
133
134
  export declare const COMMAND_LOADERS: Record<string, ModuleLoader[]>;
135
+ /**
136
+ * Every top-level command name the CLI answers to — the loader table plus the
137
+ * inline aliases/tombstones above. This is the "does this command exist?"
138
+ * predicate for code that runs BEFORE commander parses, most importantly the
139
+ * `--host`/`--device` router (lib/hosts/passthrough.ts): without it a typo'd
140
+ * command carrying `--host` reported a flag-support error instead of
141
+ * `unknown command` (RUSH-2022).
142
+ *
143
+ * Commander sub-aliases (`sessions ls`, `teams rm`, …) are deliberately absent —
144
+ * this set is top-level only. `command-registry.test.ts` pins it against the real
145
+ * registered command tree so a new command can never drift out of it.
146
+ */
147
+ export declare const KNOWN_TOP_LEVEL_COMMANDS: ReadonlySet<string>;
148
+ /** Whether `name` is a top-level command this CLI registers. See {@link KNOWN_TOP_LEVEL_COMMANDS}. */
149
+ export declare function isKnownTopLevelCommand(name: string): boolean;
@@ -25,6 +25,7 @@ export const loadMonitors = async () => (await import('../../commands/monitors.j
25
25
  export const loadProjects = async () => (await import('../../commands/projects.js')).registerProjectsCommands;
26
26
  export const loadRun = async () => (await import('../../commands/exec.js')).registerRunCommand;
27
27
  export const loadResume = async () => (await import('../../commands/resume.js')).registerResumeCommand;
28
+ export const loadReconnect = async () => (await import('../../commands/reconnect.js')).registerReconnectCommand;
28
29
  export const loadFork = async () => (await import('../../commands/fork.js')).registerForkCommand;
29
30
  export const loadDefaults = async () => (await import('../../commands/defaults.js')).registerDefaultsCommands;
30
31
  export const loadSet = async () => (await import('../../commands/set.js')).registerSetCommand;
@@ -53,6 +54,7 @@ export const loadUsage = async () => (await import('../../commands/usage.js')).r
53
54
  export const loadCost = async () => (await import('../../commands/cost.js')).registerCostCommand;
54
55
  export const loadInsights = async () => (await import('../../commands/insights.js')).registerInsightsCommand;
55
56
  export const loadPerf = async () => (await import('../../commands/perf.js')).registerPerfCommand;
57
+ // Thin deprecated alias of `agents insights mix` — no second mix implementation.
56
58
  export const loadTrends = async () => (await import('../../commands/trends.js')).registerTrendsCommand;
57
59
  export const loadOutput = async () => (await import('../../commands/output.js')).registerOutputCommand;
58
60
  export const loadBudget = async () => (await import('../../commands/budget.js')).registerBudgetCommand;
@@ -98,6 +100,7 @@ export const loadHumans = async () => (await import('../../commands/humans.js'))
98
100
  export const LAZY_COMMAND_NAMES = new Set([
99
101
  'sessions',
100
102
  'resume',
103
+ 'reconnect',
101
104
  'roster',
102
105
  'teams',
103
106
  'cloud',
@@ -146,11 +149,17 @@ export const COMMAND_LOADERS = {
146
149
  registry: [loadPackages],
147
150
  search: [loadPackages],
148
151
  install: [loadPackages],
152
+ // packages.ts also registers `publish` at top level (commands/packages.ts:435).
153
+ // It was missing here, so `agents publish` only worked via the unknown-command
154
+ // fallback that registers the whole tree — and the --host router could not see
155
+ // it as a real command at all (RUSH-2022).
156
+ publish: [loadPackages],
149
157
  routines: [loadRoutines],
150
158
  monitors: [loadMonitors],
151
159
  projects: [loadProjects],
152
160
  run: [loadRun],
153
161
  resume: [loadResume],
162
+ reconnect: [loadReconnect],
154
163
  fork: [loadFork],
155
164
  defaults: [loadDefaults],
156
165
  set: [loadSet],
@@ -231,3 +240,40 @@ export const COMMAND_LOADERS = {
231
240
  funnel: [loadFunnel],
232
241
  humans: [loadHumans],
233
242
  };
243
+ /**
244
+ * Top-level names that {@link COMMAND_LOADERS} does not carry because they are
245
+ * registered inline in src/index.ts — closures over entry-point state (the
246
+ * deprecated aliases and tombstones) plus the internal/upgrade commands. They are
247
+ * real commands, so anything that asks "does this command exist?" must count them.
248
+ */
249
+ const INLINE_COMMAND_NAMES = [
250
+ 'perms', // deprecated alias -> permissions
251
+ 'exec', // deprecated alias -> run
252
+ 'jobs', // deprecated alias -> routines
253
+ 'cron', // deprecated alias -> routines
254
+ 'check', // tombstone -> doctor --check
255
+ 'resources', // tombstone -> view --merged
256
+ 'hq', // tombstone
257
+ '_internal',
258
+ 'upgrade',
259
+ ];
260
+ /**
261
+ * Every top-level command name the CLI answers to — the loader table plus the
262
+ * inline aliases/tombstones above. This is the "does this command exist?"
263
+ * predicate for code that runs BEFORE commander parses, most importantly the
264
+ * `--host`/`--device` router (lib/hosts/passthrough.ts): without it a typo'd
265
+ * command carrying `--host` reported a flag-support error instead of
266
+ * `unknown command` (RUSH-2022).
267
+ *
268
+ * Commander sub-aliases (`sessions ls`, `teams rm`, …) are deliberately absent —
269
+ * this set is top-level only. `command-registry.test.ts` pins it against the real
270
+ * registered command tree so a new command can never drift out of it.
271
+ */
272
+ export const KNOWN_TOP_LEVEL_COMMANDS = new Set([
273
+ ...Object.keys(COMMAND_LOADERS),
274
+ ...INLINE_COMMAND_NAMES,
275
+ ]);
276
+ /** Whether `name` is a top-level command this CLI registers. See {@link KNOWN_TOP_LEVEL_COMMANDS}. */
277
+ export function isKnownTopLevelCommand(name) {
278
+ return KNOWN_TOP_LEVEL_COMMANDS.has(name);
279
+ }
@@ -1,5 +1,6 @@
1
1
  import { captureProcessStartTime } from '../platform/index.js';
2
2
  import { AgentType } from './parsers.js';
3
+ import { NoViableDeviceError } from './scheduler.js';
3
4
  /**
4
5
  * Compute the Lowest Common Ancestor (LCA) of multiple file paths.
5
6
  * Returns the deepest common directory shared by all paths.
@@ -405,6 +406,18 @@ export declare class AgentManager {
405
406
  * teammate schedules identically no matter how it was fired.
406
407
  */
407
408
  private maybeSchedulePlacement;
409
+ /** Human label of a teammate's agent for the placement fail-loud message. */
410
+ private placementAgentLabel;
411
+ /**
412
+ * Pre-flight the team pool at `teams start` (RUSH-2002): probe the pool and
413
+ * confirm each distinct agent among the pending, unpinned, pooled teammates
414
+ * has at least one viable device. Returns the first {@link NoViableDeviceError}
415
+ * so the command can fail loud BEFORE launching a wave, rather than silently
416
+ * leaving teammates stranded pending. A poolless team (no `--devices`) or a
417
+ * probe that could not gather positive evidence returns null — fail-loud fires
418
+ * only on proof that no device can run the agent, never on a probe miss.
419
+ */
420
+ preflightPlacement(taskName: string): Promise<NoViableDeviceError | null>;
408
421
  /**
409
422
  * One-ssh-per-host batched liveness/exit pre-pass for a team's remote teammates.
410
423
  * The supervisor calls this each wave BEFORE listByTask() so the per-teammate
@@ -34,7 +34,8 @@ import { resolveRemoteOsSync } from '../hosts/remote-os.js';
34
34
  import { pullRemoteLogDelta, REMOTE_MIRROR_MAX_BYTES } from '../hosts/progress.js';
35
35
  import { createRemoteWorktree, ensureRemoteRepo } from './remoteWorktree.js';
36
36
  import { getTeam } from './registry.js';
37
- import { resolvePlacement, cappedDevices } from './scheduler.js';
37
+ import { resolvePlacement, classifyExclusions, NoViableDeviceError } from './scheduler.js';
38
+ import { probePoolSignals } from './placement-probe.js';
38
39
  import { readMaxConcurrentCaps } from '../device-config.js';
39
40
  import chalk from 'chalk';
40
41
  let lastMemoryWarnAt = 0;
@@ -197,7 +198,7 @@ export function buildTeammateSpawnEnv(envOverrides) {
197
198
  */
198
199
  export { captureProcessStartTime };
199
200
  /** Agent types the team runner supports. */
200
- const TEAM_AGENT_TYPES = ['codex', 'cursor', 'claude', 'opencode', 'grok', 'antigravity', 'kimi', 'droid'];
201
+ const TEAM_AGENT_TYPES = ['codex', 'cursor', 'claude', 'opencode', 'grok', 'antigravity', 'kimi', 'droid', 'warp'];
201
202
  // Suffix appended to all prompts to ensure agents provide a summary
202
203
  const PROMPT_SUFFIX = `
203
204
 
@@ -1756,7 +1757,7 @@ export class AgentManager {
1756
1757
  * (immediate add-launch) and startReady() (staged launch) so an unpinned pool
1757
1758
  * teammate schedules identically no matter how it was fired.
1758
1759
  */
1759
- async maybeSchedulePlacement(agent, taskName) {
1760
+ async maybeSchedulePlacement(agent, taskName, opts = {}) {
1760
1761
  if (agent.hostName || agent.cloudProvider)
1761
1762
  return;
1762
1763
  const teamMeta = await getTeam(taskName);
@@ -1765,15 +1766,79 @@ export class AgentManager {
1765
1766
  const roster = await this.listByTask(taskName);
1766
1767
  const pool = teamMeta.devices ?? [];
1767
1768
  const maxConcurrent = pool.length > 1 ? readMaxConcurrentCaps(pool) : undefined;
1768
- if (maxConcurrent) {
1769
- for (const c of cappedDevices(pool, roster, maxConcurrent)) {
1770
- console.error(chalk.dim(`[placement] '${c.device}' excluded from auto-pick at its agents.max-concurrent cap (${c.running}/${c.cap} running)`));
1769
+ // On the start path (opts.probe), gather live signals so the pick is health-,
1770
+ // harness-, and load-aware (RUSH-2002); the add path stays the cap-only
1771
+ // roster count so `teams add` never blocks on an SSH fan-out. Cached per
1772
+ // (pool, agent), so a wave placing many teammates probes the pool once.
1773
+ const signals = opts.probe && pool.length > 0
1774
+ ? await probePoolSignals(pool, agent.agentType, { now: Date.now() })
1775
+ : undefined;
1776
+ const placeOpts = {
1777
+ maxConcurrent,
1778
+ signals,
1779
+ agentLabel: this.placementAgentLabel(agent),
1780
+ };
1781
+ if (signals) {
1782
+ for (const e of classifyExclusions(pool, roster, placeOpts).excluded) {
1783
+ const why = e.reason === 'capped'
1784
+ ? `at its agents.max-concurrent cap (${e.detail} running)`
1785
+ : e.reason === 'not-installed'
1786
+ ? `does not have ${this.placementAgentLabel(agent)} installed`
1787
+ : e.reason;
1788
+ console.error(chalk.dim(`[placement] '${e.device}' excluded from auto-pick — ${why}`));
1771
1789
  }
1772
1790
  }
1773
- const { device } = resolvePlacement(teamMeta, null, roster, { maxConcurrent });
1791
+ const { device } = resolvePlacement(teamMeta, null, roster, placeOpts);
1774
1792
  if (device)
1775
1793
  await this.resolveScheduledPlacement(agent, device, taskName);
1776
1794
  }
1795
+ /** Human label of a teammate's agent for the placement fail-loud message. */
1796
+ placementAgentLabel(agent) {
1797
+ return agent.version ? `${agent.agentType}@${agent.version}` : String(agent.agentType);
1798
+ }
1799
+ /**
1800
+ * Pre-flight the team pool at `teams start` (RUSH-2002): probe the pool and
1801
+ * confirm each distinct agent among the pending, unpinned, pooled teammates
1802
+ * has at least one viable device. Returns the first {@link NoViableDeviceError}
1803
+ * so the command can fail loud BEFORE launching a wave, rather than silently
1804
+ * leaving teammates stranded pending. A poolless team (no `--devices`) or a
1805
+ * probe that could not gather positive evidence returns null — fail-loud fires
1806
+ * only on proof that no device can run the agent, never on a probe miss.
1807
+ */
1808
+ async preflightPlacement(taskName) {
1809
+ await this.initialize();
1810
+ const teamMeta = await getTeam(taskName);
1811
+ const pool = teamMeta?.devices ?? [];
1812
+ if (!teamMeta || pool.length === 0)
1813
+ return null;
1814
+ const roster = await this.listByTask(taskName);
1815
+ const pending = roster.filter((a) => a.status === AgentStatus.PENDING && !a.hostName && !a.cloudProvider);
1816
+ if (pending.length === 0)
1817
+ return null;
1818
+ const maxConcurrent = pool.length > 1 ? readMaxConcurrentCaps(pool) : undefined;
1819
+ // One representative teammate per distinct agent type — the signal + gate is
1820
+ // per harness, not per teammate.
1821
+ const byAgent = new Map();
1822
+ for (const a of pending)
1823
+ if (!byAgent.has(a.agentType))
1824
+ byAgent.set(a.agentType, a);
1825
+ for (const [, rep] of byAgent) {
1826
+ const signals = await probePoolSignals(pool, rep.agentType, { now: Date.now() });
1827
+ try {
1828
+ resolvePlacement(teamMeta, null, roster, {
1829
+ maxConcurrent,
1830
+ signals,
1831
+ agentLabel: this.placementAgentLabel(rep),
1832
+ });
1833
+ }
1834
+ catch (err) {
1835
+ if (err instanceof NoViableDeviceError)
1836
+ return err;
1837
+ throw err;
1838
+ }
1839
+ }
1840
+ return null;
1841
+ }
1777
1842
  /**
1778
1843
  * One-ssh-per-host batched liveness/exit pre-pass for a team's remote teammates.
1779
1844
  * The supervisor calls this each wave BEFORE listByTask() so the per-teammate
@@ -1866,9 +1931,12 @@ export class AgentManager {
1866
1931
  // immediate-add and staged paths agree. A null pick keeps hostName null →
1867
1932
  // local spawn, unchanged. Cloud teammates never schedule.
1868
1933
  try {
1869
- await this.maybeSchedulePlacement(agent, taskName);
1934
+ await this.maybeSchedulePlacement(agent, taskName, { probe: true });
1870
1935
  }
1871
1936
  catch (err) {
1937
+ // A NoViableDeviceError means the pool cannot host this teammate right
1938
+ // now — leave it PENDING (never a silent local fallback) and surface the
1939
+ // reason; a transient device loss is retried next wave.
1872
1940
  console.error(`Could not schedule ${agent.agentId} onto the team pool:`, err);
1873
1941
  continue;
1874
1942
  }
@@ -1,5 +1,5 @@
1
1
  /** Supported agent CLI types for team spawning. */
2
- export type AgentType = 'codex' | 'gemini' | 'cursor' | 'claude' | 'opencode' | 'grok' | 'antigravity' | 'kimi' | 'droid';
2
+ export type AgentType = 'codex' | 'gemini' | 'cursor' | 'claude' | 'opencode' | 'grok' | 'antigravity' | 'kimi' | 'droid' | 'warp';
3
3
  /** Normalize a raw JSON event from any agent type into an array of unified event objects. */
4
4
  export declare function normalizeEvents(agentType: AgentType, raw: any): any[];
5
5
  /** Normalize a raw JSON event, returning only the first unified event (convenience wrapper). */
@@ -0,0 +1,21 @@
1
+ import { type AgentType } from './agents.js';
2
+ import type { DevicePlacementSignal } from './scheduler.js';
3
+ /** Per-remote readiness probe budget — matches the health probe's short window
4
+ * (`agents view` on a warm box is sub-second; a wedged one degrades to unknown). */
5
+ export declare const READY_PROBE_TIMEOUT_MS = 8000;
6
+ /** How long a probed pool snapshot is reused within a `teams start` wave. Short
7
+ * enough that a device coming online / going overloaded is seen next wave, long
8
+ * enough that placing a wave of teammates does not re-fan-out per teammate. */
9
+ export declare const SIGNAL_TTL_MS = 15000;
10
+ /** Clear the probe cache — for tests and after a device-registry change. */
11
+ export declare function clearPlacementSignalCache(): void;
12
+ /**
13
+ * Probe every device in the team pool and return a name→signal map for the pure
14
+ * placement pick. Devices with no data at all are omitted (the pick then neither
15
+ * excludes nor prefers them). Never throws — a probe failure degrades to a
16
+ * missing/partial signal.
17
+ */
18
+ export declare function probePoolSignals(pool: string[], agent: AgentType, opts?: {
19
+ force?: boolean;
20
+ now?: number;
21
+ }): Promise<Map<string, DevicePlacementSignal>>;
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Live pool probing for the teams placement scheduler (RUSH-2002).
3
+ *
4
+ * Gathers one {@link DevicePlacementSignal} per pool device so the pure pick in
5
+ * {@link ./scheduler} can filter unreachable / overloaded / not-installed
6
+ * devices and rank the rest. Two concerns, both best-effort:
7
+ *
8
+ * - reachability + headroom + load ← {@link probeFleetStats} (one parallel
9
+ * SSH fan-out over the pool; the local box is measured directly).
10
+ * - requested agent installed (+ signed in, locally) ← a one-shot readiness
11
+ * probe per remote device ({@link buildReadyProbeCommand} → `agents view`),
12
+ * the local box via {@link checkCliAvailable}/{@link checkCliSignedIn}.
13
+ *
14
+ * The result is cached briefly per (pool, agent) so a `teams start` wave that
15
+ * places N teammates probes the pool ONCE, not N times — the roster-count part
16
+ * of the rank stays live (the pure pick recounts the roster each call), only the
17
+ * SSH-measured load/harness snapshot is reused within the TTL.
18
+ *
19
+ * All SSH here is via `execFile` (async, bounded) so the whole pool is probed in
20
+ * parallel; a slow or wedged box degrades to "no signal" instead of blocking the
21
+ * launch. Remote sign-in is deliberately left unknown — sign-in detection is
22
+ * unreliable over SSH (see {@link checkCliSignedIn}); it ranks the local box
23
+ * only, and the install gate (not sign-in) is what drives the fail-loud.
24
+ */
25
+ import { execFile } from 'child_process';
26
+ import { probeFleetStats, headroom } from '../devices/health.js';
27
+ import { loadDevicesSync } from '../devices/registry.js';
28
+ import { buildSshInvocation, writeAskpassShim } from '../devices/connect.js';
29
+ import { buildReadyProbeCommand, parseReadyProbe, viewHasAgent } from '../hosts/ready.js';
30
+ import { localMachineId } from '../session/origin-machine.js';
31
+ import { normalizeHost } from '../machine-id.js';
32
+ import { checkCliAvailable, checkCliSignedIn } from './agents.js';
33
+ /** Per-remote readiness probe budget — matches the health probe's short window
34
+ * (`agents view` on a warm box is sub-second; a wedged one degrades to unknown). */
35
+ export const READY_PROBE_TIMEOUT_MS = 8_000;
36
+ /** How long a probed pool snapshot is reused within a `teams start` wave. Short
37
+ * enough that a device coming online / going overloaded is seen next wave, long
38
+ * enough that placing a wave of teammates does not re-fan-out per teammate. */
39
+ export const SIGNAL_TTL_MS = 15_000;
40
+ const cache = new Map();
41
+ function cacheKey(pool, agent) {
42
+ return `${agent}::${[...pool].map(normalizeHost).sort().join(',')}`;
43
+ }
44
+ /** Clear the probe cache — for tests and after a device-registry change. */
45
+ export function clearPlacementSignalCache() {
46
+ cache.clear();
47
+ }
48
+ /**
49
+ * Whether the requested agent is installed on a REMOTE device, via one SSH
50
+ * readiness probe. `undefined` when the probe could not answer (ssh/login
51
+ * failure) — reachability is then left to {@link probeFleetStats}; `false` when
52
+ * the box answered but agents-cli or the agent is absent (a genuine can't-run).
53
+ */
54
+ function probeRemoteInstalled(device, agent) {
55
+ let args;
56
+ let env;
57
+ try {
58
+ const shim = writeAskpassShim();
59
+ const cmd = buildReadyProbeCommand(device.shell === 'powershell' ? 'windows' : undefined);
60
+ // agentOnly: a read-only probe must never force a foreground Touch ID sheet
61
+ // on a password-auth device (mirrors probeDeviceStats in devices/health).
62
+ ({ args, env } = buildSshInvocation(device, [cmd], shim, {}, { agentOnly: true }));
63
+ }
64
+ catch {
65
+ return Promise.resolve(undefined);
66
+ }
67
+ return new Promise((resolve) => {
68
+ execFile('ssh', args, { encoding: 'utf-8', env: { ...process.env, ...env }, timeout: READY_PROBE_TIMEOUT_MS }, (err, stdout) => {
69
+ if (err || !stdout)
70
+ return resolve(undefined);
71
+ const probe = parseReadyProbe(stdout);
72
+ if (!probe.reachable)
73
+ return resolve(undefined); // ssh/login issue → unknown
74
+ if (!probe.version)
75
+ return resolve(false); // agents-cli missing → cannot run
76
+ resolve(viewHasAgent(probe.view, agent));
77
+ });
78
+ });
79
+ }
80
+ /**
81
+ * Probe every device in the team pool and return a name→signal map for the pure
82
+ * placement pick. Devices with no data at all are omitted (the pick then neither
83
+ * excludes nor prefers them). Never throws — a probe failure degrades to a
84
+ * missing/partial signal.
85
+ */
86
+ export async function probePoolSignals(pool, agent, opts = {}) {
87
+ const now = opts.now ?? Date.now();
88
+ const key = cacheKey(pool, agent);
89
+ const cached = cache.get(key);
90
+ if (!opts.force && cached && now - cached.at < SIGNAL_TTL_MS)
91
+ return cached.signals;
92
+ const reg = loadDevicesSync();
93
+ const self = normalizeHost(localMachineId());
94
+ const lookup = (name) => reg[name] ?? reg[normalizeHost(name)];
95
+ const profiles = [];
96
+ const seen = new Set();
97
+ for (const name of pool) {
98
+ const d = lookup(name);
99
+ if (d && !seen.has(d.name)) {
100
+ profiles.push(d);
101
+ seen.add(d.name);
102
+ }
103
+ }
104
+ const selfProfile = profiles.find((d) => normalizeHost(d.name) === self);
105
+ const stats = await probeFleetStats(profiles, { selfName: selfProfile?.name });
106
+ const installed = new Map(await Promise.all(profiles.map(async (d) => {
107
+ const isSelf = normalizeHost(d.name) === self;
108
+ if (isSelf) {
109
+ const inst = checkCliAvailable(agent)[0];
110
+ const signedIn = inst ? await checkCliSignedIn(agent) : undefined;
111
+ return [d.name, { installed: inst, signedIn }];
112
+ }
113
+ // Remote sign-in stays unknown (see the module docblock); the install
114
+ // gate is what drives the fail-loud.
115
+ return [d.name, { installed: await probeRemoteInstalled(d, agent), signedIn: undefined }];
116
+ })));
117
+ const signals = new Map();
118
+ for (const name of pool) {
119
+ const d = lookup(name);
120
+ const s = d ? stats.get(d.name) : undefined;
121
+ const inst = d ? installed.get(d.name) : undefined;
122
+ if (!s && !inst)
123
+ continue; // fully unknown device — leave it out of the map
124
+ signals.set(name, {
125
+ reachable: s?.reachable,
126
+ headroom: s ? headroom(s) : undefined,
127
+ loadPercent: s?.loadPercent,
128
+ memPercent: s?.memPercent,
129
+ installed: inst?.installed,
130
+ signedIn: inst?.signedIn,
131
+ });
132
+ }
133
+ cache.set(key, { at: now, signals });
134
+ return signals;
135
+ }