@phnx-labs/agents-cli 1.22.59 → 1.22.60
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 +22 -0
- package/dist/commands/perf.js +10 -0
- package/dist/commands/sessions-picker.d.ts +13 -0
- package/dist/commands/sessions-picker.js +17 -8
- package/dist/commands/sessions.js +13 -11
- package/dist/commands/teams-picker.js +20 -6
- package/dist/commands/teams.d.ts +2 -2
- package/dist/commands/teams.js +77 -21
- package/dist/lib/accounting/rotate.js +12 -4
- package/dist/lib/accounting/usage-sync.d.ts +12 -2
- package/dist/lib/accounting/usage-sync.js +34 -6
- package/dist/lib/feed/events.js +4 -0
- package/dist/lib/perf/db.d.ts +1 -1
- package/dist/lib/perf/db.js +53 -2
- package/dist/lib/perf/types.d.ts +14 -0
- package/dist/lib/session/live-metadata.js +3 -3
- package/dist/lib/teams/api.d.ts +8 -0
- package/dist/lib/teams/api.js +50 -6
- package/dist/lib/teams/delivery.d.ts +14 -4
- package/dist/lib/teams/delivery.js +15 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.22.60
|
|
4
|
+
|
|
5
|
+
- **Detect stranded uncommitted work in `agents teams status` (PHNX-2951).** A teammate that exits `COMPLETED` with no PR and uncommitted changes in its local worktree now reports `STRANDED` instead of `done`, and the status line names the worktree path so the work can be rescued before cleanup. The stranded count is computed over the full team even when `--filter` narrows the displayed agents, so `agents teams status <team> --filter running` still surfaces stranded completed teammates. Team rollups and `teams list --status` classify these teams as `stranded`, not `done`. Source: `cli/src/lib/teams/delivery.ts`, `cli/src/lib/teams/api.ts`, `cli/src/commands/teams.ts`.
|
|
6
|
+
|
|
7
|
+
- **Track agent-launch boot cost: persist + surface the `startup` phase (PHNX-3468).** `agent.run` already timed a `startup` phase (entering `spawnAgent` → child spawn) but only the total run duration reached the perf warehouse, so boot cost was untrackable across the fleet. The timer now persists its sub-phase marks into each `perf.timing` sample's `meta_json`, `aggregateSamples` folds them into a per-label `phases` break-out (p50/p90 over the samples that carried each phase), and `agents insights perf run` renders a `└ startup: p50 … p90 … (n=…)` sub-line under `agent.run`. This is the measurement the boot-perf work is graded against. Source: `cli/src/lib/feed/events.ts`, `cli/src/lib/perf/db.ts`, `cli/src/lib/perf/types.ts`, `cli/src/commands/perf.ts`.
|
|
8
|
+
|
|
9
|
+
- **`agents sessions preview <id>` now pulls the transcript digest from the
|
|
10
|
+
owning device for host-dispatched sessions (PHNX-3481).** A host dispatch
|
|
11
|
+
leaves a synthetic local index row with `machine = <peer>` and an empty
|
|
12
|
+
`filePath`, but that row does not carry the live fan-out's `_remote` marker.
|
|
13
|
+
Full-UUID preview resolved the local row and then treated it as a local live
|
|
14
|
+
session, rendering "full transcript not indexed here" without making any SSH
|
|
15
|
+
hop. Transcript reads now follow one file-aware predicate: an explicit remote
|
|
16
|
+
row still reads on its peer; a row naming another machine also reads there
|
|
17
|
+
when no transcript exists on this disk; and a synced mirror with a real local
|
|
18
|
+
file still renders locally. The direct preview, `sessions <id>`, picker view,
|
|
19
|
+
and picker/browser digest pane all share that rule. Live-registry metadata now
|
|
20
|
+
also stamps `_remote` when its execution machine differs from this box, and an
|
|
21
|
+
unreachable owner remains a loud error instead of a local placeholder.
|
|
22
|
+
Source: `cli/src/commands/sessions-picker.ts`, `cli/src/commands/sessions.ts`,
|
|
23
|
+
`cli/src/lib/session/live-metadata.ts`.
|
|
24
|
+
|
|
3
25
|
## 1.22.59
|
|
4
26
|
|
|
5
27
|
- **`agents devices disable/prefer` now actually change `--device auto` placement (PHNX-2092).**
|
package/dist/commands/perf.js
CHANGED
|
@@ -132,6 +132,16 @@ function renderLabelTable(title, rows, warnMs, limit) {
|
|
|
132
132
|
formatMs(r.maxMs),
|
|
133
133
|
formatRateColumn(r),
|
|
134
134
|
]), sliced.map((r) => r.p99Ms > warnMs));
|
|
135
|
+
// Sub-phase break-out (agent.run's `startup` = spawn overhead before the child
|
|
136
|
+
// runs, PHNX-3468). Indented under the row so a slow boot is visible next to
|
|
137
|
+
// the total without adding columns that are empty for phaseless labels.
|
|
138
|
+
for (const r of sliced) {
|
|
139
|
+
if (!r.phases)
|
|
140
|
+
continue;
|
|
141
|
+
for (const [name, ph] of Object.entries(r.phases)) {
|
|
142
|
+
console.log(chalk.gray(` └ ${name}: p50 ${formatMs(ph.p50Ms)} p90 ${formatMs(ph.p90Ms)} (n=${ph.n})`));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
135
145
|
}
|
|
136
146
|
/**
|
|
137
147
|
* Prefer SQLite samples; fall back to the legacy daily JSONL so existing
|
|
@@ -3,6 +3,19 @@ import { fetchPeerPreviewDigest } from '../lib/session/remote-list.js';
|
|
|
3
3
|
import { classifyFileChanges, changeCounts, toolHistogram, detectTestResult } from '../lib/session/digest.js';
|
|
4
4
|
import type { FileChange } from '../lib/session/digest.js';
|
|
5
5
|
import { extractArtifacts, extractHooks, extractLinks, extractSkills } from '../lib/session/highlights.js';
|
|
6
|
+
/** A session whose transcript FILE is on another machine: its `filePath` is on
|
|
7
|
+
* that peer's disk, so the preview can't parse it locally — it fetches the
|
|
8
|
+
* peer's digest over SSH and shows metadata + a "resume there" note until that
|
|
9
|
+
* lands. `_remote` is an explicit signal; a peer `machine` plus no readable
|
|
10
|
+
* local file covers host-dispatch index rows and live-registry rows that were
|
|
11
|
+
* synthesized outside the fan-out. A locally-readable synced mirror stays
|
|
12
|
+
* local even when its recorded owner is another machine.
|
|
13
|
+
*
|
|
14
|
+
* This is the READ rule and only the read rule. Whether a session may be
|
|
15
|
+
* RESUMED here is a different question with a different answer — a mirror is
|
|
16
|
+
* readable but not resumable — and `sessionOwnerDevice`
|
|
17
|
+
* (lib/session/resume-owner.ts) is the single place that answers it (RUSH-2022). */
|
|
18
|
+
export declare function transcriptOnPeerOf(session: SessionMeta): string | undefined;
|
|
6
19
|
/**
|
|
7
20
|
* Compact checklist tally for list rows and previews (RUSH-2045).
|
|
8
21
|
* Example: `✓6/8 · A5 wiring runner`. Empty string when there is no list.
|
|
@@ -25,19 +25,28 @@ import { createMemoryCache } from '../lib/memory-cache.js';
|
|
|
25
25
|
import { classifyFileChanges, changeCounts, toolHistogram, detectTestResult } from '../lib/session/digest.js';
|
|
26
26
|
import { extractArtifacts, extractHooks, extractLinks, extractRepos, extractSkills, harnessTracksBackgroundShells, isBackgroundShellStart, isSubAgentTool, } from '../lib/session/highlights.js';
|
|
27
27
|
import { getSessionPlugins, readSessionPreviewCache, writeSessionPreviewCache, readSessionContent, readArchivedSessionPreview } from '../lib/session/db.js';
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
* preview can't parse it locally — it fetches the
|
|
31
|
-
* shows metadata + a "resume there" note until that
|
|
32
|
-
*
|
|
33
|
-
* file
|
|
28
|
+
import { machineId } from '../lib/session/sync/config.js';
|
|
29
|
+
/** A session whose transcript FILE is on another machine: its `filePath` is on
|
|
30
|
+
* that peer's disk, so the preview can't parse it locally — it fetches the
|
|
31
|
+
* peer's digest over SSH and shows metadata + a "resume there" note until that
|
|
32
|
+
* lands. `_remote` is an explicit signal; a peer `machine` plus no readable
|
|
33
|
+
* local file covers host-dispatch index rows and live-registry rows that were
|
|
34
|
+
* synthesized outside the fan-out. A locally-readable synced mirror stays
|
|
35
|
+
* local even when its recorded owner is another machine.
|
|
34
36
|
*
|
|
35
37
|
* This is the READ rule and only the read rule. Whether a session may be
|
|
36
38
|
* RESUMED here is a different question with a different answer — a mirror is
|
|
37
39
|
* readable but not resumable — and `sessionOwnerDevice`
|
|
38
40
|
* (lib/session/resume-owner.ts) is the single place that answers it (RUSH-2022). */
|
|
39
|
-
function transcriptOnPeerOf(session) {
|
|
40
|
-
|
|
41
|
+
export function transcriptOnPeerOf(session) {
|
|
42
|
+
if (session._remote)
|
|
43
|
+
return session.machine;
|
|
44
|
+
if (session.machine
|
|
45
|
+
&& session.machine !== machineId()
|
|
46
|
+
&& (!session.filePath || !fs.existsSync(session.filePath))) {
|
|
47
|
+
return session.machine;
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
41
50
|
}
|
|
42
51
|
/**
|
|
43
52
|
* Compact checklist tally for list rows and previews (RUSH-2045).
|
|
@@ -55,7 +55,7 @@ import { listInstalledVersions, resolveVersionAliasLoose } from '../lib/installa
|
|
|
55
55
|
import { getAgentsInvocation } from '../lib/daemon/daemon.js';
|
|
56
56
|
import { sessionRecoveryRunArgs } from '../lib/session/recovery.js';
|
|
57
57
|
import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
|
|
58
|
-
import { sessionPicker, buildPreview, loadSessionPreviewDigest, formatTodoCompact, githubRepoUrlFromCwd, } from './sessions-picker.js';
|
|
58
|
+
import { sessionPicker, buildPreview, loadSessionPreviewDigest, transcriptOnPeerOf, formatTodoCompact, githubRepoUrlFromCwd, } from './sessions-picker.js';
|
|
59
59
|
import { setHelpSections } from '../lib/help.js';
|
|
60
60
|
import { registerSessionsTailCommand } from './sessions-tail.js';
|
|
61
61
|
import { registerSessionsResumeCommand } from './sessions-resume.js';
|
|
@@ -2017,13 +2017,14 @@ export async function renderSessionPreview(query, scope) {
|
|
|
2017
2017
|
return;
|
|
2018
2018
|
}
|
|
2019
2019
|
const session = outcome.session;
|
|
2020
|
-
|
|
2020
|
+
const transcriptPeer = transcriptOnPeerOf(session);
|
|
2021
|
+
if (transcriptPeer) {
|
|
2021
2022
|
const args = ['sessions', 'preview', session.id, '--local'];
|
|
2022
2023
|
if (scope.json)
|
|
2023
2024
|
args.push('--json');
|
|
2024
|
-
const rendered = await runOnPeer(args,
|
|
2025
|
+
const rendered = await runOnPeer(args, transcriptPeer);
|
|
2025
2026
|
if (rendered === 'no-target') {
|
|
2026
|
-
console.error(chalk.red(`Session ${session.id} is on ${
|
|
2027
|
+
console.error(chalk.red(`Session ${session.id} is on ${transcriptPeer}, but that device is not reachable.`));
|
|
2027
2028
|
process.exitCode = 1;
|
|
2028
2029
|
}
|
|
2029
2030
|
return;
|
|
@@ -3873,11 +3874,11 @@ export async function handlePickedSession(picked) {
|
|
|
3873
3874
|
}
|
|
3874
3875
|
// Reading and resuming are on DIFFERENT machines' terms, and conflating them
|
|
3875
3876
|
// is what RUSH-2022 was. Reading follows the FILE: a synced mirror sits on
|
|
3876
|
-
// this disk, so
|
|
3877
|
-
// peer. Resuming follows the HARNESS STATE, which is on the
|
|
3878
|
-
// whatever the transcript's location — a mirror included.
|
|
3877
|
+
// this disk, so a peer-owned row with no readable local transcript has to be
|
|
3878
|
+
// read on the peer. Resuming follows the HARNESS STATE, which is on the
|
|
3879
|
+
// owning machine whatever the transcript's location — a mirror included.
|
|
3879
3880
|
if (picked.action === 'view') {
|
|
3880
|
-
const readFrom = picked.session
|
|
3881
|
+
const readFrom = transcriptOnPeerOf(picked.session);
|
|
3881
3882
|
if (readFrom) {
|
|
3882
3883
|
const rc = await runOnPeer(['sessions', picked.session.shortId, '--markdown'], readFrom);
|
|
3883
3884
|
if (rc === 'no-target')
|
|
@@ -4503,7 +4504,8 @@ async function renderOneSession(query, mode, scope) {
|
|
|
4503
4504
|
}
|
|
4504
4505
|
if (outcome.kind === 'resolved') {
|
|
4505
4506
|
const resolved = outcome.session;
|
|
4506
|
-
|
|
4507
|
+
const transcriptPeer = transcriptOnPeerOf(resolved);
|
|
4508
|
+
if (transcriptPeer) {
|
|
4507
4509
|
const args = ['sessions', resolved.id, '--local'];
|
|
4508
4510
|
const flag = modeFlag(mode);
|
|
4509
4511
|
if (flag)
|
|
@@ -4518,9 +4520,9 @@ async function renderOneSession(query, mode, scope) {
|
|
|
4518
4520
|
args.push('--last', String(scope.filter.last));
|
|
4519
4521
|
if (scope.redact === false)
|
|
4520
4522
|
args.push('--no-redact');
|
|
4521
|
-
const rendered = await runOnPeer(args,
|
|
4523
|
+
const rendered = await runOnPeer(args, transcriptPeer);
|
|
4522
4524
|
if (rendered === 'no-target') {
|
|
4523
|
-
console.error(chalk.red(`Session ${resolved.id} is on ${
|
|
4525
|
+
console.error(chalk.red(`Session ${resolved.id} is on ${transcriptPeer}, but that device is not reachable.`));
|
|
4524
4526
|
process.exit(1);
|
|
4525
4527
|
}
|
|
4526
4528
|
return;
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import chalk from 'chalk';
|
|
10
10
|
import { relTime, truncate, humanDuration } from '../lib/format.js';
|
|
11
11
|
import { itemPicker } from '../lib/picker.js';
|
|
12
|
+
import { deliveryDisplayLabel, deliveryColorKey } from '../lib/teams/delivery.js';
|
|
12
13
|
const AGENT_LABEL = {
|
|
13
14
|
claude: 'Claude',
|
|
14
15
|
codex: 'Codex',
|
|
@@ -21,6 +22,7 @@ function statusColor(status) {
|
|
|
21
22
|
case 'pending': return chalk.blue;
|
|
22
23
|
case 'running': return chalk.yellow;
|
|
23
24
|
case 'completed': return chalk.green;
|
|
25
|
+
case 'stranded': return chalk.yellow;
|
|
24
26
|
case 'failed': return chalk.red;
|
|
25
27
|
case 'stopped': return chalk.gray;
|
|
26
28
|
default: return chalk.white;
|
|
@@ -61,10 +63,12 @@ function displayAgent(agent, version) {
|
|
|
61
63
|
function statusCell(t) {
|
|
62
64
|
if (t.agent_count === 0)
|
|
63
65
|
return chalk.gray('empty');
|
|
66
|
+
const done = Math.max(0, t.completed - (t.stranded ?? 0));
|
|
64
67
|
const states = [
|
|
65
68
|
[t.pending, chalk.blue, 'pending'],
|
|
66
69
|
[t.running, chalk.yellow, 'working'],
|
|
67
|
-
[
|
|
70
|
+
[done, chalk.green, 'done'],
|
|
71
|
+
[t.stranded ?? 0, chalk.yellow, 'stranded'],
|
|
68
72
|
[t.failed, chalk.red, 'failed'],
|
|
69
73
|
[t.stopped, chalk.gray, 'stopped'],
|
|
70
74
|
];
|
|
@@ -76,13 +80,16 @@ function statusCell(t) {
|
|
|
76
80
|
return nonzero.map(([n, color, label]) => color(`${n} ${label}`)).join(chalk.gray(' · '));
|
|
77
81
|
}
|
|
78
82
|
function statusSummaryParts(t) {
|
|
83
|
+
const done = Math.max(0, t.completed - (t.stranded ?? 0));
|
|
79
84
|
const parts = [];
|
|
80
85
|
if (t.pending)
|
|
81
86
|
parts.push(chalk.blue(`${t.pending} pending`));
|
|
82
87
|
if (t.running)
|
|
83
88
|
parts.push(chalk.yellow(`${t.running} working`));
|
|
84
|
-
if (
|
|
85
|
-
parts.push(chalk.green(`${
|
|
89
|
+
if (done)
|
|
90
|
+
parts.push(chalk.green(`${done} done`));
|
|
91
|
+
if (t.stranded)
|
|
92
|
+
parts.push(chalk.yellow(`${t.stranded} stranded`));
|
|
86
93
|
if (t.failed)
|
|
87
94
|
parts.push(chalk.red(`${t.failed} failed`));
|
|
88
95
|
if (t.stopped)
|
|
@@ -173,7 +180,12 @@ export function buildTeamPreview(row) {
|
|
|
173
180
|
const nameW = Math.max(6, ...row.agents.map((a) => displayHandle(a).length));
|
|
174
181
|
const agentW = Math.max(8, ...row.agents.map((a) => displayAgent(a.agent_type, a.version).length));
|
|
175
182
|
for (const a of row.agents) {
|
|
176
|
-
const
|
|
183
|
+
const delivery = a.delivery;
|
|
184
|
+
const colorKey = delivery ? deliveryColorKey(delivery, a.status) : a.status;
|
|
185
|
+
const label = delivery
|
|
186
|
+
? deliveryDisplayLabel(delivery, a.status)
|
|
187
|
+
: a.status.toUpperCase();
|
|
188
|
+
const stat = statusColor(colorKey)(label);
|
|
177
189
|
const metaParts = [];
|
|
178
190
|
if (a.task_type)
|
|
179
191
|
metaParts.push(chalk.magenta(a.task_type));
|
|
@@ -244,8 +256,8 @@ function searchHaystack(row) {
|
|
|
244
256
|
parts.push(a.status);
|
|
245
257
|
}
|
|
246
258
|
// Also include team-level status words exposed in the row ("done",
|
|
247
|
-
// "working", "pending", "failed", "stopped", "empty") so the
|
|
248
|
-
// what the user sees on screen.
|
|
259
|
+
// "working", "pending", "stranded", "failed", "stopped", "empty") so the
|
|
260
|
+
// search matches what the user sees on screen.
|
|
249
261
|
const t = row.team;
|
|
250
262
|
if (t.agent_count === 0)
|
|
251
263
|
parts.push('empty');
|
|
@@ -255,6 +267,8 @@ function searchHaystack(row) {
|
|
|
255
267
|
parts.push('working', 'running');
|
|
256
268
|
if (t.completed)
|
|
257
269
|
parts.push('done', 'completed');
|
|
270
|
+
if (t.stranded)
|
|
271
|
+
parts.push('stranded');
|
|
258
272
|
if (t.failed)
|
|
259
273
|
parts.push('failed');
|
|
260
274
|
if (t.stopped)
|
package/dist/commands/teams.d.ts
CHANGED
|
@@ -120,11 +120,11 @@ export declare function cloudDispatchOptions(agent: Pick<AgentProcess, 'prompt'
|
|
|
120
120
|
export declare function runOneWave(mgr: AgentManager, team: string, json: boolean): Promise<void>;
|
|
121
121
|
export declare function buildTeamRowsFromSnapshots(registry: TeamRegistry, agents: TeamListAgentSnapshot[],
|
|
122
122
|
/** team name -> the session that spawned it (see teamSpawners). */
|
|
123
|
-
spawners?: Map<string, TeamSpawner>): {
|
|
123
|
+
spawners?: Map<string, TeamSpawner>): Promise<{
|
|
124
124
|
rows: TeamRow[];
|
|
125
125
|
teams: TaskInfo[];
|
|
126
126
|
names: string[];
|
|
127
|
-
}
|
|
127
|
+
}>;
|
|
128
128
|
/**
|
|
129
129
|
* `teams add --remote-cwd` is a no-op trap. `--remote-cwd` comes from the shared
|
|
130
130
|
* `--device` option family (option.ts) and is meaningful for commands that *route*
|
package/dist/commands/teams.js
CHANGED
|
@@ -62,6 +62,7 @@ function statusColor(status) {
|
|
|
62
62
|
case 'running': return chalk.yellow;
|
|
63
63
|
case 'completed': return chalk.green;
|
|
64
64
|
case 'pr_open': return chalk.magenta; // RUSH-2380: process done, PR not merged
|
|
65
|
+
case 'stranded': return chalk.yellow; // PHNX-2951: done but work not committed
|
|
65
66
|
case 'failed': return chalk.red;
|
|
66
67
|
case 'stopped': return chalk.gray;
|
|
67
68
|
default: return chalk.white;
|
|
@@ -150,6 +151,7 @@ function snapshotToStatusDetail(agent) {
|
|
|
150
151
|
after: agent.after,
|
|
151
152
|
task_type: agent.task_type,
|
|
152
153
|
host: agent.host,
|
|
154
|
+
workspace_dir: agent.workspace_dir,
|
|
153
155
|
};
|
|
154
156
|
}
|
|
155
157
|
function fullName(type, version) {
|
|
@@ -784,6 +786,9 @@ function printAgentDetail(a, session) {
|
|
|
784
786
|
}
|
|
785
787
|
if (a.has_errors)
|
|
786
788
|
console.log(` ${chalk.red('! reported an error')}`);
|
|
789
|
+
if (delivery === 'stranded' && a.workspace_dir) {
|
|
790
|
+
console.log(` ${chalk.yellow('! stranded')} uncommitted work at ${a.workspace_dir}`);
|
|
791
|
+
}
|
|
787
792
|
if (a.pr_url)
|
|
788
793
|
console.log(` ${chalk.gray('PR ')}${a.pr_url}`);
|
|
789
794
|
}
|
|
@@ -864,17 +869,34 @@ function printAgentSummary(s) {
|
|
|
864
869
|
console.log(` ${chalk.gray('>')} ${truncate(firstLine, 96)}`);
|
|
865
870
|
}
|
|
866
871
|
}
|
|
872
|
+
if (delivery === 'stranded' && s.workspace_dir) {
|
|
873
|
+
console.log(` ${chalk.yellow('! stranded')} uncommitted work at ${s.workspace_dir}`);
|
|
874
|
+
}
|
|
867
875
|
if (s.pr_url)
|
|
868
876
|
console.log(` ${chalk.gray('PR ')} ${chalk.cyan(s.pr_url)}`);
|
|
869
877
|
}
|
|
878
|
+
function formatTeamStatusSummary(summary) {
|
|
879
|
+
const done = Math.max(0, summary.completed - summary.stranded);
|
|
880
|
+
const parts = [];
|
|
881
|
+
if (summary.pending > 0)
|
|
882
|
+
parts.push(`${summary.pending} pending`);
|
|
883
|
+
if (summary.running > 0 || parts.length === 0)
|
|
884
|
+
parts.push(`${summary.running} working`);
|
|
885
|
+
if (done > 0 || summary.stranded === 0)
|
|
886
|
+
parts.push(`${done} done`);
|
|
887
|
+
if (summary.stranded > 0)
|
|
888
|
+
parts.push(`${summary.stranded} stranded`);
|
|
889
|
+
if (summary.failed > 0 || parts.length === 0)
|
|
890
|
+
parts.push(`${summary.failed} failed`);
|
|
891
|
+
if (summary.stopped > 0 || parts.length === 0)
|
|
892
|
+
parts.push(`${summary.stopped} stopped`);
|
|
893
|
+
return `(${parts.join(', ')})`;
|
|
894
|
+
}
|
|
870
895
|
// Render a team's status in the same format the `status` subcommand uses, so
|
|
871
896
|
// the interactive picker's Enter action drops the user into a familiar view.
|
|
872
897
|
async function printTeamStatus(team, result) {
|
|
873
898
|
const { summary, agents } = result;
|
|
874
|
-
console.log(chalk.bold(`Team ${chalk.cyan(team)} `) +
|
|
875
|
-
chalk.gray(summary.pending > 0
|
|
876
|
-
? `(${summary.pending} pending, ${summary.running} working, ${summary.completed} done, ${summary.failed} failed, ${summary.stopped} stopped)`
|
|
877
|
-
: `(${summary.running} working, ${summary.completed} done, ${summary.failed} failed, ${summary.stopped} stopped)`));
|
|
899
|
+
console.log(chalk.bold(`Team ${chalk.cyan(team)} `) + chalk.gray(formatTeamStatusSummary(summary)));
|
|
878
900
|
if (agents.length === 0) {
|
|
879
901
|
console.log(chalk.gray(' (no teammates yet — add one with `agents teams add`)'));
|
|
880
902
|
}
|
|
@@ -900,10 +922,7 @@ async function printTeamStatus(team, result) {
|
|
|
900
922
|
// verbose/legacy layout.
|
|
901
923
|
function printTeamSummary(team, result) {
|
|
902
924
|
const { summary, agents } = result;
|
|
903
|
-
console.log(chalk.bold(`Team ${chalk.cyan(team)} `) +
|
|
904
|
-
chalk.gray(summary.pending > 0
|
|
905
|
-
? `(${summary.pending} pending, ${summary.running} working, ${summary.completed} done, ${summary.failed} failed, ${summary.stopped} stopped)`
|
|
906
|
-
: `(${summary.running} working, ${summary.completed} done, ${summary.failed} failed, ${summary.stopped} stopped)`));
|
|
925
|
+
console.log(chalk.bold(`Team ${chalk.cyan(team)} `) + chalk.gray(formatTeamStatusSummary(summary)));
|
|
907
926
|
if (agents.length === 0) {
|
|
908
927
|
console.log(chalk.gray(' (no teammates yet — add one with `agents teams add`)'));
|
|
909
928
|
}
|
|
@@ -925,12 +944,13 @@ function printTeamSummary(team, result) {
|
|
|
925
944
|
console.log(chalk.gray('Raw log: agents teams logs --team ' + team + ' --teammate <name>'));
|
|
926
945
|
}
|
|
927
946
|
// Classify a team into a single bucket for --status filtering.
|
|
928
|
-
// - empty:
|
|
929
|
-
// - waiting:
|
|
930
|
-
// - working:
|
|
931
|
-
// - failed:
|
|
932
|
-
//
|
|
933
|
-
// -
|
|
947
|
+
// - empty: no teammates (created but nobody added yet)
|
|
948
|
+
// - waiting: only staged teammates — call `teams start` to kick them off
|
|
949
|
+
// - working: at least one teammate still running
|
|
950
|
+
// - failed: at least one teammate failed or was stopped (any failure wins —
|
|
951
|
+
// even if others finished, you want to know about the failure)
|
|
952
|
+
// - stranded: everyone finished, but at least one has uncommitted work and no PR
|
|
953
|
+
// - done: everyone finished successfully, no failures, no stranded work
|
|
934
954
|
function classifyTeamStatus(t) {
|
|
935
955
|
if (t.agent_count === 0)
|
|
936
956
|
return 'empty';
|
|
@@ -943,6 +963,10 @@ function classifyTeamStatus(t) {
|
|
|
943
963
|
const accounted = t.running + t.completed + t.failed + t.stopped;
|
|
944
964
|
if (accounted < t.agent_count)
|
|
945
965
|
return 'waiting';
|
|
966
|
+
// Completed-without-delivery is not "done": the work is still in the worktree
|
|
967
|
+
// and will be lost on cleanup (PHNX-2951).
|
|
968
|
+
if ((t.stranded ?? 0) > 0)
|
|
969
|
+
return 'stranded';
|
|
946
970
|
return 'done';
|
|
947
971
|
}
|
|
948
972
|
// Merge persistent team registry with tasks derived from live agents so empty
|
|
@@ -959,6 +983,7 @@ function mergeTeams(registry, tasks) {
|
|
|
959
983
|
pending: 0,
|
|
960
984
|
running: 0,
|
|
961
985
|
completed: 0,
|
|
986
|
+
stranded: 0,
|
|
962
987
|
failed: 0,
|
|
963
988
|
stopped: 0,
|
|
964
989
|
workspace_dir: null,
|
|
@@ -969,7 +994,7 @@ function mergeTeams(registry, tasks) {
|
|
|
969
994
|
}
|
|
970
995
|
return Array.from(byName.values()).sort((a, b) => new Date(b.modified_at).getTime() - new Date(a.modified_at).getTime());
|
|
971
996
|
}
|
|
972
|
-
function buildTasksFromSnapshots(agents) {
|
|
997
|
+
async function buildTasksFromSnapshots(agents) {
|
|
973
998
|
const byTeam = new Map();
|
|
974
999
|
for (const agent of agents) {
|
|
975
1000
|
const teamAgents = byTeam.get(agent.task_name) || [];
|
|
@@ -981,6 +1006,7 @@ function buildTasksFromSnapshots(agents) {
|
|
|
981
1006
|
let pending = 0;
|
|
982
1007
|
let running = 0;
|
|
983
1008
|
let completed = 0;
|
|
1009
|
+
let stranded = 0;
|
|
984
1010
|
let failed = 0;
|
|
985
1011
|
let stopped = 0;
|
|
986
1012
|
let earliestStart = null;
|
|
@@ -998,6 +1024,16 @@ function buildTasksFromSnapshots(agents) {
|
|
|
998
1024
|
failed++;
|
|
999
1025
|
else if (status === AgentStatus.STOPPED)
|
|
1000
1026
|
stopped++;
|
|
1027
|
+
// Stranded = completed with no PR and a dirty local worktree (PHNX-2951).
|
|
1028
|
+
if (status === AgentStatus.COMPLETED &&
|
|
1029
|
+
!agent.pr_url?.trim() &&
|
|
1030
|
+
!agent.host &&
|
|
1031
|
+
agent.workspace_dir) {
|
|
1032
|
+
const dirty = await hasUncommittedChanges(agent.workspace_dir);
|
|
1033
|
+
if (dirty) {
|
|
1034
|
+
stranded++;
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1001
1037
|
const startedAt = parseTimestamp(agent.started_at);
|
|
1002
1038
|
if (startedAt && (!earliestStart || startedAt < earliestStart)) {
|
|
1003
1039
|
earliestStart = startedAt;
|
|
@@ -1017,6 +1053,7 @@ function buildTasksFromSnapshots(agents) {
|
|
|
1017
1053
|
pending,
|
|
1018
1054
|
running,
|
|
1019
1055
|
completed,
|
|
1056
|
+
stranded,
|
|
1020
1057
|
failed,
|
|
1021
1058
|
stopped,
|
|
1022
1059
|
workspace_dir: workspaceDir,
|
|
@@ -1026,7 +1063,7 @@ function buildTasksFromSnapshots(agents) {
|
|
|
1026
1063
|
}
|
|
1027
1064
|
return tasks.sort((a, b) => new Date(b.modified_at).getTime() - new Date(a.modified_at).getTime());
|
|
1028
1065
|
}
|
|
1029
|
-
export function buildTeamRowsFromSnapshots(registry, agents,
|
|
1066
|
+
export async function buildTeamRowsFromSnapshots(registry, agents,
|
|
1030
1067
|
/** team name -> the session that spawned it (see teamSpawners). */
|
|
1031
1068
|
spawners) {
|
|
1032
1069
|
const byTeam = new Map();
|
|
@@ -1035,7 +1072,26 @@ spawners) {
|
|
|
1035
1072
|
details.push(snapshotToStatusDetail(agent));
|
|
1036
1073
|
byTeam.set(agent.task_name, details);
|
|
1037
1074
|
}
|
|
1038
|
-
const teams = mergeTeams(registry, buildTasksFromSnapshots(agents));
|
|
1075
|
+
const teams = mergeTeams(registry, await buildTasksFromSnapshots(agents));
|
|
1076
|
+
// Recompute delivery for cached snapshots so `teams list` reflects stranded
|
|
1077
|
+
// work discovered by probing the real worktree (PHNX-2951).
|
|
1078
|
+
for (const details of byTeam.values()) {
|
|
1079
|
+
for (const a of details) {
|
|
1080
|
+
const snapshot = agents.find((s) => s.agent_id === a.agent_id);
|
|
1081
|
+
if (snapshot &&
|
|
1082
|
+
normalizeTeamListStatus(a.status) === AgentStatus.COMPLETED &&
|
|
1083
|
+
!snapshot.pr_url?.trim() &&
|
|
1084
|
+
!snapshot.host &&
|
|
1085
|
+
snapshot.workspace_dir) {
|
|
1086
|
+
const dirty = await hasUncommittedChanges(snapshot.workspace_dir);
|
|
1087
|
+
a.delivery = resolveTeammateDelivery({
|
|
1088
|
+
status: a.status,
|
|
1089
|
+
prUrl: snapshot.pr_url,
|
|
1090
|
+
hasUncommittedChanges: dirty,
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1039
1095
|
return {
|
|
1040
1096
|
teams,
|
|
1041
1097
|
rows: teams.map((team) => ({
|
|
@@ -1107,7 +1163,7 @@ async function loadTeamRows(_mgr) {
|
|
|
1107
1163
|
catch {
|
|
1108
1164
|
// The index is an enrichment here — a missing/locked DB just drops the column.
|
|
1109
1165
|
}
|
|
1110
|
-
return buildTeamRowsFromSnapshots(registry, agents, spawners);
|
|
1166
|
+
return await buildTeamRowsFromSnapshots(registry, agents, spawners);
|
|
1111
1167
|
}
|
|
1112
1168
|
// Picker fallback for `teams logs` when the teammate ref is omitted. Shows a
|
|
1113
1169
|
// flat list of every teammate with their team context; Enter picks one.
|
|
@@ -1271,7 +1327,7 @@ export function registerTeamsCommands(program) {
|
|
|
1271
1327
|
.alias('ls')
|
|
1272
1328
|
.description('List your teams, most recent activity first')
|
|
1273
1329
|
.option('-a, --agent <agent>', 'Filter: only teams with this agent (e.g. claude or claude@2.1.112)')
|
|
1274
|
-
.option('--status <status>', 'Filter: only teams with this status (working, done, failed, or empty)')
|
|
1330
|
+
.option('--status <status>', 'Filter: only teams with this status (working, done, stranded, failed, or empty)')
|
|
1275
1331
|
.option('--since <time>', 'Filter: teams active after this time (e.g. "2h", "7d", or ISO date)')
|
|
1276
1332
|
.option('--until <time>', 'Filter: teams active before this time (e.g. "30d", or ISO date)')
|
|
1277
1333
|
.option('-n, --limit <n>', 'Show at most this many teams (default: 20)', '20')
|
|
@@ -1298,7 +1354,7 @@ export function registerTeamsCommands(program) {
|
|
|
1298
1354
|
catch {
|
|
1299
1355
|
// The session index is an enrichment here; a missing one drops the column.
|
|
1300
1356
|
}
|
|
1301
|
-
let rows = buildTeamRowsFromSnapshots(registry, everyAgent, spawners).rows;
|
|
1357
|
+
let rows = (await buildTeamRowsFromSnapshots(registry, everyAgent, spawners)).rows;
|
|
1302
1358
|
// --- query: substring match on team name ---
|
|
1303
1359
|
if (query) {
|
|
1304
1360
|
const q = query.toLowerCase();
|
|
@@ -1318,7 +1374,7 @@ export function registerTeamsCommands(program) {
|
|
|
1318
1374
|
// --- --status: classify each team, filter ---
|
|
1319
1375
|
if (opts.status) {
|
|
1320
1376
|
const want = opts.status.toLowerCase();
|
|
1321
|
-
const validStatuses = ['working', 'done', 'failed', 'empty'];
|
|
1377
|
+
const validStatuses = ['working', 'done', 'stranded', 'failed', 'empty'];
|
|
1322
1378
|
if (!validStatuses.includes(want)) {
|
|
1323
1379
|
dieFriction('teams', 'invalid-status-filter', `Invalid --status '${opts.status}'. Use one of: ${validStatuses.join(', ')}`);
|
|
1324
1380
|
}
|
|
@@ -313,7 +313,7 @@ export function pickBalancedCandidate(candidates, nowMs = Date.now()) {
|
|
|
313
313
|
if (!deduped.has(c))
|
|
314
314
|
excluded.push(c);
|
|
315
315
|
}
|
|
316
|
-
const { picked, usageUnverified } = preferVerified(sorted, nowMs, weightedRandomByCapacity, 'representative');
|
|
316
|
+
const { picked, usageUnverified } = preferVerified(sorted, nowMs, (from) => weightedRandomByCapacity(from, nowMs), 'representative');
|
|
317
317
|
return { picked, healthy: sorted, excluded, usageUnverified };
|
|
318
318
|
}
|
|
319
319
|
/**
|
|
@@ -376,8 +376,16 @@ export { PROJECTION_HORIZON_MIN, capacityWeight };
|
|
|
376
376
|
* routing capacity (see {@link capacityWeight}). Floor each weight at 1 so a
|
|
377
377
|
* near-exhausted-but-still-eligible candidate can still be picked occasionally.
|
|
378
378
|
*/
|
|
379
|
-
function weightedRandomByCapacity(sorted) {
|
|
380
|
-
const weights = sorted.map((c) => capacityWeight(
|
|
379
|
+
function weightedRandomByCapacity(sorted, nowMs = Date.now()) {
|
|
380
|
+
const weights = sorted.map((c) => capacityWeight(
|
|
381
|
+
// An unverified (stale or absent) snapshot carries no trustworthy number,
|
|
382
|
+
// so it weights as UNVERIFIED_WEIGHT (the floor) instead of by its frozen
|
|
383
|
+
// usedPercent. Otherwise a day-old "45% used" competes as if it were live
|
|
384
|
+
// headroom and can win the draw over a verified-healthy account — which is
|
|
385
|
+
// exactly how balanced launched into an account already at its weekly cap
|
|
386
|
+
// on a worker whose usage refresh had stalled (PHNX-3479). A verified
|
|
387
|
+
// snapshot keeps its real remaining-headroom weight.
|
|
388
|
+
isUsageVerified(c, nowMs) ? getRoutingUsedPercent(c.usageSnapshot) : null, c.usageMinutesToLimit));
|
|
381
389
|
const total = weights.reduce((sum, w) => sum + w, 0);
|
|
382
390
|
if (total <= 0)
|
|
383
391
|
return sorted[0];
|
|
@@ -485,7 +493,7 @@ export function pickHarnessWeighted(byHarness, nowMs = Date.now()) {
|
|
|
485
493
|
const excluded = summaries.filter((s) => s.best === null);
|
|
486
494
|
if (healthy.length === 0)
|
|
487
495
|
return null;
|
|
488
|
-
const pickedBest = weightedRandomByCapacity(healthy.map((s) => s.best));
|
|
496
|
+
const pickedBest = weightedRandomByCapacity(healthy.map((s) => s.best), nowMs);
|
|
489
497
|
const picked = healthy.find((s) => s.best === pickedBest);
|
|
490
498
|
return { picked, healthy, excluded };
|
|
491
499
|
}
|
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import { type DeviceProfile } from '../devices/registry.js';
|
|
2
2
|
import { type ConfiguredDeviceRole } from '../device-config.js';
|
|
3
|
-
import { type CachedUsageSnapshot
|
|
3
|
+
import { type CachedUsageSnapshot } from './usage.js';
|
|
4
4
|
/** How long a single peer push may take before it is abandoned for this tick. */
|
|
5
5
|
export declare const USAGE_PUSH_DEADLINE_MS = 20000;
|
|
6
6
|
export declare const USAGE_PULL_DEADLINE_MS = 20000;
|
|
7
|
+
/**
|
|
8
|
+
* A worker's newest local usage row older than this triggers a pull from the
|
|
9
|
+
* primary. A worker cannot self-read usage (setup-token lacks `user:profile`,
|
|
10
|
+
* RUSH-2392), so its only source of a fresh row is the daemon push every
|
|
11
|
+
* `USAGE_SYNC_TICK_MS` (15m); this cutoff is 2× that tick, so one missed push
|
|
12
|
+
* cycle is tolerated before the worker actively pulls. Bounds worker staleness
|
|
13
|
+
* to ~30m instead of the days-old caches this replaces.
|
|
14
|
+
*/
|
|
15
|
+
export declare const USAGE_SYNC_MAX_AGE_MS: number;
|
|
7
16
|
/** The stdin envelope the `__usage-ingest` verb reads. `v` guards the shape. */
|
|
8
17
|
export interface UsageSyncPayload {
|
|
9
18
|
v: 1;
|
|
@@ -75,8 +84,9 @@ export interface UsagePullDeps {
|
|
|
75
84
|
listRoles?: () => Record<string, ConfiguredDeviceRole>;
|
|
76
85
|
isPinned?: (name: string) => boolean;
|
|
77
86
|
exportRows?: () => Record<string, CachedUsageSnapshot>;
|
|
78
|
-
readRow?: (usageKey: string) => Pick<UsageSnapshot, 'windows'> | null;
|
|
79
87
|
ingestRows?: (rows: Record<string, CachedUsageSnapshot>) => number;
|
|
88
|
+
/** Injectable clock for the staleness gate; defaults to `Date.now`. */
|
|
89
|
+
now?: () => number;
|
|
80
90
|
/** Read the versioned payload from the primary. Default: ssh `__usage-export`. */
|
|
81
91
|
pull?: (device: DeviceProfile) => {
|
|
82
92
|
ok: boolean;
|
|
@@ -32,10 +32,26 @@ import { sshTargetFor } from '../devices/connect.js';
|
|
|
32
32
|
import { isHostPinned } from '../devices/known-hosts.js';
|
|
33
33
|
import { machineId, normalizeHost } from '../session/sync/config.js';
|
|
34
34
|
import { isHeadedDeviceRole, listConfiguredDeviceRoles, selfConfiguredDeviceRole, } from '../device-config.js';
|
|
35
|
-
import { exportClaudeUsageCacheRows, ingestPeerClaudeUsageRows,
|
|
35
|
+
import { exportClaudeUsageCacheRows, ingestPeerClaudeUsageRows, } from './usage.js';
|
|
36
36
|
/** How long a single peer push may take before it is abandoned for this tick. */
|
|
37
37
|
export const USAGE_PUSH_DEADLINE_MS = 20_000;
|
|
38
38
|
export const USAGE_PULL_DEADLINE_MS = 20_000;
|
|
39
|
+
/**
|
|
40
|
+
* A worker's newest local usage row older than this triggers a pull from the
|
|
41
|
+
* primary. A worker cannot self-read usage (setup-token lacks `user:profile`,
|
|
42
|
+
* RUSH-2392), so its only source of a fresh row is the daemon push every
|
|
43
|
+
* `USAGE_SYNC_TICK_MS` (15m); this cutoff is 2× that tick, so one missed push
|
|
44
|
+
* cycle is tolerated before the worker actively pulls. Bounds worker staleness
|
|
45
|
+
* to ~30m instead of the days-old caches this replaces.
|
|
46
|
+
*/
|
|
47
|
+
export const USAGE_SYNC_MAX_AGE_MS = 30 * 60_000;
|
|
48
|
+
/** Epoch ms of a cache row's capture time, or null when unparseable/absent. */
|
|
49
|
+
function capturedAtMs(capturedAt) {
|
|
50
|
+
if (!capturedAt)
|
|
51
|
+
return null;
|
|
52
|
+
const ms = Date.parse(capturedAt);
|
|
53
|
+
return Number.isFinite(ms) ? ms : null;
|
|
54
|
+
}
|
|
39
55
|
/**
|
|
40
56
|
* Decide, per peer, whether to push the local usage snapshot. Pure.
|
|
41
57
|
*
|
|
@@ -109,12 +125,24 @@ export function pullUsageFromPrimary(deps = {}) {
|
|
|
109
125
|
return result;
|
|
110
126
|
}
|
|
111
127
|
const localRows = (deps.exportRows ?? exportClaudeUsageCacheRows)();
|
|
112
|
-
const readRow = deps.readRow ?? readClaudeUsageCache;
|
|
113
128
|
const localEntries = Object.entries(localRows);
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
129
|
+
// Fresh means RECENT, not "the cache equals itself". The prior check read the
|
|
130
|
+
// SAME on-disk cache twice (exportRows() and readRow()) and compared window
|
|
131
|
+
// COUNT, so `fresh.windows.length === row.windows.length` was always true and
|
|
132
|
+
// any non-empty worker cache skipped the pull forever — which is how a worker
|
|
133
|
+
// served a days-old snapshot indefinitely and balanced launched into an
|
|
134
|
+
// account already at its weekly cap. A worker cannot self-read usage, so any
|
|
135
|
+
// row older than the sync cadence is stale and must trigger a pull; the merge
|
|
136
|
+
// is newest-wins + idempotent, so pulling when the primary is no fresher is a
|
|
137
|
+
// harmless no-op.
|
|
138
|
+
const now = (deps.now ?? Date.now)();
|
|
139
|
+
const staleCutoff = now - USAGE_SYNC_MAX_AGE_MS;
|
|
140
|
+
const allFresh = localEntries.length > 0 &&
|
|
141
|
+
localEntries.every(([, row]) => {
|
|
142
|
+
const ms = capturedAtMs(row.capturedAt);
|
|
143
|
+
return ms !== null && ms >= staleCutoff;
|
|
144
|
+
});
|
|
145
|
+
if (allFresh) {
|
|
118
146
|
result.skipped = 'local usage cache is fresh';
|
|
119
147
|
return result;
|
|
120
148
|
}
|
package/dist/lib/feed/events.js
CHANGED
|
@@ -22,6 +22,8 @@ import { stampProvenance, resetEventProvenanceForTest } from '../event-provenanc
|
|
|
22
22
|
/** Lazy perf warehouse write — avoids a hard cycle at module load. */
|
|
23
23
|
function recordPerfTiming(payload) {
|
|
24
24
|
try {
|
|
25
|
+
const phases = payload.phases && Object.keys(payload.phases).length > 0 ? payload.phases : undefined;
|
|
26
|
+
const metaJson = phases ? JSON.stringify({ phases }) : undefined;
|
|
25
27
|
// Dynamic import keeps events.ts free of a load-time dependency on perf/db.
|
|
26
28
|
void import('../perf/spool.js').then(({ recordSample }) => {
|
|
27
29
|
recordSample({
|
|
@@ -33,6 +35,7 @@ function recordPerfTiming(payload) {
|
|
|
33
35
|
agentVersion: payload.version,
|
|
34
36
|
sessionId: payload.sessionId,
|
|
35
37
|
cwd: payload.cwd,
|
|
38
|
+
metaJson,
|
|
36
39
|
});
|
|
37
40
|
}).catch(() => { });
|
|
38
41
|
}
|
|
@@ -693,6 +696,7 @@ export function createTimer(label, payload = {}) {
|
|
|
693
696
|
version: merged.version,
|
|
694
697
|
sessionId: merged.sessionId,
|
|
695
698
|
cwd: merged.cwd,
|
|
699
|
+
phases: marks,
|
|
696
700
|
});
|
|
697
701
|
},
|
|
698
702
|
};
|
package/dist/lib/perf/db.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import Database from '../sqlite.js';
|
|
8
8
|
import type { AggregateOptions, PerfAggregateRow } from './types.js';
|
|
9
|
-
export type { AggregateOptions, PerfAggregateRow, PerfSample } from './types.js';
|
|
9
|
+
export type { AggregateOptions, PerfAggregateRow, PerfPhaseStat, PerfSample } from './types.js';
|
|
10
10
|
export { recordSample, shortSessionId, resolveSpoolPath } from './spool.js';
|
|
11
11
|
export { percentile } from '../percentile.js';
|
|
12
12
|
export declare const PERF_SCHEMA_VERSION = 1;
|
package/dist/lib/perf/db.js
CHANGED
|
@@ -12,6 +12,31 @@ import { localMachineId } from '../origin-machine.js';
|
|
|
12
12
|
import { resolveProjectKey } from '../project-key.js';
|
|
13
13
|
import { percentile } from '../percentile.js';
|
|
14
14
|
import { resolveSpoolPath, shortSessionId, _resetPerfSpoolForTest } from './spool.js';
|
|
15
|
+
/**
|
|
16
|
+
* Parse the `phases` map from a sample's meta_json. Fail-soft: a row with no
|
|
17
|
+
* meta_json, malformed JSON, or a non-numeric phase value contributes nothing
|
|
18
|
+
* rather than throwing (the warehouse must survive any writer's shape).
|
|
19
|
+
*/
|
|
20
|
+
function parsePhases(metaJson) {
|
|
21
|
+
if (!metaJson)
|
|
22
|
+
return undefined;
|
|
23
|
+
let parsed;
|
|
24
|
+
try {
|
|
25
|
+
parsed = JSON.parse(metaJson);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
const phases = parsed?.phases;
|
|
31
|
+
if (!phases || typeof phases !== 'object')
|
|
32
|
+
return undefined;
|
|
33
|
+
const out = {};
|
|
34
|
+
for (const [name, val] of Object.entries(phases)) {
|
|
35
|
+
if (typeof val === 'number' && Number.isFinite(val))
|
|
36
|
+
out[name] = val;
|
|
37
|
+
}
|
|
38
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
39
|
+
}
|
|
15
40
|
export { recordSample, shortSessionId, resolveSpoolPath } from './spool.js';
|
|
16
41
|
export { percentile } from '../percentile.js';
|
|
17
42
|
export const PERF_SCHEMA_VERSION = 1;
|
|
@@ -222,7 +247,7 @@ export function aggregateSamples(opts = {}) {
|
|
|
222
247
|
clauses.push('agent = ?');
|
|
223
248
|
params.push(opts.agent);
|
|
224
249
|
}
|
|
225
|
-
const rows = db.prepare(`SELECT kind, label, duration_ms, cache, exit_code, status, cwd
|
|
250
|
+
const rows = db.prepare(`SELECT kind, label, duration_ms, cache, exit_code, status, cwd, meta_json
|
|
226
251
|
FROM samples WHERE ${clauses.join(' AND ')}`).all(...params);
|
|
227
252
|
// Memoize cwd -> project key: resolveProjectKey walks the filesystem for
|
|
228
253
|
// a repo root, and many rows in one warehouse query share the same cwd.
|
|
@@ -244,10 +269,21 @@ export function aggregateSamples(opts = {}) {
|
|
|
244
269
|
const key = `${r.kind}\0${r.label}`;
|
|
245
270
|
let b = map.get(key);
|
|
246
271
|
if (!b) {
|
|
247
|
-
b = { kind: r.kind, label: r.label, durations: [], hits: 0, stale: 0, misses: 0, errors: 0, blocks: 0, timeouts: 0 };
|
|
272
|
+
b = { kind: r.kind, label: r.label, durations: [], hits: 0, stale: 0, misses: 0, errors: 0, blocks: 0, timeouts: 0, phases: new Map() };
|
|
248
273
|
map.set(key, b);
|
|
249
274
|
}
|
|
250
275
|
b.durations.push(Number(r.duration_ms));
|
|
276
|
+
const phases = parsePhases(r.meta_json);
|
|
277
|
+
if (phases) {
|
|
278
|
+
for (const [name, ms] of Object.entries(phases)) {
|
|
279
|
+
let arr = b.phases.get(name);
|
|
280
|
+
if (!arr) {
|
|
281
|
+
arr = [];
|
|
282
|
+
b.phases.set(name, arr);
|
|
283
|
+
}
|
|
284
|
+
arr.push(ms);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
251
287
|
if (r.cache === 'hit')
|
|
252
288
|
b.hits++;
|
|
253
289
|
else if (r.cache === 'stale-prefetch')
|
|
@@ -301,6 +337,21 @@ export function aggregateSamples(opts = {}) {
|
|
|
301
337
|
}
|
|
302
338
|
if (b.timeouts > 0)
|
|
303
339
|
row.timeoutRate = Math.round((b.timeouts / n) * 1000) / 1000;
|
|
340
|
+
if (b.phases.size > 0) {
|
|
341
|
+
const phases = {};
|
|
342
|
+
for (const [name, durs] of b.phases) {
|
|
343
|
+
if (durs.length === 0)
|
|
344
|
+
continue;
|
|
345
|
+
const ps = durs.slice().sort((a, c) => a - c);
|
|
346
|
+
phases[name] = {
|
|
347
|
+
n: ps.length,
|
|
348
|
+
p50Ms: Math.round(percentile(ps, 50)),
|
|
349
|
+
p90Ms: Math.round(percentile(ps, 90)),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
if (Object.keys(phases).length > 0)
|
|
353
|
+
row.phases = phases;
|
|
354
|
+
}
|
|
304
355
|
if (opts.project)
|
|
305
356
|
row.project = opts.project;
|
|
306
357
|
out.push(row);
|
package/dist/lib/perf/types.d.ts
CHANGED
|
@@ -21,6 +21,13 @@ export interface PerfSample {
|
|
|
21
21
|
status?: string;
|
|
22
22
|
metaJson?: string;
|
|
23
23
|
}
|
|
24
|
+
/** Percentiles for one sub-phase (e.g. agent.run's `startup`) across a bucket. */
|
|
25
|
+
export interface PerfPhaseStat {
|
|
26
|
+
/** Samples that carried this phase (may be < the row's `n`). */
|
|
27
|
+
n: number;
|
|
28
|
+
p50Ms: number;
|
|
29
|
+
p90Ms: number;
|
|
30
|
+
}
|
|
24
31
|
export interface PerfAggregateRow {
|
|
25
32
|
kind: string;
|
|
26
33
|
label: string;
|
|
@@ -31,6 +38,13 @@ export interface PerfAggregateRow {
|
|
|
31
38
|
meanMs: number;
|
|
32
39
|
maxMs: number;
|
|
33
40
|
minMs: number;
|
|
41
|
+
/**
|
|
42
|
+
* Per-sub-phase percentiles parsed from each sample's meta_json `phases`.
|
|
43
|
+
* Present only when at least one sample in the bucket recorded a phase
|
|
44
|
+
* (agent.run records `startup` — time from spawnAgent entry to child spawn —
|
|
45
|
+
* so boot cost is trackable independently of the total run, PHNX-3468).
|
|
46
|
+
*/
|
|
47
|
+
phases?: Record<string, PerfPhaseStat>;
|
|
34
48
|
cacheHitPct?: number;
|
|
35
49
|
cacheStalePct?: number;
|
|
36
50
|
cacheMissPct?: number;
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
// exist. It parses no transcript and renders nothing — it only reshapes process
|
|
16
16
|
// state the live registry already computed. When the transcript IS on disk, the
|
|
17
17
|
// synthesized row carries its path, so the downstream `buildPreview` renders the
|
|
18
|
-
// real digest
|
|
18
|
+
// real digest. Without a path, a peer-owned row fetches its digest from that peer;
|
|
19
|
+
// a genuinely local row renders the header + a "not indexed here" note.
|
|
19
20
|
import { deriveShortId } from '../text/short-id.js';
|
|
20
21
|
import { isSessionTrackedAgent } from './types.js';
|
|
21
22
|
/**
|
|
@@ -50,9 +51,8 @@ export function activeSessionToSessionMeta(active, self, nowMs) {
|
|
|
50
51
|
topic: active.topic,
|
|
51
52
|
version: active.version,
|
|
52
53
|
messageCount: undefined,
|
|
53
|
-
// The row runs HERE — the local live registry is machine-local, so a match
|
|
54
|
-
// resolves without an SSH hop back to a peer.
|
|
55
54
|
machine: active.machine ?? self,
|
|
55
|
+
_remote: (active.machine ?? self) !== self,
|
|
56
56
|
ticketId: active.ticket?.id,
|
|
57
57
|
prUrl: active.pr?.url,
|
|
58
58
|
prNumber: active.pr?.number,
|
package/dist/lib/teams/api.d.ts
CHANGED
|
@@ -62,6 +62,8 @@ export interface AgentStatusDetail {
|
|
|
62
62
|
task_type?: TaskType | null;
|
|
63
63
|
/** Device name the teammate runs on for a distributed (--on) teammate; null for local. */
|
|
64
64
|
host?: string | null;
|
|
65
|
+
/** Absolute path to the teammate's worktree, when known. */
|
|
66
|
+
workspace_dir?: string | null;
|
|
65
67
|
/** Sanitized evidence observed at the lifecycle boundary that failed. */
|
|
66
68
|
failure?: TeammateFailure | null;
|
|
67
69
|
}
|
|
@@ -73,6 +75,7 @@ export interface TaskStatusResult {
|
|
|
73
75
|
pending: number;
|
|
74
76
|
running: number;
|
|
75
77
|
completed: number;
|
|
78
|
+
stranded: number;
|
|
76
79
|
failed: number;
|
|
77
80
|
stopped: number;
|
|
78
81
|
};
|
|
@@ -126,6 +129,8 @@ export interface AgentStatusSummary {
|
|
|
126
129
|
/** Device name for a distributed (--on) teammate; null for local. */
|
|
127
130
|
host: string | null;
|
|
128
131
|
failure: TeammateFailure | null;
|
|
132
|
+
/** Absolute path to the teammate's worktree, when known. */
|
|
133
|
+
workspace_dir?: string | null;
|
|
129
134
|
/** ISO timestamp — feed back via --since for delta polling. */
|
|
130
135
|
cursor: string;
|
|
131
136
|
}
|
|
@@ -137,6 +142,7 @@ export interface TaskStatusSummaryResult {
|
|
|
137
142
|
pending: number;
|
|
138
143
|
running: number;
|
|
139
144
|
completed: number;
|
|
145
|
+
stranded: number;
|
|
140
146
|
failed: number;
|
|
141
147
|
stopped: number;
|
|
142
148
|
};
|
|
@@ -164,6 +170,8 @@ export interface TaskInfo {
|
|
|
164
170
|
pending: number;
|
|
165
171
|
running: number;
|
|
166
172
|
completed: number;
|
|
173
|
+
/** Completed teammates with uncommitted work and no PR (PHNX-2951). */
|
|
174
|
+
stranded: number;
|
|
167
175
|
failed: number;
|
|
168
176
|
stopped: number;
|
|
169
177
|
workspace_dir: string | null;
|
package/dist/lib/teams/api.js
CHANGED
|
@@ -3,6 +3,7 @@ import { getDelta } from './summarizer.js';
|
|
|
3
3
|
import { debug } from './debug.js';
|
|
4
4
|
import { buildClaudeLabelMap } from '../session/discover.js';
|
|
5
5
|
import { resolveTeammateDelivery } from './delivery.js';
|
|
6
|
+
import { hasUncommittedChanges } from './worktree.js';
|
|
6
7
|
/**
|
|
7
8
|
* Truncate a bash command for status output.
|
|
8
9
|
* Handles heredocs specially - shows the redirect target instead of contents.
|
|
@@ -126,6 +127,7 @@ export function toAgentStatusSummary(detail) {
|
|
|
126
127
|
.map((m) => trimMessage(m)),
|
|
127
128
|
host: detail.host ?? null,
|
|
128
129
|
failure: detail.failure ?? null,
|
|
130
|
+
workspace_dir: detail.workspace_dir ?? null,
|
|
129
131
|
cursor: detail.cursor,
|
|
130
132
|
};
|
|
131
133
|
}
|
|
@@ -207,7 +209,7 @@ parentSessionId) {
|
|
|
207
209
|
? allAgents
|
|
208
210
|
: allAgents.filter((a) => a.status === effectiveFilter);
|
|
209
211
|
const agentStatuses = [];
|
|
210
|
-
const counts = { pending: 0, running: 0, completed: 0, failed: 0, stopped: 0 };
|
|
212
|
+
const counts = { pending: 0, running: 0, completed: 0, stranded: 0, failed: 0, stopped: 0 };
|
|
211
213
|
// Count ALL agents for summary (not just filtered)
|
|
212
214
|
for (const agent of allAgents) {
|
|
213
215
|
if (agent.status === AgentStatus.PENDING)
|
|
@@ -221,6 +223,30 @@ parentSessionId) {
|
|
|
221
223
|
else if (agent.status === AgentStatus.STOPPED)
|
|
222
224
|
counts.stopped++;
|
|
223
225
|
}
|
|
226
|
+
// Stranded count is computed over ALL agents, not the filter-narrowed view,
|
|
227
|
+
// so a `--filter running` status still reports teammates that completed dirty.
|
|
228
|
+
// Cache the probe result so we can reuse it when building details for the
|
|
229
|
+
// filtered subset without probing the worktree twice.
|
|
230
|
+
const uncommittedCache = new Map();
|
|
231
|
+
for (const agent of allAgents) {
|
|
232
|
+
if (agent.status !== AgentStatus.COMPLETED)
|
|
233
|
+
continue;
|
|
234
|
+
const shouldProbeWorktree = !agent.prUrl?.trim() &&
|
|
235
|
+
!agent.hostName &&
|
|
236
|
+
Boolean(agent.workspaceDir);
|
|
237
|
+
const hasUncommitted = shouldProbeWorktree
|
|
238
|
+
? await hasUncommittedChanges(agent.workspaceDir)
|
|
239
|
+
: false;
|
|
240
|
+
uncommittedCache.set(agent.agentId, hasUncommitted);
|
|
241
|
+
const delivery = resolveTeammateDelivery({
|
|
242
|
+
status: agent.status,
|
|
243
|
+
prUrl: agent.prUrl,
|
|
244
|
+
hasUncommittedChanges: hasUncommitted,
|
|
245
|
+
});
|
|
246
|
+
if (delivery === 'stranded') {
|
|
247
|
+
counts.stranded++;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
224
250
|
// Build details only for filtered agents
|
|
225
251
|
let maxTimestamp = since || new Date(0).toISOString(); // Track max timestamp for cursor
|
|
226
252
|
const claudeLabels = allAgents.some((agent) => agent.agentType === 'claude')
|
|
@@ -237,6 +263,12 @@ parentSessionId) {
|
|
|
237
263
|
if (agentTimestamp > maxTimestamp) {
|
|
238
264
|
maxTimestamp = agentTimestamp;
|
|
239
265
|
}
|
|
266
|
+
const hasUncommitted = uncommittedCache.get(agent.agentId) ?? false;
|
|
267
|
+
const delivery = resolveTeammateDelivery({
|
|
268
|
+
status: agent.status,
|
|
269
|
+
prUrl: agent.prUrl,
|
|
270
|
+
hasUncommittedChanges: hasUncommitted,
|
|
271
|
+
});
|
|
240
272
|
const detail = {
|
|
241
273
|
agent_id: agent.agentId,
|
|
242
274
|
agent_type: agent.agentType,
|
|
@@ -254,15 +286,13 @@ parentSessionId) {
|
|
|
254
286
|
after: agent.after,
|
|
255
287
|
task_type: agent.taskType,
|
|
256
288
|
host: agent.hostName,
|
|
289
|
+
workspace_dir: agent.workspaceDir,
|
|
257
290
|
failure: agent.failure,
|
|
258
291
|
mode: agent.mode,
|
|
259
292
|
cloud_session_id: agent.cloudSessionId,
|
|
260
293
|
cloud_provider: agent.cloudProvider,
|
|
261
294
|
pr_url: agent.prUrl,
|
|
262
|
-
delivery
|
|
263
|
-
status: agent.status,
|
|
264
|
-
prUrl: agent.prUrl,
|
|
265
|
-
}),
|
|
295
|
+
delivery,
|
|
266
296
|
files_created: delta.new_files_created,
|
|
267
297
|
files_modified: delta.new_files_modified,
|
|
268
298
|
files_read: delta.new_files_read,
|
|
@@ -297,7 +327,7 @@ export async function handleTasks(manager, limit = 10) {
|
|
|
297
327
|
}
|
|
298
328
|
const tasks = [];
|
|
299
329
|
for (const [taskName, agents] of taskMap) {
|
|
300
|
-
let pending = 0, running = 0, completed = 0, failed = 0, stopped = 0;
|
|
330
|
+
let pending = 0, running = 0, completed = 0, stranded = 0, failed = 0, stopped = 0;
|
|
301
331
|
let earliestStart = null;
|
|
302
332
|
let latestActivity = null;
|
|
303
333
|
let workspaceDir = null;
|
|
@@ -313,6 +343,19 @@ export async function handleTasks(manager, limit = 10) {
|
|
|
313
343
|
failed++;
|
|
314
344
|
else if (agent.status === AgentStatus.STOPPED)
|
|
315
345
|
stopped++;
|
|
346
|
+
// Stranded = completed, no PR, local worktree still dirty. Probes the real
|
|
347
|
+
// worktree so `teams tasks` and `teams list --status` don't classify lost
|
|
348
|
+
// work as done (PHNX-2951).
|
|
349
|
+
if (agent.status === AgentStatus.COMPLETED && !agent.prUrl?.trim() && !agent.hostName && agent.workspaceDir) {
|
|
350
|
+
const delivery = resolveTeammateDelivery({
|
|
351
|
+
status: agent.status,
|
|
352
|
+
prUrl: agent.prUrl,
|
|
353
|
+
hasUncommittedChanges: await hasUncommittedChanges(agent.workspaceDir),
|
|
354
|
+
});
|
|
355
|
+
if (delivery === 'stranded') {
|
|
356
|
+
stranded++;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
316
359
|
// Track earliest start (created_at)
|
|
317
360
|
if (!earliestStart || agent.startedAt < earliestStart) {
|
|
318
361
|
earliestStart = agent.startedAt;
|
|
@@ -336,6 +379,7 @@ export async function handleTasks(manager, limit = 10) {
|
|
|
336
379
|
pending,
|
|
337
380
|
running,
|
|
338
381
|
completed,
|
|
382
|
+
stranded,
|
|
339
383
|
failed,
|
|
340
384
|
stopped,
|
|
341
385
|
workspace_dir: workspaceDir,
|
|
@@ -12,13 +12,17 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { AgentStatus } from './agents.js';
|
|
14
14
|
/** Postcondition-facing delivery of a teammate's work. */
|
|
15
|
-
export type TeammateDelivery = 'pending' | 'in_progress' | 'pr_open' | 'pr_merged' | 'no_pr' | 'failed' | 'stopped';
|
|
15
|
+
export type TeammateDelivery = 'pending' | 'in_progress' | 'pr_open' | 'pr_merged' | 'no_pr' | 'stranded' | 'failed' | 'stopped';
|
|
16
16
|
/**
|
|
17
17
|
* Derive delivery from process status and PR state.
|
|
18
18
|
*
|
|
19
19
|
* When the process completed with a `prUrl` and merge is unknown or false,
|
|
20
20
|
* delivery is `pr_open` — pessimistic: assume open until proven merged so an
|
|
21
21
|
* orchestrator never mistakes "agent stopped" for "work on main".
|
|
22
|
+
*
|
|
23
|
+
* When the process completed with no PR and uncommitted changes remain in the
|
|
24
|
+
* worktree, delivery is `stranded` — the work exists only locally and will be
|
|
25
|
+
* lost if the worktree is cleaned up (PHNX-2951).
|
|
22
26
|
*/
|
|
23
27
|
export declare function resolveTeammateDelivery(opts: {
|
|
24
28
|
status: AgentStatus | string;
|
|
@@ -28,14 +32,20 @@ export declare function resolveTeammateDelivery(opts: {
|
|
|
28
32
|
* `null`/`undefined` = unknown; with a `prUrl` that means `pr_open`.
|
|
29
33
|
*/
|
|
30
34
|
prMerged?: boolean | null;
|
|
35
|
+
/**
|
|
36
|
+
* Whether the teammate's worktree has uncommitted changes. Only consulted
|
|
37
|
+
* when the process completed without a PR URL.
|
|
38
|
+
*/
|
|
39
|
+
hasUncommittedChanges?: boolean | null;
|
|
31
40
|
}): TeammateDelivery;
|
|
32
41
|
/**
|
|
33
42
|
* Human label for `teams status` rows. Replaces bare COMPLETED with PR OPEN
|
|
34
|
-
* when delivery is still pending merge
|
|
43
|
+
* when delivery is still pending merge, and with STRANDED when uncommitted
|
|
44
|
+
* work is stranded in the worktree.
|
|
35
45
|
*/
|
|
36
46
|
export declare function deliveryDisplayLabel(delivery: TeammateDelivery, processStatus: AgentStatus | string): string;
|
|
37
47
|
/**
|
|
38
|
-
* Color key for statusColor-style switches. `pr_open`
|
|
39
|
-
*
|
|
48
|
+
* Color key for statusColor-style switches. `pr_open` and `stranded` get their
|
|
49
|
+
* own keys so the rows are visually distinct from green COMPLETED.
|
|
40
50
|
*/
|
|
41
51
|
export declare function deliveryColorKey(delivery: TeammateDelivery, processStatus: string): string;
|
|
@@ -17,6 +17,10 @@ import { AgentStatus } from './agents.js';
|
|
|
17
17
|
* When the process completed with a `prUrl` and merge is unknown or false,
|
|
18
18
|
* delivery is `pr_open` — pessimistic: assume open until proven merged so an
|
|
19
19
|
* orchestrator never mistakes "agent stopped" for "work on main".
|
|
20
|
+
*
|
|
21
|
+
* When the process completed with no PR and uncommitted changes remain in the
|
|
22
|
+
* worktree, delivery is `stranded` — the work exists only locally and will be
|
|
23
|
+
* lost if the worktree is cleaned up (PHNX-2951).
|
|
20
24
|
*/
|
|
21
25
|
export function resolveTeammateDelivery(opts) {
|
|
22
26
|
const status = String(opts.status);
|
|
@@ -30,8 +34,9 @@ export function resolveTeammateDelivery(opts) {
|
|
|
30
34
|
return 'stopped';
|
|
31
35
|
if (status === AgentStatus.COMPLETED || status === 'completed') {
|
|
32
36
|
const prUrl = opts.prUrl?.trim();
|
|
33
|
-
if (!prUrl)
|
|
34
|
-
return 'no_pr';
|
|
37
|
+
if (!prUrl) {
|
|
38
|
+
return opts.hasUncommittedChanges ? 'stranded' : 'no_pr';
|
|
39
|
+
}
|
|
35
40
|
if (opts.prMerged === true)
|
|
36
41
|
return 'pr_merged';
|
|
37
42
|
return 'pr_open';
|
|
@@ -40,21 +45,26 @@ export function resolveTeammateDelivery(opts) {
|
|
|
40
45
|
}
|
|
41
46
|
/**
|
|
42
47
|
* Human label for `teams status` rows. Replaces bare COMPLETED with PR OPEN
|
|
43
|
-
* when delivery is still pending merge
|
|
48
|
+
* when delivery is still pending merge, and with STRANDED when uncommitted
|
|
49
|
+
* work is stranded in the worktree.
|
|
44
50
|
*/
|
|
45
51
|
export function deliveryDisplayLabel(delivery, processStatus) {
|
|
46
52
|
if (delivery === 'pr_open')
|
|
47
53
|
return 'PR OPEN';
|
|
48
54
|
if (delivery === 'pr_merged')
|
|
49
55
|
return 'COMPLETED';
|
|
56
|
+
if (delivery === 'stranded')
|
|
57
|
+
return 'STRANDED';
|
|
50
58
|
return String(processStatus).toUpperCase();
|
|
51
59
|
}
|
|
52
60
|
/**
|
|
53
|
-
* Color key for statusColor-style switches. `pr_open`
|
|
54
|
-
*
|
|
61
|
+
* Color key for statusColor-style switches. `pr_open` and `stranded` get their
|
|
62
|
+
* own keys so the rows are visually distinct from green COMPLETED.
|
|
55
63
|
*/
|
|
56
64
|
export function deliveryColorKey(delivery, processStatus) {
|
|
57
65
|
if (delivery === 'pr_open')
|
|
58
66
|
return 'pr_open';
|
|
67
|
+
if (delivery === 'stranded')
|
|
68
|
+
return 'stranded';
|
|
59
69
|
return String(processStatus);
|
|
60
70
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phnx-labs/agents-cli",
|
|
3
|
-
"version": "1.22.
|
|
3
|
+
"version": "1.22.60",
|
|
4
4
|
"description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|