@phnx-labs/agents-cli 1.20.88 → 1.20.90

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 (102) hide show
  1. package/CHANGELOG.md +503 -0
  2. package/README.md +15 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/commands.js +7 -7
  5. package/dist/commands/exec.js +7 -1
  6. package/dist/commands/factory.js +26 -2
  7. package/dist/commands/funnel.js +16 -1
  8. package/dist/commands/harness.d.ts +27 -0
  9. package/dist/commands/harness.js +120 -13
  10. package/dist/commands/menubar.js +117 -34
  11. package/dist/commands/profiles.d.ts +3 -0
  12. package/dist/commands/profiles.js +1 -1
  13. package/dist/commands/routines.d.ts +19 -0
  14. package/dist/commands/routines.js +51 -7
  15. package/dist/commands/secrets-rotate-passphrase.d.ts +17 -0
  16. package/dist/commands/secrets-rotate-passphrase.js +96 -0
  17. package/dist/commands/secrets.d.ts +10 -1
  18. package/dist/commands/secrets.js +20 -6
  19. package/dist/commands/sessions-browser.d.ts +4 -0
  20. package/dist/commands/sessions-browser.js +51 -9
  21. package/dist/commands/sessions-favorite.d.ts +20 -0
  22. package/dist/commands/sessions-favorite.js +120 -0
  23. package/dist/commands/sessions.d.ts +110 -21
  24. package/dist/commands/sessions.js +395 -74
  25. package/dist/commands/setup-secrets.d.ts +7 -0
  26. package/dist/commands/setup-secrets.js +12 -9
  27. package/dist/commands/versions.js +12 -4
  28. package/dist/commands/view.d.ts +14 -1
  29. package/dist/commands/view.js +103 -128
  30. package/dist/commands/webhook.js +7 -2
  31. package/dist/lib/agents.d.ts +4 -2
  32. package/dist/lib/agents.js +21 -6
  33. package/dist/lib/commands.js +9 -1
  34. package/dist/lib/daemon.d.ts +29 -0
  35. package/dist/lib/daemon.js +58 -4
  36. package/dist/lib/events.d.ts +1 -1
  37. package/dist/lib/factory/snapshot.d.ts +78 -0
  38. package/dist/lib/factory/snapshot.js +209 -0
  39. package/dist/lib/fs-atomic.d.ts +14 -1
  40. package/dist/lib/fs-atomic.js +35 -3
  41. package/dist/lib/funnel.d.ts +1 -0
  42. package/dist/lib/funnel.js +8 -0
  43. package/dist/lib/hosts/dispatch.js +19 -1
  44. package/dist/lib/hq/floor.js +12 -0
  45. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  46. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  47. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -2
  48. package/dist/lib/menubar/install-menubar.d.ts +53 -2
  49. package/dist/lib/menubar/install-menubar.js +183 -28
  50. package/dist/lib/picker.d.ts +27 -2
  51. package/dist/lib/picker.js +71 -7
  52. package/dist/lib/platform/process.d.ts +2 -0
  53. package/dist/lib/platform/process.js +5 -3
  54. package/dist/lib/profiles.d.ts +48 -0
  55. package/dist/lib/profiles.js +67 -0
  56. package/dist/lib/resources.d.ts +8 -0
  57. package/dist/lib/resources.js +34 -1
  58. package/dist/lib/rotate.d.ts +24 -2
  59. package/dist/lib/rotate.js +63 -6
  60. package/dist/lib/routines-placement.d.ts +2 -1
  61. package/dist/lib/routines-placement.js +8 -4
  62. package/dist/lib/routines.d.ts +57 -1
  63. package/dist/lib/routines.js +74 -1
  64. package/dist/lib/runner.d.ts +2 -0
  65. package/dist/lib/runner.js +21 -8
  66. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  67. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  68. package/dist/lib/secrets/bundles.js +9 -34
  69. package/dist/lib/secrets/filestore.d.ts +152 -34
  70. package/dist/lib/secrets/filestore.js +676 -123
  71. package/dist/lib/session/active.d.ts +109 -3
  72. package/dist/lib/session/active.js +269 -13
  73. package/dist/lib/session/db.d.ts +14 -0
  74. package/dist/lib/session/db.js +35 -0
  75. package/dist/lib/session/favorites.d.ts +39 -0
  76. package/dist/lib/session/favorites.js +101 -0
  77. package/dist/lib/session/host-link.d.ts +68 -0
  78. package/dist/lib/session/host-link.js +64 -0
  79. package/dist/lib/session/presence.d.ts +85 -0
  80. package/dist/lib/session/presence.js +150 -0
  81. package/dist/lib/session/remote-active.d.ts +4 -1
  82. package/dist/lib/session/remote-active.js +8 -2
  83. package/dist/lib/session/remote-list.d.ts +10 -0
  84. package/dist/lib/session/remote-list.js +47 -9
  85. package/dist/lib/session/viewing-in.d.ts +31 -0
  86. package/dist/lib/session/viewing-in.js +47 -0
  87. package/dist/lib/state.d.ts +17 -0
  88. package/dist/lib/state.js +30 -2
  89. package/dist/lib/tmux/binary.d.ts +7 -0
  90. package/dist/lib/tmux/binary.js +11 -1
  91. package/dist/lib/triggers/handlers.d.ts +95 -0
  92. package/dist/lib/triggers/handlers.js +384 -0
  93. package/dist/lib/triggers/webhook.d.ts +10 -2
  94. package/dist/lib/triggers/webhook.js +65 -11
  95. package/dist/lib/types.d.ts +4 -3
  96. package/dist/lib/usage-backoff.d.ts +29 -0
  97. package/dist/lib/usage-backoff.js +165 -0
  98. package/dist/lib/usage.d.ts +112 -5
  99. package/dist/lib/usage.js +464 -46
  100. package/dist/lib/watchdog/runner.d.ts +13 -0
  101. package/dist/lib/watchdog/runner.js +16 -1
  102. package/package.json +1 -1
@@ -11,6 +11,7 @@ import * as path from 'path';
11
11
  import * as yaml from 'yaml';
12
12
  import { AGENTS, ensureCommandsDir, agentConfigDirName, resolveAgentName } from './agents.js';
13
13
  import { capableAgents, isCapable, supports } from './capabilities.js';
14
+ import { isDirectoryDoc } from './resources.js';
14
15
  import { markdownToToml } from './convert.js';
15
16
  import { getCommandsDir, getUserCommandsDir, getEnabledExtraRepos, getProjectAgentsDir, getSkillsDir, getTrashCommandsDir } from './state.js';
16
17
  import { getEffectiveHome, getVersionHomePath, listInstalledVersions, resolveVersion } from './versions.js';
@@ -150,6 +151,8 @@ export function discoverCommands(repoPath) {
150
151
  for (const file of fs.readdirSync(commandsDir)) {
151
152
  if (file.endsWith('.md')) {
152
153
  const name = file.replace('.md', '');
154
+ if (isDirectoryDoc('commands', name))
155
+ continue;
153
156
  const sourcePath = path.join(commandsDir, file);
154
157
  const metadata = parseCommandMetadata(sourcePath);
155
158
  const validation = validateCommandMetadata(metadata, name);
@@ -694,7 +697,12 @@ export function listCentralCommands() {
694
697
  if (!fs.existsSync(dir))
695
698
  continue;
696
699
  for (const f of fs.readdirSync(dir).filter((f) => f.endsWith('.md'))) {
697
- seen.add(f.replace('.md', ''));
700
+ const name = f.replace('.md', '');
701
+ // A directory's README/AGENTS/CLAUDE/GEMINI documents the dir, it is not a
702
+ // command. Without this the picker offers a name resolveResource refuses.
703
+ if (isDirectoryDoc('commands', name))
704
+ continue;
705
+ seen.add(name);
698
706
  }
699
707
  }
700
708
  return Array.from(seen);
@@ -86,6 +86,23 @@ export declare function log(level: string, message: string): void;
86
86
  * anchoring failed (logged, non-fatal).
87
87
  */
88
88
  export declare function anchorDaemonCwd(): string | null;
89
+ /**
90
+ * Surface, at the daemon's OWN startup, that it was launched from an ephemeral
91
+ * root that will wedge it if the directory is removed. This is the runtime
92
+ * companion to the launch-time check in validateDaemonBinary (which only runs
93
+ * when the daemon is *spawned* via getDaemonLaunch): a direct
94
+ * `agents __daemon-run` from a temp or worktree build — e.g. a review/verify
95
+ * checkout under /tmp — never passes through that path, so without this the
96
+ * wedge risk stays invisible until jobs start ENOENT-ing on their dynamic
97
+ * imports. Best-effort and non-fatal; the cwd is already handled by
98
+ * anchorDaemonCwd, but a deleted module root can only be flagged, not repaired.
99
+ *
100
+ * `resolveBin` is injectable (defaults to getAgentsBinPath) so the wiring — the
101
+ * predicate call, the WARN, and the non-fatal guard around a throwing resolver —
102
+ * is testable. Returns the warning message it logged, or null when the launch
103
+ * root is stable (or could not be resolved).
104
+ */
105
+ export declare function warnEphemeralDaemonRoot(resolveBin?: () => string): string | null;
89
106
  export declare function runDaemon(): Promise<void>;
90
107
  /**
91
108
  * Write a launchd plist or systemd unit with owner-only permissions atomically.
@@ -175,6 +192,18 @@ export declare function getAgentsInvocation(subArgs: string[], agentsBin?: strin
175
192
  command: string;
176
193
  args: string[];
177
194
  };
195
+ /**
196
+ * A daemon binary living under an ephemeral path — a git worktree, or a temp
197
+ * directory (`/tmp`, `/var/folders`, `/dev/shm`) — is a latent wedge. The daemon
198
+ * is long-lived but resolves its own job modules by dynamic `import()` rooted at
199
+ * this entry (getAgentsBinPath → process.argv[1]). If that directory is later
200
+ * removed (`git worktree remove`, a `/tmp` cleanup, a review/verify checkout
201
+ * teardown) the running daemon keeps ENOENT-ing on every job it loads —
202
+ * `anchorDaemonCwd` rescues the cwd, but nothing can re-root a deleted module
203
+ * tree. Returns a human phrase naming the ephemeral kind, or null for a stable
204
+ * install path (version home, a global npm prefix, a normal source checkout).
205
+ */
206
+ export declare function describeEphemeralDaemonRoot(binPath: string): string | null;
178
207
  export declare function validateDaemonBinary(binPath: string): {
179
208
  warnings: string[];
180
209
  };
@@ -321,6 +321,39 @@ export function anchorDaemonCwd() {
321
321
  return null;
322
322
  }
323
323
  }
324
+ /**
325
+ * Surface, at the daemon's OWN startup, that it was launched from an ephemeral
326
+ * root that will wedge it if the directory is removed. This is the runtime
327
+ * companion to the launch-time check in validateDaemonBinary (which only runs
328
+ * when the daemon is *spawned* via getDaemonLaunch): a direct
329
+ * `agents __daemon-run` from a temp or worktree build — e.g. a review/verify
330
+ * checkout under /tmp — never passes through that path, so without this the
331
+ * wedge risk stays invisible until jobs start ENOENT-ing on their dynamic
332
+ * imports. Best-effort and non-fatal; the cwd is already handled by
333
+ * anchorDaemonCwd, but a deleted module root can only be flagged, not repaired.
334
+ *
335
+ * `resolveBin` is injectable (defaults to getAgentsBinPath) so the wiring — the
336
+ * predicate call, the WARN, and the non-fatal guard around a throwing resolver —
337
+ * is testable. Returns the warning message it logged, or null when the launch
338
+ * root is stable (or could not be resolved).
339
+ */
340
+ export function warnEphemeralDaemonRoot(resolveBin = getAgentsBinPath) {
341
+ try {
342
+ const bin = resolveBin();
343
+ const ephemeralRoot = describeEphemeralDaemonRoot(bin);
344
+ if (!ephemeralRoot)
345
+ return null;
346
+ const message = `Daemon launched from ${ephemeralRoot} (${bin}); if that directory is removed, ` +
347
+ `every routine will fail with ENOENT on its module imports. Run the daemon from the ` +
348
+ `globally installed binary instead (npm i -g @phnx-labs/agents-cli), then restart it.`;
349
+ log('WARN', message);
350
+ return message;
351
+ }
352
+ catch (err) {
353
+ log('WARN', `Could not check daemon launch root: ${err.message}`);
354
+ return null;
355
+ }
356
+ }
324
357
  export async function runDaemon() {
325
358
  // Single-instance guard: a direct `agents __daemon-run` (manual, or a
326
359
  // service-manager restart racing a live predecessor) must not clobber a
@@ -334,6 +367,7 @@ export async function runDaemon() {
334
367
  }
335
368
  log('INFO', `Daemon started (PID: ${process.pid})`);
336
369
  anchorDaemonCwd();
370
+ warnEphemeralDaemonRoot();
337
371
  // The daemon holds NO Claude credential of its own. Routine runs authenticate
338
372
  // exactly like an interactive `agents run`: through the per-account
339
373
  // CLAUDE_CONFIG_DIR login on this device (its own auto-refreshing
@@ -1106,15 +1140,35 @@ function daemonPathValue(agentsBin, systemDirs) {
1106
1140
  export function getAgentsInvocation(subArgs, agentsBin = getAgentsBinPath()) {
1107
1141
  return getCliLaunch(subArgs, agentsBin);
1108
1142
  }
1143
+ /**
1144
+ * A daemon binary living under an ephemeral path — a git worktree, or a temp
1145
+ * directory (`/tmp`, `/var/folders`, `/dev/shm`) — is a latent wedge. The daemon
1146
+ * is long-lived but resolves its own job modules by dynamic `import()` rooted at
1147
+ * this entry (getAgentsBinPath → process.argv[1]). If that directory is later
1148
+ * removed (`git worktree remove`, a `/tmp` cleanup, a review/verify checkout
1149
+ * teardown) the running daemon keeps ENOENT-ing on every job it loads —
1150
+ * `anchorDaemonCwd` rescues the cwd, but nothing can re-root a deleted module
1151
+ * tree. Returns a human phrase naming the ephemeral kind, or null for a stable
1152
+ * install path (version home, a global npm prefix, a normal source checkout).
1153
+ */
1154
+ export function describeEphemeralDaemonRoot(binPath) {
1155
+ if (/[/\\]\.agents[/\\]worktrees[/\\]/.test(binPath))
1156
+ return 'a git worktree';
1157
+ if (/^(?:\/private)?\/tmp[/\\]|^(?:\/private)?\/var\/folders[/\\]|^\/dev\/shm[/\\]/.test(binPath)) {
1158
+ return 'a temporary directory';
1159
+ }
1160
+ return null;
1161
+ }
1109
1162
  export function validateDaemonBinary(binPath) {
1110
1163
  const warnings = [];
1111
1164
  if (BUN_VIRTUAL_ROOT.test(binPath)) {
1112
1165
  throw new Error(`Refusing to supervise daemon: resolved binary is a bun virtual path (${binPath}). ` +
1113
1166
  `Install agents globally (npm i -g @phnx-labs/agents-cli) and restart.`);
1114
1167
  }
1115
- if (/[/\\]\.agents[/\\]worktrees[/\\]/.test(binPath)) {
1116
- warnings.push(`Warning: daemon binary is inside a git worktree (${binPath}). ` +
1117
- `A worktree deletion will wedge the daemon. Use the globally installed binary instead.`);
1168
+ const ephemeralRoot = describeEphemeralDaemonRoot(binPath);
1169
+ if (ephemeralRoot) {
1170
+ warnings.push(`Warning: daemon binary is inside ${ephemeralRoot} (${binPath}). ` +
1171
+ `Deleting it will wedge the daemon. Use the globally installed binary instead.`);
1118
1172
  }
1119
1173
  if (!fs.existsSync(binPath) && !/\.(c|m)?js$/.test(binPath)) {
1120
1174
  warnings.push(`Warning: daemon binary does not exist on disk (${binPath}).`);
@@ -1132,7 +1186,7 @@ export function startDetached(opts = {}) {
1132
1186
  // and a console-close event tears it down when the launcher exits (#556).
1133
1187
  const child = spawn(command, args, {
1134
1188
  stdio: ['ignore', logFd, logFd],
1135
- ...backgroundSpawnOptions({ fdStdio: true }),
1189
+ ...backgroundSpawnOptions({ cwd: os.homedir(), fdStdio: true }),
1136
1190
  env: opts.env ?? process.env,
1137
1191
  });
1138
1192
  // A failed spawn (ENOENT/EACCES) emits 'error' asynchronously; without a
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { type ActorKind } from './actor.js';
15
15
  export type EventLevel = 'audit' | 'warn' | 'info' | 'debug';
16
- export type EventType = 'agent.run.start' | 'agent.run.end' | 'agent.spawn.start' | 'agent.spawn.end' | 'version.install' | 'version.switch' | 'version.remove' | 'skill.install' | 'skill.remove' | 'browser.launch' | 'browser.close' | 'browser.navigate' | 'browser.screenshot' | 'secrets.get' | 'secrets.unlocked' | 'secrets.set' | 'secrets.delete' | 'secrets.rename' | 'cloud.dispatch' | 'cloud.complete' | 'cloud.cancel' | 'cloud.message' | 'teams.create' | 'teams.add' | 'teams.start' | 'teams.complete' | 'teams.disband' | 'hook.fire' | 'hook.complete' | 'hook.error' | 'mcp.add' | 'mcp.remove' | 'mcp.register' | 'resource.sync' | 'rotation.resolved' | 'command.start' | 'command.end' | 'perf.timing' | 'session.start' | 'session.end' | 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created' | 'task.completed' | 'checklist.created' | 'status.posted' | 'file.edited' | 'friction' | 'error' | 'warn' | 'info' | 'debug';
16
+ export type EventType = 'agent.run.start' | 'agent.run.end' | 'agent.spawn.start' | 'agent.spawn.end' | 'version.install' | 'version.switch' | 'version.remove' | 'skill.install' | 'skill.remove' | 'browser.launch' | 'browser.close' | 'browser.navigate' | 'browser.screenshot' | 'secrets.get' | 'secrets.unlocked' | 'secrets.set' | 'secrets.delete' | 'secrets.rename' | 'cloud.dispatch' | 'cloud.complete' | 'cloud.cancel' | 'cloud.message' | 'teams.create' | 'teams.add' | 'teams.start' | 'teams.complete' | 'teams.disband' | 'hook.fire' | 'hook.complete' | 'hook.error' | 'mcp.add' | 'mcp.remove' | 'mcp.register' | 'resource.sync' | 'rotation.resolved' | 'command.start' | 'command.end' | 'perf.timing' | 'session.start' | 'session.end' | 'webhook.received' | 'webhook.authorized' | 'webhook.rejected' | 'webhook.matched' | 'webhook.fired' | 'webhook.handler.start' | 'webhook.handler.end' | 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created' | 'task.completed' | 'checklist.created' | 'status.posted' | 'file.edited' | 'friction' | 'error' | 'warn' | 'info' | 'debug';
17
17
  export declare function levelFor(event: EventType): EventLevel;
18
18
  export interface EventMeta {
19
19
  ts: string;
@@ -0,0 +1,78 @@
1
+ import { type ActiveSession } from '../session/active.js';
2
+ import { serializeActiveSessionsForJson } from '../../commands/sessions.js';
3
+ import { readAuthHealthCache, type AuthVerdict } from '../auth-health.js';
4
+ import { readStatsCache } from '../devices/stats-cache.js';
5
+ export declare const FACTORY_PROJECTS: readonly [{
6
+ readonly name: "Prix";
7
+ readonly repo: "phnx-labs/prix";
8
+ }, {
9
+ readonly name: "Rush App";
10
+ readonly repo: "phnx-labs/rush";
11
+ }, {
12
+ readonly name: "Rush CLI";
13
+ readonly repo: "phnx-labs/rush-cli";
14
+ }, {
15
+ readonly name: "Agents CLI";
16
+ readonly repo: "phnx-labs/agents-cli";
17
+ }, {
18
+ readonly name: "Linear CLI";
19
+ readonly repo: "phnx-labs/linear-cli";
20
+ }];
21
+ export interface FactoryConfig {
22
+ source: 'default' | 'file';
23
+ ceiling: number;
24
+ max_dispatch_per_tick: number;
25
+ per_project: Record<string, {
26
+ weight: number;
27
+ cap: number;
28
+ }>;
29
+ idle_boxes: string[];
30
+ digest: {
31
+ times: string[];
32
+ tz: string;
33
+ };
34
+ }
35
+ export interface FactorySnapshot {
36
+ generatedAt: string;
37
+ sessions: ReturnType<typeof serializeActiveSessionsForJson>;
38
+ queues: Record<string, {
39
+ todo: number;
40
+ inProgress: number;
41
+ blocked: number;
42
+ }>;
43
+ prs: Array<{
44
+ repo: string;
45
+ number: number;
46
+ ci: string;
47
+ review: string;
48
+ mergeable: string;
49
+ }>;
50
+ devices: Array<{
51
+ name: string;
52
+ load: number | null;
53
+ idle: boolean;
54
+ }>;
55
+ recentRuns: Array<{
56
+ routine: string;
57
+ status: string;
58
+ durationMs: number | null;
59
+ }>;
60
+ auth: {
61
+ claude: AuthVerdict | null;
62
+ };
63
+ config: FactoryConfig;
64
+ }
65
+ export interface SnapshotDependencies {
66
+ home: string;
67
+ now: () => Date;
68
+ activeSessions: () => Promise<ActiveSession[]>;
69
+ run: (file: string, args: string[]) => Promise<string>;
70
+ readAuth: () => ReturnType<typeof readAuthHealthCache>;
71
+ readDeviceStats: () => ReturnType<typeof readStatsCache>;
72
+ }
73
+ export declare function readFactoryConfig(home: string): FactoryConfig;
74
+ export declare function queueCounts(todoPayload: unknown, openPayload: unknown): FactorySnapshot['queues'][string];
75
+ export declare function parsePullRequests(repo: string, payload: unknown): FactorySnapshot['prs'];
76
+ export declare function parseDevices(payload: unknown, cached?: ReturnType<typeof readStatsCache>): FactorySnapshot['devices'];
77
+ export declare function readRecentRuns(home: string, limit?: number): FactorySnapshot['recentRuns'];
78
+ export declare function buildFactorySnapshot(overrides?: Partial<SnapshotDependencies>): Promise<FactorySnapshot>;
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Read-only Software Factory state aggregation.
3
+ *
4
+ * ~/.agents/factory.yml example:
5
+ *
6
+ * ceiling: 4
7
+ * max_dispatch_per_tick: 2
8
+ * per_project:
9
+ * Agents CLI: { weight: 2, cap: 2 }
10
+ * idle_boxes: [yosemite-m1, yosemite-m2]
11
+ * digest: { times: ["09:00", "17:00"], tz: America/Los_Angeles }
12
+ */
13
+ import * as fs from 'fs';
14
+ import * as os from 'os';
15
+ import * as path from 'path';
16
+ import { execFile as execFileCallback } from 'child_process';
17
+ import { promisify } from 'util';
18
+ import * as yaml from 'yaml';
19
+ import { getActiveSessions } from '../session/active.js';
20
+ import { serializeActiveSessionsForJson } from '../../commands/sessions.js';
21
+ import { readAuthHealthCache } from '../auth-health.js';
22
+ import { readStatsCache } from '../devices/stats-cache.js';
23
+ const execFile = promisify(execFileCallback);
24
+ export const FACTORY_PROJECTS = [
25
+ { name: 'Prix', repo: 'phnx-labs/prix' },
26
+ { name: 'Rush App', repo: 'phnx-labs/rush' },
27
+ { name: 'Rush CLI', repo: 'phnx-labs/rush-cli' },
28
+ { name: 'Agents CLI', repo: 'phnx-labs/agents-cli' },
29
+ { name: 'Linear CLI', repo: 'phnx-labs/linear-cli' },
30
+ ];
31
+ const defaults = () => ({
32
+ source: 'default',
33
+ ceiling: 4,
34
+ max_dispatch_per_tick: 2,
35
+ per_project: {},
36
+ idle_boxes: [],
37
+ digest: { times: [], tz: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC' },
38
+ });
39
+ export function readFactoryConfig(home) {
40
+ const file = path.join(home, '.agents', 'factory.yml');
41
+ if (!fs.existsSync(file))
42
+ return defaults();
43
+ const raw = yaml.parse(fs.readFileSync(file, 'utf8'));
44
+ if (!raw || typeof raw !== 'object')
45
+ throw new Error(`${file} must contain a YAML mapping`);
46
+ const base = defaults();
47
+ return {
48
+ source: 'file',
49
+ ceiling: integer(raw.ceiling ?? base.ceiling, 'ceiling'),
50
+ max_dispatch_per_tick: integer(raw.max_dispatch_per_tick ?? base.max_dispatch_per_tick, 'max_dispatch_per_tick'),
51
+ per_project: parseProjectConfig(raw.per_project),
52
+ idle_boxes: stringArray(raw.idle_boxes, 'idle_boxes'),
53
+ digest: raw.digest ? {
54
+ times: stringArray(raw.digest.times, 'digest.times'),
55
+ tz: typeof raw.digest.tz === 'string' ? raw.digest.tz : base.digest.tz,
56
+ } : base.digest,
57
+ };
58
+ }
59
+ function integer(value, name) {
60
+ if (!Number.isInteger(value) || value < 0)
61
+ throw new Error(`factory.yml ${name} must be a non-negative integer`);
62
+ return value;
63
+ }
64
+ function stringArray(value, name) {
65
+ if (value === undefined)
66
+ return [];
67
+ if (!Array.isArray(value) || value.some((v) => typeof v !== 'string'))
68
+ throw new Error(`factory.yml ${name} must be a string array`);
69
+ return value;
70
+ }
71
+ function parseProjectConfig(value) {
72
+ if (value === undefined)
73
+ return {};
74
+ if (!value || typeof value !== 'object' || Array.isArray(value))
75
+ throw new Error('factory.yml per_project must be a mapping');
76
+ return Object.fromEntries(Object.entries(value).map(([name, item]) => {
77
+ const record = item;
78
+ return [name, { weight: integer(record?.weight, `per_project.${name}.weight`), cap: integer(record?.cap, `per_project.${name}.cap`) }];
79
+ }));
80
+ }
81
+ function json(text) {
82
+ return JSON.parse(text);
83
+ }
84
+ function issueCount(payload) {
85
+ if (Array.isArray(payload))
86
+ return payload.length;
87
+ if (payload && typeof payload === 'object') {
88
+ const record = payload;
89
+ if (typeof record.count === 'number')
90
+ return record.count;
91
+ if (Array.isArray(record.issues))
92
+ return record.issues.length;
93
+ }
94
+ return 0;
95
+ }
96
+ export function queueCounts(todoPayload, openPayload) {
97
+ const issues = openPayload && typeof openPayload === 'object' && Array.isArray(openPayload.issues)
98
+ ? openPayload.issues : [];
99
+ const blocked = issues.filter((issue) => {
100
+ const state = issue.state && typeof issue.state === 'object' ? issue.state : {};
101
+ const labels = issue.labels && typeof issue.labels === 'object' && Array.isArray(issue.labels.nodes)
102
+ ? issue.labels.nodes : [];
103
+ return String(state.name ?? '').toLowerCase().includes('block') || labels.some((label) => String(label.name ?? '').toLowerCase() === 'blocked');
104
+ }).length;
105
+ const inProgress = issues.filter((issue) => {
106
+ const state = issue.state && typeof issue.state === 'object' ? issue.state : {};
107
+ return String(state.type ?? '').toLowerCase() === 'started' && !String(state.name ?? '').toLowerCase().includes('block');
108
+ }).length;
109
+ return { todo: issueCount(todoPayload), inProgress, blocked };
110
+ }
111
+ export function parsePullRequests(repo, payload) {
112
+ if (!Array.isArray(payload))
113
+ return [];
114
+ return payload.map((raw) => {
115
+ const pr = raw;
116
+ const checks = Array.isArray(pr.statusCheckRollup) ? pr.statusCheckRollup : [];
117
+ const states = checks.map((check) => String(check.conclusion ?? check.state ?? check.status ?? '').toUpperCase());
118
+ const ci = states.some((s) => ['FAILURE', 'ERROR', 'CANCELLED', 'TIMED_OUT'].includes(s)) ? 'failing'
119
+ : states.some((s) => ['', 'PENDING', 'QUEUED', 'IN_PROGRESS'].includes(s)) ? 'pending'
120
+ : states.length > 0 ? 'passing' : 'none';
121
+ return {
122
+ repo,
123
+ number: Number(pr.number),
124
+ ci,
125
+ review: String(pr.reviewDecision ?? 'none').toLowerCase(),
126
+ mergeable: String(pr.mergeable ?? 'unknown').toLowerCase(),
127
+ };
128
+ });
129
+ }
130
+ export function parseDevices(payload, cached = {}) {
131
+ const rows = Array.isArray(payload) ? payload : [];
132
+ return rows.map((raw) => {
133
+ const row = raw;
134
+ const name = String(row.name ?? row.host ?? '');
135
+ const stats = row.stats && typeof row.stats === 'object' ? row.stats : cached[name] ?? row;
136
+ const candidate = stats.loadPercent ?? stats.load ?? stats.loadPct;
137
+ const load = typeof candidate === 'number' && Number.isFinite(candidate) ? candidate : null;
138
+ return { name, load, idle: row.idle === true || (load !== null && load < 20) };
139
+ }).filter((row) => row.name.length > 0);
140
+ }
141
+ export function readRecentRuns(home, limit = 3) {
142
+ const root = path.join(home, '.agents', '.history', 'runs');
143
+ if (!fs.existsSync(root))
144
+ return [];
145
+ const found = [];
146
+ for (const routine of fs.readdirSync(root)) {
147
+ const routineDir = path.join(root, routine);
148
+ if (!fs.statSync(routineDir).isDirectory())
149
+ continue;
150
+ const routineRuns = [];
151
+ for (const run of fs.readdirSync(routineDir)) {
152
+ const file = path.join(routineDir, run, 'meta.json');
153
+ if (!fs.existsSync(file))
154
+ continue;
155
+ try {
156
+ const meta = JSON.parse(fs.readFileSync(file, 'utf8'));
157
+ const started = Date.parse(String(meta.startedAt ?? meta.createdAt ?? ''));
158
+ const ended = Date.parse(String(meta.finishedAt ?? meta.completedAt ?? meta.updatedAt ?? ''));
159
+ const duration = typeof meta.durationMs === 'number' ? meta.durationMs : Number.isFinite(started) && Number.isFinite(ended) ? ended - started : null;
160
+ routineRuns.push({ routine, status: String(meta.status ?? 'unknown'), durationMs: duration, mtime: fs.statSync(file).mtimeMs });
161
+ }
162
+ catch { /* A concurrently-written or malformed run is not a completed outcome. */ }
163
+ }
164
+ found.push(...routineRuns.sort((a, b) => b.mtime - a.mtime).slice(0, limit));
165
+ }
166
+ return found.sort((a, b) => b.mtime - a.mtime).map(({ mtime: _, ...run }) => run);
167
+ }
168
+ function latestClaudeVerdict(entries) {
169
+ return Object.entries(entries)
170
+ .filter(([key]) => key.split(':').includes('claude'))
171
+ .map(([, value]) => value)
172
+ .sort((a, b) => b.checkedAt - a.checkedAt)[0]?.verdict ?? null;
173
+ }
174
+ export async function buildFactorySnapshot(overrides = {}) {
175
+ const deps = {
176
+ home: os.homedir(),
177
+ now: () => new Date(),
178
+ activeSessions: () => getActiveSessions(),
179
+ run: async (file, args) => (await execFile(file, args, { maxBuffer: 10 * 1024 * 1024 })).stdout,
180
+ readAuth: readAuthHealthCache,
181
+ readDeviceStats: readStatsCache,
182
+ ...overrides,
183
+ };
184
+ const linear = path.join(deps.home, '.agents', 'skills', 'linear', 'scripts', 'linear');
185
+ const safeRun = async (file, args) => {
186
+ try {
187
+ return json(await deps.run(file, args));
188
+ }
189
+ catch {
190
+ return null;
191
+ }
192
+ };
193
+ const sessionsPromise = deps.activeSessions().then(serializeActiveSessionsForJson);
194
+ const queuePromise = Promise.all(FACTORY_PROJECTS.map(async ({ name }) => {
195
+ const query = (status) => safeRun(linear, ['tasks', '--project', name, '--label', 'pilot', '--status', status, '--cycle', 'all', '--all', '--json']);
196
+ const [todo, open] = await Promise.all([query('todo'), query('open')]);
197
+ return [name, queueCounts(todo, open)];
198
+ })).then(Object.fromEntries);
199
+ const prsPromise = Promise.all(FACTORY_PROJECTS.map(async ({ repo }) => parsePullRequests(repo, await safeRun('gh', ['pr', 'list', '--repo', repo, '--state', 'open', '--json', 'number,title,statusCheckRollup,reviewDecision,mergeable'])))).then((rows) => rows.flat());
200
+ // `devices list --json` is the registry's read-only JSON surface. Load comes
201
+ // from the daemon-warmed cache so snapshot never probes or writes reachability.
202
+ const devicesPromise = safeRun('agents', ['devices', 'list', '--json']).then((payload) => parseDevices(payload, deps.readDeviceStats()));
203
+ const [sessions, queues, prs, devices] = await Promise.all([sessionsPromise, queuePromise, prsPromise, devicesPromise]);
204
+ return {
205
+ generatedAt: deps.now().toISOString(), sessions, queues, prs, devices,
206
+ recentRuns: readRecentRuns(deps.home), auth: { claude: latestClaudeVerdict(deps.readAuth()) },
207
+ config: readFactoryConfig(deps.home),
208
+ };
209
+ }
@@ -16,9 +16,22 @@ export declare function atomicWriteFileSync(filePath: string, content: string, o
16
16
  * releases the lock. Retries with capped linear back-off until either the lock
17
17
  * is acquired or LOCK_ACQUIRE_TIMEOUT_MS elapses. Breaks stale locks older than
18
18
  * LOCK_STALE_MS, so a crashed holder never blocks past the stale window.
19
+ *
20
+ * `fn` is handed a `heartbeat()` it can call during a long, fully SYNCHRONOUS
21
+ * critical section. proper-lockfile keeps a held lock "alive" by refreshing its
22
+ * lockfile mtime on a `setTimeout` every `stale/2` — but that timer only fires
23
+ * when the event loop gets a turn. A synchronous hold that outruns `stale`
24
+ * (e.g. the scrypt-bound rotation loop in filestore.ts, ~16s on a real store)
25
+ * never yields, so the timer cannot run: the lock ages past `stale` mid-hold and a
26
+ * peer contending for it treats the live holder as crashed, breaks the lock, and
27
+ * interleaves — corrupting the invariant the lock exists to protect, with no crash
28
+ * involved. `heartbeat()` drives the same refresh synchronously (bumps the lockfile
29
+ * mtime), so a long sync holder stays fresh while the short `stale` window still
30
+ * detects a genuinely crashed holder within LOCK_STALE_MS. Callers whose critical
31
+ * section is short (a single read-modify-write) can ignore it.
19
32
  */
20
33
  export interface FileLockOptions {
21
34
  staleMs?: number;
22
35
  acquireTimeoutMs?: number;
23
36
  }
24
- export declare function withFileLock<T>(filePath: string, fn: () => T, opts?: FileLockOptions): T;
37
+ export declare function withFileLock<T>(filePath: string, fn: (heartbeat: () => void) => T, opts?: FileLockOptions): T;
@@ -58,12 +58,20 @@ export function atomicWriteFileSync(filePath, content, options = 'utf-8') {
58
58
  export function withFileLock(filePath, fn, opts = {}) {
59
59
  let release = null;
60
60
  let lastError;
61
+ // Set if a peer breaks this lock while we hold it. proper-lockfile reports that
62
+ // from its own refresh TIMER, so the default handler rethrows asynchronously —
63
+ // an uncatchable crash of the whole CLI process, from a callback no caller is
64
+ // on the stack for. Capture it instead and surface it synchronously below.
65
+ let compromised = null;
61
66
  const staleMs = opts.staleMs ?? LOCK_STALE_MS;
62
67
  const acquireTimeoutMs = opts.acquireTimeoutMs ?? LOCK_ACQUIRE_TIMEOUT_MS;
63
68
  const deadline = Date.now() + acquireTimeoutMs;
64
69
  for (let attempt = 0;; attempt++) {
65
70
  try {
66
- release = lockfile.lockSync(filePath, { stale: staleMs });
71
+ release = lockfile.lockSync(filePath, {
72
+ stale: staleMs,
73
+ onCompromised: (err) => { compromised = err; },
74
+ });
67
75
  break;
68
76
  }
69
77
  catch (err) {
@@ -78,10 +86,34 @@ export function withFileLock(filePath, fn, opts = {}) {
78
86
  const message = lastError instanceof Error ? lastError.message : String(lastError);
79
87
  throw new Error(`Could not acquire lock for ${filePath} after ${acquireTimeoutMs}ms: ${message}`);
80
88
  }
89
+ // proper-lockfile's lock dir is `<filePath>.lock`; touching its mtime is exactly
90
+ // what proper-lockfile's own async updater does, so the staleness check keys off
91
+ // a fresh mtime. Best-effort: a failed touch just leaves the async updater's
92
+ // behaviour unchanged (no worse than before this heartbeat existed).
93
+ const lockDir = `${filePath}.lock`;
94
+ const heartbeat = () => {
95
+ try {
96
+ const now = new Date();
97
+ fs.utimesSync(lockDir, now, now);
98
+ }
99
+ catch { /* best effort */ }
100
+ };
81
101
  try {
82
- return fn();
102
+ const result = fn(heartbeat);
103
+ // A compromised lock means a peer may have written under us — the caller must
104
+ // not treat the result as if it held exclusivity throughout.
105
+ if (compromised) {
106
+ throw new Error(`Lock for ${filePath} was broken by another process while held: ` +
107
+ `${compromised.message}`);
108
+ }
109
+ return result;
83
110
  }
84
111
  finally {
85
- release();
112
+ // Releasing a lock a peer already stole throws ENOTACQUIRED; that is the
113
+ // stolen case, already reported above, so don't mask it with a teardown error.
114
+ try {
115
+ release();
116
+ }
117
+ catch { /* already gone */ }
86
118
  }
87
119
  }
@@ -2,4 +2,5 @@ export declare const FUNNEL_PORTS: readonly [443, 8443, 10000];
2
2
  export type FunnelPort = typeof FUNNEL_PORTS[number];
3
3
  export declare function parseFunnelPort(value: string | number): FunnelPort;
4
4
  export declare function buildFunnelStatusCommand(): string;
5
+ export declare function buildFunnelDownCommand(publicPort: FunnelPort): string;
5
6
  export declare function buildFunnelUpCommand(publicPort: FunnelPort, localPort: number): string;
@@ -9,6 +9,14 @@ export function parseFunnelPort(value) {
9
9
  export function buildFunnelStatusCommand() {
10
10
  return 'tailscale funnel status';
11
11
  }
12
+ export function buildFunnelDownCommand(publicPort) {
13
+ return [
14
+ 'tailscale',
15
+ 'funnel',
16
+ `--https=${publicPort}`,
17
+ 'off',
18
+ ].map(shellQuote).join(' ');
19
+ }
12
20
  export function buildFunnelUpCommand(publicPort, localPort) {
13
21
  if (!Number.isInteger(localPort) || localPort <= 0 || localPort > 65535) {
14
22
  throw new Error('Local port must be between 1 and 65535');
@@ -93,7 +93,25 @@ export function remoteCdPrefix(remoteCwd, opts = {}) {
93
93
  * collision, mirroring `buildExecEnv`'s `...options.env` precedence (exec.ts).
94
94
  */
95
95
  export function withActorEnv(env) {
96
- return { ...actorEnv(resolveActor()), ...(env ?? {}) };
96
+ return { ...actorEnv(resolveActor()), ...terminalIdEnv(), ...(env ?? {}) };
97
+ }
98
+ /**
99
+ * Forward the launching editor tab's `AGENT_TERMINAL_ID` across the SSH hop.
100
+ *
101
+ * Factory stamps it on every terminal it spawns, and the remote `agents run`
102
+ * records it in its pid registry (`writePidSessionEntry`) — which is what lets a
103
+ * tab ask the device "which session is MY terminal running?" instead of guessing
104
+ * from local state. Without the forward the remote registry has no terminal id,
105
+ * that question is unanswerable, and the tab is stuck with its spawn-time id even
106
+ * after the agent has moved to a different session (a `/clear`, or an exit and
107
+ * rerun in the same tab).
108
+ *
109
+ * Same shape as the actor provenance above: absent when the launch did not come
110
+ * from a tracked terminal, never fabricated.
111
+ */
112
+ function terminalIdEnv() {
113
+ const terminalId = process.env.AGENT_TERMINAL_ID?.trim();
114
+ return terminalId ? { AGENT_TERMINAL_ID: terminalId } : {};
97
115
  }
98
116
  /**
99
117
  * Launch a detached login-shell command in its own Unix session/process group.
@@ -30,6 +30,18 @@ function teammateName(session, teammatesById) {
30
30
  function moodForSession(session, hasOpenBlock) {
31
31
  if (session.status === 'abandoned')
32
32
  return 'blocked';
33
+ // A crashed session is NOT `done` — it stopped without finishing, and its last
34
+ // parsed turn often still says `working`, which would otherwise reach the Floor
35
+ // as a happily-running agent. Both lost-host states need a human.
36
+ if (session.status === 'crashed')
37
+ return 'blocked';
38
+ // An orphaned session that is genuinely mid-question needs an answer; one that
39
+ // is merely idle-and-unwatched needs someone to reattach or clean it up. Same
40
+ // distinction `isAwaitingUser` draws — giving both the high-intensity "needs
41
+ // input" alert would train the operator to ignore it.
42
+ if (session.status === 'orphaned') {
43
+ return session.activity === 'waiting_input' ? 'waiting' : 'blocked';
44
+ }
33
45
  if (session.status === 'closed')
34
46
  return 'done';
35
47
  if (session.status === 'input_required' || session.activity === 'waiting_input' || hasOpenBlock)
@@ -6,7 +6,7 @@
6
6
  <dict>
7
7
  <key>Resources/AppIcon.icns</key>
8
8
  <data>
9
- DFq5H08EkhgWIC3UvGMR9B58BZw=
9
+ jOjZVimcFRHoP2VPzgn8uM8mjkA=
10
10
  </data>
11
11
  </dict>
12
12
  <key>files2</key>
@@ -15,7 +15,7 @@
15
15
  <dict>
16
16
  <key>hash2</key>
17
17
  <data>
18
- mBSjM6jlvN7J1jQowsvqTl7CHM0pur0e6qsHMuzk5k0=
18
+ GFvSLeNYJ3ASxW3OzcuiB4aID0gZUBkpozmWZkbTFNw=
19
19
  </data>
20
20
  </dict>
21
21
  </dict>