@phnx-labs/agents-cli 1.20.51 → 1.20.53

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 (142) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +12 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/browser.js +215 -7
  5. package/dist/commands/cloud.d.ts +3 -0
  6. package/dist/commands/cloud.js +8 -1
  7. package/dist/commands/events.d.ts +1 -1
  8. package/dist/commands/events.js +2 -3
  9. package/dist/commands/exec.js +82 -3
  10. package/dist/commands/factory.js +8 -0
  11. package/dist/commands/feed.d.ts +38 -0
  12. package/dist/commands/feed.js +274 -0
  13. package/dist/commands/logs.d.ts +5 -1
  14. package/dist/commands/logs.js +248 -3
  15. package/dist/commands/mcp.js +7 -0
  16. package/dist/commands/memory.d.ts +9 -0
  17. package/dist/commands/memory.js +164 -0
  18. package/dist/commands/message.d.ts +11 -6
  19. package/dist/commands/message.js +140 -5
  20. package/dist/commands/routines.js +12 -0
  21. package/dist/commands/secrets-migrate.d.ts +2 -1
  22. package/dist/commands/secrets-migrate.js +88 -13
  23. package/dist/commands/secrets.d.ts +22 -0
  24. package/dist/commands/secrets.js +176 -42
  25. package/dist/commands/sessions.js +10 -1
  26. package/dist/commands/teams.js +4 -0
  27. package/dist/commands/worktree.js +4 -2
  28. package/dist/index.js +21 -22
  29. package/dist/lib/agents.js +249 -17
  30. package/dist/lib/answer-router.d.ts +75 -0
  31. package/dist/lib/answer-router.js +149 -0
  32. package/dist/lib/ask-classifier.d.ts +71 -0
  33. package/dist/lib/ask-classifier.js +197 -0
  34. package/dist/lib/browser/login-detection.d.ts +94 -0
  35. package/dist/lib/browser/login-detection.js +274 -0
  36. package/dist/lib/browser/profiles.d.ts +17 -8
  37. package/dist/lib/browser/profiles.js +27 -8
  38. package/dist/lib/browser/secret-ref.d.ts +10 -0
  39. package/dist/lib/browser/secret-ref.js +14 -0
  40. package/dist/lib/browser/service.js +14 -12
  41. package/dist/lib/cloud/antigravity.d.ts +0 -2
  42. package/dist/lib/cloud/antigravity.js +2 -17
  43. package/dist/lib/cloud/codex.js +3 -18
  44. package/dist/lib/cloud/rush.d.ts +15 -0
  45. package/dist/lib/cloud/rush.js +10 -16
  46. package/dist/lib/cloud/stream.js +2 -0
  47. package/dist/lib/cloud/types.d.ts +21 -0
  48. package/dist/lib/cloud/types.js +81 -0
  49. package/dist/lib/crabbox/cli.d.ts +1 -1
  50. package/dist/lib/crabbox/cli.js +12 -2
  51. package/dist/lib/crabbox/lease.d.ts +19 -0
  52. package/dist/lib/crabbox/lease.js +22 -11
  53. package/dist/lib/crabbox/progress.d.ts +62 -0
  54. package/dist/lib/crabbox/progress.js +129 -0
  55. package/dist/lib/crabbox/runtimes.d.ts +38 -1
  56. package/dist/lib/crabbox/runtimes.js +98 -5
  57. package/dist/lib/daemon.d.ts +12 -9
  58. package/dist/lib/daemon.js +32 -17
  59. package/dist/lib/events.d.ts +31 -5
  60. package/dist/lib/events.js +291 -101
  61. package/dist/lib/exec.js +20 -1
  62. package/dist/lib/feed-outcome.d.ts +101 -0
  63. package/dist/lib/feed-outcome.js +244 -0
  64. package/dist/lib/feed-policy.d.ts +30 -0
  65. package/dist/lib/feed-policy.js +133 -0
  66. package/dist/lib/feed.d.ts +180 -0
  67. package/dist/lib/feed.js +627 -0
  68. package/dist/lib/git.d.ts +17 -1
  69. package/dist/lib/git.js +20 -1
  70. package/dist/lib/hooks.js +529 -14
  71. package/dist/lib/hosts/passthrough.d.ts +3 -3
  72. package/dist/lib/hosts/passthrough.js +3 -3
  73. package/dist/lib/mailbox-gc.d.ts +22 -0
  74. package/dist/lib/mailbox-gc.js +161 -0
  75. package/dist/lib/mailbox.d.ts +26 -2
  76. package/dist/lib/mailbox.js +80 -5
  77. package/dist/lib/mcp.js +82 -0
  78. package/dist/lib/memory.d.ts +55 -0
  79. package/dist/lib/memory.js +274 -0
  80. package/dist/lib/notify.d.ts +16 -0
  81. package/dist/lib/notify.js +61 -0
  82. package/dist/lib/operator.d.ts +26 -0
  83. package/dist/lib/operator.js +107 -0
  84. package/dist/lib/plugins.d.ts +35 -0
  85. package/dist/lib/plugins.js +217 -0
  86. package/dist/lib/remote-agents-json.d.ts +14 -0
  87. package/dist/lib/remote-agents-json.js +94 -0
  88. package/dist/lib/resources/mcp.js +44 -0
  89. package/dist/lib/resources/memory.d.ts +15 -0
  90. package/dist/lib/resources/memory.js +46 -0
  91. package/dist/lib/resources/types.d.ts +2 -2
  92. package/dist/lib/rotate.js +2 -0
  93. package/dist/lib/runner.d.ts +43 -0
  94. package/dist/lib/runner.js +323 -74
  95. package/dist/lib/sandbox.js +6 -0
  96. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  97. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  98. package/dist/lib/secrets/agent.d.ts +21 -0
  99. package/dist/lib/secrets/agent.js +63 -1
  100. package/dist/lib/secrets/bundles.d.ts +33 -1
  101. package/dist/lib/secrets/bundles.js +76 -21
  102. package/dist/lib/secrets/icloud-import.d.ts +79 -0
  103. package/dist/lib/secrets/icloud-import.js +203 -0
  104. package/dist/lib/secrets/index.d.ts +142 -2
  105. package/dist/lib/secrets/index.js +695 -30
  106. package/dist/lib/secrets/remote.js +1 -1
  107. package/dist/lib/secrets/sync.js +1 -1
  108. package/dist/lib/session/active.d.ts +18 -0
  109. package/dist/lib/session/active.js +47 -17
  110. package/dist/lib/session/db.d.ts +9 -1
  111. package/dist/lib/session/db.js +18 -3
  112. package/dist/lib/session/discover.d.ts +13 -0
  113. package/dist/lib/session/discover.js +32 -2
  114. package/dist/lib/session/parse.d.ts +8 -0
  115. package/dist/lib/session/parse.js +42 -21
  116. package/dist/lib/session/remote-active.js +8 -89
  117. package/dist/lib/session/state.d.ts +11 -0
  118. package/dist/lib/session/state.js +50 -1
  119. package/dist/lib/session/tail.d.ts +23 -4
  120. package/dist/lib/session/tail.js +34 -16
  121. package/dist/lib/session/throughput.d.ts +30 -0
  122. package/dist/lib/session/throughput.js +86 -0
  123. package/dist/lib/shim-heal.d.ts +12 -3
  124. package/dist/lib/shim-heal.js +12 -6
  125. package/dist/lib/staleness/detectors/subagents.js +57 -3
  126. package/dist/lib/staleness/writers/hooks.js +7 -3
  127. package/dist/lib/staleness/writers/subagents.js +37 -6
  128. package/dist/lib/startup/command-registry.d.ts +2 -0
  129. package/dist/lib/startup/command-registry.js +4 -0
  130. package/dist/lib/state.d.ts +2 -0
  131. package/dist/lib/state.js +25 -8
  132. package/dist/lib/subagents.d.ts +52 -0
  133. package/dist/lib/subagents.js +315 -12
  134. package/dist/lib/teams/agents.js +6 -3
  135. package/dist/lib/teams/worktree.d.ts +8 -0
  136. package/dist/lib/teams/worktree.js +8 -0
  137. package/dist/lib/types.d.ts +20 -2
  138. package/dist/lib/versions.js +38 -48
  139. package/dist/lib/whats-new.d.ts +5 -3
  140. package/dist/lib/whats-new.js +25 -5
  141. package/package.json +4 -3
  142. package/scripts/postinstall.js +61 -1
@@ -0,0 +1,149 @@
1
+ import { injectTargetFromReplyRail } from './session/inject.js';
2
+ /**
3
+ * Match free-text answer against question options.
4
+ * Exact (case-insensitive) > startsWith > includes. Returns 0-based index or -1.
5
+ */
6
+ export function matchOptionIndex(answer, options) {
7
+ if (!options?.length)
8
+ return -1;
9
+ const needle = answer.trim().toLowerCase();
10
+ if (!needle)
11
+ return -1;
12
+ const labels = options.map((o) => (o.label ?? '').trim());
13
+ const exact = labels.findIndex((l) => l.toLowerCase() === needle);
14
+ if (exact >= 0)
15
+ return exact;
16
+ const starts = labels.findIndex((l) => l.toLowerCase().startsWith(needle));
17
+ if (starts >= 0)
18
+ return starts;
19
+ const includes = labels.findIndex((l) => l.toLowerCase().includes(needle));
20
+ return includes;
21
+ }
22
+ /**
23
+ * Build the keystroke payload that closes an AskUserQuestion TUI.
24
+ * Numbered options are selected by digit (1-based) + Enter. Free text with an
25
+ * "Other" option selects Other then types; without Other, types the answer.
26
+ */
27
+ export function keystrokesForAnswer(answer, options) {
28
+ const idx = matchOptionIndex(answer, options);
29
+ if (idx >= 0) {
30
+ // AskUserQuestion / plan select-lists are 1-indexed digits.
31
+ return { payload: `${idx + 1}`, matched: 'option' };
32
+ }
33
+ if (options?.length) {
34
+ const otherIdx = options.findIndex((o) => /^other$/i.test((o.label ?? '').trim()));
35
+ if (otherIdx >= 0) {
36
+ // Select Other, then type the free text on the next field.
37
+ return { payload: `${otherIdx + 1}\n${answer}`, matched: 'other' };
38
+ }
39
+ }
40
+ return { payload: answer, matched: 'free-text' };
41
+ }
42
+ /** True when the session is waiting on user input (parked on a question/plan). */
43
+ export function isParkedOnInput(session) {
44
+ if (!session)
45
+ return false;
46
+ if (session.status === 'input_required')
47
+ return true;
48
+ if (session.activity === 'waiting_input')
49
+ return true;
50
+ if (session.awaitingReason === 'question' || session.awaitingReason === 'plan_review' || session.awaitingReason === 'permission') {
51
+ return true;
52
+ }
53
+ return false;
54
+ }
55
+ /** True when an open feed block still needs an answer. */
56
+ export function isOpenQuestionBlock(block) {
57
+ if (!block)
58
+ return false;
59
+ if (block.answer || block.parkedAt || block.continuedAt || block.defaultedAt)
60
+ return false;
61
+ return (block.questions?.length ?? 0) > 0;
62
+ }
63
+ function injectTargetForSession(session) {
64
+ if (session.provenance?.reply) {
65
+ const fromRail = injectTargetFromReplyRail(session.provenance.reply);
66
+ if (fromRail)
67
+ return fromRail;
68
+ }
69
+ // Only the agents-pty sidecar is addressable by session id. Generic headless
70
+ // runs have no PTY server entry — those go through resume, not inject.
71
+ if (session.sessionId && session.host === 'pty') {
72
+ return { backend: 'pty', id: session.sessionId };
73
+ }
74
+ return null;
75
+ }
76
+ /**
77
+ * Pick the delivery mechanism for one answer.
78
+ *
79
+ * Precedence:
80
+ * 1. Parked on open question + injectable rail (tmux/iterm/pty) → keystroke
81
+ * 2. Parked on open question + headless (no rail) → resume with answer
82
+ * 3. Parked on open question + no rail + interactive → refuse (don't mailbox-drop)
83
+ * 4. Otherwise → mailbox (running agent between tool calls)
84
+ */
85
+ export function resolveAnswerRoute(input) {
86
+ const { answer, block, session } = input;
87
+ const openQ = isOpenQuestionBlock(block);
88
+ const parked = isParkedOnInput(session);
89
+ // Options from the first question on the block (or the session's structured question).
90
+ const options = block?.questions?.[0]?.options ??
91
+ session?.question?.options?.map((o) => ({ label: o.label }));
92
+ if (openQ && parked && session) {
93
+ const inject = injectTargetForSession(session);
94
+ const { payload, matched } = keystrokesForAnswer(answer, options);
95
+ if (inject) {
96
+ const kind = inject.backend === 'tmux' ? 'tmux'
97
+ : inject.backend === 'iterm' ? 'iterm'
98
+ : inject.backend === 'pty' ? 'pty'
99
+ : 'pty';
100
+ return {
101
+ kind,
102
+ reason: `Parked on open question — drive ${inject.backend} selection (${matched}).`,
103
+ payload,
104
+ inject,
105
+ };
106
+ }
107
+ // Headless / no rail: re-enter via resume.
108
+ if (session.context === 'headless' || session.context === 'teams' || !session.tty) {
109
+ const sid = session.sessionId ?? input.mailboxId;
110
+ if (!sid) {
111
+ return {
112
+ kind: 'refuse',
113
+ reason: 'Parked headless agent has no session id to resume.',
114
+ };
115
+ }
116
+ return {
117
+ kind: 'resume',
118
+ reason: 'Parked headless agent — resume with the answer as the next user turn.',
119
+ payload: answer,
120
+ resume: { sessionId: sid, agent: session.kind },
121
+ };
122
+ }
123
+ return {
124
+ kind: 'refuse',
125
+ reason: 'Agent is parked on a question but has no addressable terminal (no tmux/iterm/pty rail). ' +
126
+ 'Open its terminal and answer there, or run `agents sessions focus <id>` first.',
127
+ };
128
+ }
129
+ // Open block but agent is still looping between tool calls — mailbox is correct.
130
+ // No block — free-form mid-flight steer — mailbox.
131
+ return {
132
+ kind: 'mailbox',
133
+ reason: openQ
134
+ ? 'Open block on a running agent — deliver via mailbox at next tool call.'
135
+ : 'No open question — deliver via mailbox at next tool call.',
136
+ payload: answer,
137
+ };
138
+ }
139
+ /** Build argv for a headless resume that continues with the answer text. */
140
+ export function resumeArgv(route) {
141
+ if (route.kind !== 'resume' || !route.resume) {
142
+ throw new Error('resumeArgv requires a resume route');
143
+ }
144
+ const { agent, sessionId } = route.resume;
145
+ const text = route.payload ?? '';
146
+ // `agents run <agent> --resume <id> -- <answer>` — the trailing prompt is the
147
+ // next user turn after native resume (claude/codex) or /continue replay.
148
+ return ['run', agent, '--resume', sessionId, '--', text];
149
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Ask classifier + stall suppression (RUSH-1477).
3
+ *
4
+ * ~39% of AskUserQuestion calls are workflow-stalls ("should I…?", "what's next?",
5
+ * "merge now?"). The feed's first job is to make those disappear so real Decisions
6
+ * and Approvals stay visible.
7
+ *
8
+ * Pipeline:
9
+ * 1. Classify every block into Decision / Approval / Clarification / Stall / Fyi
10
+ * 2. Suppress stalls (and pure FYIs) with an auto-answer so they never render
11
+ * 3. Surface Decision + Approval (+ Clarification when it needs a real fact)
12
+ *
13
+ * Rules-based on stable high-volume shapes. Pure classify/match; suppression has
14
+ * side effects via the feed store when applied.
15
+ */
16
+ import type { OpenBlock } from './feed.js';
17
+ /** Taxonomy for a published ask. Exactly one class per block. */
18
+ export type AskClass = 'decision' | 'approval' | 'clarification' | 'stall' | 'fyi';
19
+ export interface Classification {
20
+ class: AskClass;
21
+ /** Why this class was chosen (rule name). */
22
+ rule: string;
23
+ /** True when the feed should hide this block and auto-answer it. */
24
+ suppress: boolean;
25
+ /** Auto-answer text when suppress is true. */
26
+ autoAnswer?: string;
27
+ }
28
+ /** Scan text used for classification. */
29
+ export declare function askScanText(block: Pick<OpenBlock, 'questions'>): string;
30
+ /**
31
+ * Classify one ask from free text (+ optional explicit blockClass from the agent).
32
+ * Explicit blockClass from the agent is honored as a floor: 'decision' never becomes
33
+ * a suppressible stall (false-suppress rate ~0 for real Decisions).
34
+ */
35
+ export declare function classifyAsk(text: string, opts?: {
36
+ header?: string;
37
+ blockClass?: OpenBlock['blockClass'];
38
+ }): Classification;
39
+ /** Classify a full open block (all questions concatenated). */
40
+ export declare function classifyBlock(block: OpenBlock): Classification;
41
+ export interface SuppressResult {
42
+ blockId: string;
43
+ class: AskClass;
44
+ rule: string;
45
+ autoAnswer: string;
46
+ /** True when the block was removed from the visible feed. */
47
+ suppressed: boolean;
48
+ }
49
+ /**
50
+ * Auto-answer + remove a suppressible block so it never renders as a card.
51
+ * Logs the stall as answered by policy:stall-suppression.
52
+ */
53
+ export declare function suppressStallBlock(block: OpenBlock, root?: string): SuppressResult;
54
+ export interface FeedFilterResult {
55
+ /** Blocks that should render as cards. */
56
+ surfaced: OpenBlock[];
57
+ /** Suppression audit rows (stalls auto-resolved). */
58
+ suppressed: SuppressResult[];
59
+ /** Per-class counts over the input set (before suppression). */
60
+ counts: Record<AskClass, number>;
61
+ }
62
+ /**
63
+ * Classify every block; optionally apply stall suppression (mutate store).
64
+ * When `apply` is false, only classifies (dry run for --json audit).
65
+ */
66
+ export declare function filterBlocksForFeed(blocks: OpenBlock[], opts?: {
67
+ apply?: boolean;
68
+ root?: string;
69
+ }): FeedFilterResult;
70
+ /** Human digest: "31 stalls auto-resolved by policy". */
71
+ export declare function suppressionDigest(result: FeedFilterResult): string;
@@ -0,0 +1,197 @@
1
+ import { recordAnswer, recordMessageReceipt, removeBlock } from './feed.js';
2
+ import { enqueue, mailboxDir } from './mailbox.js';
3
+ /** Scan text used for classification. */
4
+ export function askScanText(block) {
5
+ return block.questions
6
+ .map((q) => [q.header, q.text].filter(Boolean).join(' '))
7
+ .join('\n')
8
+ .toLowerCase();
9
+ }
10
+ const RULES = [
11
+ // FYI / done — no action needed
12
+ {
13
+ name: 'fyi-done',
14
+ class: 'fyi',
15
+ suppress: true,
16
+ autoAnswer: 'acknowledged',
17
+ test: (t) => /\b(fyi|for your information|just letting you know|heads.?up|no action needed|already done|completed successfully)\b/.test(t) ||
18
+ /^(done|shipped|merged|complete)[.!]?$/.test(t.trim()),
19
+ },
20
+ // Stall: "should I / want me to / shall I continue"
21
+ {
22
+ name: 'stall-should-i',
23
+ class: 'stall',
24
+ suppress: true,
25
+ autoAnswer: 'Yes, continue.',
26
+ test: (t) => /\b(should i|shall i|want me to|would you like me to|do you want me to|can i go ahead|ok to proceed|okay to continue)\b/.test(t),
27
+ },
28
+ // Stall: "what's next / continue?"
29
+ {
30
+ name: 'stall-whats-next',
31
+ class: 'stall',
32
+ suppress: true,
33
+ autoAnswer: 'Continue with the next step.',
34
+ test: (t) => /\b(what('?s| is)? next|what now|continue\??|keep going\??|ready for the next|next step\??)\b/.test(t) ||
35
+ /^continue\??$/.test(t.trim()),
36
+ },
37
+ // Stall: verify-then-proceed / looks good?
38
+ {
39
+ name: 'stall-verify-proceed',
40
+ class: 'stall',
41
+ suppress: true,
42
+ autoAnswer: 'Looks good — proceed.',
43
+ test: (t) => /\b(looks good\??|does this look (ok|right|good)|sound good\??|ready to (go|ship|land)\??|any objections\??)\b/.test(t),
44
+ },
45
+ // Approval: merge/release/ship/commit yes-no (surfaces; policy can default later)
46
+ {
47
+ name: 'approval-merge-release',
48
+ class: 'approval',
49
+ suppress: false,
50
+ test: (t) => /\b(merge (now|this|the pr|it)\??|release\??|ship (it|now)\??|publish\??|cut (a )?release|tag (and )?release|commit (this|these changes)\??)\b/.test(t) ||
51
+ /\b(approve|deny|allow|reject)\b.*\b(merge|release|deploy|pr)\b/.test(t),
52
+ },
53
+ // Clarification: which X / which repo / pick one fact
54
+ {
55
+ name: 'clarification-which',
56
+ class: 'clarification',
57
+ suppress: false,
58
+ test: (t) => /\b(which (repo|branch|host|file|path|environment|env|project|package|version|account|device|machine)|what is the (path|url|id|name) of)\b/.test(t) ||
59
+ /\b(missing (the )?(path|url|id|name|repo)|need (the )?(path|url|id|repo) for)\b/.test(t),
60
+ },
61
+ // Decision: scope / approach / tradeoff (never auto-suppress)
62
+ {
63
+ name: 'decision-scope-approach',
64
+ class: 'decision',
65
+ suppress: false,
66
+ test: (t) => /\b(scope|approach|architecture|design|trade-?off|which approach|how should we|prefer A or B|option a or b)\b/.test(t) ||
67
+ /\b(break(ing)? change|public api|migrate everyone|rewrite)\b/.test(t),
68
+ },
69
+ ];
70
+ /**
71
+ * Classify one ask from free text (+ optional explicit blockClass from the agent).
72
+ * Explicit blockClass from the agent is honored as a floor: 'decision' never becomes
73
+ * a suppressible stall (false-suppress rate ~0 for real Decisions).
74
+ */
75
+ export function classifyAsk(text, opts) {
76
+ const scan = [opts?.header, text].filter(Boolean).join(' ').toLowerCase();
77
+ for (const rule of RULES) {
78
+ if (!rule.test(scan))
79
+ continue;
80
+ // Safety: agent-tagged decisions never suppress.
81
+ if (opts?.blockClass === 'decision' && rule.suppress) {
82
+ return { class: 'decision', rule: 'agent-blockClass-decision', suppress: false };
83
+ }
84
+ return {
85
+ class: rule.class,
86
+ rule: rule.name,
87
+ suppress: rule.suppress,
88
+ autoAnswer: rule.autoAnswer,
89
+ };
90
+ }
91
+ // Explicit agent tag with no text rule.
92
+ if (opts?.blockClass === 'decision') {
93
+ return { class: 'decision', rule: 'agent-blockClass-decision', suppress: false };
94
+ }
95
+ if (opts?.blockClass === 'approval') {
96
+ return { class: 'approval', rule: 'agent-blockClass-approval', suppress: false };
97
+ }
98
+ // Default: surface as decision so we never silent-drop unknowns.
99
+ return { class: 'decision', rule: 'default-decision', suppress: false };
100
+ }
101
+ /** Classify a full open block (all questions concatenated). */
102
+ export function classifyBlock(block) {
103
+ const text = block.questions.map((q) => q.text).join(' ');
104
+ const header = block.questions.map((q) => q.header).filter(Boolean).join(' ');
105
+ return classifyAsk(text, { header, blockClass: block.blockClass });
106
+ }
107
+ /**
108
+ * Auto-answer + remove a suppressible block so it never renders as a card.
109
+ * Logs the stall as answered by policy:stall-suppression.
110
+ */
111
+ export function suppressStallBlock(block, root) {
112
+ const c = classifyBlock(block);
113
+ if (!c.suppress || !c.autoAnswer) {
114
+ return { blockId: block.blockId, class: c.class, rule: c.rule, autoAnswer: '', suppressed: false };
115
+ }
116
+ const claim = recordAnswer(block.blockId, {
117
+ answeredFrom: 'policy',
118
+ answeredBy: 'stall-suppression',
119
+ operatorId: 'policy',
120
+ verified: true,
121
+ }, root);
122
+ if (!claim.ok) {
123
+ return { blockId: block.blockId, class: c.class, rule: c.rule, autoAnswer: c.autoAnswer, suppressed: false };
124
+ }
125
+ try {
126
+ // Mailbox lives under the global mailbox root (not the feed dir).
127
+ const msgId = enqueue(mailboxDir(block.mailboxId), {
128
+ to: block.mailboxId,
129
+ text: c.autoAnswer,
130
+ from: 'stall-suppression',
131
+ blockId: block.blockId,
132
+ });
133
+ recordMessageReceipt(block.blockId, { msgId, status: 'queued', at: new Date().toISOString(), from: 'stall-suppression' }, root);
134
+ }
135
+ catch {
136
+ // Mailbox write can fail if the box id is invalid; still drop the card.
137
+ }
138
+ removeBlock(block.blockId, root);
139
+ return {
140
+ blockId: block.blockId,
141
+ class: c.class,
142
+ rule: c.rule,
143
+ autoAnswer: c.autoAnswer,
144
+ suppressed: true,
145
+ };
146
+ }
147
+ /**
148
+ * Classify every block; optionally apply stall suppression (mutate store).
149
+ * When `apply` is false, only classifies (dry run for --json audit).
150
+ */
151
+ export function filterBlocksForFeed(blocks, opts) {
152
+ const counts = {
153
+ decision: 0,
154
+ approval: 0,
155
+ clarification: 0,
156
+ stall: 0,
157
+ fyi: 0,
158
+ };
159
+ const surfaced = [];
160
+ const suppressed = [];
161
+ const apply = opts?.apply === true;
162
+ for (const block of blocks) {
163
+ const c = classifyBlock(block);
164
+ counts[c.class] += 1;
165
+ if (c.suppress) {
166
+ if (apply) {
167
+ const r = suppressStallBlock(block, opts?.root);
168
+ suppressed.push(r);
169
+ if (!r.suppressed)
170
+ surfaced.push(block);
171
+ }
172
+ else {
173
+ suppressed.push({
174
+ blockId: block.blockId,
175
+ class: c.class,
176
+ rule: c.rule,
177
+ autoAnswer: c.autoAnswer ?? '',
178
+ suppressed: false,
179
+ });
180
+ }
181
+ }
182
+ else {
183
+ surfaced.push(block);
184
+ }
185
+ }
186
+ return { surfaced, suppressed, counts };
187
+ }
188
+ /** Human digest: "31 stalls auto-resolved by policy". */
189
+ export function suppressionDigest(result) {
190
+ const n = result.suppressed.filter((s) => s.suppressed || s.class === 'stall' || s.class === 'fyi').length;
191
+ if (n === 0)
192
+ return '';
193
+ const applied = result.suppressed.filter((s) => s.suppressed).length;
194
+ if (applied > 0)
195
+ return `${applied} stall${applied === 1 ? '' : 's'} auto-resolved by policy`;
196
+ return `${n} stall${n === 1 ? '' : 's'} eligible for suppression`;
197
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Best-effort login-state detection for browser profiles.
3
+ *
4
+ * Reads a profile's Chromium cookie store (presence only — never decrypts the
5
+ * Keychain-encrypted values) to tell which login-gated services have a live
6
+ * session in that profile. Powers the `agents browser start` guardrail (warn
7
+ * when an agent opens a logged-out profile for a login-gated URL) and the
8
+ * `agents browser profiles logins` view.
9
+ *
10
+ * Everything here is advisory: any failure (missing DB, locked file, unknown
11
+ * schema) degrades to "no known session" and NEVER throws to callers — browser
12
+ * start must not slow down or break because cookie inspection hiccuped.
13
+ */
14
+ /** A login-gated service signature: host substrings + the auth-cookie names
15
+ * whose presence indicates a live session, plus optional login metadata. */
16
+ interface ServiceSignature {
17
+ hosts: string[];
18
+ cookies: string[];
19
+ /**
20
+ * Where an interactive login for this service starts, and the bundle key
21
+ * PREFIX its credentials live under. By convention a profile's `--secrets`
22
+ * bundle holds `<PREFIX>_USERNAME` / `<PREFIX>_PASSWORD` (see
23
+ * `credKeysForService`). The prefix is explicit — never string-munged from the
24
+ * service id — so e.g. `x` → `X`, `google` → `GOOGLE`.
25
+ */
26
+ login?: {
27
+ loginUrl: string;
28
+ keyPrefix: string;
29
+ };
30
+ }
31
+ /**
32
+ * Known login-gated services. Presence of ANY listed cookie on a matching host
33
+ * (unexpired, or a session cookie) = logged in. Deliberately conservative: a
34
+ * service absent from this map simply never triggers a warning — we do not
35
+ * guess. Each cookie name here is the definitive authenticated-session token for
36
+ * that service (e.g. LinkedIn's `li_at` is only set for a signed-in member;
37
+ * visitor cookies like `bcookie`/`JSESSIONID` are NOT auth). Services whose
38
+ * session is not reliably expressed as a recognizable cookie (e.g. Attio, whose
39
+ * only same-site cookies are third-party analytics) are deliberately omitted so
40
+ * we never emit a false "logged out" warning. Extend as needed.
41
+ */
42
+ export declare const AUTH_SIGNATURES: Record<string, ServiceSignature>;
43
+ /**
44
+ * The bundle keys a profile's `--secrets` bundle should hold to log into
45
+ * `service`: `<PREFIX>_USERNAME` / `<PREFIX>_PASSWORD`. Null for services with no
46
+ * login metadata. Feed a resolved value to the page with
47
+ * `agents browser type <ref> --secret <bundle>/<KEY>` (never printed).
48
+ */
49
+ export declare function credKeysForService(service: string): {
50
+ user: string;
51
+ pass: string;
52
+ } | null;
53
+ /** The interactive login URL for a service, or null. */
54
+ export declare function loginUrlForService(service: string): string | null;
55
+ export interface CookieRow {
56
+ host_key: string;
57
+ name: string;
58
+ }
59
+ /**
60
+ * Pure core: given a profile's (already expiry-filtered) cookie rows, return the
61
+ * services with a live session. Exported for direct unit testing without disk.
62
+ */
63
+ export declare function detectServices(rows: CookieRow[]): string[];
64
+ /** Map a URL to a known login-gated service key, or null. */
65
+ export declare function serviceForUrl(url: string): string | null;
66
+ /** A saved-login row from Chromium `Login Data` (username plaintext; password
67
+ * encrypted and never read). */
68
+ export interface LoginRow {
69
+ origin_url: string;
70
+ username_value: string;
71
+ signon_realm: string;
72
+ }
73
+ /**
74
+ * Map a profile's saved logins to service → account username (identity, not
75
+ * session). Best-effort; `{}` if `Login Data` is unreadable. The first non-empty
76
+ * username per service wins.
77
+ */
78
+ export declare function accountsForProfile(profileName: string): Promise<Record<string, string>>;
79
+ /**
80
+ * For each service with a LIVE session in the profile (cookie-gated), the
81
+ * account it is signed in as (from saved logins). Intersecting with live
82
+ * sessions means a stale saved-login for a service you've since logged out of
83
+ * never shows.
84
+ */
85
+ export declare function loginsWithAccountsForProfile(profileName: string): Promise<Array<{
86
+ service: string;
87
+ username?: string;
88
+ }>>;
89
+ /** Services with a live session in the given profile (best-effort; [] if none
90
+ * detected or the cookie store is unreadable). */
91
+ export declare function loginsForProfile(profileName: string): Promise<string[]>;
92
+ /** Profile names that have a live session for the given service. */
93
+ export declare function profilesLoggedInto(service: string): Promise<string[]>;
94
+ export {};