@phnx-labs/agents-cli 1.20.92 → 1.21.0

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 (146) hide show
  1. package/CHANGELOG.md +385 -0
  2. package/README.md +3 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/beta.js +3 -5
  5. package/dist/commands/doctor.js +23 -0
  6. package/dist/commands/events.js +91 -1
  7. package/dist/commands/exec.js +13 -0
  8. package/dist/commands/mailboxes.js +39 -1
  9. package/dist/commands/message.js +12 -1
  10. package/dist/commands/packages.js +14 -5
  11. package/dist/commands/projects.d.ts +38 -0
  12. package/dist/commands/projects.js +355 -48
  13. package/dist/commands/routines.js +27 -1
  14. package/dist/commands/rules.js +6 -0
  15. package/dist/commands/secrets.d.ts +17 -0
  16. package/dist/commands/secrets.js +198 -7
  17. package/dist/commands/send.d.ts +14 -12
  18. package/dist/commands/send.js +105 -35
  19. package/dist/commands/sessions.d.ts +17 -0
  20. package/dist/commands/sessions.js +16 -8
  21. package/dist/commands/setup-browser.js +5 -1
  22. package/dist/commands/setup-fleet.js +5 -0
  23. package/dist/commands/setup-preferences.d.ts +53 -0
  24. package/dist/commands/setup-preferences.js +142 -0
  25. package/dist/commands/setup.js +8 -9
  26. package/dist/commands/ssh.js +220 -10
  27. package/dist/commands/sync.d.ts +2 -2
  28. package/dist/commands/sync.js +13 -12
  29. package/dist/commands/view.js +4 -0
  30. package/dist/commands/watchdog.js +26 -3
  31. package/dist/index.js +38 -6
  32. package/dist/lib/activity.d.ts +15 -0
  33. package/dist/lib/activity.js +26 -2
  34. package/dist/lib/auto-dispatch.d.ts +6 -1
  35. package/dist/lib/auto-dispatch.js +7 -2
  36. package/dist/lib/beta.d.ts +1 -1
  37. package/dist/lib/beta.js +1 -1
  38. package/dist/lib/channels/send.d.ts +83 -0
  39. package/dist/lib/channels/send.js +112 -0
  40. package/dist/lib/daemon.d.ts +13 -0
  41. package/dist/lib/daemon.js +80 -60
  42. package/dist/lib/device-config.d.ts +82 -0
  43. package/dist/lib/device-config.js +296 -0
  44. package/dist/lib/event-provenance.d.ts +19 -0
  45. package/dist/lib/event-provenance.js +48 -0
  46. package/dist/lib/events-ingest.d.ts +46 -0
  47. package/dist/lib/events-ingest.js +182 -0
  48. package/dist/lib/events.d.ts +17 -5
  49. package/dist/lib/events.js +59 -56
  50. package/dist/lib/feed.d.ts +1 -1
  51. package/dist/lib/feed.js +2 -0
  52. package/dist/lib/git.d.ts +13 -2
  53. package/dist/lib/git.js +38 -6
  54. package/dist/lib/linear-project-counts.d.ts +129 -0
  55. package/dist/lib/linear-project-counts.js +206 -0
  56. package/dist/lib/linear-projects.d.ts +62 -0
  57. package/dist/lib/linear-projects.js +144 -0
  58. package/dist/lib/mailbox-gc.js +30 -7
  59. package/dist/lib/mailbox.d.ts +14 -1
  60. package/dist/lib/mailbox.js +35 -3
  61. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  62. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  63. package/dist/lib/menubar/install-menubar.d.ts +5 -21
  64. package/dist/lib/menubar/install-menubar.js +35 -44
  65. package/dist/lib/menubar/notify-desktop.d.ts +17 -2
  66. package/dist/lib/menubar/notify-desktop.js +8 -2
  67. package/dist/lib/migrate.js +1 -1
  68. package/dist/lib/models.js +21 -11
  69. package/dist/lib/overdue.d.ts +1 -1
  70. package/dist/lib/overdue.js +54 -19
  71. package/dist/lib/project-import.d.ts +96 -0
  72. package/dist/lib/project-import.js +171 -0
  73. package/dist/lib/project-probe.d.ts +75 -0
  74. package/dist/lib/project-probe.js +160 -0
  75. package/dist/lib/project-resources.d.ts +8 -0
  76. package/dist/lib/project-resources.js +31 -3
  77. package/dist/lib/project-status.d.ts +32 -1
  78. package/dist/lib/project-status.js +82 -1
  79. package/dist/lib/projects.d.ts +6 -0
  80. package/dist/lib/projects.js +12 -0
  81. package/dist/lib/remote-agents-json.d.ts +14 -1
  82. package/dist/lib/remote-agents-json.js +21 -3
  83. package/dist/lib/routine-notify.d.ts +11 -0
  84. package/dist/lib/routine-notify.js +22 -0
  85. package/dist/lib/routines.d.ts +53 -0
  86. package/dist/lib/routines.js +105 -4
  87. package/dist/lib/rules/run-sync.d.ts +18 -0
  88. package/dist/lib/rules/run-sync.js +92 -0
  89. package/dist/lib/run-notify.js +3 -0
  90. package/dist/lib/scheduler.js +12 -2
  91. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  92. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  93. package/dist/lib/secrets/audit.d.ts +1 -1
  94. package/dist/lib/secrets/audit.js +53 -10
  95. package/dist/lib/secrets/index.d.ts +4 -5
  96. package/dist/lib/secrets/index.js +4 -5
  97. package/dist/lib/secrets/list-filter.d.ts +20 -5
  98. package/dist/lib/secrets/list-filter.js +22 -6
  99. package/dist/lib/secrets/usage-db.d.ts +106 -0
  100. package/dist/lib/secrets/usage-db.js +236 -0
  101. package/dist/lib/session/active.d.ts +16 -2
  102. package/dist/lib/session/active.js +8 -4
  103. package/dist/lib/session/bundle.js +5 -1
  104. package/dist/lib/session/remote-active.d.ts +5 -1
  105. package/dist/lib/session/remote-active.js +4 -1
  106. package/dist/lib/session/remote-list.d.ts +1 -15
  107. package/dist/lib/session/remote-list.js +22 -111
  108. package/dist/lib/session/sync/config.d.ts +13 -5
  109. package/dist/lib/session/sync/config.js +21 -13
  110. package/dist/lib/sqlite.js +28 -1
  111. package/dist/lib/startup/command-registry.d.ts +0 -2
  112. package/dist/lib/startup/command-registry.js +1 -5
  113. package/dist/lib/state.d.ts +19 -2
  114. package/dist/lib/state.js +99 -10
  115. package/dist/lib/sync-umbrella.d.ts +11 -23
  116. package/dist/lib/sync-umbrella.js +14 -45
  117. package/dist/lib/teams/agents.d.ts +35 -3
  118. package/dist/lib/teams/agents.js +52 -8
  119. package/dist/lib/teams/scheduler.d.ts +29 -2
  120. package/dist/lib/teams/scheduler.js +60 -16
  121. package/dist/lib/types.d.ts +22 -5
  122. package/dist/lib/versions.d.ts +6 -0
  123. package/dist/lib/versions.js +6 -4
  124. package/package.json +1 -2
  125. package/dist/commands/drive.d.ts +0 -10
  126. package/dist/commands/drive.js +0 -183
  127. package/dist/commands/hq.d.ts +0 -2
  128. package/dist/commands/hq.js +0 -58
  129. package/dist/commands/sessions-sync.d.ts +0 -17
  130. package/dist/commands/sessions-sync.js +0 -135
  131. package/dist/commands/sync-provision.d.ts +0 -23
  132. package/dist/commands/sync-provision.js +0 -107
  133. package/dist/lib/drive-sync.d.ts +0 -45
  134. package/dist/lib/drive-sync.js +0 -238
  135. package/dist/lib/hq/floor.d.ts +0 -87
  136. package/dist/lib/hq/floor.js +0 -243
  137. package/dist/lib/session/sync/crdt.d.ts +0 -44
  138. package/dist/lib/session/sync/crdt.js +0 -119
  139. package/dist/lib/session/sync/manifest.d.ts +0 -62
  140. package/dist/lib/session/sync/manifest.js +0 -100
  141. package/dist/lib/session/sync/provision.d.ts +0 -49
  142. package/dist/lib/session/sync/provision.js +0 -91
  143. package/dist/lib/session/sync/r2.d.ts +0 -32
  144. package/dist/lib/session/sync/r2.js +0 -121
  145. package/dist/lib/session/sync/sync.d.ts +0 -106
  146. package/dist/lib/session/sync/sync.js +0 -374
@@ -0,0 +1,62 @@
1
+ /** The minimal Linear project shape the link flow needs (id + name + url). */
2
+ export interface LinearProjectLite {
3
+ id: string;
4
+ name: string;
5
+ /** Project URL, when the `linear` CLI JSON carries one. Never fabricated. */
6
+ url?: string;
7
+ }
8
+ /**
9
+ * Collapse a Linear name / repo slug / folder path to one comparison key:
10
+ * lowercase, keep only the last path segment, strip separators.
11
+ * "Agents CLI" -> "agentscli"
12
+ * "phnx-labs/agents-cli" -> "agentscli"
13
+ * "~/src/.../agents-cli" -> "agentscli"
14
+ */
15
+ export declare function normalizeProjectKey(s: string): string;
16
+ /**
17
+ * Find the Linear project that best matches a repo slug or folder name.
18
+ * Exact normalized match first, then a containment fallback (either direction),
19
+ * so "agents-cli-web" still suggests "Agents CLI" when no exact peer exists.
20
+ *
21
+ * Kept for parity with the Factory original — the `link` command uses
22
+ * {@link pickLinearProject} instead: this one returns the FIRST match (silent
23
+ * on duplicate names), which is fine for a UI suggestion but never for a write
24
+ * path.
25
+ */
26
+ export declare function matchLinearProject(slugOrName: string, projects: LinearProjectLite[]): LinearProjectLite | undefined;
27
+ /**
28
+ * Find the local checkout directory a Linear project name refers to, on EXACT
29
+ * key equality only — never containment. `matchLinearProject`'s containment
30
+ * fallback is right for suggesting a link a human then confirms; this backs
31
+ * `projects import --from-linear`, which writes `root`/`repo` with nobody
32
+ * looking, and "Agents CLI" must not silently bind `agents-cli-web`.
33
+ *
34
+ * Returns `undefined` when nothing matches, and also when SEVERAL dirs key to
35
+ * the same value (`agents-cli` and `agents_cli`) — an ambiguous match is not a
36
+ * match.
37
+ */
38
+ export declare function matchLocalCheckoutExact(name: string, dirNames: string[]): string | undefined;
39
+ /** The outcome of picking one Linear project out of the workspace list. */
40
+ export type LinearPick = {
41
+ kind: 'match';
42
+ project: LinearProjectLite;
43
+ } | {
44
+ kind: 'candidates';
45
+ projects: LinearProjectLite[];
46
+ } | {
47
+ kind: 'none';
48
+ };
49
+ /**
50
+ * Pick the Linear project a query refers to. An exact id or exact normalized
51
+ * name match is confident enough to write; anything weaker (several exact-name
52
+ * peers, or only containment matches) comes back as a candidate LIST for the
53
+ * user to disambiguate — the link command never guesses on a weak signal.
54
+ */
55
+ export declare function pickLinearProject(query: string, projects: LinearProjectLite[]): LinearPick;
56
+ /**
57
+ * List the workspace's Linear projects via the `linear` CLI on PATH. Throws a
58
+ * clear error when the binary is missing, errors, or returns a shape we can't
59
+ * use — this backs an explicit user command, so a silent empty list would send
60
+ * the user down a wrong "no matches" path.
61
+ */
62
+ export declare function listLinearProjects(): LinearProjectLite[];
@@ -0,0 +1,144 @@
1
+ // Linear project matching for `agents projects link --linear`.
2
+ //
3
+ // A project's identity shows up three ways — a Linear project name ("Agents CLI"),
4
+ // a GitHub repo slug ("phnx-labs/agents-cli"), and a filesystem folder
5
+ // (".../agents-cli"). normalizeProjectKey() collapses all three to one comparison
6
+ // key so they compare equal, and matchLinearProject() binds a repo/folder to the
7
+ // Linear project the user most likely means.
8
+ //
9
+ // Ported from apps/factory/src/core/linearProjects.ts (no cross-package imports —
10
+ // repo rule); keep the two in sync. The matcher half is PURE so it unit-tests
11
+ // without a live `linear` binary; the `linear projects --json` shell-out lives at
12
+ // the bottom (listLinearProjects) and fails LOUD — it's behind an explicit user
13
+ // command, not a best-effort card enrichment.
14
+ import { execFileSync } from 'child_process';
15
+ /**
16
+ * Collapse a Linear name / repo slug / folder path to one comparison key:
17
+ * lowercase, keep only the last path segment, strip separators.
18
+ * "Agents CLI" -> "agentscli"
19
+ * "phnx-labs/agents-cli" -> "agentscli"
20
+ * "~/src/.../agents-cli" -> "agentscli"
21
+ */
22
+ export function normalizeProjectKey(s) {
23
+ const last = s.toLowerCase().split('/').filter(Boolean).pop() ?? '';
24
+ return last.replace(/[-_\s.]/g, '');
25
+ }
26
+ /**
27
+ * Find the Linear project that best matches a repo slug or folder name.
28
+ * Exact normalized match first, then a containment fallback (either direction),
29
+ * so "agents-cli-web" still suggests "Agents CLI" when no exact peer exists.
30
+ *
31
+ * Kept for parity with the Factory original — the `link` command uses
32
+ * {@link pickLinearProject} instead: this one returns the FIRST match (silent
33
+ * on duplicate names), which is fine for a UI suggestion but never for a write
34
+ * path.
35
+ */
36
+ export function matchLinearProject(slugOrName, projects) {
37
+ const key = normalizeProjectKey(slugOrName);
38
+ if (!key)
39
+ return undefined;
40
+ const exact = projects.find((p) => normalizeProjectKey(p.name) === key);
41
+ if (exact)
42
+ return exact;
43
+ return projects.find((p) => {
44
+ const pk = normalizeProjectKey(p.name);
45
+ return pk.length > 0 && (pk.includes(key) || key.includes(pk));
46
+ });
47
+ }
48
+ /**
49
+ * Collapse a Linear **display name** or a directory basename to one comparison
50
+ * key. Deliberately NOT `normalizeProjectKey`: that one keeps only the segment
51
+ * after the last `/`, which is right for an `owner/repo` slug or a filesystem
52
+ * path and wrong for a display name, where `/` is ordinary punctuation. Keying
53
+ * "Rush / Web" the path way yields `web`, which exact-matches an unrelated
54
+ * `web/` checkout — the precise silent mis-binding this whole match path exists
55
+ * to prevent.
56
+ */
57
+ function checkoutMatchKey(s) {
58
+ return s.toLowerCase().replace(/[^a-z0-9]+/g, '');
59
+ }
60
+ /**
61
+ * Find the local checkout directory a Linear project name refers to, on EXACT
62
+ * key equality only — never containment. `matchLinearProject`'s containment
63
+ * fallback is right for suggesting a link a human then confirms; this backs
64
+ * `projects import --from-linear`, which writes `root`/`repo` with nobody
65
+ * looking, and "Agents CLI" must not silently bind `agents-cli-web`.
66
+ *
67
+ * Returns `undefined` when nothing matches, and also when SEVERAL dirs key to
68
+ * the same value (`agents-cli` and `agents_cli`) — an ambiguous match is not a
69
+ * match.
70
+ */
71
+ export function matchLocalCheckoutExact(name, dirNames) {
72
+ const key = checkoutMatchKey(name);
73
+ if (!key)
74
+ return undefined;
75
+ const hits = dirNames.filter((d) => checkoutMatchKey(d) === key);
76
+ return hits.length === 1 ? hits[0] : undefined;
77
+ }
78
+ /**
79
+ * Pick the Linear project a query refers to. An exact id or exact normalized
80
+ * name match is confident enough to write; anything weaker (several exact-name
81
+ * peers, or only containment matches) comes back as a candidate LIST for the
82
+ * user to disambiguate — the link command never guesses on a weak signal.
83
+ */
84
+ export function pickLinearProject(query, projects) {
85
+ const q = query.trim();
86
+ if (!q)
87
+ return { kind: 'none' };
88
+ const byId = projects.find((p) => p.id === q);
89
+ if (byId)
90
+ return { kind: 'match', project: byId };
91
+ const key = normalizeProjectKey(q);
92
+ if (!key)
93
+ return { kind: 'none' };
94
+ const exact = projects.filter((p) => normalizeProjectKey(p.name) === key);
95
+ if (exact.length === 1)
96
+ return { kind: 'match', project: exact[0] };
97
+ if (exact.length > 1)
98
+ return { kind: 'candidates', projects: exact };
99
+ const containment = projects.filter((p) => {
100
+ const pk = normalizeProjectKey(p.name);
101
+ return pk.length > 0 && (pk.includes(key) || key.includes(pk));
102
+ });
103
+ return containment.length > 0 ? { kind: 'candidates', projects: containment } : { kind: 'none' };
104
+ }
105
+ /**
106
+ * List the workspace's Linear projects via the `linear` CLI on PATH. Throws a
107
+ * clear error when the binary is missing, errors, or returns a shape we can't
108
+ * use — this backs an explicit user command, so a silent empty list would send
109
+ * the user down a wrong "no matches" path.
110
+ */
111
+ export function listLinearProjects() {
112
+ let out;
113
+ try {
114
+ out = execFileSync('linear', ['projects', '--json'], {
115
+ encoding: 'utf8',
116
+ timeout: 8000,
117
+ maxBuffer: 16 * 1024 * 1024,
118
+ });
119
+ }
120
+ catch {
121
+ throw new Error('Could not list Linear projects — is the `linear` CLI installed and logged in? (`brew install linear-cli`, `linear auth login`)');
122
+ }
123
+ let parsed;
124
+ try {
125
+ parsed = JSON.parse(out);
126
+ }
127
+ catch {
128
+ throw new Error('`linear projects --json` returned invalid JSON');
129
+ }
130
+ if (!Array.isArray(parsed))
131
+ throw new Error('`linear projects --json` did not return a list');
132
+ return parsed.flatMap((x) => {
133
+ if (x && typeof x === 'object' && !Array.isArray(x)) {
134
+ const o = x;
135
+ if (typeof o.id === 'string' && typeof o.name === 'string') {
136
+ const p = { id: o.id, name: o.name };
137
+ if (typeof o.url === 'string')
138
+ p.url = o.url;
139
+ return [p];
140
+ }
141
+ }
142
+ return [];
143
+ });
144
+ }
@@ -8,9 +8,9 @@
8
8
  */
9
9
  import * as fs from 'fs';
10
10
  import * as path from 'path';
11
- import { getMailboxRootDir } from './state.js';
11
+ import { getFeedDir, getMailboxRootDir } from './state.js';
12
12
  import { mailboxDir, isValidMailboxId, readMessage, sweepExpired, } from './mailbox.js';
13
- import { listBlocks, removeBlock } from './feed.js';
13
+ import { listBlocks, removeBlock, recordMessageReceipt } from './feed.js';
14
14
  const DEFAULT_MAX_CONSUMED_AGE_MINUTES = 24 * 60;
15
15
  function consumedAgeMinutes(file, now) {
16
16
  try {
@@ -31,7 +31,7 @@ function jsonFiles(dir) {
31
31
  }
32
32
  return names.filter((n) => n.endsWith('.json')).sort();
33
33
  }
34
- function archiveAllPending(boxDir, reason) {
34
+ function archiveAllPending(boxDir, reason, feedRoot) {
35
35
  let n = 0;
36
36
  for (const dir of [path.join(boxDir, 'inbox'), path.join(boxDir, 'processing')]) {
37
37
  for (const name of jsonFiles(dir)) {
@@ -45,6 +45,14 @@ function archiveAllPending(boxDir, reason) {
45
45
  fs.writeFileSync(tmp, JSON.stringify(msg, null, 2), 'utf-8');
46
46
  fs.renameSync(tmp, dest);
47
47
  fs.unlinkSync(src);
48
+ if (msg.blockId) {
49
+ try {
50
+ recordMessageReceipt(msg.blockId, { msgId: msg.msgId, status: reason === 'expired' ? 'expired' : 'dropped', at: new Date().toISOString(), from: msg.from }, feedRoot);
51
+ }
52
+ catch {
53
+ // Receipt surfacing is best-effort; never stall GC.
54
+ }
55
+ }
48
56
  }
49
57
  else {
50
58
  fs.renameSync(src, dest);
@@ -58,6 +66,16 @@ function archiveAllPending(boxDir, reason) {
58
66
  }
59
67
  return n;
60
68
  }
69
+ function blockAgeMinutes(blockId, feedRoot, now) {
70
+ const file = path.join(feedRoot ?? getFeedDir(), `${blockId}.json`);
71
+ try {
72
+ const stat = fs.statSync(file);
73
+ return (now.getTime() - stat.mtimeMs) / 60_000;
74
+ }
75
+ catch {
76
+ return Number.POSITIVE_INFINITY;
77
+ }
78
+ }
61
79
  function pruneConsumed(boxDir, maxAgeMinutes, now) {
62
80
  let n = 0;
63
81
  const consumed = path.join(boxDir, 'consumed');
@@ -115,7 +133,7 @@ export function gcMailbox(activeBoxIds, options = {}) {
115
133
  const boxDir = mailboxDir(name, root);
116
134
  if (!activeBoxIds.has(name)) {
117
135
  result.deadBoxes++;
118
- result.messagesDroppedDead += archiveAllPending(boxDir, 'dead');
136
+ result.messagesDroppedDead += archiveAllPending(boxDir, 'dead', feedRoot);
119
137
  result.consumedPruned += pruneConsumed(boxDir, maxConsumedAgeMinutes, now);
120
138
  // Also prune the empty box dir if it is now empty.
121
139
  try {
@@ -136,13 +154,18 @@ export function gcMailbox(activeBoxIds, options = {}) {
136
154
  else {
137
155
  // Live box: archive expired messages (same path as drain/peek) so GC does
138
156
  // not leave expired files in inbox/processing while only bumping metrics.
139
- result.messagesDroppedExpired += sweepExpired(boxDir, name, now);
157
+ result.messagesDroppedExpired += sweepExpired(boxDir, name, now, feedRoot);
140
158
  result.consumedPruned += pruneConsumed(boxDir, maxConsumedAgeMinutes, now);
141
159
  }
142
160
  }
143
161
  for (const blockId of blocksToRemove) {
144
- if (removeBlock(blockId, feedRoot)) {
145
- result.blocksRemoved++;
162
+ // A dead box's block is kept for up to maxConsumedAgeMinutes after a bounce
163
+ // receipt is written so the operator/sender can see the failure. Stale blocks
164
+ // (older than the prune window) are removed.
165
+ if (blockAgeMinutes(blockId, feedRoot, now) >= maxConsumedAgeMinutes) {
166
+ if (removeBlock(blockId, feedRoot)) {
167
+ result.blocksRemoved++;
168
+ }
146
169
  }
147
170
  }
148
171
  return result;
@@ -1,3 +1,13 @@
1
+ /** Default delivery TTL for messages enqueued without an explicit one (24 hours). */
2
+ export declare const DEFAULT_TTL_SECONDS: number;
3
+ /** Env var that overrides {@link DEFAULT_TTL_SECONDS} with a duration like "24h" or "3600". */
4
+ export declare const MAILBOX_TTL_ENV = "AGENTS_MAILBOX_TTL";
5
+ /**
6
+ * Resolve the default mailbox TTL in seconds. Honors `AGENTS_MAILBOX_TTL`
7
+ * (parsed by {@link parseDuration}); falls back to 24h. A malformed env value
8
+ * fails loud instead of silently disabling expiry.
9
+ */
10
+ export declare function resolveDefaultTtlSeconds(): number;
1
11
  /** A single mailbox message. `text` may embed `host:/path` clip tokens. */
2
12
  export interface MailboxMessage {
3
13
  /** Unique, time-sortable id. Also the on-disk filename stem. */
@@ -54,8 +64,11 @@ export declare function isExpired(msg: MailboxMessage, now?: Date): boolean;
54
64
  /**
55
65
  * Move expired messages from inbox/ and processing/ into consumed/ with a
56
66
  * `dropped: expired` marker. Called by drain/peek before returning messages.
67
+ *
68
+ * When a dropped message carries a `blockId`, a failure receipt is surfaced
69
+ * back to the feed store so the sender sees the bounce.
57
70
  */
58
- export declare function sweepExpired(boxDir: string, boxId?: string, now?: Date): number;
71
+ export declare function sweepExpired(boxDir: string, boxId?: string, now?: Date, feedRoot?: string): number;
59
72
  /**
60
73
  * Drain the box: return every pending message addressed to it, in FIFO order,
61
74
  * removing them from the queue. Claim-first (inbox → processing → consumed) so
@@ -20,6 +20,26 @@ import * as path from 'path';
20
20
  import { randomUUID } from 'crypto';
21
21
  import { getMailboxRootDir } from './state.js';
22
22
  import { recordMessageReceipt } from './feed.js';
23
+ import { parseDuration } from './hooks/cache.js';
24
+ /** Default delivery TTL for messages enqueued without an explicit one (24 hours). */
25
+ export const DEFAULT_TTL_SECONDS = 24 * 60 * 60;
26
+ /** Env var that overrides {@link DEFAULT_TTL_SECONDS} with a duration like "24h" or "3600". */
27
+ export const MAILBOX_TTL_ENV = 'AGENTS_MAILBOX_TTL';
28
+ /**
29
+ * Resolve the default mailbox TTL in seconds. Honors `AGENTS_MAILBOX_TTL`
30
+ * (parsed by {@link parseDuration}); falls back to 24h. A malformed env value
31
+ * fails loud instead of silently disabling expiry.
32
+ */
33
+ export function resolveDefaultTtlSeconds() {
34
+ const raw = process.env[MAILBOX_TTL_ENV];
35
+ if (raw == null || raw === '')
36
+ return DEFAULT_TTL_SECONDS;
37
+ const parsed = parseDuration(raw);
38
+ if (parsed == null || parsed <= 0) {
39
+ throw new Error(`Invalid ${MAILBOX_TTL_ENV}=${JSON.stringify(raw)}: expected a positive duration (e.g. 24h, 30m, 3600).`);
40
+ }
41
+ return parsed;
42
+ }
23
43
  /**
24
44
  * A mailboxId must be a single, separator-free path segment. Real ids (session
25
45
  * UUID / teams agentId / loop runId) already satisfy this; rejecting anything
@@ -78,8 +98,9 @@ export function enqueue(boxDir, msg) {
78
98
  text: msg.text,
79
99
  blockId: msg.blockId,
80
100
  };
81
- if (msg.ttlSeconds != null && msg.ttlSeconds > 0) {
82
- record.expiresAt = new Date(now.getTime() + msg.ttlSeconds * 1000).toISOString();
101
+ const ttlSeconds = msg.ttlSeconds ?? resolveDefaultTtlSeconds();
102
+ if (ttlSeconds > 0) {
103
+ record.expiresAt = new Date(now.getTime() + ttlSeconds * 1000).toISOString();
83
104
  }
84
105
  const target = path.join(inboxDir(boxDir), `${msgId}.json`);
85
106
  const tmp = `${target}.${process.pid}.tmp`;
@@ -139,8 +160,11 @@ function archiveDropped(boxDir, name, reason) {
139
160
  /**
140
161
  * Move expired messages from inbox/ and processing/ into consumed/ with a
141
162
  * `dropped: expired` marker. Called by drain/peek before returning messages.
163
+ *
164
+ * When a dropped message carries a `blockId`, a failure receipt is surfaced
165
+ * back to the feed store so the sender sees the bounce.
142
166
  */
143
- export function sweepExpired(boxDir, boxId = path.basename(boxDir), now = new Date()) {
167
+ export function sweepExpired(boxDir, boxId = path.basename(boxDir), now = new Date(), feedRoot) {
144
168
  ensureDirs(boxDir);
145
169
  let n = 0;
146
170
  for (const dir of [inboxDir(boxDir), processingDir(boxDir)]) {
@@ -154,6 +178,14 @@ export function sweepExpired(boxDir, boxId = path.basename(boxDir), now = new Da
154
178
  fs.writeFileSync(tmp, JSON.stringify(msg, null, 2), 'utf-8');
155
179
  fs.renameSync(tmp, dest);
156
180
  fs.unlinkSync(path.join(dir, name));
181
+ if (msg.blockId) {
182
+ try {
183
+ recordMessageReceipt(msg.blockId, { msgId: msg.msgId, status: 'expired', at: new Date().toISOString(), from: msg.from }, feedRoot ?? process.env.AGENTS_FEED_DIR);
184
+ }
185
+ catch {
186
+ // Receipt surfacing is best-effort; never stall expiry.
187
+ }
188
+ }
157
189
  n++;
158
190
  }
159
191
  catch {
@@ -34,29 +34,12 @@ export declare function codesignVerifies(appPath: string): boolean;
34
34
  * A Developer-ID-signed but un-notarized app is rejected by `spctl --assess`,
35
35
  * which macOS surfaces as "the app is damaged" and can crash AppKit during
36
36
  * launch. This is separate from `codesign --verify`: a signature can be valid
37
- * while Gatekeeper still refuses to run it.
37
+ * while Gatekeeper still refuses to run it. The release notarizes + staples the
38
+ * helper (menubar/scripts/build.sh, gated by verify-menubar-helper.sh), so a
39
+ * shipped bundle passes this; the launch guards use it to fail loud rather than
40
+ * bootstrap a helper macOS would reject.
38
41
  */
39
42
  export declare function gatekeeperAssesses(appPath: string): boolean;
40
- /**
41
- * Guarantee the installed bundle has a signature Gatekeeper will accept on THIS
42
- * machine.
43
- *
44
- * npm's pack/extract strips the ad-hoc/linker signature the release baked into
45
- * the helper, leaving `code object is not signed at all`. On macOS 26+ the
46
- * kernel's code-signing monitor SIGKILLs an unsigned/invalid binary at launch
47
- * (`SIGKILL (Code Signature Invalid)`), so under the launchd `KeepAlive` service
48
- * an ad-hoc release helper crash-loops forever and its unstable identity makes
49
- * the Accessibility grant (needed for the clip→paste keystroke in Clip.swift)
50
- * re-prompt every time. A fresh ad-hoc re-sign gives the on-disk bytes a
51
- * matching cdhash, which the kernel accepts.
52
- *
53
- * A Developer-ID-signed helper survives npm untouched — its embedded signature
54
- * still verifies — but if the release was not notarized, Gatekeeper rejects it.
55
- * In that case we strip the quarantine xattr and re-sign ad-hoc so the helper
56
- * can launch locally. The stable fix is to notarize the release build; this
57
- * fallback just prevents a crash-loop while the user is on an un-notarized cut.
58
- */
59
- export declare function ensureValidSignature(appPath: string): boolean;
60
43
  /**
61
44
  * Copy the bundled `.app` to the stable user path (idempotent unless forced).
62
45
  * Returns the installed executable path, or null if no source bundle ships
@@ -65,6 +48,7 @@ export declare function ensureValidSignature(appPath: string): boolean;
65
48
  export declare function ensureMenubarAppInstalled(opts?: {
66
49
  forceReinstall?: boolean;
67
50
  }): string | null;
51
+ export declare function generateServicePlist(execPath: string): string;
68
52
  /**
69
53
  * Restart the menu-bar launchd agent from a clean state.
70
54
  *
@@ -25,6 +25,21 @@ import { getCliVersion, resolveAgentsBin, resolveInstalledLayout } from '../vers
25
25
  const APP_BUNDLE_NAME = 'MenubarHelper.app';
26
26
  const INSTALL_DIR_NAME = 'agents-cli';
27
27
  const SERVICE_LABEL = 'com.phnx-labs.agents-menubar';
28
+ /**
29
+ * Minimum seconds between launchd restarts of the helper (`ThrottleInterval`).
30
+ *
31
+ * The helper can crash at startup on a loaded machine: `NSApplication.shared`
32
+ * segfaults inside `SLSNewConnection` when WindowServer is too starved to hand
33
+ * out a connection. With `KeepAlive` and no throttle, launchd relaunches on its
34
+ * 10s default, and each attempt spawns a fresh `agents doctor --json` before
35
+ * dying — so a starved box gets hit harder the worse it gets. 30s bounds that
36
+ * respawn rate while staying well inside "the menu bar came back on its own".
37
+ *
38
+ * This only paces the restarts. What actually stops the pile-up is the helper
39
+ * bounding and group-killing its own children (menubar/Sources/MenubarHelper/
40
+ * ChildProcess.swift); the two are complementary, not alternatives.
41
+ */
42
+ const MENUBAR_THROTTLE_SECONDS = 30;
28
43
  function onDarwin() {
29
44
  return process.platform === 'darwin';
30
45
  }
@@ -181,41 +196,15 @@ export function codesignVerifies(appPath) {
181
196
  * A Developer-ID-signed but un-notarized app is rejected by `spctl --assess`,
182
197
  * which macOS surfaces as "the app is damaged" and can crash AppKit during
183
198
  * launch. This is separate from `codesign --verify`: a signature can be valid
184
- * while Gatekeeper still refuses to run it.
199
+ * while Gatekeeper still refuses to run it. The release notarizes + staples the
200
+ * helper (menubar/scripts/build.sh, gated by verify-menubar-helper.sh), so a
201
+ * shipped bundle passes this; the launch guards use it to fail loud rather than
202
+ * bootstrap a helper macOS would reject.
185
203
  */
186
204
  export function gatekeeperAssesses(appPath) {
187
205
  const r = spawnSync('spctl', ['--assess', '--type', 'exec', appPath], { stdio: ['ignore', 'ignore', 'ignore'] });
188
206
  return r.status === 0;
189
207
  }
190
- /**
191
- * Guarantee the installed bundle has a signature Gatekeeper will accept on THIS
192
- * machine.
193
- *
194
- * npm's pack/extract strips the ad-hoc/linker signature the release baked into
195
- * the helper, leaving `code object is not signed at all`. On macOS 26+ the
196
- * kernel's code-signing monitor SIGKILLs an unsigned/invalid binary at launch
197
- * (`SIGKILL (Code Signature Invalid)`), so under the launchd `KeepAlive` service
198
- * an ad-hoc release helper crash-loops forever and its unstable identity makes
199
- * the Accessibility grant (needed for the clip→paste keystroke in Clip.swift)
200
- * re-prompt every time. A fresh ad-hoc re-sign gives the on-disk bytes a
201
- * matching cdhash, which the kernel accepts.
202
- *
203
- * A Developer-ID-signed helper survives npm untouched — its embedded signature
204
- * still verifies — but if the release was not notarized, Gatekeeper rejects it.
205
- * In that case we strip the quarantine xattr and re-sign ad-hoc so the helper
206
- * can launch locally. The stable fix is to notarize the release build; this
207
- * fallback just prevents a crash-loop while the user is on an un-notarized cut.
208
- */
209
- export function ensureValidSignature(appPath) {
210
- if (codesignVerifies(appPath) && gatekeeperAssesses(appPath))
211
- return true;
212
- // Drop any quarantine/xattrs the tarball round-trip added (they can break
213
- // both codesign and Gatekeeper), then re-sign ad-hoc under the helper's
214
- // stable bundle identifier.
215
- spawnSync('xattr', ['-cr', appPath], { stdio: ['ignore', 'ignore', 'ignore'] });
216
- spawnSync('codesign', ['--force', '--sign', '-', '--identifier', SERVICE_LABEL, appPath], { stdio: ['ignore', 'ignore', 'ignore'] });
217
- return codesignVerifies(appPath);
218
- }
219
208
  /**
220
209
  * Copy the bundled `.app` to the stable user path (idempotent unless forced).
221
210
  * Returns the installed executable path, or null if no source bundle ships
@@ -229,13 +218,9 @@ export function ensureMenubarAppInstalled(opts = {}) {
229
218
  return null;
230
219
  const dest = installedAppPath();
231
220
  if (!opts.forceReinstall && fs.existsSync(dest)) {
232
- // Self-heal an already-installed bundle whose signature npm stripped on a
233
- // prior upgrade (macOS 26+ SIGKILLs it otherwise) without a forced recopy.
234
- ensureValidSignature(dest);
235
221
  return installedExecutablePath();
236
222
  }
237
223
  copyAppBundle(src, dest);
238
- ensureValidSignature(dest);
239
224
  // A fresh copy is exactly when the bundle's icon can be new (first install) or
240
225
  // superseded (upgrade) — register it so LaunchServices knows the bundle and can
241
226
  // resolve its AppIcon for the left-hand slot of daemon notifications.
@@ -245,7 +230,7 @@ export function ensureMenubarAppInstalled(opts = {}) {
245
230
  function xmlEscape(s) {
246
231
  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
247
232
  }
248
- function generateServicePlist(execPath) {
233
+ export function generateServicePlist(execPath) {
249
234
  const home = os.homedir();
250
235
  const logPath = path.join(getHelpersDir(), 'menubar', 'menubar.log');
251
236
  fs.mkdirSync(path.dirname(logPath), { recursive: true });
@@ -281,6 +266,8 @@ function generateServicePlist(execPath) {
281
266
  <true/>
282
267
  <key>KeepAlive</key>
283
268
  <true/>
269
+ <key>ThrottleInterval</key>
270
+ <integer>${MENUBAR_THROTTLE_SECONDS}</integer>
284
271
  <key>ProcessType</key>
285
272
  <string>Interactive</string>
286
273
  <key>StandardOutPath</key>
@@ -331,12 +318,15 @@ export function enableMenubarService(opts = { clearOptOut: true }) {
331
318
  const exec = ensureMenubarAppInstalled({ forceReinstall: true });
332
319
  if (!exec)
333
320
  return false;
334
- // Never bootstrap a helper the kernel will kill on launch: an invalid
335
- // signature under launchd KeepAlive is an infinite crash loop. If the bundle
336
- // can't be made valid (re-sign already attempted in ensureMenubarAppInstalled),
337
- // skip the service rather than spin the loop.
338
- if (!codesignVerifies(installedAppPath())) {
339
- process.stderr.write('agents: menu-bar helper has no valid code signature; skipping launch to avoid a crash loop.\n');
321
+ // Never bootstrap a helper macOS will reject at launch: an invalid signature
322
+ // under launchd KeepAlive crash-loops forever, and an un-notarized bundle is
323
+ // rejected by Gatekeeper as "damaged". A shipped helper is Developer-ID signed
324
+ // AND notarized (release build + the verify-menubar-helper.sh prepack gate), so
325
+ // this passes; if it ever doesn't, skip the service and point at the upgrade
326
+ // rather than re-signing over it (an ad-hoc re-sign never satisfies Gatekeeper).
327
+ if (!(codesignVerifies(installedAppPath()) && gatekeeperAssesses(installedAppPath()))) {
328
+ process.stderr.write('agents: menu-bar helper is not notarized/valid on this machine; skipping launch. ' +
329
+ 'Upgrade to a notarized build (npm i -g @phnx-labs/agents-cli), then `agents menubar setup`.\n');
340
330
  return false;
341
331
  }
342
332
  if (opts.clearOptOut)
@@ -560,11 +550,12 @@ export function runMenubarSetup() {
560
550
  return { steps, configured: false, status: getMenubarStatus() };
561
551
  }
562
552
  step('bundle', before.installedVersion === getCliVersion() ? 'ok' : 'changed', `${installedAppPath()} (${getCliVersion()})`);
563
- if (!codesignVerifies(installedAppPath())) {
564
- step('signature', 'failed', 'no valid code signature — refusing to start it (launchd KeepAlive would crash-loop)');
553
+ if (!(codesignVerifies(installedAppPath()) && gatekeeperAssesses(installedAppPath()))) {
554
+ step('signature', 'failed', 'not notarized/valid on this machine — refusing to start it (Gatekeeper rejects an ' +
555
+ 'un-notarized helper as "damaged"). Upgrade to a notarized build of agents-cli.');
565
556
  return { steps, configured: false, status: getMenubarStatus() };
566
557
  }
567
- step('signature', 'ok', 'valid');
558
+ step('signature', 'ok', 'valid + notarized');
568
559
  // Clear the sticky opt-out: running `setup` is an explicit request for the
569
560
  // menu bar, so a stale `menubar disable` must not silently win.
570
561
  clearMenubarOptOut();
@@ -4,8 +4,12 @@
4
4
  * The one place the daemon (overdue routines, heal, routine start/finish/output)
5
5
  * emits a native desktop notification. On macOS it routes through the installed
6
6
  * `MenubarHelper.app` companion — a one-shot `MenubarHelper --notify` invocation —
7
- * so the notification is attributed to that bundle and carries the agents-cli /
8
- * Agentialized mark instead of the generic AppleScript icon. When the companion
7
+ * so the notification is attributed to that bundle and carries the agents-cli
8
+ * mark instead of the generic AppleScript icon. A banner carries two images: the
9
+ * agents-cli app icon on the LEFT (the sender) and, when the event belongs to one
10
+ * harness, that agent's avatar on the RIGHT (`agent`, rendered by
11
+ * AgentAvatar.swift) — the same layout macOS gives a YouTube notification, app on
12
+ * the left and the channel on the right. When the companion
9
13
  * app is not installed (menu bar disabled, a Linux package, a dev checkout), it
10
14
  * degrades to `osascript` so an overdue/heal notice is never silently lost — the
11
15
  * generic icon is the acceptable cost of preserving delivery, not a bug hidden by
@@ -32,6 +36,17 @@ export interface DesktopNotification {
32
36
  * macOS-only (osascript / notify-send have no click target). See routine-notify.ts.
33
37
  */
34
38
  action?: string;
39
+ /**
40
+ * Harness the notification is ABOUT (`claude`, `codex`, … — an `AgentId`).
41
+ * macOS draws two images on a banner: the sending bundle's app icon on the
42
+ * LEFT and `contentImage` on the RIGHT. The companion renders this id as the
43
+ * right-hand avatar (AgentAvatar.swift), so a banner reads "agents-cli, about
44
+ * Claude" the way a YouTube notification reads "YouTube, from this channel".
45
+ * Omit it when no single harness owns the event (a daemon heal, a fan-out
46
+ * across several agents) — the right slot then stays empty rather than
47
+ * repeating the left one. macOS-only; osascript / notify-send carry no image.
48
+ */
49
+ agent?: string;
35
50
  }
36
51
  /** Argv for the MenubarHelper one-shot notify mode. Exported for tests. */
37
52
  export declare function buildMenubarNotifyArgs(n: DesktopNotification): string[];
@@ -4,8 +4,12 @@
4
4
  * The one place the daemon (overdue routines, heal, routine start/finish/output)
5
5
  * emits a native desktop notification. On macOS it routes through the installed
6
6
  * `MenubarHelper.app` companion — a one-shot `MenubarHelper --notify` invocation —
7
- * so the notification is attributed to that bundle and carries the agents-cli /
8
- * Agentialized mark instead of the generic AppleScript icon. When the companion
7
+ * so the notification is attributed to that bundle and carries the agents-cli
8
+ * mark instead of the generic AppleScript icon. A banner carries two images: the
9
+ * agents-cli app icon on the LEFT (the sender) and, when the event belongs to one
10
+ * harness, that agent's avatar on the RIGHT (`agent`, rendered by
11
+ * AgentAvatar.swift) — the same layout macOS gives a YouTube notification, app on
12
+ * the left and the channel on the right. When the companion
9
13
  * app is not installed (menu bar disabled, a Linux package, a dev checkout), it
10
14
  * degrades to `osascript` so an overdue/heal notice is never silently lost — the
11
15
  * generic icon is the acceptable cost of preserving delivery, not a bug hidden by
@@ -35,6 +39,8 @@ export function buildMenubarNotifyArgs(n) {
35
39
  args.push('--subtitle', n.subtitle);
36
40
  if (n.action)
37
41
  args.push('--action', n.action);
42
+ if (n.agent)
43
+ args.push('--agent', n.agent);
38
44
  return args;
39
45
  }
40
46
  /** AppleScript for the osascript degradation path. Exported for tests. */
@@ -1708,7 +1708,7 @@ function migrateVersionResourcesToPatterns() {
1708
1708
  }
1709
1709
  }
1710
1710
  if (changed) {
1711
- const META_HEADER = '# agents-cli metadata\n# Auto-generated - do not edit manually\n# https://github.com/phnx-labs/agents-cli\n\n';
1711
+ const META_HEADER = '# agents-cli metadata\n# Auto-generated - do not edit manually\n# https://github.com/phnx-labs/agents-cli\n# yaml-language-server: $schema=https://raw.githubusercontent.com/phnx-labs/agents-cli/main/apps/cli/schema/agents-yaml.schema.json\n\n';
1712
1712
  fs.writeFileSync(metaFile, META_HEADER + yaml.stringify(meta), 'utf-8');
1713
1713
  console.error('Migrated agents.yaml versions: entries to pattern format');
1714
1714
  }