@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.
- package/CHANGELOG.md +503 -0
- package/README.md +15 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/commands.js +7 -7
- package/dist/commands/exec.js +7 -1
- package/dist/commands/factory.js +26 -2
- package/dist/commands/funnel.js +16 -1
- package/dist/commands/harness.d.ts +27 -0
- package/dist/commands/harness.js +120 -13
- package/dist/commands/menubar.js +117 -34
- package/dist/commands/profiles.d.ts +3 -0
- package/dist/commands/profiles.js +1 -1
- package/dist/commands/routines.d.ts +19 -0
- package/dist/commands/routines.js +51 -7
- package/dist/commands/secrets-rotate-passphrase.d.ts +17 -0
- package/dist/commands/secrets-rotate-passphrase.js +96 -0
- package/dist/commands/secrets.d.ts +10 -1
- package/dist/commands/secrets.js +20 -6
- package/dist/commands/sessions-browser.d.ts +4 -0
- package/dist/commands/sessions-browser.js +51 -9
- package/dist/commands/sessions-favorite.d.ts +20 -0
- package/dist/commands/sessions-favorite.js +120 -0
- package/dist/commands/sessions.d.ts +110 -21
- package/dist/commands/sessions.js +395 -74
- package/dist/commands/setup-secrets.d.ts +7 -0
- package/dist/commands/setup-secrets.js +12 -9
- package/dist/commands/versions.js +12 -4
- package/dist/commands/view.d.ts +14 -1
- package/dist/commands/view.js +103 -128
- package/dist/commands/webhook.js +7 -2
- package/dist/lib/agents.d.ts +4 -2
- package/dist/lib/agents.js +21 -6
- package/dist/lib/commands.js +9 -1
- package/dist/lib/daemon.d.ts +29 -0
- package/dist/lib/daemon.js +58 -4
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/factory/snapshot.d.ts +78 -0
- package/dist/lib/factory/snapshot.js +209 -0
- package/dist/lib/fs-atomic.d.ts +14 -1
- package/dist/lib/fs-atomic.js +35 -3
- package/dist/lib/funnel.d.ts +1 -0
- package/dist/lib/funnel.js +8 -0
- package/dist/lib/hosts/dispatch.js +19 -1
- package/dist/lib/hq/floor.js +12 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -2
- package/dist/lib/menubar/install-menubar.d.ts +53 -2
- package/dist/lib/menubar/install-menubar.js +183 -28
- package/dist/lib/picker.d.ts +27 -2
- package/dist/lib/picker.js +71 -7
- package/dist/lib/platform/process.d.ts +2 -0
- package/dist/lib/platform/process.js +5 -3
- package/dist/lib/profiles.d.ts +48 -0
- package/dist/lib/profiles.js +67 -0
- package/dist/lib/resources.d.ts +8 -0
- package/dist/lib/resources.js +34 -1
- package/dist/lib/rotate.d.ts +24 -2
- package/dist/lib/rotate.js +63 -6
- package/dist/lib/routines-placement.d.ts +2 -1
- package/dist/lib/routines-placement.js +8 -4
- package/dist/lib/routines.d.ts +57 -1
- package/dist/lib/routines.js +74 -1
- package/dist/lib/runner.d.ts +2 -0
- package/dist/lib/runner.js +21 -8
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/bundles.js +9 -34
- package/dist/lib/secrets/filestore.d.ts +152 -34
- package/dist/lib/secrets/filestore.js +676 -123
- package/dist/lib/session/active.d.ts +109 -3
- package/dist/lib/session/active.js +269 -13
- package/dist/lib/session/db.d.ts +14 -0
- package/dist/lib/session/db.js +35 -0
- package/dist/lib/session/favorites.d.ts +39 -0
- package/dist/lib/session/favorites.js +101 -0
- package/dist/lib/session/host-link.d.ts +68 -0
- package/dist/lib/session/host-link.js +64 -0
- package/dist/lib/session/presence.d.ts +85 -0
- package/dist/lib/session/presence.js +150 -0
- package/dist/lib/session/remote-active.d.ts +4 -1
- package/dist/lib/session/remote-active.js +8 -2
- package/dist/lib/session/remote-list.d.ts +10 -0
- package/dist/lib/session/remote-list.js +47 -9
- package/dist/lib/session/viewing-in.d.ts +31 -0
- package/dist/lib/session/viewing-in.js +47 -0
- package/dist/lib/state.d.ts +17 -0
- package/dist/lib/state.js +30 -2
- package/dist/lib/tmux/binary.d.ts +7 -0
- package/dist/lib/tmux/binary.js +11 -1
- package/dist/lib/triggers/handlers.d.ts +95 -0
- package/dist/lib/triggers/handlers.js +384 -0
- package/dist/lib/triggers/webhook.d.ts +10 -2
- package/dist/lib/triggers/webhook.js +65 -11
- package/dist/lib/types.d.ts +4 -3
- package/dist/lib/usage-backoff.d.ts +29 -0
- package/dist/lib/usage-backoff.js +165 -0
- package/dist/lib/usage.d.ts +112 -5
- package/dist/lib/usage.js +464 -46
- package/dist/lib/watchdog/runner.d.ts +13 -0
- package/dist/lib/watchdog/runner.js +16 -1
- package/package.json +1 -1
package/dist/lib/commands.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/dist/lib/daemon.d.ts
CHANGED
|
@@ -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
|
};
|
package/dist/lib/daemon.js
CHANGED
|
@@ -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
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
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
|
package/dist/lib/events.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/dist/lib/fs-atomic.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/fs-atomic.js
CHANGED
|
@@ -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, {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/lib/funnel.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/funnel.js
CHANGED
|
@@ -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.
|
package/dist/lib/hq/floor.js
CHANGED
|
@@ -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)
|
|
Binary file
|
|
Binary file
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
<dict>
|
|
7
7
|
<key>Resources/AppIcon.icns</key>
|
|
8
8
|
<data>
|
|
9
|
-
|
|
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
|
-
|
|
18
|
+
GFvSLeNYJ3ASxW3OzcuiB4aID0gZUBkpozmWZkbTFNw=
|
|
19
19
|
</data>
|
|
20
20
|
</dict>
|
|
21
21
|
</dict>
|