@phnx-labs/agents-cli 1.20.93 → 1.21.1

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 (128) hide show
  1. package/CHANGELOG.md +290 -0
  2. package/README.md +4 -1
  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/exec.js +13 -0
  7. package/dist/commands/feed.js +37 -23
  8. package/dist/commands/mailboxes.js +39 -1
  9. package/dist/commands/message.js +12 -1
  10. package/dist/commands/monitors.js +8 -6
  11. package/dist/commands/packages.js +14 -5
  12. package/dist/commands/projects.d.ts +28 -0
  13. package/dist/commands/projects.js +173 -47
  14. package/dist/commands/routines.js +27 -1
  15. package/dist/commands/rules.js +6 -0
  16. package/dist/commands/sessions.d.ts +17 -0
  17. package/dist/commands/sessions.js +16 -8
  18. package/dist/commands/setup-browser.js +5 -1
  19. package/dist/commands/setup-fleet.js +5 -0
  20. package/dist/commands/setup-preferences.d.ts +53 -0
  21. package/dist/commands/setup-preferences.js +142 -0
  22. package/dist/commands/setup.js +8 -9
  23. package/dist/commands/ssh.js +220 -10
  24. package/dist/commands/sync.d.ts +2 -2
  25. package/dist/commands/sync.js +4 -9
  26. package/dist/commands/watchdog.js +26 -3
  27. package/dist/index.js +22 -6
  28. package/dist/lib/activity.d.ts +13 -0
  29. package/dist/lib/activity.js +22 -2
  30. package/dist/lib/auto-dispatch.d.ts +6 -1
  31. package/dist/lib/auto-dispatch.js +7 -2
  32. package/dist/lib/beta.d.ts +1 -1
  33. package/dist/lib/beta.js +1 -1
  34. package/dist/lib/channels/providers/openclaw-telegram.js +1 -1
  35. package/dist/lib/channels/resolve.d.ts +18 -1
  36. package/dist/lib/channels/resolve.js +15 -5
  37. package/dist/lib/daemon.d.ts +13 -0
  38. package/dist/lib/daemon.js +80 -60
  39. package/dist/lib/device-config.d.ts +82 -0
  40. package/dist/lib/device-config.js +296 -0
  41. package/dist/lib/event-provenance.d.ts +19 -0
  42. package/dist/lib/event-provenance.js +48 -0
  43. package/dist/lib/events.d.ts +2 -2
  44. package/dist/lib/events.js +4 -53
  45. package/dist/lib/feed-broadcast.d.ts +41 -5
  46. package/dist/lib/feed-broadcast.js +110 -12
  47. package/dist/lib/feed-post.d.ts +16 -2
  48. package/dist/lib/feed-post.js +35 -7
  49. package/dist/lib/feed.d.ts +1 -1
  50. package/dist/lib/feed.js +3 -1
  51. package/dist/lib/git.d.ts +13 -2
  52. package/dist/lib/git.js +38 -6
  53. package/dist/lib/hooks.d.ts +16 -1
  54. package/dist/lib/hooks.js +61 -1
  55. package/dist/lib/linear-project-counts.d.ts +72 -5
  56. package/dist/lib/linear-project-counts.js +90 -6
  57. package/dist/lib/linear-projects.d.ts +12 -0
  58. package/dist/lib/linear-projects.js +30 -0
  59. package/dist/lib/mailbox-gc.js +30 -7
  60. package/dist/lib/mailbox.d.ts +14 -1
  61. package/dist/lib/mailbox.js +35 -3
  62. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  63. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  64. package/dist/lib/menubar/install-menubar.d.ts +5 -21
  65. package/dist/lib/menubar/install-menubar.js +35 -44
  66. package/dist/lib/migrate.js +1 -1
  67. package/dist/lib/models.js +21 -11
  68. package/dist/lib/monitors/config.d.ts +1 -1
  69. package/dist/lib/monitors/dispatch.d.ts +6 -2
  70. package/dist/lib/monitors/dispatch.js +10 -15
  71. package/dist/lib/notify.d.ts +42 -6
  72. package/dist/lib/notify.js +41 -32
  73. package/dist/lib/overdue.d.ts +1 -1
  74. package/dist/lib/overdue.js +54 -19
  75. package/dist/lib/project-import.d.ts +96 -0
  76. package/dist/lib/project-import.js +171 -0
  77. package/dist/lib/remote-agents-json.d.ts +14 -1
  78. package/dist/lib/remote-agents-json.js +21 -3
  79. package/dist/lib/routines.d.ts +53 -0
  80. package/dist/lib/routines.js +105 -4
  81. package/dist/lib/rules/run-sync.d.ts +18 -0
  82. package/dist/lib/rules/run-sync.js +92 -0
  83. package/dist/lib/scheduler.js +12 -2
  84. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  85. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  86. package/dist/lib/secrets/index.d.ts +4 -5
  87. package/dist/lib/secrets/index.js +4 -5
  88. package/dist/lib/session/active.d.ts +16 -2
  89. package/dist/lib/session/active.js +8 -4
  90. package/dist/lib/session/bundle.js +5 -1
  91. package/dist/lib/session/remote-list.d.ts +1 -15
  92. package/dist/lib/session/remote-list.js +22 -111
  93. package/dist/lib/session/sync/config.d.ts +13 -5
  94. package/dist/lib/session/sync/config.js +21 -13
  95. package/dist/lib/startup/command-registry.d.ts +0 -2
  96. package/dist/lib/startup/command-registry.js +1 -5
  97. package/dist/lib/state.d.ts +7 -2
  98. package/dist/lib/state.js +85 -10
  99. package/dist/lib/sync-umbrella.d.ts +11 -23
  100. package/dist/lib/sync-umbrella.js +14 -45
  101. package/dist/lib/teams/agents.d.ts +35 -3
  102. package/dist/lib/teams/agents.js +52 -8
  103. package/dist/lib/teams/scheduler.d.ts +29 -2
  104. package/dist/lib/teams/scheduler.js +60 -16
  105. package/dist/lib/types.d.ts +23 -1
  106. package/package.json +1 -2
  107. package/dist/commands/drive.d.ts +0 -10
  108. package/dist/commands/drive.js +0 -183
  109. package/dist/commands/hq.d.ts +0 -2
  110. package/dist/commands/hq.js +0 -58
  111. package/dist/commands/sessions-sync.d.ts +0 -17
  112. package/dist/commands/sessions-sync.js +0 -135
  113. package/dist/commands/sync-provision.d.ts +0 -23
  114. package/dist/commands/sync-provision.js +0 -107
  115. package/dist/lib/drive-sync.d.ts +0 -45
  116. package/dist/lib/drive-sync.js +0 -238
  117. package/dist/lib/hq/floor.d.ts +0 -87
  118. package/dist/lib/hq/floor.js +0 -243
  119. package/dist/lib/session/sync/crdt.d.ts +0 -44
  120. package/dist/lib/session/sync/crdt.js +0 -119
  121. package/dist/lib/session/sync/manifest.d.ts +0 -62
  122. package/dist/lib/session/sync/manifest.js +0 -100
  123. package/dist/lib/session/sync/provision.d.ts +0 -49
  124. package/dist/lib/session/sync/provision.js +0 -91
  125. package/dist/lib/session/sync/r2.d.ts +0 -32
  126. package/dist/lib/session/sync/r2.js +0 -121
  127. package/dist/lib/session/sync/sync.d.ts +0 -106
  128. package/dist/lib/session/sync/sync.js +0 -374
package/dist/lib/hooks.js CHANGED
@@ -952,6 +952,44 @@ export function listCentralHooks() {
952
952
  }
953
953
  return results;
954
954
  }
955
+ /**
956
+ * Normalize a hook `timeout` from agents.yaml into a whole number of seconds.
957
+ *
958
+ * A bare number stays seconds (`timeout: 30` → 30) for backward compatibility.
959
+ * A Go-style duration string is parsed into seconds: `5s`, `2m`, `1h30m`,
960
+ * `90s`, `1h`. This intentionally does NOT reuse {@link parseTimeout} from
961
+ * routines.ts — that one returns milliseconds, has no seconds (`s`) unit, and
962
+ * floors at one minute, none of which fit hook timeouts (typically 5–600s).
963
+ *
964
+ * Returns the seconds value, or `null` when the input is not a positive number
965
+ * or a parseable duration string — the caller decides how to surface that.
966
+ */
967
+ export function normalizeHookTimeoutSeconds(value) {
968
+ if (typeof value === 'number') {
969
+ return Number.isFinite(value) && value > 0 ? value : null;
970
+ }
971
+ if (typeof value === 'string') {
972
+ const s = value.trim();
973
+ if (s === '')
974
+ return null;
975
+ // A bare integer string means seconds, matching the bare-number form.
976
+ if (/^\d+$/.test(s)) {
977
+ const n = Number(s);
978
+ return n > 0 ? n : null;
979
+ }
980
+ const m = s.match(/^(?:(\d+)w)?(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/i);
981
+ if (!m)
982
+ return null;
983
+ const weeks = Number(m[1] || 0);
984
+ const days = Number(m[2] || 0);
985
+ const hours = Number(m[3] || 0);
986
+ const minutes = Number(m[4] || 0);
987
+ const seconds = Number(m[5] || 0);
988
+ const total = ((weeks * 7 + days) * 24 + hours) * 3600 + minutes * 60 + seconds;
989
+ return total > 0 ? total : null;
990
+ }
991
+ return null;
992
+ }
955
993
  /**
956
994
  * Parse hook manifests. Reads system hooks from ~/.agents/.system/hooks.yaml
957
995
  * (npm-shipped defaults) and user hooks from the `hooks:` section of
@@ -959,7 +997,9 @@ export function listCentralHooks() {
959
997
  * A user entry with `enabled: false` disables the system-shipped hook of
960
998
  * the same name without forking the system file.
961
999
  *
962
- * Hooks marked `enabled: false` are dropped from the returned map.
1000
+ * Hooks marked `enabled: false` are dropped from the returned map. A hook
1001
+ * `timeout` written as a duration string (`5s`, `2m`) is normalized to a
1002
+ * seconds number here, so every downstream serializer keeps reading a number.
963
1003
  */
964
1004
  export function parseHookManifest(opts = {}) {
965
1005
  const warn = opts.warn !== false;
@@ -1026,6 +1066,26 @@ export function parseHookManifest(opts = {}) {
1026
1066
  if (def.enabled === false)
1027
1067
  delete merged[name];
1028
1068
  }
1069
+ // Normalize each surviving hook's timeout to a seconds number, so the raw
1070
+ // agents.yaml can express it as a duration string (`5s`, `2m`) while every
1071
+ // downstream serializer keeps consuming a plain number. An unparseable value
1072
+ // is dropped with a warning rather than silently coerced to a wrong duration.
1073
+ for (const [name, def] of Object.entries(merged)) {
1074
+ const raw = def.timeout;
1075
+ if (raw === undefined)
1076
+ continue;
1077
+ const seconds = normalizeHookTimeoutSeconds(raw);
1078
+ if (seconds === null) {
1079
+ if (warn) {
1080
+ console.warn(`[agents hooks] Hook '${name}' has an invalid timeout ${JSON.stringify(raw)}; ` +
1081
+ `expected seconds or a duration string like '5s', '2m', '1h30m'. Ignoring it.`);
1082
+ }
1083
+ delete def.timeout;
1084
+ }
1085
+ else {
1086
+ def.timeout = seconds;
1087
+ }
1088
+ }
1029
1089
  return merged;
1030
1090
  }
1031
1091
  export function selectHookManifest(manifest, selected) {
@@ -7,6 +7,12 @@
7
7
  * TYPE (triage / backlog / unstarted / started / completed / canceled), never
8
8
  * hardcoded state names, same convention as `auto-dispatch-linear.ts`.
9
9
  *
10
+ * The same fetch also yields the **next milestone** — the earliest-dated
11
+ * milestone with unfinished issues — because each issue node carries its
12
+ * `projectMilestone`. A percentage tells you how far along a project is; the
13
+ * milestone tells you what it is due to hit next, which is the thing a person
14
+ * actually plans around. Deriving it here costs no extra request.
15
+ *
10
16
  * This is a best-effort card enrichment, not an explicit command: every failure
11
17
  * (no credential, offline, API error, timeout) degrades to `undefined` and the
12
18
  * card simply omits the line — never a hang, never a throw. `--no-remote`
@@ -18,6 +24,32 @@
18
24
  * budget; a capped fetch reports `truncated: true` and the card renders the
19
25
  * total as a lower bound (`2500+ done`), never as the complete count.
20
26
  */
27
+ /**
28
+ * The next checkpoint the project is working toward: the earliest-dated
29
+ * milestone that still has unfinished issues. A percentage says how far along
30
+ * the project is; this says what it is due to hit next.
31
+ */
32
+ export interface LinearMilestone {
33
+ name: string;
34
+ /** `YYYY-MM-DD` as Linear stores it. Absent when the milestone has no date. */
35
+ targetDate?: string;
36
+ /** Issues in this milestone in a `completed`-type state. */
37
+ done: number;
38
+ /**
39
+ * Issues assigned to this milestone. Legitimately `0` — a milestone can be
40
+ * declared with a date long before any issue is filed under it (that is the
41
+ * state of every milestone in this repo's own Linear project), and such a
42
+ * milestone is still the next checkpoint. The card omits the fraction rather
43
+ * than printing a meaningless `0/0`.
44
+ */
45
+ total: number;
46
+ }
47
+ /** A milestone as the project declares it, independent of any issue. */
48
+ export interface LinearMilestoneNode {
49
+ id?: string;
50
+ name?: string;
51
+ targetDate?: string | null;
52
+ }
21
53
  /** The counts the card renders. `total` counts every issue in the project. */
22
54
  export interface LinearProjectCounts {
23
55
  /** Issues in a `completed`-type state. */
@@ -31,20 +63,40 @@ export interface LinearProjectCounts {
31
63
  * bound (rendered `2500+`), never presented as the complete count.
32
64
  */
33
65
  truncated?: boolean;
66
+ /**
67
+ * The next unfinished milestone, when the project has one. Derived from the
68
+ * SAME paged issue fetch as the counts — a milestone is only ever a grouping
69
+ * of these issues, so asking Linear again would spend a second round trip to
70
+ * learn what the first already said.
71
+ */
72
+ nextMilestone?: LinearMilestone;
73
+ }
74
+ /** One issue node as the query selects it. */
75
+ export interface LinearIssueNode {
76
+ state?: {
77
+ type?: string;
78
+ } | null;
79
+ projectMilestone?: {
80
+ id?: string;
81
+ name?: string;
82
+ targetDate?: string | null;
83
+ } | null;
34
84
  }
35
85
  /** The GraphQL response shape this module consumes (recorded for the tests). */
36
86
  export interface LinearIssuesResponse {
37
87
  issues?: {
38
- nodes?: Array<{
39
- state?: {
40
- type?: string;
41
- } | null;
42
- }>;
88
+ nodes?: LinearIssueNode[];
43
89
  pageInfo?: {
44
90
  hasNextPage?: boolean;
45
91
  endCursor?: string | null;
46
92
  };
47
93
  };
94
+ /** Only the FIRST page asks for this — the milestone list does not paginate. */
95
+ project?: {
96
+ projectMilestones?: {
97
+ nodes?: LinearMilestoneNode[];
98
+ };
99
+ } | null;
48
100
  }
49
101
  /**
50
102
  * Pure mapping: a Linear issues response → card counts, grouping by state
@@ -52,6 +104,21 @@ export interface LinearIssuesResponse {
52
104
  * an issue with no state still counts toward `total`.
53
105
  */
54
106
  export declare function countsFromIssuesResponse(data: LinearIssuesResponse): LinearProjectCounts;
107
+ /**
108
+ * Pick the milestone the project is working toward next.
109
+ *
110
+ * The **declared** list is authoritative for which milestones exist, their
111
+ * names, and their dates; issues only supply progress. Deriving the list from
112
+ * issues instead looks tempting (it costs no extra request) and is wrong: a
113
+ * milestone with nothing filed under it yet would be invisible, and that is the
114
+ * common case — every milestone in this repo's own Linear project has zero
115
+ * issues assigned, so an issue-derived list showed nothing at all.
116
+ *
117
+ * Next = the earliest-dated milestone that is not finished. A milestone with no
118
+ * issues counts as unfinished (it is upcoming work, not completed work). Undated
119
+ * milestones sort last, ties break by declaration order, so the answer is stable.
120
+ */
121
+ export declare function nextMilestone(declared: LinearMilestoneNode[], nodes: LinearIssueNode[]): LinearMilestone | undefined;
55
122
  /**
56
123
  * Fetch issue counts for one Linear project, paging `issues` filtered by
57
124
  * project id. One shared AbortController bounds the WHOLE paged fetch at ~8s;
@@ -7,6 +7,12 @@
7
7
  * TYPE (triage / backlog / unstarted / started / completed / canceled), never
8
8
  * hardcoded state names, same convention as `auto-dispatch-linear.ts`.
9
9
  *
10
+ * The same fetch also yields the **next milestone** — the earliest-dated
11
+ * milestone with unfinished issues — because each issue node carries its
12
+ * `projectMilestone`. A percentage tells you how far along a project is; the
13
+ * milestone tells you what it is due to hit next, which is the thing a person
14
+ * actually plans around. Deriving it here costs no extra request.
15
+ *
10
16
  * This is a best-effort card enrichment, not an explicit command: every failure
11
17
  * (no credential, offline, API error, timeout) degrades to `undefined` and the
12
18
  * card simply omits the line — never a hang, never a throw. `--no-remote`
@@ -44,7 +50,65 @@ export function countsFromIssuesResponse(data) {
44
50
  else if (type === 'started')
45
51
  inProgress++;
46
52
  }
47
- return { done, total: nodes.length, inProgress };
53
+ const counts = { done, total: nodes.length, inProgress };
54
+ const next = nextMilestone(data.project?.projectMilestones?.nodes ?? [], nodes);
55
+ if (next)
56
+ counts.nextMilestone = next;
57
+ return counts;
58
+ }
59
+ /**
60
+ * Pick the milestone the project is working toward next.
61
+ *
62
+ * The **declared** list is authoritative for which milestones exist, their
63
+ * names, and their dates; issues only supply progress. Deriving the list from
64
+ * issues instead looks tempting (it costs no extra request) and is wrong: a
65
+ * milestone with nothing filed under it yet would be invisible, and that is the
66
+ * common case — every milestone in this repo's own Linear project has zero
67
+ * issues assigned, so an issue-derived list showed nothing at all.
68
+ *
69
+ * Next = the earliest-dated milestone that is not finished. A milestone with no
70
+ * issues counts as unfinished (it is upcoming work, not completed work). Undated
71
+ * milestones sort last, ties break by declaration order, so the answer is stable.
72
+ */
73
+ export function nextMilestone(declared, nodes) {
74
+ // Progress per milestone id, from whatever issues do carry one.
75
+ const progress = new Map();
76
+ for (const n of nodes) {
77
+ const id = n?.projectMilestone?.id;
78
+ if (!id)
79
+ continue;
80
+ const p = progress.get(id) ?? { done: 0, total: 0 };
81
+ p.total++;
82
+ if (n.state?.type === 'completed')
83
+ p.done++;
84
+ progress.set(id, p);
85
+ }
86
+ const candidates = declared
87
+ .map((d, order) => {
88
+ if (!d?.id || typeof d.name !== 'string' || !d.name)
89
+ return undefined;
90
+ const p = progress.get(d.id) ?? { done: 0, total: 0 };
91
+ const m = { name: d.name, done: p.done, total: p.total, order };
92
+ if (d.targetDate)
93
+ m.targetDate = d.targetDate;
94
+ return m;
95
+ })
96
+ .filter((m) => m !== undefined)
97
+ // total 0 means "declared, nothing filed yet" — unfinished, not done.
98
+ .filter((m) => m.total === 0 || m.done < m.total);
99
+ if (candidates.length === 0)
100
+ return undefined;
101
+ candidates.sort((a, b) => {
102
+ if (a.targetDate && b.targetDate)
103
+ return a.targetDate < b.targetDate ? -1 : a.targetDate > b.targetDate ? 1 : a.order - b.order;
104
+ if (a.targetDate)
105
+ return -1;
106
+ if (b.targetDate)
107
+ return 1;
108
+ return a.order - b.order;
109
+ });
110
+ const { order: _order, ...m } = candidates[0];
111
+ return m;
48
112
  }
49
113
  /** $LINEAR_API_KEY → macOS Keychain → ~/.linear-cli/config.json. Null if none. */
50
114
  function resolveApiKey() {
@@ -71,12 +135,16 @@ export async function fetchLinearProjectCounts(projectId, fetchPage = fetchLinea
71
135
  const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
72
136
  try {
73
137
  const all = [];
138
+ // Declared on the project, not on its issues — only page 0 asks for it.
139
+ let declared = [];
74
140
  let after;
75
141
  let truncated = false;
76
142
  for (let page = 0;; page++) {
77
143
  const data = await fetchPage(projectId, after, ctrl.signal);
78
144
  if (!data)
79
145
  return undefined;
146
+ if (page === 0)
147
+ declared = data.project?.projectMilestones?.nodes ?? [];
80
148
  all.push(...(data.issues?.nodes ?? []));
81
149
  const pi = data.issues?.pageInfo;
82
150
  if (!pi?.hasNextPage || !pi.endCursor)
@@ -88,7 +156,13 @@ export async function fetchLinearProjectCounts(projectId, fetchPage = fetchLinea
88
156
  }
89
157
  after = pi.endCursor;
90
158
  }
91
- return { ...countsFromIssuesResponse({ issues: { nodes: all } }), ...(truncated ? { truncated } : {}) };
159
+ return {
160
+ ...countsFromIssuesResponse({
161
+ issues: { nodes: all },
162
+ project: { projectMilestones: { nodes: declared } },
163
+ }),
164
+ ...(truncated ? { truncated } : {}),
165
+ };
92
166
  }
93
167
  catch {
94
168
  return undefined;
@@ -102,14 +176,24 @@ async function fetchLinearIssuesPage(projectId, after, signal) {
102
176
  const apiKey = resolveApiKey();
103
177
  if (!apiKey)
104
178
  return undefined;
179
+ // The declared-milestone list rides along on the FIRST page only — it does
180
+ // not paginate, and re-requesting it per page would spend up to MAX_PAGES
181
+ // copies of the same answer.
182
+ const issuesSelection = 'issues(filter:{ project:{ id:{ eq:$p } } }, first:' +
183
+ PAGE_SIZE +
184
+ ', after:$after){ nodes{ state{ type } projectMilestone{ id } } pageInfo{ hasNextPage endCursor } }';
185
+ const milestonesSelection = 'project(id:$pid){ projectMilestones(first:50){ nodes{ id name targetDate } } }';
186
+ const first = after === undefined;
105
187
  const res = await fetch(LINEAR_API, {
106
188
  method: 'POST',
107
189
  headers: { Authorization: apiKey, 'Content-Type': 'application/json' },
108
190
  body: JSON.stringify({
109
- query: 'query($p:ID!, $after:String){ issues(filter:{ project:{ id:{ eq:$p } } }, first:' +
110
- PAGE_SIZE +
111
- ', after:$after){ nodes{ state{ type } } pageInfo{ hasNextPage endCursor } } }',
112
- variables: { p: projectId, after: after ?? null },
191
+ query: first
192
+ ? `query($p:ID!, $pid:String!, $after:String){ ${issuesSelection} ${milestonesSelection} }`
193
+ : `query($p:ID!, $after:String){ ${issuesSelection} }`,
194
+ variables: first
195
+ ? { p: projectId, pid: projectId, after: null }
196
+ : { p: projectId, after },
113
197
  }),
114
198
  signal,
115
199
  });
@@ -24,6 +24,18 @@ export declare function normalizeProjectKey(s: string): string;
24
24
  * path.
25
25
  */
26
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;
27
39
  /** The outcome of picking one Linear project out of the workspace list. */
28
40
  export type LinearPick = {
29
41
  kind: 'match';
@@ -45,6 +45,36 @@ export function matchLinearProject(slugOrName, projects) {
45
45
  return pk.length > 0 && (pk.includes(key) || key.includes(pk));
46
46
  });
47
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
+ }
48
78
  /**
49
79
  * Pick the Linear project a query refers to. An exact id or exact normalized
50
80
  * name match is confident enough to write; anything weaker (several exact-name
@@ -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
  *