@phnx-labs/agents-cli 1.20.76 → 1.20.78

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/CHANGELOG.md +264 -0
  2. package/README.md +2 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/doctor.d.ts +9 -0
  5. package/dist/commands/doctor.js +143 -9
  6. package/dist/commands/exec.js +87 -19
  7. package/dist/commands/repo.js +8 -0
  8. package/dist/commands/routines.js +275 -15
  9. package/dist/commands/secrets.js +65 -17
  10. package/dist/commands/sessions-browser.d.ts +45 -3
  11. package/dist/commands/sessions-browser.js +128 -12
  12. package/dist/commands/sessions-export.d.ts +3 -0
  13. package/dist/commands/sessions-export.js +13 -10
  14. package/dist/commands/sessions-picker.d.ts +28 -1
  15. package/dist/commands/sessions-picker.js +222 -15
  16. package/dist/commands/sessions.d.ts +117 -4
  17. package/dist/commands/sessions.js +350 -62
  18. package/dist/commands/ssh.d.ts +13 -0
  19. package/dist/commands/ssh.js +48 -3
  20. package/dist/index.js +5 -6
  21. package/dist/lib/activity.d.ts +5 -4
  22. package/dist/lib/activity.js +450 -36
  23. package/dist/lib/agents.d.ts +0 -21
  24. package/dist/lib/agents.js +0 -37
  25. package/dist/lib/auto-pull.d.ts +16 -8
  26. package/dist/lib/auto-pull.js +23 -28
  27. package/dist/lib/cloud/session-index.js +2 -2
  28. package/dist/lib/daemon.d.ts +9 -32
  29. package/dist/lib/daemon.js +70 -84
  30. package/dist/lib/devices/fleet-divergence.d.ts +101 -0
  31. package/dist/lib/devices/fleet-divergence.js +188 -0
  32. package/dist/lib/devices/fleet-inventory.d.ts +19 -0
  33. package/dist/lib/devices/fleet-inventory.js +57 -0
  34. package/dist/lib/devices/fleet.d.ts +16 -1
  35. package/dist/lib/devices/fleet.js +10 -2
  36. package/dist/lib/devices/health-report.d.ts +10 -2
  37. package/dist/lib/devices/health-report.js +32 -1
  38. package/dist/lib/events.d.ts +1 -1
  39. package/dist/lib/exec.d.ts +16 -0
  40. package/dist/lib/exec.js +27 -4
  41. package/dist/lib/feed.d.ts +7 -1
  42. package/dist/lib/feed.js +96 -6
  43. package/dist/lib/git.d.ts +13 -0
  44. package/dist/lib/git.js +102 -4
  45. package/dist/lib/heal.d.ts +7 -4
  46. package/dist/lib/heal.js +10 -22
  47. package/dist/lib/hosts/dispatch.d.ts +9 -0
  48. package/dist/lib/hosts/dispatch.js +24 -4
  49. package/dist/lib/hosts/passthrough.js +7 -2
  50. package/dist/lib/hosts/remote-cmd.d.ts +11 -0
  51. package/dist/lib/hosts/remote-cmd.js +33 -8
  52. package/dist/lib/hosts/remote-session-id.d.ts +45 -0
  53. package/dist/lib/hosts/remote-session-id.js +84 -0
  54. package/dist/lib/hosts/run-target.d.ts +4 -0
  55. package/dist/lib/hosts/run-target.js +5 -1
  56. package/dist/lib/hosts/session-index.js +3 -3
  57. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +2 -0
  58. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  59. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  60. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
  61. package/dist/lib/menubar/install-menubar.d.ts +9 -0
  62. package/dist/lib/menubar/install-menubar.js +14 -0
  63. package/dist/lib/menubar/notify-desktop.d.ts +44 -0
  64. package/dist/lib/menubar/notify-desktop.js +78 -0
  65. package/dist/lib/overdue.d.ts +4 -5
  66. package/dist/lib/overdue.js +8 -41
  67. package/dist/lib/routine-notify.d.ts +76 -0
  68. package/dist/lib/routine-notify.js +190 -0
  69. package/dist/lib/routines-placement.d.ts +38 -0
  70. package/dist/lib/routines-placement.js +79 -0
  71. package/dist/lib/routines-project.d.ts +97 -0
  72. package/dist/lib/routines-project.js +349 -0
  73. package/dist/lib/routines.d.ts +68 -0
  74. package/dist/lib/routines.js +74 -7
  75. package/dist/lib/runner.d.ts +12 -2
  76. package/dist/lib/runner.js +156 -28
  77. package/dist/lib/sandbox.js +0 -6
  78. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  79. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  80. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  81. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  82. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  83. package/dist/lib/secrets/agent.d.ts +11 -4
  84. package/dist/lib/secrets/agent.js +46 -24
  85. package/dist/lib/secrets/bundles.d.ts +21 -7
  86. package/dist/lib/secrets/bundles.js +82 -38
  87. package/dist/lib/secrets/index.d.ts +11 -2
  88. package/dist/lib/secrets/index.js +21 -9
  89. package/dist/lib/secrets/scope.d.ts +26 -0
  90. package/dist/lib/secrets/scope.js +29 -0
  91. package/dist/lib/secrets/session-store.d.ts +16 -2
  92. package/dist/lib/secrets/session-store.js +118 -15
  93. package/dist/lib/secrets/unlock-hints.d.ts +27 -0
  94. package/dist/lib/secrets/unlock-hints.js +36 -0
  95. package/dist/lib/session/active.d.ts +17 -0
  96. package/dist/lib/session/active.js +105 -7
  97. package/dist/lib/session/db.d.ts +43 -0
  98. package/dist/lib/session/db.js +191 -6
  99. package/dist/lib/session/digest.js +126 -21
  100. package/dist/lib/session/discover.d.ts +28 -3
  101. package/dist/lib/session/discover.js +110 -14
  102. package/dist/lib/session/origin-machine.d.ts +18 -0
  103. package/dist/lib/session/origin-machine.js +34 -0
  104. package/dist/lib/session/parse.js +11 -2
  105. package/dist/lib/session/prompt.d.ts +7 -0
  106. package/dist/lib/session/prompt.js +37 -0
  107. package/dist/lib/session/provenance.d.ts +7 -0
  108. package/dist/lib/session/render.js +18 -16
  109. package/dist/lib/session/state.d.ts +5 -0
  110. package/dist/lib/session/state.js +94 -12
  111. package/dist/lib/session/sync/config.js +2 -2
  112. package/dist/lib/session/types.d.ts +25 -1
  113. package/dist/lib/session/types.js +8 -0
  114. package/dist/lib/smart-launch.d.ts +86 -0
  115. package/dist/lib/smart-launch.js +172 -0
  116. package/dist/lib/state.d.ts +7 -3
  117. package/dist/lib/state.js +20 -5
  118. package/dist/lib/types.d.ts +10 -0
  119. package/package.json +1 -1
@@ -37,6 +37,13 @@ export interface SessionProvenance {
37
37
  mux?: MuxLocation;
38
38
  /** Whether an existing rail can type back into this session (see module doc). */
39
39
  reply: ReplyRail;
40
+ /** The initiating device, resolved at query time from ssh.clientIp against the
41
+ * device registry (ssh transport only). Answers "who launched this and from
42
+ * where" without scraping ps/who/tailscale. */
43
+ origin?: {
44
+ device: string;
45
+ user?: string;
46
+ };
40
47
  }
41
48
  /** Env vars that carry provenance. Kept small so the macOS `ps` scan stays cheap. */
42
49
  export declare const PROVENANCE_ENV_KEYS: readonly ["SSH_CONNECTION", "SSH_TTY", "TMUX", "TMUX_PANE", "TERM_PROGRAM", "STY", "ITERM_SESSION_ID"];
@@ -13,6 +13,7 @@ import { cleanSessionPrompt, extractSessionTopic } from './prompt.js';
13
13
  import { renderMarkdown } from '../markdown.js';
14
14
  import { redactSecrets } from '../redact.js';
15
15
  import { classifyFileChanges, changeCounts, toolHistogram, detectTestResult } from './digest.js';
16
+ import { extractTodoProgressFromEvents } from './state.js';
16
17
  // ── Path helpers ──────────────────────────────────────────────────────────────
17
18
  /**
18
19
  * Return absPath relative to cwd; fall back to ~/… then absolute.
@@ -528,7 +529,7 @@ export function renderSummary(events, cwd) {
528
529
  // Commands with timestamps
529
530
  const cmdList = [];
530
531
  // Plan items
531
- const todoItems = [];
532
+ const todoItems = extractTodoProgressFromEvents(events)?.items.map(item => item.content) ?? [];
532
533
  let exitPlanContent = null;
533
534
  let planFilePath = null;
534
535
  // Subagent spawns
@@ -569,19 +570,6 @@ export function renderSummary(events, cwd) {
569
570
  recentActivity.push({ kind: 'cmd', label: cmd, ts });
570
571
  }
571
572
  }
572
- // Plan items: TodoWrite items + TaskCreate descriptions (project's task tracker)
573
- if (tool === 'TodoWrite' && Array.isArray(args.todos)) {
574
- for (const item of args.todos) {
575
- const text = item.content || item.text || String(item);
576
- if (text && !todoItems.includes(text))
577
- todoItems.push(text);
578
- }
579
- }
580
- if (tool === 'TaskCreate' && (args.description || args.prompt)) {
581
- const text = String(args.description || args.prompt || '').slice(0, 140);
582
- if (text && !todoItems.includes(text))
583
- todoItems.push(text);
584
- }
585
573
  if (tool === 'ExitPlanMode') {
586
574
  exitPlanContent = args.result || args.plan || args.content || null;
587
575
  }
@@ -796,6 +784,9 @@ export function parseRoleList(raw, flag) {
796
784
  return parts;
797
785
  }
798
786
  function roleOfEvent(e) {
787
+ // Synthetic scaffolding still maps to 'user' here so `--exclude user` drops it
788
+ // like any other user-role event (pre-flag behavior). `--include user`'s
789
+ // "genuine intent only" carve-out lives in applyRoleFilter, not here.
799
790
  if (e.type === 'message' && e.role === 'user')
800
791
  return 'user';
801
792
  if (e.type === 'message' && e.role === 'assistant')
@@ -817,7 +808,14 @@ function applyRoleFilter(events, opts) {
817
808
  const set = new Set(opts.include);
818
809
  return events.filter(e => {
819
810
  const role = roleOfEvent(e);
820
- return role !== null && set.has(role);
811
+ if (role === null)
812
+ return false;
813
+ // `--include user` means genuine user intent, so drop harness-injected
814
+ // `_synthetic` scaffolding (bash-input, system-reminder) even though it
815
+ // carries role=user. `--exclude user` still drops it via roleOfEvent.
816
+ if (role === 'user' && e._synthetic)
817
+ return false;
818
+ return set.has(role);
821
819
  });
822
820
  }
823
821
  if (opts.exclude && opts.exclude.length > 0) {
@@ -845,7 +843,11 @@ function applyTurnSlice(events, opts) {
845
843
  if (!Number.isFinite(n) || n <= 0) {
846
844
  throw new Error(`Turn count must be a positive integer, got ${n}`);
847
845
  }
848
- const isTurnStart = (e) => e.type === 'message' && e.role === 'user';
846
+ // A turn starts at a GENUINE user message harness-injected `_synthetic`
847
+ // scaffolding (`<bash-input>`/`<bash-stdout>`, etc.) does not start a turn,
848
+ // so `--first N` / `--last N` count real asks, not the jump command that
849
+ // opened the session and its shell output.
850
+ const isTurnStart = (e) => e.type === 'message' && e.role === 'user' && !e._synthetic;
849
851
  const turnStartIdx = [];
850
852
  for (let i = 0; i < events.length; i++)
851
853
  if (isTurnStart(events[i]))
@@ -126,6 +126,11 @@ export interface StateContext {
126
126
  * list, so a session with no plan carries no `todos` field.
127
127
  */
128
128
  export declare function extractTodoProgress(args?: Record<string, any>): TodoProgress | undefined;
129
+ /** Fold snapshot checklist tools and Claude TaskCreate/TaskUpdate event logs. */
130
+ export declare function extractTodoProgressFromEvents(events: SessionEvent[]): TodoProgress | undefined;
131
+ /** Derive a recency-ordered, de-duplicated list of directories touched by tools. */
132
+ export declare function extractRecentDirectoriesTouched(events: SessionEvent[], cwd?: string): string[] | undefined;
133
+ export declare const WORKTREE_RE: RegExp;
129
134
  /** Detect a worktree from the session cwd, per the `.agents/worktrees/<slug>/` convention. */
130
135
  export declare function detectWorktree(cwd?: string, branch?: string): DetectedWorktree | undefined;
131
136
  /** Detect a tracker ticket from free text (prompt/topic) then a branch name. */
@@ -14,6 +14,7 @@
14
14
  * markers. Codex has no such tools, so it falls back to last-role + question
15
15
  * shape + mtime — same function, driven off the normalized events.
16
16
  */
17
+ import * as path from 'path';
17
18
  import { summarizeToolUse } from './parse.js';
18
19
  /**
19
20
  * Detect per-session rate-limit / usage-limit signals in assistant or error
@@ -47,9 +48,9 @@ const PROSE_QUESTION_FRESH_MS = 30 * 60_000;
47
48
  /** Claude tool names that structurally mean "the agent handed control back to you". */
48
49
  const PLAN_TOOL = 'ExitPlanMode';
49
50
  const ASK_TOOL = 'AskUserQuestion';
50
- /** The live plan/checklist tools: Claude's `TodoWrite` and Codex's `update_plan`. */
51
- const TODO_TOOL = 'TodoWrite';
52
- const CODEX_PLAN_TOOL = 'update_plan';
51
+ const SNAPSHOT_TODO_TOOLS = new Set(['TodoWrite', 'todo_write', 'update_plan']);
52
+ const TASK_CREATE_TOOL = 'TaskCreate';
53
+ const TASK_UPDATE_TOOL = 'TaskUpdate';
53
54
  /**
54
55
  * Derive live plan progress from a checklist tool call's args. Accepts both
55
56
  * Claude's `TodoWrite` (`todos: [{content,status,activeForm}]`) and Codex's
@@ -58,10 +59,11 @@ const CODEX_PLAN_TOOL = 'update_plan';
58
59
  * list, so a session with no plan carries no `todos` field.
59
60
  */
60
61
  export function extractTodoProgress(args) {
61
- const raw = Array.isArray(args?.todos)
62
- ? args.todos
63
- : Array.isArray(args?.plan)
64
- ? args.plan
62
+ const input = args?.input && typeof args.input === 'object' ? args.input : args;
63
+ const raw = Array.isArray(input?.todos)
64
+ ? input.todos
65
+ : Array.isArray(input?.plan)
66
+ ? input.plan
65
67
  : undefined;
66
68
  if (!Array.isArray(raw) || raw.length === 0)
67
69
  return undefined;
@@ -78,7 +80,8 @@ export function extractTodoProgress(args) {
78
80
  if (!content)
79
81
  continue;
80
82
  const status = t?.status === 'completed' || t?.status === 'in_progress' ? t.status : 'pending';
81
- items.push(activeForm ? { content, status, activeForm } : { content, status });
83
+ const description = typeof t?.description === 'string' && t.description ? t.description : undefined;
84
+ items.push({ content, status, ...(description ? { description } : {}), ...(activeForm ? { activeForm } : {}) });
82
85
  }
83
86
  if (items.length === 0)
84
87
  return undefined;
@@ -91,6 +94,86 @@ export function extractTodoProgress(args) {
91
94
  activeForm: inProgress ? inProgress.activeForm ?? inProgress.content : undefined,
92
95
  };
93
96
  }
97
+ /** Fold snapshot checklist tools and Claude TaskCreate/TaskUpdate event logs. */
98
+ export function extractTodoProgressFromEvents(events) {
99
+ let items = [];
100
+ let nextTaskId = 1;
101
+ let sawChecklist = false;
102
+ for (const event of events) {
103
+ if (event.type !== 'tool_use')
104
+ continue;
105
+ const args = event.args ?? {};
106
+ if (SNAPSHOT_TODO_TOOLS.has(event.tool ?? '')) {
107
+ const input = args.input && typeof args.input === 'object' ? args.input : args;
108
+ const raw = Array.isArray(input.todos) ? input.todos : Array.isArray(input.plan) ? input.plan : undefined;
109
+ if (raw) {
110
+ items = raw.map((item) => ({ ...item }));
111
+ sawChecklist = true;
112
+ }
113
+ continue;
114
+ }
115
+ if (event.tool === TASK_CREATE_TOOL) {
116
+ const content = args.subject || args.description;
117
+ if (typeof content !== 'string' || !content.trim())
118
+ continue;
119
+ items.push({
120
+ taskId: String(nextTaskId++),
121
+ content: content.trim(),
122
+ description: typeof args.description === 'string' ? args.description : undefined,
123
+ activeForm: typeof args.activeForm === 'string' ? args.activeForm : undefined,
124
+ status: 'pending',
125
+ });
126
+ sawChecklist = true;
127
+ continue;
128
+ }
129
+ if (event.tool === TASK_UPDATE_TOOL) {
130
+ const taskId = String(args.taskId ?? args.task_id ?? '');
131
+ const index = items.findIndex(item => String(item.taskId ?? '') === taskId);
132
+ if (index < 0)
133
+ continue;
134
+ if (args.status === 'deleted') {
135
+ items.splice(index, 1);
136
+ continue;
137
+ }
138
+ const prior = items[index];
139
+ items[index] = {
140
+ ...prior,
141
+ ...(typeof args.subject === 'string' ? { content: args.subject } : {}),
142
+ ...(typeof args.description === 'string' ? { description: args.description } : {}),
143
+ ...(typeof args.activeForm === 'string' ? { activeForm: args.activeForm } : {}),
144
+ ...(typeof args.status === 'string' ? { status: args.status } : {}),
145
+ };
146
+ }
147
+ }
148
+ return sawChecklist ? extractTodoProgress({ todos: items }) : undefined;
149
+ }
150
+ /** Derive a recency-ordered, de-duplicated list of directories touched by tools. */
151
+ export function extractRecentDirectoriesTouched(events, cwd) {
152
+ const dirs = [];
153
+ const add = (value, file = false) => {
154
+ if (typeof value !== 'string' || !value.trim())
155
+ return;
156
+ const resolved = path.isAbsolute(value) ? value : path.resolve(cwd || process.cwd(), value);
157
+ const dir = file ? path.dirname(resolved) : resolved;
158
+ const old = dirs.indexOf(dir);
159
+ if (old >= 0)
160
+ dirs.splice(old, 1);
161
+ dirs.push(dir);
162
+ };
163
+ for (const event of events) {
164
+ if (event.type !== 'tool_use')
165
+ continue;
166
+ const tool = event.tool ?? '';
167
+ const args = event.args ?? {};
168
+ if (['Edit', 'Write', 'edit_file', 'write_file', 'create_file', 'edit', 'write'].includes(tool)) {
169
+ add(args.file_path ?? args.filePath ?? args.path ?? event.path, true);
170
+ }
171
+ else if (['Bash', 'exec_command', 'run_shell_command', 'shell', 'Execute'].includes(tool)) {
172
+ add(args.cwd ?? args.Cwd ?? args.workdir ?? args.working_directory ?? cwd);
173
+ }
174
+ }
175
+ return dirs.length ? dirs.slice(-10) : undefined;
176
+ }
94
177
  /** Trailing '?' or a leading interrogative — a question aimed at the user. */
95
178
  const QUESTION_TRAILING = /\?["'”)\]]?\s*$/;
96
179
  const QUESTION_PHRASE = /\b(shall i|should i|do you want|would you like|which (?:one|option|approach|of)|can you (?:confirm|clarify)|please (?:confirm|clarify|advise)|let me know|are you (?:ok|okay|sure)|proceed\?)\b/i;
@@ -105,7 +188,7 @@ const TICKET_BRANCH_RE = /(?:^|[/_-])([a-z]{2,6})-(\d{2,6})(?=[/_-]|$)/;
105
188
  /** Keys that look like tickets but aren't — avoid false positives from branches. */
106
189
  const TICKET_DENYLIST = new Set(['UTF', 'SHA', 'ISO', 'RFC', 'IPV', 'X86', 'ARM', 'MP', 'H']);
107
190
  const PR_URL_RE = /https:\/\/github\.com\/[^\s"'()<>]+\/pull\/(\d+)/;
108
- const WORKTREE_RE = /\/\.agents\/worktrees\/([^/]+)/;
191
+ export const WORKTREE_RE = /\/\.agents\/worktrees\/([^/]+)/;
109
192
  /** gh invocations that create/open a PR. */
110
193
  const GH_PR_CREATE_RE = /\bgh\s+pr\s+(?:create|new)\b/;
111
194
  /** gh invocation that opens an issue — the created number is read from its result. */
@@ -325,11 +408,10 @@ export function inferActivity(events, ctx = {}) {
325
408
  .slice(-3)
326
409
  .map(e => oneLine(e.content ?? ''))
327
410
  .filter(Boolean);
328
- // Live plan progress: the most recent TodoWrite's checklist (RUSH-1380). Attached
411
+ // Live plan progress: snapshot checklist tools or the folded Task* event log. Attached
329
412
  // to `base` so every return path below carries it — a working, waiting, or idle
330
413
  // session all keep showing how far the plan got.
331
- const lastTodo = lastOf(meaningful, e => e.type === 'tool_use' && (e.tool === TODO_TOOL || e.tool === CODEX_PLAN_TOOL));
332
- const todos = lastTodo ? extractTodoProgress(lastTodo.args) : undefined;
414
+ const todos = extractTodoProgressFromEvents(meaningful);
333
415
  const base = {
334
416
  activity: 'idle',
335
417
  lastRole: lastMsg?.role,
@@ -15,8 +15,8 @@ function resolveR2Config() {
15
15
  // The daemon monitor loop is headless by construction (no TTY, ever), so
16
16
  // isHeadlessSecretsContext() is true here and this resolves broker-only — a
17
17
  // broker miss can never pop an unattended Touch ID sheet on the user's screen
18
- // (same rationale as daemon.ts readDaemonClaudeOAuthToken). Using the shared
19
- // predicate rather than a literal keeps it consistent with the other callers
18
+ // (the same broker-only-when-headless rationale the secrets readers use). Using
19
+ // the shared predicate rather than a literal keeps it consistent with the other callers
20
20
  // and lets any interactive caller of loadR2Config still prompt.
21
21
  const { env } = readAndResolveBundleEnv(SYNC_BUNDLE, { caller: 'sessions-sync', agentOnly: isHeadlessSecretsContext() });
22
22
  const accountId = env.R2_ACCOUNT_ID?.trim();
@@ -10,6 +10,12 @@
10
10
  export type SessionAgentId = 'claude' | 'codex' | 'gemini' | 'antigravity' | 'opencode' | 'openclaw' | 'rush' | 'hermes' | 'grok' | 'kimi' | 'droid';
11
11
  /** All agents with session discovery support, in display order. */
12
12
  export declare const SESSION_AGENTS: SessionAgentId[];
13
+ /**
14
+ * True when `agent` stores session data `agents sessions` can discover (a member
15
+ * of {@link SESSION_AGENTS}). The single predicate every session-index writer
16
+ * gates on, so "is this a trackable agent?" is decided in exactly one place.
17
+ */
18
+ export declare function isSessionTrackedAgent(agent: string): agent is SessionAgentId;
13
19
  /** A single normalized event within a session (message, tool call, thinking, etc.). */
14
20
  export interface SessionEvent {
15
21
  type: 'message' | 'tool_use' | 'tool_result' | 'thinking' | 'error' | 'init' | 'result' | 'usage' | 'attachment';
@@ -25,6 +31,16 @@ export interface SessionEvent {
25
31
  output?: string;
26
32
  /** Internal: marks tool_use events from local commands */
27
33
  _local?: boolean;
34
+ /**
35
+ * Internal: marks a `role=user` message that is harness-injected scaffolding
36
+ * (Claude `<bash-input>`/`<bash-stdout>` from `!`-prefix runs, `<system-reminder>`,
37
+ * `<task-notification>`, `<command-*>` wrappers, `[Request interrupted]`, skill
38
+ * bodies, hook feedback) rather than a genuine user turn. Set centrally in
39
+ * `parseSession` via `isSyntheticUserMessage`. Such events are excluded from
40
+ * `--include user` and are not counted as turn starts by `--first`/`--last`,
41
+ * but stay in the default full stream so `--markdown` keeps full fidelity.
42
+ */
43
+ _synthetic?: boolean;
28
44
  model?: string;
29
45
  inputTokens?: number;
30
46
  outputTokens?: number;
@@ -41,10 +57,12 @@ export interface SessionAttachment {
41
57
  mediaType: string;
42
58
  sizeBytes?: number;
43
59
  }
44
- /** One checklist item, as Claude's `TodoWrite` (`content`) or Codex's `update_plan` (`step`) emits it. */
60
+ /** One normalized checklist/task item emitted by any transcript harness. */
45
61
  export interface TodoItem {
46
62
  content: string;
47
63
  status: 'pending' | 'in_progress' | 'completed';
64
+ /** Optional longer explanation supplied by task-based harnesses. */
65
+ description?: string;
48
66
  /** Present-continuous label shown while this item is the active step. */
49
67
  activeForm?: string;
50
68
  }
@@ -152,6 +170,12 @@ export interface SessionMeta {
152
170
  * instead of re-parsing the transcript. Absent when the session wrote no list.
153
171
  */
154
172
  todos?: TodoProgress;
173
+ /** Most-recent unique directories changed or used as a shell working directory. */
174
+ recentDirectoriesTouched?: string[];
175
+ /** Linear project containing ticketId, resolved lazily and cached in SQLite. */
176
+ linearProject?: string;
177
+ /** Browser URL for linearProject. */
178
+ linearProjectUrl?: string;
155
179
  /**
156
180
  * True when the session was spawned programmatically (SDK entrypoint) rather
157
181
  * than by a human at the Claude CLI. Captured at scan time from the JSONL
@@ -8,3 +8,11 @@
8
8
  */
9
9
  /** All agents with session discovery support, in display order. */
10
10
  export const SESSION_AGENTS = ['claude', 'codex', 'gemini', 'antigravity', 'opencode', 'openclaw', 'rush', 'hermes', 'grok', 'kimi', 'droid'];
11
+ /**
12
+ * True when `agent` stores session data `agents sessions` can discover (a member
13
+ * of {@link SESSION_AGENTS}). The single predicate every session-index writer
14
+ * gates on, so "is this a trackable agent?" is decided in exactly one place.
15
+ */
16
+ export function isSessionTrackedAgent(agent) {
17
+ return SESSION_AGENTS.includes(agent);
18
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Device affinity for `--device auto` / `--host auto`.
3
+ *
4
+ * Picks a host from 14d session usage (launches by machine), weighted sample
5
+ * among online devices. Most-used has highest probability — not a hard lock.
6
+ * Account selection is separate (`--strategy balanced` / rotate.ts).
7
+ */
8
+ import { type AffinityRow } from './session/db.js';
9
+ /** Peakiness of usage weights; >1 amplifies the most-used option. */
10
+ export declare const DEFAULT_AFFINITY_ALPHA = 1.3;
11
+ export interface WeightedCandidate {
12
+ key: string;
13
+ launches: number;
14
+ weight: number;
15
+ }
16
+ /**
17
+ * Convert affinity rows into positive weights: launches^α (floor 1 for any
18
+ * row with launches > 0 so a single-use host still participates).
19
+ */
20
+ export declare function affinityWeights(rows: AffinityRow[], alpha?: number): WeightedCandidate[];
21
+ /**
22
+ * Weighted random sample. Returns null when candidates is empty.
23
+ * Pure — inject `rng` for tests.
24
+ */
25
+ export declare function sampleWeighted(candidates: WeightedCandidate[], rng?: () => number): string | null;
26
+ /** Online device names from the local registry (+ always include local). */
27
+ export declare function listOnlineDeviceNames(localName?: string): string[];
28
+ export interface DeviceAffinityOptions {
29
+ sinceDays?: number;
30
+ alpha?: number;
31
+ /**
32
+ * Eligible hosts (normalized). Defaults to online devices + local.
33
+ * Empty after filter → fall back to local.
34
+ */
35
+ eligibleHosts?: string[];
36
+ localMachine?: string;
37
+ /** Injected affinity (tests). When omitted, reads sessions.db. */
38
+ deviceAffinity?: AffinityRow[];
39
+ rng?: () => number;
40
+ project?: string;
41
+ }
42
+ export interface DeviceAffinityPlan {
43
+ /** null means run locally (do not pass --host). */
44
+ host: string | null;
45
+ deviceCandidates: WeightedCandidate[];
46
+ pickedDeviceKey: string | null;
47
+ }
48
+ /**
49
+ * Resolve host for `--device auto`. Does NOT pick harness or accounts.
50
+ */
51
+ export declare function resolveDeviceAffinity(opts?: DeviceAffinityOptions): DeviceAffinityPlan;
52
+ /** True when a host flag value means affinity pick. */
53
+ export declare function isDeviceAuto(value: string | undefined | null): boolean;
54
+ export type DeviceAutoHostOptions = {
55
+ host?: string;
56
+ device?: string;
57
+ on?: string;
58
+ computer?: string;
59
+ /** @deprecated Hidden alias for `--device auto`. */
60
+ smart?: boolean;
61
+ balanced?: boolean;
62
+ strategy?: string;
63
+ };
64
+ export type DeviceAutoApplyResult = {
65
+ /** True when affinity ran (success or degrade-to-local). */
66
+ attempted: boolean;
67
+ /** True when deprecated `--smart` was seen. */
68
+ deprecationSmart: boolean;
69
+ /** Affinity threw: host slots cleared → local; message for stderr. */
70
+ skipped?: string;
71
+ /** Present when affinity resolved without error. */
72
+ banner?: {
73
+ hostLabel: string;
74
+ deviceHint: string;
75
+ acctNote: string;
76
+ };
77
+ };
78
+ /**
79
+ * Apply `--device auto` / `--host auto` (and deprecated `--smart`) onto run options.
80
+ * Mutates `options` in place. On affinity failure, clears auto slots (local) and
81
+ * returns `skipped` — never throws; callers must not hard-exit.
82
+ */
83
+ export declare function applyDeviceAutoToOptions(options: DeviceAutoHostOptions, deps?: {
84
+ resolve?: () => DeviceAffinityPlan;
85
+ accountPickerRequested?: boolean;
86
+ }): DeviceAutoApplyResult;
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Device affinity for `--device auto` / `--host auto`.
3
+ *
4
+ * Picks a host from 14d session usage (launches by machine), weighted sample
5
+ * among online devices. Most-used has highest probability — not a hard lock.
6
+ * Account selection is separate (`--strategy balanced` / rotate.ts).
7
+ */
8
+ import { queryAffinityRollup } from './session/db.js';
9
+ import { localMachineId } from './session/origin-machine.js';
10
+ import { loadDevicesSync } from './devices/registry.js';
11
+ import { normalizeHost } from './machine-id.js';
12
+ /** Peakiness of usage weights; >1 amplifies the most-used option. */
13
+ export const DEFAULT_AFFINITY_ALPHA = 1.3;
14
+ /**
15
+ * Convert affinity rows into positive weights: launches^α (floor 1 for any
16
+ * row with launches > 0 so a single-use host still participates).
17
+ */
18
+ export function affinityWeights(rows, alpha = DEFAULT_AFFINITY_ALPHA) {
19
+ return rows
20
+ .filter((r) => r.launches > 0 && r.key && r.key !== '(unknown)')
21
+ .map((r) => ({
22
+ key: r.key,
23
+ launches: r.launches,
24
+ weight: Math.max(1, Math.pow(r.launches, alpha)),
25
+ }));
26
+ }
27
+ /**
28
+ * Weighted random sample. Returns null when candidates is empty.
29
+ * Pure — inject `rng` for tests.
30
+ */
31
+ export function sampleWeighted(candidates, rng = Math.random) {
32
+ if (candidates.length === 0)
33
+ return null;
34
+ const total = candidates.reduce((s, c) => s + c.weight, 0);
35
+ if (total <= 0)
36
+ return candidates[0].key;
37
+ let roll = rng() * total;
38
+ for (const c of candidates) {
39
+ roll -= c.weight;
40
+ if (roll <= 0)
41
+ return c.key;
42
+ }
43
+ return candidates[candidates.length - 1].key;
44
+ }
45
+ /** Online device names from the local registry (+ always include local). */
46
+ export function listOnlineDeviceNames(localName = localMachineId()) {
47
+ const names = new Set([normalizeHost(localName)]);
48
+ try {
49
+ const reg = loadDevicesSync();
50
+ for (const [name, d] of Object.entries(reg)) {
51
+ if (!d || typeof d !== 'object')
52
+ continue;
53
+ const online = d.tailscale?.online;
54
+ // No tailscale snapshot → treat as candidate (registry-only box).
55
+ if (online === false)
56
+ continue;
57
+ names.add(normalizeHost(name));
58
+ }
59
+ }
60
+ catch {
61
+ /* registry missing — local only */
62
+ }
63
+ return [...names];
64
+ }
65
+ /**
66
+ * Resolve host for `--device auto`. Does NOT pick harness or accounts.
67
+ */
68
+ export function resolveDeviceAffinity(opts = {}) {
69
+ const local = normalizeHost(opts.localMachine ?? localMachineId());
70
+ const alpha = opts.alpha ?? DEFAULT_AFFINITY_ALPHA;
71
+ const rng = opts.rng ?? Math.random;
72
+ const sinceDays = opts.sinceDays ?? 14;
73
+ const sinceMs = Date.now() - sinceDays * 24 * 60 * 60 * 1000;
74
+ const eligible = new Set((opts.eligibleHosts ?? listOnlineDeviceNames(local)).map(normalizeHost));
75
+ if (eligible.size === 0)
76
+ eligible.add(local);
77
+ const deviceRows = opts.deviceAffinity ??
78
+ queryAffinityRollup({
79
+ groupBy: 'machine',
80
+ sinceMs,
81
+ excludeTeamOrigin: true,
82
+ onlyCli: true,
83
+ project: opts.project,
84
+ });
85
+ // Hosts with history get launches^α; online hosts with no history explore at weight 1.
86
+ const launchesByHost = new Map();
87
+ for (const r of deviceRows) {
88
+ const k = normalizeHost(r.key);
89
+ if (!eligible.has(k) || k === '(unknown)')
90
+ continue;
91
+ launchesByHost.set(k, (launchesByHost.get(k) ?? 0) + r.launches);
92
+ }
93
+ let deviceWeights = [...eligible].map((key) => {
94
+ const launches = launchesByHost.get(key) ?? 0;
95
+ return {
96
+ key,
97
+ launches,
98
+ weight: launches > 0 ? Math.max(1, Math.pow(launches, alpha)) : 1,
99
+ };
100
+ });
101
+ if (deviceWeights.length === 0) {
102
+ deviceWeights = [{ key: local, launches: 1, weight: 1 }];
103
+ }
104
+ const pickedDeviceKey = sampleWeighted(deviceWeights, rng);
105
+ const hostNorm = pickedDeviceKey ? normalizeHost(pickedDeviceKey) : local;
106
+ const host = hostNorm === local ? null : hostNorm;
107
+ return {
108
+ host,
109
+ deviceCandidates: deviceWeights,
110
+ pickedDeviceKey,
111
+ };
112
+ }
113
+ /** True when a host flag value means affinity pick. */
114
+ export function isDeviceAuto(value) {
115
+ return typeof value === 'string' && value.trim().toLowerCase() === 'auto';
116
+ }
117
+ const HOST_SLOTS = ['host', 'device', 'on', 'computer'];
118
+ /**
119
+ * Apply `--device auto` / `--host auto` (and deprecated `--smart`) onto run options.
120
+ * Mutates `options` in place. On affinity failure, clears auto slots (local) and
121
+ * returns `skipped` — never throws; callers must not hard-exit.
122
+ */
123
+ export function applyDeviceAutoToOptions(options, deps = {}) {
124
+ let deprecationSmart = false;
125
+ if (options.smart) {
126
+ const anyHost = HOST_SLOTS.some((k) => typeof options[k] === 'string' && options[k].trim() !== '');
127
+ if (!anyHost)
128
+ options.device = 'auto';
129
+ deprecationSmart = true;
130
+ }
131
+ const hasAuto = HOST_SLOTS.some((k) => isDeviceAuto(options[k]));
132
+ if (!hasAuto) {
133
+ return { attempted: false, deprecationSmart };
134
+ }
135
+ try {
136
+ const plan = (deps.resolve ?? (() => resolveDeviceAffinity({})))();
137
+ const concrete = plan.host; // null = local
138
+ for (const k of HOST_SLOTS) {
139
+ if (isDeviceAuto(options[k])) {
140
+ options[k] = concrete ?? undefined;
141
+ }
142
+ }
143
+ const accountPickerRequested = deps.accountPickerRequested ?? false;
144
+ if (!accountPickerRequested && !options.strategy && !options.balanced) {
145
+ options.balanced = true;
146
+ }
147
+ const hostLabel = concrete ?? 'local';
148
+ const deviceHint = plan.deviceCandidates
149
+ .slice(0, 4)
150
+ .map((c) => `${c.key}:${c.launches}`)
151
+ .join(', ');
152
+ const acctNote = accountPickerRequested ? 'accounts=picker' : 'accounts=balanced';
153
+ return {
154
+ attempted: true,
155
+ deprecationSmart,
156
+ banner: { hostLabel, deviceHint, acctNote },
157
+ };
158
+ }
159
+ catch (err) {
160
+ // Graceful degrade: clear auto slots so the run continues locally.
161
+ for (const k of HOST_SLOTS) {
162
+ if (isDeviceAuto(options[k])) {
163
+ options[k] = undefined;
164
+ }
165
+ }
166
+ return {
167
+ attempted: true,
168
+ deprecationSmart,
169
+ skipped: err.message,
170
+ };
171
+ }
172
+ }
@@ -121,9 +121,13 @@ export declare function getSystemRoutinesDir(): string;
121
121
  * Path to a project-scoped routines directory (`<project>/.agents/routines/`),
122
122
  * or null when no project `.agents/` is found by walking up from cwd.
123
123
  *
124
- * Project routines participate in `list`/`view`/`run` for inspection but are
125
- * NOT fired by the daemon (which runs from $HOME and loads only user + system
126
- * routines). Opt-in firing for project routines is tracked as a follow-up.
124
+ * Project routines participate in `list`/`view` for inspection always. Daemon
125
+ * firing requires an explicit opt-in via `agents routines enable-project`
126
+ * (writes `meta.routines.projects`); after opt-in, `agents routines sync`
127
+ * materialises them into the user layer with `source:` provenance so the
128
+ * daemon (which loads user + system only) can fire them safely. A project's
129
+ * own `agents.yaml` `routines.enable: true` is a documentation signal only —
130
+ * it does not auto-enable firing. See `lib/routines-project.ts`.
127
131
  */
128
132
  export declare function getProjectRoutinesDir(cwd?: string): string | null;
129
133
  /** Path to routine execution logs (~/.agents/.history/runs/). */
package/dist/lib/state.js CHANGED
@@ -319,9 +319,13 @@ export function getSystemRoutinesDir() { return SYSTEM_ROUTINES_DIR; }
319
319
  * Path to a project-scoped routines directory (`<project>/.agents/routines/`),
320
320
  * or null when no project `.agents/` is found by walking up from cwd.
321
321
  *
322
- * Project routines participate in `list`/`view`/`run` for inspection but are
323
- * NOT fired by the daemon (which runs from $HOME and loads only user + system
324
- * routines). Opt-in firing for project routines is tracked as a follow-up.
322
+ * Project routines participate in `list`/`view` for inspection always. Daemon
323
+ * firing requires an explicit opt-in via `agents routines enable-project`
324
+ * (writes `meta.routines.projects`); after opt-in, `agents routines sync`
325
+ * materialises them into the user layer with `source:` provenance so the
326
+ * daemon (which loads user + system only) can fire them safely. A project's
327
+ * own `agents.yaml` `routines.enable: true` is a documentation signal only —
328
+ * it does not auto-enable firing. See `lib/routines-project.ts`.
325
329
  */
326
330
  export function getProjectRoutinesDir(cwd = process.cwd()) {
327
331
  const projectAgentsDir = getProjectAgentsDir(cwd);
@@ -383,10 +387,21 @@ export function getTeamsDir() { return TEAMS_DIR; }
383
387
  export function getTeamsAgentsDir() { return TEAMS_AGENTS_DIR; }
384
388
  /** Path to the team registry — list of named teams with timestamps. Durable runtime, per-machine. */
385
389
  export function getTeamsRegistryPath() { return path.join(HISTORY_DIR, 'teams', 'registry.json'); }
390
+ /**
391
+ * The devices dir (registry + ignore-list live here). Read at CALL time so a
392
+ * test can redirect it to a temp dir via AGENTS_DEVICES_DIR without racing the
393
+ * module-load capture of HISTORY_DIR — mirrors the AGENTS_EVENTS_PATH /
394
+ * AGENTS_SECRETS_AGENT_DIR test-isolation escape hatches. Never set in
395
+ * production code; it exists so the vitest fork's device-registry writes can
396
+ * never reach the user's real ~/.agents/.history/devices (RUSH-2042).
397
+ */
398
+ function getDevicesDir() {
399
+ return process.env.AGENTS_DEVICES_DIR ?? path.join(HISTORY_DIR, 'devices');
400
+ }
386
401
  /** Path to the device registry — SSH device profiles with platform/auth metadata. Durable runtime, per-machine (host list + addresses are NOT pulled by `agents repo push`). */
387
- export function getDevicesRegistryPath() { return path.join(HISTORY_DIR, 'devices', 'registry.json'); }
402
+ export function getDevicesRegistryPath() { return path.join(getDevicesDir(), 'registry.json'); }
388
403
  /** Path to the device ignore-list — tailscale node names the user dismissed, so auto-discovery never re-suggests them. Per-machine, same dir as the registry. */
389
- export function getDevicesIgnoredPath() { return path.join(HISTORY_DIR, 'devices', 'ignored.json'); }
404
+ export function getDevicesIgnoredPath() { return path.join(getDevicesDir(), 'ignored.json'); }
390
405
  /** Dir of "pending device" sentinels (~/.agents/.cache/state/devices-pending/) — one empty-ish file per newly-discovered, not-yet-approved tailnet node. Written by the daemon probe, read by the menu-bar helper (mirrors the attention sentinel dir). */
391
406
  export function getDevicesPendingDir() { return path.join(RUNTIME_STATE_DIR, 'devices-pending'); }
392
407
  /** Path to cloud dispatch cache (~/.agents/.cache/cloud/). */