@phnx-labs/agents-cli 1.20.85 → 1.20.87
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 +168 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/events.d.ts +16 -0
- package/dist/commands/events.js +44 -5
- package/dist/commands/models.js +1 -1
- package/dist/commands/sessions-browser.d.ts +18 -0
- package/dist/commands/sessions-browser.js +126 -24
- package/dist/commands/sessions-picker.d.ts +21 -8
- package/dist/commands/sessions-picker.js +88 -11
- package/dist/commands/sessions.d.ts +19 -0
- package/dist/commands/sessions.js +147 -18
- package/dist/commands/ssh.js +59 -1
- package/dist/commands/teams-picker.d.ts +2 -0
- package/dist/commands/teams-picker.js +2 -1
- package/dist/commands/teams.d.ts +4 -1
- package/dist/commands/teams.js +106 -70
- package/dist/commands/view.js +14 -3
- package/dist/index.js +31 -1
- package/dist/lib/claude-account-token.d.ts +12 -0
- package/dist/lib/claude-account-token.js +63 -0
- package/dist/lib/devices/registry.d.ts +25 -0
- package/dist/lib/devices/registry.js +82 -1
- package/dist/lib/events.d.ts +8 -1
- package/dist/lib/events.js +13 -0
- package/dist/lib/exec.js +10 -1
- package/dist/lib/format.d.ts +7 -0
- package/dist/lib/format.js +11 -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/models.d.ts +21 -0
- package/dist/lib/models.js +133 -4
- 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/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +2 -2
- package/dist/lib/session/db.d.ts +21 -0
- package/dist/lib/session/db.js +45 -4
- package/dist/lib/session/parse.d.ts +11 -0
- package/dist/lib/session/parse.js +24 -7
- package/dist/lib/session/remote-list.d.ts +7 -0
- package/dist/lib/session/remote-list.js +8 -4
- package/dist/lib/session/state.d.ts +6 -5
- package/dist/lib/session/state.js +84 -11
- package/dist/lib/session/team-filter.d.ts +22 -3
- package/dist/lib/session/team-filter.js +106 -17
- package/dist/lib/session/types.d.ts +8 -0
- package/dist/lib/signin-badge.d.ts +17 -0
- package/dist/lib/signin-badge.js +19 -0
- package/dist/lib/state.d.ts +2 -0
- package/dist/lib/state.js +2 -0
- package/dist/lib/usage.js +1 -60
- package/package.json +1 -1
package/dist/lib/session/db.js
CHANGED
|
@@ -16,8 +16,10 @@ import { machineForSessionFile } from './origin-machine.js';
|
|
|
16
16
|
import { loadSessionActorIndex, readSessionActorRecord } from './actor-sidecar.js';
|
|
17
17
|
const SESSIONS_DIR = getSessionsDir();
|
|
18
18
|
const DB_PATH = getSessionsDbPath();
|
|
19
|
-
/** Current schema version; bumped when migrations are added.
|
|
20
|
-
|
|
19
|
+
/** Current schema version; bumped when migrations are added. Exported so tests
|
|
20
|
+
* assert against the constant instead of hardcoding a number that every bump
|
|
21
|
+
* then has to chase (docs/05-sessions.md calls the constant the source of truth). */
|
|
22
|
+
export const SCHEMA_VERSION = 21;
|
|
21
23
|
/**
|
|
22
24
|
* Canonicalize a file path for use as a scan_ledger key. The same physical
|
|
23
25
|
* session file is reachable via multiple aliases — `~/.claude/projects/x.jsonl`
|
|
@@ -74,6 +76,7 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|
|
74
76
|
pr_number INTEGER,
|
|
75
77
|
worktree_slug TEXT,
|
|
76
78
|
ticket_id TEXT,
|
|
79
|
+
spawned_team TEXT,
|
|
77
80
|
plan TEXT,
|
|
78
81
|
machine TEXT,
|
|
79
82
|
todos TEXT,
|
|
@@ -383,6 +386,19 @@ function migrateSchema(db, fromVersion) {
|
|
|
383
386
|
db.exec(`ALTER TABLE sessions ADD COLUMN model TEXT`);
|
|
384
387
|
db.exec(`DELETE FROM scan_ledger; DELETE FROM dir_ledger;`);
|
|
385
388
|
}
|
|
389
|
+
if (fromVersion < 21) {
|
|
390
|
+
// v20 → v21: persist the team a session SPAWNED (`agents teams create/add`).
|
|
391
|
+
// The value was already derived at scan time (discover.ts detectSpawnedTeam)
|
|
392
|
+
// and set on SessionMeta, but had no column — so it was dropped at the write
|
|
393
|
+
// and no consumer ever saw it. Wipe BOTH ledgers, not just scan_ledger: with
|
|
394
|
+
// dir_ledger intact, collectChangedFilesInLeafDirs treats every non-live-root
|
|
395
|
+
// dir as unchanged and derives its hot set from the scan stamps just deleted,
|
|
396
|
+
// so archived dirs would never be re-parsed and would stay NULL forever.
|
|
397
|
+
const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
|
|
398
|
+
if (!cols.some(c => c.name === 'spawned_team'))
|
|
399
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN spawned_team TEXT`);
|
|
400
|
+
db.exec(`DELETE FROM scan_ledger; DELETE FROM dir_ledger;`);
|
|
401
|
+
}
|
|
386
402
|
}
|
|
387
403
|
/** Open (or return the cached) sessions database, applying migrations as needed. */
|
|
388
404
|
export function getDB() {
|
|
@@ -744,7 +760,7 @@ const upsertSessionStmt = (db) => db.prepare(`
|
|
|
744
760
|
project, cwd, git_branch, topic, label, message_count, token_count,
|
|
745
761
|
output_tokens, cost_usd, duration_ms, model,
|
|
746
762
|
file_path, file_mtime_ms, file_size, scanned_at, is_team_origin,
|
|
747
|
-
pr_url, pr_number, worktree_slug, ticket_id, plan, todos,
|
|
763
|
+
pr_url, pr_number, worktree_slug, ticket_id, spawned_team, plan, todos,
|
|
748
764
|
recent_directories_touched, linear_project, linear_project_url, machine,
|
|
749
765
|
actor, initiated_by
|
|
750
766
|
) VALUES (
|
|
@@ -753,7 +769,7 @@ const upsertSessionStmt = (db) => db.prepare(`
|
|
|
753
769
|
@project, @cwd, @git_branch, @topic, @label, @message_count, @token_count,
|
|
754
770
|
@output_tokens, @cost_usd, @duration_ms, @model,
|
|
755
771
|
@file_path, @file_mtime_ms, @file_size, @scanned_at, @is_team_origin,
|
|
756
|
-
@pr_url, @pr_number, @worktree_slug, @ticket_id, @plan, @todos,
|
|
772
|
+
@pr_url, @pr_number, @worktree_slug, @ticket_id, @spawned_team, @plan, @todos,
|
|
757
773
|
@recent_directories_touched, @linear_project, @linear_project_url, @machine,
|
|
758
774
|
@actor, @initiated_by
|
|
759
775
|
)
|
|
@@ -795,6 +811,7 @@ const upsertSessionStmt = (db) => db.prepare(`
|
|
|
795
811
|
pr_number = excluded.pr_number,
|
|
796
812
|
worktree_slug = excluded.worktree_slug,
|
|
797
813
|
ticket_id = excluded.ticket_id,
|
|
814
|
+
spawned_team = excluded.spawned_team,
|
|
798
815
|
plan = excluded.plan,
|
|
799
816
|
todos = excluded.todos,
|
|
800
817
|
recent_directories_touched = excluded.recent_directories_touched,
|
|
@@ -911,6 +928,7 @@ export function upsertSession(meta, content, scan) {
|
|
|
911
928
|
pr_number: meta.prNumber ?? null,
|
|
912
929
|
worktree_slug: meta.worktreeSlug ?? null,
|
|
913
930
|
ticket_id: meta.ticketId ?? null,
|
|
931
|
+
spawned_team: meta.spawnedTeam ?? null,
|
|
914
932
|
plan: meta.plan ?? null,
|
|
915
933
|
todos: meta.todos ? JSON.stringify(meta.todos) : null,
|
|
916
934
|
recent_directories_touched: meta.recentDirectoriesTouched ? JSON.stringify(meta.recentDirectoriesTouched) : null,
|
|
@@ -1031,6 +1049,7 @@ export function upsertSessionsBatch(entries) {
|
|
|
1031
1049
|
pr_number: meta.prNumber ?? null,
|
|
1032
1050
|
worktree_slug: meta.worktreeSlug ?? null,
|
|
1033
1051
|
ticket_id: meta.ticketId ?? null,
|
|
1052
|
+
spawned_team: meta.spawnedTeam ?? null,
|
|
1034
1053
|
plan: meta.plan ?? null,
|
|
1035
1054
|
todos: meta.todos ? JSON.stringify(meta.todos) : null,
|
|
1036
1055
|
recent_directories_touched: meta.recentDirectoriesTouched ? JSON.stringify(meta.recentDirectoriesTouched) : null,
|
|
@@ -1219,6 +1238,7 @@ function rowToMeta(row) {
|
|
|
1219
1238
|
prNumber: row.pr_number ?? undefined,
|
|
1220
1239
|
worktreeSlug: row.worktree_slug ?? undefined,
|
|
1221
1240
|
ticketId: row.ticket_id ?? undefined,
|
|
1241
|
+
spawnedTeam: row.spawned_team ?? undefined,
|
|
1222
1242
|
plan: row.plan ?? undefined,
|
|
1223
1243
|
todos: parseJsonColumn(row.todos),
|
|
1224
1244
|
recentDirectoriesTouched: parseJsonColumn(row.recent_directories_touched),
|
|
@@ -1483,6 +1503,27 @@ export function queryUsageRollup(options) {
|
|
|
1483
1503
|
`;
|
|
1484
1504
|
return db.prepare(sql).all(...params);
|
|
1485
1505
|
}
|
|
1506
|
+
/**
|
|
1507
|
+
* Map every team name to the session that ran `agents teams create/add` for it.
|
|
1508
|
+
*
|
|
1509
|
+
* One scan over the rows that carry a `spawned_team`, rather than a query per
|
|
1510
|
+
* team — `agents teams list` needs the whole map at once, and the column has no
|
|
1511
|
+
* index. When two sessions spawned the same team name (a team re-created after a
|
|
1512
|
+
* disband), the most recent wins, which is the one whose work the name refers to.
|
|
1513
|
+
*/
|
|
1514
|
+
export function teamSpawners() {
|
|
1515
|
+
const db = getDB();
|
|
1516
|
+
const rows = db
|
|
1517
|
+
.prepare(`SELECT spawned_team, id, short_id, actor FROM sessions
|
|
1518
|
+
WHERE spawned_team IS NOT NULL AND spawned_team != ''
|
|
1519
|
+
ORDER BY timestamp ASC`)
|
|
1520
|
+
.all();
|
|
1521
|
+
const out = new Map();
|
|
1522
|
+
for (const r of rows) {
|
|
1523
|
+
out.set(r.spawned_team, { sessionId: r.id, shortId: r.short_id, actor: r.actor ?? undefined });
|
|
1524
|
+
}
|
|
1525
|
+
return out;
|
|
1526
|
+
}
|
|
1486
1527
|
/**
|
|
1487
1528
|
* Return the N most expensive sessions (cost_usd DESC, NULLs excluded),
|
|
1488
1529
|
* honoring the same filter shape as querySessions. Drops rows whose JSONL
|
|
@@ -27,6 +27,17 @@ export declare function safeReadSessionFile(filePath: string, maxBytes?: number)
|
|
|
27
27
|
export declare function parseSession(filePath: string, agent?: SessionAgentId): SessionEvent[];
|
|
28
28
|
/** Infer the agent type from a session file path using known directory conventions. */
|
|
29
29
|
export declare function detectAgent(filePath: string): SessionAgentId | null;
|
|
30
|
+
/**
|
|
31
|
+
* Checklist-snapshot tool names across harnesses — each sends the WHOLE list on
|
|
32
|
+
* every write, so the last call is the current checklist. Claude `TodoWrite`,
|
|
33
|
+
* Kimi `TodoList`, Droid/OpenCode `todo_write`, Codex `update_plan`.
|
|
34
|
+
*/
|
|
35
|
+
export declare const SNAPSHOT_TODO_TOOLS: Set<string>;
|
|
36
|
+
/**
|
|
37
|
+
* Whether a harness's checklist status means "finished". Claude/Codex write
|
|
38
|
+
* `completed`; Kimi writes `done`.
|
|
39
|
+
*/
|
|
40
|
+
export declare function isCompletedTodoStatus(status: unknown): boolean;
|
|
30
41
|
/**
|
|
31
42
|
* Summarize a tool_use into a one-liner string.
|
|
32
43
|
*/
|
|
@@ -205,6 +205,19 @@ export function detectAgent(filePath) {
|
|
|
205
205
|
return 'gemini';
|
|
206
206
|
return null;
|
|
207
207
|
}
|
|
208
|
+
/**
|
|
209
|
+
* Checklist-snapshot tool names across harnesses — each sends the WHOLE list on
|
|
210
|
+
* every write, so the last call is the current checklist. Claude `TodoWrite`,
|
|
211
|
+
* Kimi `TodoList`, Droid/OpenCode `todo_write`, Codex `update_plan`.
|
|
212
|
+
*/
|
|
213
|
+
export const SNAPSHOT_TODO_TOOLS = new Set(['TodoWrite', 'TodoList', 'todo_write', 'update_plan']);
|
|
214
|
+
/**
|
|
215
|
+
* Whether a harness's checklist status means "finished". Claude/Codex write
|
|
216
|
+
* `completed`; Kimi writes `done`.
|
|
217
|
+
*/
|
|
218
|
+
export function isCompletedTodoStatus(status) {
|
|
219
|
+
return status === 'completed' || status === 'done';
|
|
220
|
+
}
|
|
208
221
|
/**
|
|
209
222
|
* Summarize a tool_use into a one-liner string.
|
|
210
223
|
*/
|
|
@@ -214,12 +227,13 @@ export function summarizeToolUse(tool, args) {
|
|
|
214
227
|
switch (tool) {
|
|
215
228
|
case 'Bash':
|
|
216
229
|
return `Bash: ${truncate(String(args.command || '').replace(/\n/g, ' ').trim(), 120)}`;
|
|
230
|
+
// `path` is the Kimi spelling of Claude's `file_path` for the same tools.
|
|
217
231
|
case 'Read':
|
|
218
|
-
return `Read ${shortenPath(args.file_path || '')}`;
|
|
232
|
+
return `Read ${shortenPath(args.file_path || args.path || '')}`;
|
|
219
233
|
case 'Write':
|
|
220
|
-
return `Write ${shortenPath(args.file_path || '')}`;
|
|
234
|
+
return `Write ${shortenPath(args.file_path || args.path || '')}`;
|
|
221
235
|
case 'Edit':
|
|
222
|
-
return `Edit ${shortenPath(args.file_path || '')}`;
|
|
236
|
+
return `Edit ${shortenPath(args.file_path || args.path || '')}`;
|
|
223
237
|
case 'Glob':
|
|
224
238
|
return `Glob ${args.pattern || ''}`;
|
|
225
239
|
case 'Grep':
|
|
@@ -234,14 +248,17 @@ export function summarizeToolUse(tool, args) {
|
|
|
234
248
|
const steps = Array.isArray(args.plan) ? args.plan.length : 0;
|
|
235
249
|
return `Plan: ${steps} step${steps === 1 ? '' : 's'}`;
|
|
236
250
|
}
|
|
237
|
-
//
|
|
238
|
-
|
|
251
|
+
// Live checklist: show progress + the current step, not a bare "TodoWrite".
|
|
252
|
+
// Claude writes `TodoWrite`, Kimi writes `TodoList`; both carry the whole list
|
|
253
|
+
// under `todos`, with Kimi spelling the item text `title` and "done" `done`.
|
|
254
|
+
case 'TodoWrite':
|
|
255
|
+
case 'TodoList': {
|
|
239
256
|
const todos = Array.isArray(args.todos) ? args.todos : [];
|
|
240
257
|
if (todos.length === 0)
|
|
241
258
|
return 'Plan: 0 steps';
|
|
242
|
-
const done = todos.filter((t) => t?.status
|
|
259
|
+
const done = todos.filter((t) => isCompletedTodoStatus(t?.status)).length;
|
|
243
260
|
const active = todos.find((t) => t?.status === 'in_progress');
|
|
244
|
-
const step = active?.activeForm || active?.content;
|
|
261
|
+
const step = active?.activeForm || active?.content || active?.title;
|
|
245
262
|
return step
|
|
246
263
|
? `Plan ${done}/${todos.length}: ${truncate(String(step), 80)}`
|
|
247
264
|
: `Plan: ${done}/${todos.length} done`;
|
|
@@ -20,6 +20,13 @@ export interface RemoteListResult {
|
|
|
20
20
|
sessions: SessionMeta[];
|
|
21
21
|
/** How many peer machines we attempted to reach (drives the empty-fleet tip). */
|
|
22
22
|
deviceCount: number;
|
|
23
|
+
/**
|
|
24
|
+
* Peers that failed to answer, by display name. The stderr note above is
|
|
25
|
+
* enough for a printed listing, but the interactive browser repaints over it —
|
|
26
|
+
* so callers rendering a full-screen UI need the outcome as data to tell
|
|
27
|
+
* "that box is asleep" apart from "that box has no matching sessions".
|
|
28
|
+
*/
|
|
29
|
+
unreachable: string[];
|
|
23
30
|
}
|
|
24
31
|
/**
|
|
25
32
|
* Gather listing sessions from other machines. With an explicit `hosts` list
|
|
@@ -97,9 +97,9 @@ async function fetchByTarget(target, machine, display, forwardedArgs, os) {
|
|
|
97
97
|
const { code, stdout } = await sshCapture(target, remoteListCommand(forwardedArgs, os), REMOTE_TIMEOUT_MS);
|
|
98
98
|
if (code !== 0) {
|
|
99
99
|
process.stderr.write(chalk.gray(` ${display}: unreachable or no agents CLI — skipped\n`));
|
|
100
|
-
return [];
|
|
100
|
+
return { sessions: [], unreachable: display };
|
|
101
101
|
}
|
|
102
|
-
return parseRemoteList(stdout, machine);
|
|
102
|
+
return { sessions: parseRemoteList(stdout, machine) };
|
|
103
103
|
}
|
|
104
104
|
/**
|
|
105
105
|
* Gather listing sessions from other machines. With an explicit `hosts` list
|
|
@@ -122,7 +122,7 @@ export async function gatherRemoteList(forwardedArgs, hosts) {
|
|
|
122
122
|
reg = await loadDevices();
|
|
123
123
|
}
|
|
124
124
|
catch {
|
|
125
|
-
return { sessions: [], deviceCount: 0 };
|
|
125
|
+
return { sessions: [], deviceCount: 0, unreachable: [] };
|
|
126
126
|
}
|
|
127
127
|
for (const d of Object.values(reg)) {
|
|
128
128
|
if (d.tailscale?.online !== true)
|
|
@@ -147,7 +147,11 @@ export async function gatherRemoteList(forwardedArgs, hosts) {
|
|
|
147
147
|
}
|
|
148
148
|
}
|
|
149
149
|
const results = await Promise.all(targets.map((t) => fetchByTarget(t.target, t.machine, t.name, forwardedArgs, t.os)));
|
|
150
|
-
return {
|
|
150
|
+
return {
|
|
151
|
+
sessions: results.flatMap((r) => r.sessions),
|
|
152
|
+
deviceCount: targets.length,
|
|
153
|
+
unreachable: results.map((r) => r.unreachable).filter((n) => !!n),
|
|
154
|
+
};
|
|
151
155
|
}
|
|
152
156
|
/** Resolve a peer's SSH target (and OS) from the device registry by its
|
|
153
157
|
* normalized machine id — the same id the fan-out tags rows with. Returns
|
|
@@ -119,11 +119,12 @@ export interface StateContext {
|
|
|
119
119
|
activeWindowMs?: number;
|
|
120
120
|
}
|
|
121
121
|
/**
|
|
122
|
-
* Derive live plan progress from a checklist tool call's args. Accepts
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
122
|
+
* Derive live plan progress from a checklist tool call's args. Accepts Claude's
|
|
123
|
+
* `TodoWrite` (`todos: [{content,status,activeForm}]`), Kimi's `TodoList`
|
|
124
|
+
* (`todos: [{title,status}]`, where finished is `done` rather than `completed`)
|
|
125
|
+
* and Codex's `update_plan` (`plan: [{step,status}]`) shapes, so the CLI is the
|
|
126
|
+
* single source of checklist state for every agent. Returns undefined when there
|
|
127
|
+
* is no usable list, so a session with no plan carries no `todos` field.
|
|
127
128
|
*/
|
|
128
129
|
export declare function extractTodoProgress(args?: Record<string, any>): TodoProgress | undefined;
|
|
129
130
|
/** Fold snapshot checklist tools and Claude TaskCreate/TaskUpdate event logs. */
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* shape + mtime — same function, driven off the normalized events.
|
|
16
16
|
*/
|
|
17
17
|
import * as path from 'path';
|
|
18
|
-
import { summarizeToolUse } from './parse.js';
|
|
18
|
+
import { isCompletedTodoStatus, SNAPSHOT_TODO_TOOLS, summarizeToolUse } from './parse.js';
|
|
19
19
|
/**
|
|
20
20
|
* Detect per-session rate-limit / usage-limit signals in assistant or error
|
|
21
21
|
* text (RUSH-1523). Matches the same shapes Factory's prewarm detectBlockingPrompt
|
|
@@ -48,15 +48,15 @@ const PROSE_QUESTION_FRESH_MS = 30 * 60_000;
|
|
|
48
48
|
/** Claude tool names that structurally mean "the agent handed control back to you". */
|
|
49
49
|
const PLAN_TOOL = 'ExitPlanMode';
|
|
50
50
|
const ASK_TOOL = 'AskUserQuestion';
|
|
51
|
-
const SNAPSHOT_TODO_TOOLS = new Set(['TodoWrite', 'todo_write', 'update_plan']);
|
|
52
51
|
const TASK_CREATE_TOOL = 'TaskCreate';
|
|
53
52
|
const TASK_UPDATE_TOOL = 'TaskUpdate';
|
|
54
53
|
/**
|
|
55
|
-
* Derive live plan progress from a checklist tool call's args. Accepts
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
54
|
+
* Derive live plan progress from a checklist tool call's args. Accepts Claude's
|
|
55
|
+
* `TodoWrite` (`todos: [{content,status,activeForm}]`), Kimi's `TodoList`
|
|
56
|
+
* (`todos: [{title,status}]`, where finished is `done` rather than `completed`)
|
|
57
|
+
* and Codex's `update_plan` (`plan: [{step,status}]`) shapes, so the CLI is the
|
|
58
|
+
* single source of checklist state for every agent. Returns undefined when there
|
|
59
|
+
* is no usable list, so a session with no plan carries no `todos` field.
|
|
60
60
|
*/
|
|
61
61
|
export function extractTodoProgress(args) {
|
|
62
62
|
const input = args?.input && typeof args.input === 'object' ? args.input : args;
|
|
@@ -76,10 +76,16 @@ export function extractTodoProgress(args) {
|
|
|
76
76
|
? t.text
|
|
77
77
|
: typeof t?.step === 'string' && t.step
|
|
78
78
|
? t.step
|
|
79
|
-
:
|
|
79
|
+
: typeof t?.title === 'string' && t.title
|
|
80
|
+
? t.title
|
|
81
|
+
: activeForm ?? '';
|
|
80
82
|
if (!content)
|
|
81
83
|
continue;
|
|
82
|
-
const status = t?.status
|
|
84
|
+
const status = isCompletedTodoStatus(t?.status)
|
|
85
|
+
? 'completed'
|
|
86
|
+
: t?.status === 'in_progress'
|
|
87
|
+
? 'in_progress'
|
|
88
|
+
: 'pending';
|
|
83
89
|
const description = typeof t?.description === 'string' && t.description ? t.description : undefined;
|
|
84
90
|
items.push({ content, status, ...(description ? { description } : {}), ...(activeForm ? { activeForm } : {}) });
|
|
85
91
|
}
|
|
@@ -195,12 +201,66 @@ const GH_PR_CREATE_RE = /\bgh\s+pr\s+(?:create|new)\b/;
|
|
|
195
201
|
const GH_ISSUE_CREATE_RE = /\bgh\s+issue\s+create\b/;
|
|
196
202
|
/** A created GitHub issue URL (…/issues/123) in tool-result output. */
|
|
197
203
|
const GH_ISSUE_URL_RE = /https:\/\/github\.com\/[^\s"'()<>]+\/issues\/(\d+)/;
|
|
204
|
+
/**
|
|
205
|
+
* Flags of `teams create` / `teams add` that take a value, so the value is not
|
|
206
|
+
* mistaken for the positional team name. Mirrors their value-taking flags in
|
|
207
|
+
* `commands/teams.ts` — most are `.option('… <x>')` registrations, but
|
|
208
|
+
* `--device`/`--host` come from `addHostOption`, so auditing this list against
|
|
209
|
+
* `.option(` alone would wrongly drop them. A flag missing here degrades to "no
|
|
210
|
+
* team detected", never to a wrong one.
|
|
211
|
+
*/
|
|
212
|
+
const TEAM_VALUE_FLAGS = [
|
|
213
|
+
'-d', '--description', '--use-worktree', '--devices', '--hosts', '--repo',
|
|
214
|
+
'-n', '--name', '-m', '--mode', '-e', '--effort', '--model', '--env',
|
|
215
|
+
'--cwd', '--worktree', '--after', '--task-type', '--cloud', '--branch',
|
|
216
|
+
'--device', '--host',
|
|
217
|
+
];
|
|
218
|
+
/**
|
|
219
|
+
* One flag value: a quoted string or a bare token. `-d "sessions lineage"` is the
|
|
220
|
+
* common shape — `--description` is usually a phrase — and a value pattern of
|
|
221
|
+
* `\S+` alone stops at the first space, leaving the rest of the phrase to be read
|
|
222
|
+
* as the positional team name (`… -d "sessions lineage" my-team` detected
|
|
223
|
+
* `lineage`). Quotes are matched as a unit so the whole value is consumed.
|
|
224
|
+
*
|
|
225
|
+
* A value containing an ESCAPED quote (`-d "say \"hi\" now"`) stops the quoted
|
|
226
|
+
* branch early and the match then fails outright — which is the intended failure
|
|
227
|
+
* direction: no team detected rather than a wrong one.
|
|
228
|
+
*/
|
|
229
|
+
const FLAG_VALUE = String.raw `(?:"[^"\n]*"|'[^'\n]*'|\S+)`;
|
|
198
230
|
/**
|
|
199
231
|
* `agents teams create <name>` / `agents teams add <team> …` (also the `ag` alias).
|
|
200
232
|
* The team NAME is the first bareword after the sub-verb, skipping any flags. This
|
|
201
233
|
* is the structural signal that a session SPAWNED a team (vs. was spawned by one).
|
|
234
|
+
*
|
|
235
|
+
* The separators are spaces/tabs, never `\s`: a command string routinely embeds
|
|
236
|
+
* documentation and quoted output, and `\s` let the flag-skip run across newlines
|
|
237
|
+
* to capture a word from a completely different line (a real scan produced
|
|
238
|
+
* `team:installed` from a heredoc). For the same reason the flag-skip is bounded
|
|
239
|
+
* rather than unlimited — a real invocation carries a handful of flags before the
|
|
240
|
+
* name, not dozens.
|
|
202
241
|
*/
|
|
203
|
-
const TEAMS_SPAWN_RE =
|
|
242
|
+
const TEAMS_SPAWN_RE = new RegExp(
|
|
243
|
+
// Start of an actually-executed command: string start, a newline, or a shell
|
|
244
|
+
// separator. Without this, a backticked mention inside prose or tool output
|
|
245
|
+
// ("… and `agents teams add --device auto`") reads as a spawn.
|
|
246
|
+
String.raw `(?:^|[\n;&|(]|&&|\|\|)[ \t]*` +
|
|
247
|
+
String.raw `ag(?:ents)?[ \t]+teams?[ \t]+(?:create|add)[ \t]+` +
|
|
248
|
+
// Flags before the positional name. A value-taking flag must swallow its
|
|
249
|
+
// value, or `--device auto` leaves `auto` looking like the team name — and
|
|
250
|
+
// the generic branch must exclude those flags, or it swallows the flag alone
|
|
251
|
+
// and hands the value back as the name.
|
|
252
|
+
String.raw `(?:(?:${TEAM_VALUE_FLAGS.join('|')})[= \t]${FLAG_VALUE}[ \t]+` +
|
|
253
|
+
String.raw `|(?!(?:${TEAM_VALUE_FLAGS.join('|')})[= \t])--?[a-z][\w-]*(?:=\S+)?[ \t]+){0,6}` +
|
|
254
|
+
// A team name may start with a digit — `createTeam` validates nothing, and
|
|
255
|
+
// `2fa-migration` is a legal name — so the class stays [A-Za-z0-9]. The
|
|
256
|
+
// all-digits case is rejected in the guard below instead.
|
|
257
|
+
String.raw `([A-Za-z0-9][\w-]*)`);
|
|
258
|
+
/**
|
|
259
|
+
* Sub-verbs that can follow `teams create|add` in prose ("teams add a teammate")
|
|
260
|
+
* but are never a team name. Guards the common case where the match came from a
|
|
261
|
+
* sentence rather than an executed command.
|
|
262
|
+
*/
|
|
263
|
+
const NON_TEAM_WORDS = new Set(['a', 'an', 'the', 'to', 'for', 'with', 'and', 'this', 'your', 'my', 'it']);
|
|
204
264
|
/** Collapse to a single trimmed line for a one-row preview cell. */
|
|
205
265
|
function oneLine(s) {
|
|
206
266
|
return s.replace(/\s+/g, ' ').trim();
|
|
@@ -253,7 +313,20 @@ export function detectSpawnedTeam(command) {
|
|
|
253
313
|
if (!command)
|
|
254
314
|
return undefined;
|
|
255
315
|
const m = command.match(TEAMS_SPAWN_RE);
|
|
256
|
-
|
|
316
|
+
if (!m)
|
|
317
|
+
return undefined;
|
|
318
|
+
const name = m[1];
|
|
319
|
+
// A single character is a doc placeholder (`agents teams create t --host <box>`)
|
|
320
|
+
// far more often than a real team, and an English article is prose. Both used to
|
|
321
|
+
// land in the index as a team name, and now that the name is rendered on the row
|
|
322
|
+
// a wrong one is worse than none.
|
|
323
|
+
// A single character is a doc placeholder (`agents teams create t --host <name>`)
|
|
324
|
+
// far more often than a real team; an all-digits token is a flag value or a list
|
|
325
|
+
// index that leaked through, never a name someone typed. Both had reached the
|
|
326
|
+
// index, and now that the name is rendered a wrong one is worse than none.
|
|
327
|
+
if (name.length < 2 || /^\d+$/.test(name) || NON_TEAM_WORDS.has(name.toLowerCase()))
|
|
328
|
+
return undefined;
|
|
329
|
+
return name;
|
|
257
330
|
}
|
|
258
331
|
/**
|
|
259
332
|
* True when a tool_use call CREATES a tracker ticket — a Linear MCP `create_issue`
|
|
@@ -12,14 +12,33 @@ import type { SessionMeta, TeamOrigin } from './types.js';
|
|
|
12
12
|
*
|
|
13
13
|
* Primary signal is `session.isTeamOrigin`, captured at scan time from the
|
|
14
14
|
* JSONL `entrypoint` field ('sdk-cli' for team spawns, 'cli' for real CLI).
|
|
15
|
-
* When a team meta.json exists we additionally enrich with handle/mode
|
|
16
|
-
*
|
|
17
|
-
* was cleaned up still get recognized
|
|
15
|
+
* When a team meta.json exists we additionally enrich with handle/mode/team and
|
|
16
|
+
* the orchestrator that spawned it — but its absence no longer demotes a
|
|
17
|
+
* session: older team runs whose meta dir was cleaned up still get recognized
|
|
18
|
+
* via the entrypoint flag.
|
|
18
19
|
*
|
|
19
20
|
* Returns the TeamOrigin metadata when the session is team-origin, or null
|
|
20
21
|
* when it is a normal interactive session.
|
|
21
22
|
*/
|
|
22
23
|
export declare function classifyTeamSession(session: SessionMeta): TeamOrigin | null;
|
|
24
|
+
/**
|
|
25
|
+
* A team name / handle as it is safe to render: a real string, terminal escapes
|
|
26
|
+
* stripped, or undefined.
|
|
27
|
+
*
|
|
28
|
+
* These values reach the row and the preview pane, and for a peer's row they are
|
|
29
|
+
* whatever JSON that machine sent — `parseRemoteList` copies the object through
|
|
30
|
+
* without inspecting its fields, so neither the type nor the content is ours to
|
|
31
|
+
* assume. A non-string `spawnedTeam` used to throw out of `teamBadge`, which runs
|
|
32
|
+
* on every row, taking down the whole listing rather than one entry.
|
|
33
|
+
*/
|
|
34
|
+
export declare function safeTeamText(value: unknown): string | undefined;
|
|
35
|
+
/** Drop the cached teammate index — for tests that rewrite AGENTS_TEAMS_DIR. */
|
|
36
|
+
export declare function _resetTeamOriginIndex(): void;
|
|
37
|
+
/**
|
|
38
|
+
* Attach `teamOrigin` to every team-spawned row in `sessions`, from the shared
|
|
39
|
+
* teammate index rather than a stat per row.
|
|
40
|
+
*/
|
|
41
|
+
export declare function enrichTeamOrigins(sessions: SessionMeta[]): SessionMeta[];
|
|
23
42
|
/** Result of splitting sessions into visible and hidden (team-origin) groups. */
|
|
24
43
|
export interface FilterResult {
|
|
25
44
|
visible: SessionMeta[];
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import * as fs from 'fs';
|
|
10
10
|
import * as os from 'os';
|
|
11
11
|
import * as path from 'path';
|
|
12
|
+
import { sanitizeForTerminal } from './parse.js';
|
|
12
13
|
import { getTeamsAgentsDir } from '../state.js';
|
|
13
14
|
const HOME = os.homedir();
|
|
14
15
|
// Default path; tests can override via AGENTS_TEAMS_DIR env var.
|
|
@@ -21,33 +22,121 @@ function teamsAgentsDir() {
|
|
|
21
22
|
*
|
|
22
23
|
* Primary signal is `session.isTeamOrigin`, captured at scan time from the
|
|
23
24
|
* JSONL `entrypoint` field ('sdk-cli' for team spawns, 'cli' for real CLI).
|
|
24
|
-
* When a team meta.json exists we additionally enrich with handle/mode
|
|
25
|
-
*
|
|
26
|
-
* was cleaned up still get recognized
|
|
25
|
+
* When a team meta.json exists we additionally enrich with handle/mode/team and
|
|
26
|
+
* the orchestrator that spawned it — but its absence no longer demotes a
|
|
27
|
+
* session: older team runs whose meta dir was cleaned up still get recognized
|
|
28
|
+
* via the entrypoint flag.
|
|
27
29
|
*
|
|
28
30
|
* Returns the TeamOrigin metadata when the session is team-origin, or null
|
|
29
31
|
* when it is a normal interactive session.
|
|
30
32
|
*/
|
|
31
33
|
export function classifyTeamSession(session) {
|
|
32
|
-
const
|
|
33
|
-
if (
|
|
34
|
-
|
|
35
|
-
const raw = fs.readFileSync(metaPath, 'utf-8');
|
|
36
|
-
const meta = JSON.parse(raw);
|
|
37
|
-
const name = typeof meta.name === 'string' && meta.name ? meta.name : undefined;
|
|
38
|
-
const handle = name ?? session.id.slice(0, 8);
|
|
39
|
-
const mode = typeof meta.mode === 'string' ? meta.mode : undefined;
|
|
40
|
-
return { handle, mode };
|
|
41
|
-
}
|
|
42
|
-
catch {
|
|
43
|
-
return { handle: session.id.slice(0, 8) };
|
|
44
|
-
}
|
|
45
|
-
}
|
|
34
|
+
const origin = teamOriginIndex().get(session.id);
|
|
35
|
+
if (origin)
|
|
36
|
+
return origin;
|
|
46
37
|
if (session.isTeamOrigin) {
|
|
47
38
|
return { handle: session.id.slice(0, 8) };
|
|
48
39
|
}
|
|
49
40
|
return null;
|
|
50
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Parse one teammate `meta.json` into a {@link TeamOrigin}. Degrades to a bare
|
|
44
|
+
* handle when the file is unreadable or malformed — a teammate whose record we
|
|
45
|
+
* can't parse is still a teammate.
|
|
46
|
+
*/
|
|
47
|
+
function readTeamOrigin(metaPath, agentId) {
|
|
48
|
+
try {
|
|
49
|
+
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
50
|
+
const str = (v) => (typeof v === 'string' && v ? v : undefined);
|
|
51
|
+
return {
|
|
52
|
+
origin: {
|
|
53
|
+
handle: str(meta.name) ?? agentId.slice(0, 8),
|
|
54
|
+
mode: str(meta.mode),
|
|
55
|
+
team: str(meta.task_name),
|
|
56
|
+
parentSessionId: str(meta.parent_session_id),
|
|
57
|
+
},
|
|
58
|
+
sessionId: str(meta.remote_session_id),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return { origin: { handle: agentId.slice(0, 8) } };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* A team name / handle as it is safe to render: a real string, terminal escapes
|
|
67
|
+
* stripped, or undefined.
|
|
68
|
+
*
|
|
69
|
+
* These values reach the row and the preview pane, and for a peer's row they are
|
|
70
|
+
* whatever JSON that machine sent — `parseRemoteList` copies the object through
|
|
71
|
+
* without inspecting its fields, so neither the type nor the content is ours to
|
|
72
|
+
* assume. A non-string `spawnedTeam` used to throw out of `teamBadge`, which runs
|
|
73
|
+
* on every row, taking down the whole listing rather than one entry.
|
|
74
|
+
*/
|
|
75
|
+
export function safeTeamText(value) {
|
|
76
|
+
if (typeof value !== 'string' || value === '')
|
|
77
|
+
return undefined;
|
|
78
|
+
return sanitizeForTerminal(value);
|
|
79
|
+
}
|
|
80
|
+
/** Cached teammate index; the directory is small (teams GC it after 7 days). */
|
|
81
|
+
let originIndexCache = null;
|
|
82
|
+
/** Drop the cached teammate index — for tests that rewrite AGENTS_TEAMS_DIR. */
|
|
83
|
+
export function _resetTeamOriginIndex() {
|
|
84
|
+
originIndexCache = null;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Every teammate record, keyed by the session ids it can be reached under.
|
|
88
|
+
*
|
|
89
|
+
* A teammate's directory name is its **agent id**, which is only sometimes the
|
|
90
|
+
* id of the transcript it produced: the harness mints its own session id, and
|
|
91
|
+
* the spawn records it separately as `remote_session_id`. Keying the lookup on
|
|
92
|
+
* the directory name alone therefore missed most teammates — on a live box, 14
|
|
93
|
+
* of 16 records were reachable only by `remote_session_id` — so a teammate row
|
|
94
|
+
* could not name its team however good the record was. Both keys are registered.
|
|
95
|
+
*
|
|
96
|
+
* Read once per process rather than per row: the old per-session `existsSync` +
|
|
97
|
+
* `readFileSync` cost a pair of syscalls for every row in the pool, which the
|
|
98
|
+
* interactive browser re-pays on each hotkey.
|
|
99
|
+
*/
|
|
100
|
+
function teamOriginIndex() {
|
|
101
|
+
if (originIndexCache)
|
|
102
|
+
return originIndexCache;
|
|
103
|
+
const dir = teamsAgentsDir();
|
|
104
|
+
const index = new Map();
|
|
105
|
+
let entries;
|
|
106
|
+
try {
|
|
107
|
+
entries = fs.readdirSync(dir);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
entries = [];
|
|
111
|
+
}
|
|
112
|
+
for (const agentId of entries) {
|
|
113
|
+
const metaPath = path.join(dir, agentId, 'meta.json');
|
|
114
|
+
if (!fs.existsSync(metaPath))
|
|
115
|
+
continue;
|
|
116
|
+
const { origin, sessionId } = readTeamOrigin(metaPath, agentId);
|
|
117
|
+
index.set(agentId, origin);
|
|
118
|
+
if (sessionId)
|
|
119
|
+
index.set(sessionId, origin);
|
|
120
|
+
}
|
|
121
|
+
originIndexCache = index;
|
|
122
|
+
return index;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Attach `teamOrigin` to every team-spawned row in `sessions`, from the shared
|
|
126
|
+
* teammate index rather than a stat per row.
|
|
127
|
+
*/
|
|
128
|
+
export function enrichTeamOrigins(sessions) {
|
|
129
|
+
const index = teamOriginIndex();
|
|
130
|
+
return sessions.map((session) => {
|
|
131
|
+
// A peer's rows are classified on the peer (its meta.json is on its disk, not
|
|
132
|
+
// ours) and ride across in the --json fan-out already populated. Re-deriving
|
|
133
|
+
// here would find no local record and downgrade a named teammate to a bare id.
|
|
134
|
+
if (session.teamOrigin)
|
|
135
|
+
return session;
|
|
136
|
+
const origin = index.get(session.id) ?? (session.isTeamOrigin ? { handle: session.id.slice(0, 8) } : null);
|
|
137
|
+
return origin ? { ...session, teamOrigin: origin } : session;
|
|
138
|
+
});
|
|
139
|
+
}
|
|
51
140
|
/**
|
|
52
141
|
* Split `sessions` into visible and hidden (team-origin) groups.
|
|
53
142
|
* When `showTeams` is true every session is visible and `teamOrigin` is
|
|
@@ -88,6 +88,14 @@ export interface TeamOrigin {
|
|
|
88
88
|
handle?: string;
|
|
89
89
|
/** Agent mode: 'plan', 'edit', 'auto', or 'skip' ('full' accepted as legacy alias for 'skip'). */
|
|
90
90
|
mode?: string;
|
|
91
|
+
/** The team this teammate belongs to (`task_name` in its meta.json). */
|
|
92
|
+
team?: string;
|
|
93
|
+
/**
|
|
94
|
+
* The orchestrator session that spawned this teammate (`parent_session_id`).
|
|
95
|
+
* Absent for a team started outside any agent session, and for teammates whose
|
|
96
|
+
* meta dir has aged past the teams cleanup window.
|
|
97
|
+
*/
|
|
98
|
+
parentSessionId?: string;
|
|
91
99
|
}
|
|
92
100
|
/** Lightweight metadata for a discovered session, used in listings and pickers. */
|
|
93
101
|
export interface SessionMeta {
|
|
@@ -23,6 +23,23 @@ export declare function loginHint(agentId: AgentId): string;
|
|
|
23
23
|
* agents (the ones the feature is for), so the resume's `forceInteractive` flag is
|
|
24
24
|
* consulted directly.
|
|
25
25
|
*/
|
|
26
|
+
/**
|
|
27
|
+
* Is a Claude run on this box going to authenticate from an ambient
|
|
28
|
+
* `CLAUDE_CODE_OAUTH_TOKEN` rather than a per-version login?
|
|
29
|
+
*
|
|
30
|
+
* `AccountInfo.signedIn` is `!!email` read from a version home's `.claude.json`
|
|
31
|
+
* (agents.ts), so a version with no account written there reports signed-out —
|
|
32
|
+
* even though Claude Code authenticates fine from the env token and the run
|
|
33
|
+
* succeeds. Rendering that as "logged out" sends people hunting a login that is
|
|
34
|
+
* not missing (a real fleet incident: every version on a box read as locked out
|
|
35
|
+
* while all of them answered a live prompt).
|
|
36
|
+
*
|
|
37
|
+
* It is also the more useful warning: an ambient token is ONE account, so every
|
|
38
|
+
* version on the box resolves to it and balanced rotation across them rotates
|
|
39
|
+
* nothing. `env` is a parameter so the branch is testable without mutating the
|
|
40
|
+
* process environment.
|
|
41
|
+
*/
|
|
42
|
+
export declare function ambientClaudeToken(agentId: AgentId | string, env?: NodeJS.ProcessEnv): boolean;
|
|
26
43
|
export declare function shouldCheckLoginBeforeLaunch(o: {
|
|
27
44
|
interactive?: boolean;
|
|
28
45
|
forceInteractive?: boolean;
|
package/dist/lib/signin-badge.js
CHANGED
|
@@ -46,6 +46,25 @@ export function loginHint(agentId) {
|
|
|
46
46
|
* agents (the ones the feature is for), so the resume's `forceInteractive` flag is
|
|
47
47
|
* consulted directly.
|
|
48
48
|
*/
|
|
49
|
+
/**
|
|
50
|
+
* Is a Claude run on this box going to authenticate from an ambient
|
|
51
|
+
* `CLAUDE_CODE_OAUTH_TOKEN` rather than a per-version login?
|
|
52
|
+
*
|
|
53
|
+
* `AccountInfo.signedIn` is `!!email` read from a version home's `.claude.json`
|
|
54
|
+
* (agents.ts), so a version with no account written there reports signed-out —
|
|
55
|
+
* even though Claude Code authenticates fine from the env token and the run
|
|
56
|
+
* succeeds. Rendering that as "logged out" sends people hunting a login that is
|
|
57
|
+
* not missing (a real fleet incident: every version on a box read as locked out
|
|
58
|
+
* while all of them answered a live prompt).
|
|
59
|
+
*
|
|
60
|
+
* It is also the more useful warning: an ambient token is ONE account, so every
|
|
61
|
+
* version on the box resolves to it and balanced rotation across them rotates
|
|
62
|
+
* nothing. `env` is a parameter so the branch is testable without mutating the
|
|
63
|
+
* process environment.
|
|
64
|
+
*/
|
|
65
|
+
export function ambientClaudeToken(agentId, env = process.env) {
|
|
66
|
+
return agentId === 'claude' && (env.CLAUDE_CODE_OAUTH_TOKEN ?? '').trim().length > 0;
|
|
67
|
+
}
|
|
49
68
|
export function shouldCheckLoginBeforeLaunch(o) {
|
|
50
69
|
if (o.json || o.quiet || o.authCheckDisabled || o.rotated)
|
|
51
70
|
return false;
|