@phnx-labs/agents-cli 1.21.0 → 1.21.2

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 (102) hide show
  1. package/CHANGELOG.md +198 -0
  2. package/README.md +2 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/doctor.js +5 -2
  5. package/dist/commands/feed.js +63 -40
  6. package/dist/commands/hooks.js +9 -45
  7. package/dist/commands/menubar.js +24 -24
  8. package/dist/commands/message.js +23 -3
  9. package/dist/commands/monitors.js +8 -6
  10. package/dist/commands/perf.d.ts +13 -0
  11. package/dist/commands/perf.js +80 -23
  12. package/dist/commands/projects.d.ts +11 -0
  13. package/dist/commands/projects.js +153 -21
  14. package/dist/commands/routines.js +46 -1
  15. package/dist/commands/ssh.js +69 -0
  16. package/dist/commands/trends.d.ts +2 -0
  17. package/dist/commands/trends.js +158 -0
  18. package/dist/commands/usage.d.ts +4 -4
  19. package/dist/commands/view.d.ts +6 -0
  20. package/dist/commands/view.js +90 -45
  21. package/dist/index.js +14 -1
  22. package/dist/lib/activity.d.ts +6 -0
  23. package/dist/lib/activity.js +3 -0
  24. package/dist/lib/agents.js +2 -2
  25. package/dist/lib/analytics/dashboard.d.ts +11 -0
  26. package/dist/lib/analytics/dashboard.js +31 -0
  27. package/dist/lib/analytics/recipes.d.ts +32 -0
  28. package/dist/lib/analytics/recipes.js +316 -0
  29. package/dist/lib/analytics/usage-db.d.ts +84 -0
  30. package/dist/lib/analytics/usage-db.js +301 -0
  31. package/dist/lib/browser/service.js +18 -0
  32. package/dist/lib/channels/providers/openclaw-telegram.js +1 -1
  33. package/dist/lib/channels/resolve.d.ts +18 -1
  34. package/dist/lib/channels/resolve.js +15 -5
  35. package/dist/lib/cli-resources.d.ts +20 -0
  36. package/dist/lib/cli-resources.js +48 -1
  37. package/dist/lib/daemon.js +51 -14
  38. package/dist/lib/devices/health-report.d.ts +5 -0
  39. package/dist/lib/devices/health-report.js +3 -0
  40. package/dist/lib/feed-broadcast.d.ts +93 -12
  41. package/dist/lib/feed-broadcast.js +235 -30
  42. package/dist/lib/feed-post.d.ts +16 -2
  43. package/dist/lib/feed-post.js +35 -7
  44. package/dist/lib/feed.js +1 -1
  45. package/dist/lib/fleet-cache.d.ts +37 -0
  46. package/dist/lib/fleet-cache.js +40 -0
  47. package/dist/lib/fleet-status.d.ts +53 -0
  48. package/dist/lib/fleet-status.js +120 -0
  49. package/dist/lib/friction-heuristics.d.ts +32 -0
  50. package/dist/lib/friction-heuristics.js +47 -0
  51. package/dist/lib/hooks/cache.js +28 -6
  52. package/dist/lib/hooks/profile.d.ts +8 -0
  53. package/dist/lib/hooks/profile.js +14 -4
  54. package/dist/lib/hooks.d.ts +16 -1
  55. package/dist/lib/hooks.js +133 -18
  56. package/dist/lib/linear-cache.d.ts +63 -0
  57. package/dist/lib/linear-cache.js +146 -0
  58. package/dist/lib/linear-project-counts.d.ts +35 -5
  59. package/dist/lib/linear-project-counts.js +61 -16
  60. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  61. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +3 -1
  62. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  63. package/dist/lib/menubar/install-menubar.d.ts +7 -0
  64. package/dist/lib/menubar/install-menubar.js +36 -6
  65. package/dist/lib/monitors/config.d.ts +1 -1
  66. package/dist/lib/monitors/dispatch.d.ts +6 -2
  67. package/dist/lib/monitors/dispatch.js +10 -15
  68. package/dist/lib/notify.d.ts +42 -6
  69. package/dist/lib/notify.js +41 -32
  70. package/dist/lib/perf/db.d.ts +6 -1
  71. package/dist/lib/perf/db.js +35 -5
  72. package/dist/lib/perf/types.d.ts +10 -0
  73. package/dist/lib/project-doctor.d.ts +36 -0
  74. package/dist/lib/project-doctor.js +45 -0
  75. package/dist/lib/project-import.d.ts +11 -1
  76. package/dist/lib/project-import.js +17 -3
  77. package/dist/lib/project-status.d.ts +25 -5
  78. package/dist/lib/project-status.js +48 -6
  79. package/dist/lib/rotate.d.ts +27 -0
  80. package/dist/lib/rotate.js +44 -17
  81. package/dist/lib/routines.d.ts +16 -0
  82. package/dist/lib/routines.js +39 -0
  83. package/dist/lib/runner.js +34 -0
  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/usage-db.d.ts +3 -63
  87. package/dist/lib/secrets/usage-db.js +46 -186
  88. package/dist/lib/session/db.d.ts +2 -1
  89. package/dist/lib/session/db.js +14 -3
  90. package/dist/lib/session/discover.d.ts +3 -0
  91. package/dist/lib/session/discover.js +8 -0
  92. package/dist/lib/session/types.d.ts +1 -0
  93. package/dist/lib/startup/command-registry.d.ts +1 -0
  94. package/dist/lib/startup/command-registry.js +2 -0
  95. package/dist/lib/state.d.ts +31 -3
  96. package/dist/lib/state.js +53 -10
  97. package/dist/lib/types.d.ts +14 -4
  98. package/dist/lib/usage-refresh.d.ts +106 -0
  99. package/dist/lib/usage-refresh.js +238 -0
  100. package/dist/lib/usage.d.ts +152 -17
  101. package/dist/lib/usage.js +393 -79
  102. package/package.json +1 -1
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Disk TTL cache for the Linear answers behind the `agents projects` card.
3
+ *
4
+ * Linear meters two budgets independently, and only one of them binds. Observed
5
+ * on this account's response headers:
6
+ *
7
+ * x-ratelimit-requests-limit: 2500 remaining: 2
8
+ * x-ratelimit-complexity-limit: 3000000 remaining: 2999987
9
+ *
10
+ * Requests are scarce; complexity is 99.999% untouched. So the thing to
11
+ * optimize is the NUMBER of calls, not their cost — and the way to spend 2500
12
+ * of them is an agent (or a watch loop) running `projects status` repeatedly.
13
+ * A human typing it is not the exhauster.
14
+ *
15
+ * The CLI is a short-lived process, so an in-memory memo would only help within
16
+ * one invocation, which is the case that never needed help. This caches to disk.
17
+ *
18
+ * **One file per key, written by atomic rename.** A single JSON document holding
19
+ * every entry has to be read, modified, and written back, and that sequence is
20
+ * not atomic across processes — measured on this machine, two concurrent writers
21
+ * of 40 distinct keys each left **8 of 80** surviving. This box routinely runs a
22
+ * dozen agent sessions, so that is the normal case, not a corner. Per-key files
23
+ * remove the shared mutable document entirely: two processes caching different
24
+ * projects never touch the same path, and two caching the SAME project race only
25
+ * to write identical data. `writeFileSync` to a temp path followed by `rename`
26
+ * makes each file appear whole or not at all, so a reader never sees a partial
27
+ * write.
28
+ *
29
+ * The load-bearing behavior is what happens on FAILURE: a stale entry keeps
30
+ * being served, marked stale, instead of the line vanishing. That rule is
31
+ * borrowed from `mergeAuthHealthEntries` — one 8s timeout must not flip a
32
+ * populated chip to empty — and it is the fix for the card silently losing its
33
+ * Linear line mid-session when the request budget ran out.
34
+ */
35
+ import * as fs from 'fs';
36
+ import * as path from 'path';
37
+ import { getCacheDir } from './state.js';
38
+ /** Matches `SKILL_INDEX_TTL_MS` (`lib/registry.ts`) — the repo's TTL convention. */
39
+ export const LINEAR_CACHE_TTL_MS = 10 * 60_000;
40
+ const CACHE_SUBDIR = 'linear-projects';
41
+ /** Sits beside the per-project files; its own file, so it cannot be clobbered by them. */
42
+ const RATE_LIMIT_FILE = 'rate-limit.json';
43
+ /**
44
+ * Where the snapshot lives. `AGENTS_LINEAR_CACHE_PATH` overrides the directory,
45
+ * mirroring `AGENTS_FACTORY_PROJECTS_PATH` (`auto-dispatch.ts`) — `getCacheDir()`
46
+ * resolves `HOME` once at module load, so a test that swaps `process.env.HOME`
47
+ * afterwards would otherwise read and WRITE the developer's real cache.
48
+ */
49
+ function cacheDir() {
50
+ return process.env.AGENTS_LINEAR_CACHE_PATH ?? path.join(getCacheDir(), CACHE_SUBDIR);
51
+ }
52
+ /**
53
+ * One file per project id. Linear ids are UUIDs, but this is a filename built
54
+ * from external input, so anything outside the safe set is encoded rather than
55
+ * trusted — a `/` or `..` must never escape the cache directory.
56
+ */
57
+ function entryPath(projectId) {
58
+ return path.join(cacheDir(), `${projectId.replace(/[^a-zA-Z0-9._-]/g, '_')}.json`);
59
+ }
60
+ /** Parse a cache file, treating absent/corrupt/wrong-shaped as simply absent. */
61
+ function readJson(file, valid) {
62
+ try {
63
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
64
+ return valid(raw) ? raw : undefined;
65
+ }
66
+ catch {
67
+ return undefined; // absent or corrupt — an empty cache is always a valid answer
68
+ }
69
+ }
70
+ /**
71
+ * Write whole-or-not-at-all: a temp file in the same directory (so `rename`
72
+ * stays on one filesystem and is therefore atomic) swapped into place. A reader
73
+ * concurrent with this never observes a half-written document.
74
+ */
75
+ function writeJson(file, value) {
76
+ try {
77
+ fs.mkdirSync(path.dirname(file), { recursive: true });
78
+ const tmp = `${file}.${process.pid}.tmp`;
79
+ fs.writeFileSync(tmp, JSON.stringify(value), 'utf8');
80
+ fs.renameSync(tmp, file);
81
+ }
82
+ catch {
83
+ /* best-effort: an unwritable cache degrades to no cache, never to an error */
84
+ }
85
+ }
86
+ function isEntry(raw) {
87
+ return (!!raw &&
88
+ typeof raw === 'object' &&
89
+ typeof raw.at === 'number' &&
90
+ 'value' in raw);
91
+ }
92
+ /** Look up a project's cached answer. Returns stale entries too — the caller decides. */
93
+ export function readCached(projectId, nowMs) {
94
+ const entry = readJson(entryPath(projectId), isEntry);
95
+ if (!entry)
96
+ return undefined;
97
+ const ageMs = nowMs - entry.at;
98
+ return { value: entry.value, ageMs, stale: ageMs > LINEAR_CACHE_TTL_MS };
99
+ }
100
+ /** Store a freshly fetched answer. */
101
+ export function writeCached(projectId, value, nowMs) {
102
+ writeJson(entryPath(projectId), { at: nowMs, value });
103
+ }
104
+ /** Drop one project's entry — used when `projects link` re-points a definition. */
105
+ export function invalidateCached(projectId) {
106
+ try {
107
+ fs.rmSync(entryPath(projectId), { force: true });
108
+ }
109
+ catch {
110
+ /* already gone is the desired state */
111
+ }
112
+ }
113
+ function isRateLimitFile(raw) {
114
+ return !!raw && typeof raw === 'object' && typeof raw.until === 'number';
115
+ }
116
+ /** True when a prior 429 said the budget is exhausted and has not yet reset. */
117
+ export function isRateLimited(nowMs) {
118
+ const f = readJson(path.join(cacheDir(), RATE_LIMIT_FILE), isRateLimitFile);
119
+ return !!f && f.until > nowMs;
120
+ }
121
+ /**
122
+ * Read a 429's `x-ratelimit-requests-reset` header into an epoch-ms instant.
123
+ * Linear sends epoch milliseconds; anything absent, non-numeric, or already in
124
+ * the past is not usable and the caller backs off a TTL instead. Pure, so the
125
+ * parsing is testable without a live 429.
126
+ */
127
+ export function parseRateLimitReset(header, nowMs) {
128
+ if (!header)
129
+ return undefined;
130
+ const n = Number(header);
131
+ if (!Number.isFinite(n) || n <= nowMs)
132
+ return undefined;
133
+ return n;
134
+ }
135
+ /**
136
+ * Record a 429 so the next runs don't spend a request learning the same thing.
137
+ * `resetAtMs` comes from {@link parseRateLimitReset}; without it, back off one TTL.
138
+ */
139
+ export function noteRateLimited(resetAtMs, nowMs) {
140
+ // The invariant this owns: `until` is always in the future. A reset already
141
+ // elapsed would record a window that is over before it is written, which
142
+ // reads as "not rate limited" and sends the next run straight back into the
143
+ // 429 it just took.
144
+ const until = resetAtMs && resetAtMs > nowMs ? resetAtMs : nowMs + LINEAR_CACHE_TTL_MS;
145
+ writeJson(path.join(cacheDir(), RATE_LIMIT_FILE), { until });
146
+ }
@@ -43,12 +43,21 @@ export interface LinearMilestone {
43
43
  * than printing a meaningless `0/0`.
44
44
  */
45
45
  total: number;
46
+ /** True when Linear itself flags this as the project's next milestone. */
47
+ isNext?: boolean;
46
48
  }
47
49
  /** A milestone as the project declares it, independent of any issue. */
48
50
  export interface LinearMilestoneNode {
49
51
  id?: string;
50
52
  name?: string;
51
53
  targetDate?: string | null;
54
+ /**
55
+ * Linear's own marker. Observed values: `"next"` (it flags exactly one) and
56
+ * `"unstarted"`. Treated as an opaque string and only compared to `"next"` —
57
+ * the enum is not documented as closed, so switching exhaustively on it would
58
+ * break the day Linear adds a value.
59
+ */
60
+ status?: string | null;
52
61
  }
53
62
  /** The counts the card renders. `total` counts every issue in the project. */
54
63
  export interface LinearProjectCounts {
@@ -64,10 +73,22 @@ export interface LinearProjectCounts {
64
73
  */
65
74
  truncated?: boolean;
66
75
  /**
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.
76
+ * True when this answer came from the cache after a failed or skipped fetch.
77
+ * The card labels it rather than dropping the line — a populated Linear row
78
+ * that silently vanishes on one timeout is the defect this replaces.
79
+ */
80
+ stale?: boolean;
81
+ /**
82
+ * Every milestone the project declares, in the order the card shows them:
83
+ * unfinished first by target date, then the finished ones. A project with
84
+ * three checkpoints has three; showing only the next one hides the shape of
85
+ * the plan, which is what `projects view` exists to show.
86
+ */
87
+ milestones?: LinearMilestone[];
88
+ /**
89
+ * The one the project is working toward — `milestones[0]` when there is an
90
+ * unfinished one. Kept as its own field because the compact card shows only
91
+ * this, while `view` shows the whole list.
71
92
  */
72
93
  nextMilestone?: LinearMilestone;
73
94
  }
@@ -118,6 +139,15 @@ export declare function countsFromIssuesResponse(data: LinearIssuesResponse): Li
118
139
  * issues counts as unfinished (it is upcoming work, not completed work). Undated
119
140
  * milestones sort last, ties break by declaration order, so the answer is stable.
120
141
  */
142
+ export declare function orderedMilestones(declared: LinearMilestoneNode[], nodes: LinearIssueNode[]): LinearMilestone[];
143
+ /**
144
+ * The milestone the project is working toward next.
145
+ *
146
+ * Linear flags one itself (`status: "next"`), and that is the answer the user
147
+ * sees in Linear's own UI, so it wins when present. Only when nothing is
148
+ * flagged does this fall back to "earliest-dated unfinished", which is a
149
+ * reasonable guess but still a guess.
150
+ */
121
151
  export declare function nextMilestone(declared: LinearMilestoneNode[], nodes: LinearIssueNode[]): LinearMilestone | undefined;
122
152
  /**
123
153
  * Fetch issue counts for one Linear project, paging `issues` filtered by
@@ -126,4 +156,4 @@ export declare function nextMilestone(declared: LinearMilestoneNode[], nodes: Li
126
156
  * card just omits the line. `fetchPage` is injectable for tests — the
127
157
  * accumulator (cursor hand-off, cap) is the risky logic, not the HTTP.
128
158
  */
129
- export declare function fetchLinearProjectCounts(projectId: string, fetchPage?: (projectId: string, after: string | undefined, signal: AbortSignal) => Promise<LinearIssuesResponse | undefined>): Promise<LinearProjectCounts | undefined>;
159
+ export declare function fetchLinearProjectCounts(projectId: string, fetchPage?: (projectId: string, after: string | undefined, signal: AbortSignal) => Promise<LinearIssuesResponse | undefined>, nowMs?: number): Promise<LinearProjectCounts | undefined>;
@@ -28,6 +28,7 @@ import * as fs from 'fs';
28
28
  import * as os from 'os';
29
29
  import * as path from 'path';
30
30
  import { resolveLinearApiKey } from './auto-dispatch-linear.js';
31
+ import { isRateLimited, noteRateLimited, parseRateLimitReset, readCached, writeCached } from './linear-cache.js';
31
32
  const LINEAR_API = 'https://api.linear.app/graphql';
32
33
  /** Overall budget across all pages — the card must never hang on Linear. */
33
34
  const TIMEOUT_MS = 8_000;
@@ -51,7 +52,11 @@ export function countsFromIssuesResponse(data) {
51
52
  inProgress++;
52
53
  }
53
54
  const counts = { done, total: nodes.length, inProgress };
54
- const next = nextMilestone(data.project?.projectMilestones?.nodes ?? [], nodes);
55
+ const declared = data.project?.projectMilestones?.nodes ?? [];
56
+ const ordered = orderedMilestones(declared, nodes);
57
+ if (ordered.length)
58
+ counts.milestones = ordered;
59
+ const next = nextMilestone(declared, nodes);
55
60
  if (next)
56
61
  counts.nextMilestone = next;
57
62
  return counts;
@@ -70,7 +75,7 @@ export function countsFromIssuesResponse(data) {
70
75
  * issues counts as unfinished (it is upcoming work, not completed work). Undated
71
76
  * milestones sort last, ties break by declaration order, so the answer is stable.
72
77
  */
73
- export function nextMilestone(declared, nodes) {
78
+ export function orderedMilestones(declared, nodes) {
74
79
  // Progress per milestone id, from whatever issues do carry one.
75
80
  const progress = new Map();
76
81
  for (const n of nodes) {
@@ -83,7 +88,7 @@ export function nextMilestone(declared, nodes) {
83
88
  p.done++;
84
89
  progress.set(id, p);
85
90
  }
86
- const candidates = declared
91
+ const all = declared
87
92
  .map((d, order) => {
88
93
  if (!d?.id || typeof d.name !== 'string' || !d.name)
89
94
  return undefined;
@@ -91,14 +96,18 @@ export function nextMilestone(declared, nodes) {
91
96
  const m = { name: d.name, done: p.done, total: p.total, order };
92
97
  if (d.targetDate)
93
98
  m.targetDate = d.targetDate;
99
+ if (d.status === 'next')
100
+ m.isNext = true;
94
101
  return m;
95
102
  })
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) => {
103
+ .filter((m) => m !== undefined);
104
+ // total 0 means "declared, nothing filed yet" — unfinished, not done.
105
+ const open = (m) => m.total === 0 || m.done < m.total;
106
+ all.sort((a, b) => {
107
+ // Unfinished before finished: what is still ahead is what a reader is
108
+ // scanning for.
109
+ if (open(a) !== open(b))
110
+ return open(a) ? -1 : 1;
102
111
  if (a.targetDate && b.targetDate)
103
112
  return a.targetDate < b.targetDate ? -1 : a.targetDate > b.targetDate ? 1 : a.order - b.order;
104
113
  if (a.targetDate)
@@ -107,8 +116,20 @@ export function nextMilestone(declared, nodes) {
107
116
  return 1;
108
117
  return a.order - b.order;
109
118
  });
110
- const { order: _order, ...m } = candidates[0];
111
- return m;
119
+ return all.map(({ order: _order, ...m }) => m);
120
+ }
121
+ /**
122
+ * The milestone the project is working toward next.
123
+ *
124
+ * Linear flags one itself (`status: "next"`), and that is the answer the user
125
+ * sees in Linear's own UI, so it wins when present. Only when nothing is
126
+ * flagged does this fall back to "earliest-dated unfinished", which is a
127
+ * reasonable guess but still a guess.
128
+ */
129
+ export function nextMilestone(declared, nodes) {
130
+ const ordered = orderedMilestones(declared, nodes);
131
+ const open = ordered.filter((m) => m.total === 0 || m.done < m.total);
132
+ return open.find((m) => m.isNext) ?? open[0];
112
133
  }
113
134
  /** $LINEAR_API_KEY → macOS Keychain → ~/.linear-cli/config.json. Null if none. */
114
135
  function resolveApiKey() {
@@ -130,7 +151,17 @@ function resolveApiKey() {
130
151
  * card just omits the line. `fetchPage` is injectable for tests — the
131
152
  * accumulator (cursor hand-off, cap) is the risky logic, not the HTTP.
132
153
  */
133
- export async function fetchLinearProjectCounts(projectId, fetchPage = fetchLinearIssuesPage) {
154
+ export async function fetchLinearProjectCounts(projectId, fetchPage = fetchLinearIssuesPage, nowMs = Date.now()) {
155
+ // Requests are the scarce budget (2500/hr; complexity is untouched), and this
156
+ // pages up to 10 of them per project per call. Serve a fresh snapshot without
157
+ // spending any.
158
+ const cached = readCached(projectId, nowMs);
159
+ if (cached && !cached.stale)
160
+ return cached.value;
161
+ // A prior 429 said there is nothing left to spend — don't spend one finding
162
+ // that out again. Fall through to the stale snapshot rather than no line.
163
+ if (isRateLimited(nowMs))
164
+ return cached ? { ...cached.value, stale: true } : undefined;
134
165
  const ctrl = new AbortController();
135
166
  const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
136
167
  try {
@@ -141,8 +172,12 @@ export async function fetchLinearProjectCounts(projectId, fetchPage = fetchLinea
141
172
  let truncated = false;
142
173
  for (let page = 0;; page++) {
143
174
  const data = await fetchPage(projectId, after, ctrl.signal);
175
+ // A failed fetch keeps the last good answer on screen, marked stale,
176
+ // instead of the line vanishing. One 8s timeout must not blank a chip
177
+ // that was populated a minute ago — the rule `mergeAuthHealthEntries`
178
+ // already encodes for account health.
144
179
  if (!data)
145
- return undefined;
180
+ return cached ? { ...cached.value, stale: true } : undefined;
146
181
  if (page === 0)
147
182
  declared = data.project?.projectMilestones?.nodes ?? [];
148
183
  all.push(...(data.issues?.nodes ?? []));
@@ -156,16 +191,18 @@ export async function fetchLinearProjectCounts(projectId, fetchPage = fetchLinea
156
191
  }
157
192
  after = pi.endCursor;
158
193
  }
159
- return {
194
+ const counts = {
160
195
  ...countsFromIssuesResponse({
161
196
  issues: { nodes: all },
162
197
  project: { projectMilestones: { nodes: declared } },
163
198
  }),
164
199
  ...(truncated ? { truncated } : {}),
165
200
  };
201
+ writeCached(projectId, counts, nowMs);
202
+ return counts;
166
203
  }
167
204
  catch {
168
- return undefined;
205
+ return cached ? { ...cached.value, stale: true } : undefined;
169
206
  }
170
207
  finally {
171
208
  clearTimeout(timer);
@@ -182,7 +219,7 @@ async function fetchLinearIssuesPage(projectId, after, signal) {
182
219
  const issuesSelection = 'issues(filter:{ project:{ id:{ eq:$p } } }, first:' +
183
220
  PAGE_SIZE +
184
221
  ', after:$after){ nodes{ state{ type } projectMilestone{ id } } pageInfo{ hasNextPage endCursor } }';
185
- const milestonesSelection = 'project(id:$pid){ projectMilestones(first:50){ nodes{ id name targetDate } } }';
222
+ const milestonesSelection = 'project(id:$pid){ projectMilestones(first:50){ nodes{ id name targetDate status } } }';
186
223
  const first = after === undefined;
187
224
  const res = await fetch(LINEAR_API, {
188
225
  method: 'POST',
@@ -197,6 +234,14 @@ async function fetchLinearIssuesPage(projectId, after, signal) {
197
234
  }),
198
235
  signal,
199
236
  });
237
+ if (res.status === 429) {
238
+ // Record when the budget refills so later runs skip the call entirely
239
+ // rather than spending one of the zero remaining requests to be told so.
240
+ // The header is epoch milliseconds; absent or unparseable, back off a TTL.
241
+ const now = Date.now();
242
+ noteRateLimited(parseRateLimitReset(res.headers.get('x-ratelimit-requests-reset'), now), now);
243
+ return undefined;
244
+ }
200
245
  if (!res.ok)
201
246
  return undefined;
202
247
  const json = (await res.json());
@@ -7,7 +7,9 @@
7
7
  <key>CFBundleIdentifier</key>
8
8
  <string>com.phnx-labs.agents-menubar</string>
9
9
  <key>CFBundleName</key>
10
- <string>Agents Menu Bar</string>
10
+ <string>AGI Menu</string>
11
+ <key>CFBundleDisplayName</key>
12
+ <string>AGI Menu</string>
11
13
  <key>CFBundleIconFile</key>
12
14
  <string>AppIcon</string>
13
15
  <key>CFBundlePackageType</key>
@@ -40,10 +40,17 @@ export declare function codesignVerifies(appPath: string): boolean;
40
40
  * bootstrap a helper macOS would reject.
41
41
  */
42
42
  export declare function gatekeeperAssesses(appPath: string): boolean;
43
+ /** True when the bundle carries a Developer ID TeamIdentifier (not ad-hoc). */
44
+ export declare function hasDeveloperIdSignature(appPath: string): boolean;
43
45
  /**
44
46
  * Copy the bundled `.app` to the stable user path (idempotent unless forced).
45
47
  * Returns the installed executable path, or null if no source bundle ships
46
48
  * with this install (e.g. Linux package, or a build without the helper).
49
+ *
50
+ * Also heals an older install that was ad-hoc re-signed over a Developer ID
51
+ * source: that unstable identity made Accessibility re-prompt on every upgrade.
52
+ * When the shipped source is Developer ID and the installed copy is only
53
+ * ad-hoc, replace it — even without forceReinstall.
47
54
  */
48
55
  export declare function ensureMenubarAppInstalled(opts?: {
49
56
  forceReinstall?: boolean;
@@ -205,10 +205,25 @@ export function gatekeeperAssesses(appPath) {
205
205
  const r = spawnSync('spctl', ['--assess', '--type', 'exec', appPath], { stdio: ['ignore', 'ignore', 'ignore'] });
206
206
  return r.status === 0;
207
207
  }
208
+ /** True when the bundle carries a Developer ID TeamIdentifier (not ad-hoc). */
209
+ export function hasDeveloperIdSignature(appPath) {
210
+ const r = spawnSync('codesign', ['-dv', '--verbose=4', appPath], {
211
+ stdio: ['ignore', 'pipe', 'pipe'],
212
+ encoding: 'utf-8',
213
+ });
214
+ const out = `${r.stderr || ''}${r.stdout || ''}`;
215
+ const team = out.match(/TeamIdentifier=([A-Z0-9]+)/)?.[1];
216
+ return Boolean(team && team !== 'not set');
217
+ }
208
218
  /**
209
219
  * Copy the bundled `.app` to the stable user path (idempotent unless forced).
210
220
  * Returns the installed executable path, or null if no source bundle ships
211
221
  * with this install (e.g. Linux package, or a build without the helper).
222
+ *
223
+ * Also heals an older install that was ad-hoc re-signed over a Developer ID
224
+ * source: that unstable identity made Accessibility re-prompt on every upgrade.
225
+ * When the shipped source is Developer ID and the installed copy is only
226
+ * ad-hoc, replace it — even without forceReinstall.
212
227
  */
213
228
  export function ensureMenubarAppInstalled(opts = {}) {
214
229
  if (!onDarwin())
@@ -218,7 +233,11 @@ export function ensureMenubarAppInstalled(opts = {}) {
218
233
  return null;
219
234
  const dest = installedAppPath();
220
235
  if (!opts.forceReinstall && fs.existsSync(dest)) {
221
- return installedExecutablePath();
236
+ const sourceIsDevId = hasDeveloperIdSignature(src);
237
+ const destIsDevId = hasDeveloperIdSignature(dest);
238
+ if (!(sourceIsDevId && !destIsDevId)) {
239
+ return installedExecutablePath();
240
+ }
222
241
  }
223
242
  copyAppBundle(src, dest);
224
243
  // A fresh copy is exactly when the bundle's icon can be new (first install) or
@@ -325,7 +344,7 @@ export function enableMenubarService(opts = { clearOptOut: true }) {
325
344
  // this passes; if it ever doesn't, skip the service and point at the upgrade
326
345
  // rather than re-signing over it (an ad-hoc re-sign never satisfies Gatekeeper).
327
346
  if (!(codesignVerifies(installedAppPath()) && gatekeeperAssesses(installedAppPath()))) {
328
- process.stderr.write('agents: menu-bar helper is not notarized/valid on this machine; skipping launch. ' +
347
+ process.stderr.write('agents: AGI Menu is not notarized/valid on this machine; skipping launch. ' +
329
348
  'Upgrade to a notarized build (npm i -g @phnx-labs/agents-cli), then `agents menubar setup`.\n');
330
349
  return false;
331
350
  }
@@ -472,8 +491,10 @@ export function installMenubarLaunchAgentOnUpgrade() {
472
491
  }
473
492
  // Re-enable (recopy helper + rewrite plist) when the version drifted OR the
474
493
  // plist's baked interpreter/entry no longer point at the install now running
475
- // `agents` — the dual-install skew a version bump alone can't catch.
476
- if (menubarSetupStale() || menubarSetupNeedsRepoint()) {
494
+ // `agents` — the dual-install skew a version bump alone can't catch — OR the
495
+ // installed copy is still ad-hoc while the shipped source is Developer ID
496
+ // (older heal path; Accessibility re-prompts until the identity is restored).
497
+ if (menubarSetupStale() || menubarSetupNeedsRepoint() || installedNeedsDevIdHeal()) {
477
498
  enableMenubarService({ clearOptOut: false });
478
499
  }
479
500
  }
@@ -481,6 +502,15 @@ export function installMenubarLaunchAgentOnUpgrade() {
481
502
  /* never block startup on the menu bar */
482
503
  }
483
504
  }
505
+ /** True when App Support still has an ad-hoc copy but the npm bundle is Developer ID. */
506
+ function installedNeedsDevIdHeal() {
507
+ const src = sourceAppPath();
508
+ if (!src || !fs.existsSync(installedAppPath()))
509
+ return false;
510
+ if (hasDeveloperIdSignature(installedAppPath()))
511
+ return false;
512
+ return hasDeveloperIdSignature(src);
513
+ }
484
514
  /**
485
515
  * Decide which live helper processes must be ended so exactly one status item
486
516
  * survives. Pure so the choice is unit-testable without a live menu bar.
@@ -521,12 +551,12 @@ export function runMenubarSetup() {
521
551
  steps.push({ name, outcome, detail });
522
552
  };
523
553
  if (!onDarwin()) {
524
- step('platform', 'failed', `the menu bar helper is macOS only (this is ${process.platform})`);
554
+ step('platform', 'failed', `AGI Menu is macOS only (this is ${process.platform})`);
525
555
  return { steps, configured: false, status: getMenubarStatus() };
526
556
  }
527
557
  const before = getMenubarStatus();
528
558
  if (!sourceAppPath()) {
529
- step('bundle', 'failed', 'no menu-bar helper bundle ships with this install');
559
+ step('bundle', 'failed', 'no AGI Menu bundle ships with this install');
530
560
  return { steps, configured: false, status: before };
531
561
  }
532
562
  // 3 before 1: end the running copies BEFORE swapping the bundle underneath
@@ -80,7 +80,7 @@ export interface ActionConfig {
80
80
  timeout?: string;
81
81
  /** routine: name of an existing routine to fire. */
82
82
  routine?: string;
83
- /** notify: channel for the notification (default `telegram`). */
83
+ /** notify: override the owner channel (defaults to `notify.owner.channel`). */
84
84
  notifyChannel?: string;
85
85
  /** webhook-out: URL to POST the event to. */
86
86
  url?: string;
@@ -5,8 +5,12 @@
5
5
  * action goes through the *same* detached spawn cron and webhook fires use
6
6
  * (executeJobDetached, lib/runner.ts) — a monitor never duplicates spawn logic,
7
7
  * it synthesizes a JobConfig and hands it to the one dispatch seam. `notify`
8
- * reuses the openclaw Telegram path (lib/notify.ts); `webhook-out` POSTs the event.
8
+ * routes the owner through the one channel seam (sendToOwner → lookupTransport,
9
+ * lib/notify.ts) — recipient from notify.owner, no hardcoded chat id, and an
10
+ * unresolvable channel comes back as `ok: false` instead of exiting the daemon;
11
+ * `webhook-out` POSTs the event.
9
12
  */
13
+ import type { Meta } from '../types.js';
10
14
  import type { ActionConfig, MonitorConfig, MonitorEvent } from './config.js';
11
15
  /** Outcome of a dispatched action. */
12
16
  export interface DispatchResult {
@@ -25,4 +29,4 @@ export declare function injectEvent(prompt: string, event: MonitorEvent): string
25
29
  * use. `routine` fires an existing routine with the event injected. `notify` and
26
30
  * `webhook-out` are terminal side-effects.
27
31
  */
28
- export declare function dispatchAction(monitor: MonitorConfig, event: MonitorEvent): Promise<DispatchResult>;
32
+ export declare function dispatchAction(monitor: MonitorConfig, event: MonitorEvent, meta?: Meta): Promise<DispatchResult>;
@@ -5,14 +5,14 @@
5
5
  * action goes through the *same* detached spawn cron and webhook fires use
6
6
  * (executeJobDetached, lib/runner.ts) — a monitor never duplicates spawn logic,
7
7
  * it synthesizes a JobConfig and hands it to the one dispatch seam. `notify`
8
- * reuses the openclaw Telegram path (lib/notify.ts); `webhook-out` POSTs the event.
8
+ * routes the owner through the one channel seam (sendToOwner → lookupTransport,
9
+ * lib/notify.ts) — recipient from notify.owner, no hardcoded chat id, and an
10
+ * unresolvable channel comes back as `ok: false` instead of exiting the daemon;
11
+ * `webhook-out` POSTs the event.
9
12
  */
10
- import { execFile } from 'child_process';
11
- import { promisify } from 'util';
12
13
  import { executeJobDetached } from '../runner.js';
13
14
  import { readJob } from '../routines.js';
14
- import { buildOpenClawNotifyArgs } from '../notify.js';
15
- const execFileAsync = promisify(execFile);
15
+ import { sendToOwner } from '../notify.js';
16
16
  /** Replace `{event}` in a prompt with the fired event summary. */
17
17
  export function injectEvent(prompt, event) {
18
18
  return prompt.replace(/\{event\}/g, event.summary);
@@ -24,7 +24,7 @@ export function injectEvent(prompt, event) {
24
24
  * use. `routine` fires an existing routine with the event injected. `notify` and
25
25
  * `webhook-out` are terminal side-effects.
26
26
  */
27
- export async function dispatchAction(monitor, event) {
27
+ export async function dispatchAction(monitor, event, meta) {
28
28
  const action = monitor.action;
29
29
  if (action.type === 'run') {
30
30
  const job = {
@@ -63,16 +63,11 @@ export async function dispatchAction(monitor, event) {
63
63
  }
64
64
  }
65
65
  if (action.type === 'notify') {
66
- const args = buildOpenClawNotifyArgs(event.summary, {
67
- channel: action.notifyChannel ?? 'telegram',
66
+ const result = await sendToOwner(event.summary, {
67
+ ...(meta ? { meta } : {}),
68
+ ...(action.notifyChannel ? { channel: action.notifyChannel } : {}),
68
69
  });
69
- try {
70
- await execFileAsync('openclaw', args);
71
- return { kind: 'notify', ok: true };
72
- }
73
- catch (err) {
74
- return { kind: 'notify', ok: false, error: err.message };
75
- }
70
+ return { kind: 'notify', ok: result.ok, ...(result.ok ? {} : { error: result.error }) };
76
71
  }
77
72
  // webhook-out
78
73
  if (!action.url)
@@ -1,9 +1,29 @@
1
+ /**
2
+ * Owner notifier — the one seam for "ping the human" messages.
3
+ *
4
+ * Every human-facing owner notification (feed urgent-block dispatch, monitor
5
+ * `notify` action, `agents notify`) funnels through the single channel seam:
6
+ * `lookupTransport(channel, meta).provider.send(text, opts)`. The recipient comes
7
+ * from `notify.owner` in agents.yaml — never a hardcoded chat id — so changing the
8
+ * owner is honoured by every path at once. `notify.transports` picks the actual
9
+ * provider per host (rush telegram on zion, openclaw-telegram on mac-mini).
10
+ * Best-effort: a delivery failure is returned to the caller, never thrown, so a
11
+ * notification hiccup never blocks the agent. That is why this module resolves
12
+ * with `lookupTransport` and not the `die()`-capable `resolveTransport` — the
13
+ * monitor daemon and the feed-dispatch loop call in here, and `process.exit()`
14
+ * would take them down on a typo'd channel name, bypassing their try/catch.
15
+ */
1
16
  import type { OpenBlock } from './feed.js';
2
- export interface NotifyOptions {
17
+ import type { Meta } from './types.js';
18
+ import type { SendResult } from './channels/registry.js';
19
+ export interface OwnerNotifyOptions {
20
+ /** Config source (defaults to `readMeta()`); lets callers/tests inject it. */
21
+ meta?: Meta;
22
+ /** Override the owner channel from `notify.owner.channel`. */
3
23
  channel?: string;
4
- account?: string;
5
- /** OpenClaw destination (Telegram chat id). Defaults to Muqsit's chat. */
24
+ /** Override the owner target from `notify.owner.to`. */
6
25
  target?: string;
26
+ /** Resolve + build the delivery but do not actually send. */
7
27
  dryRun?: boolean;
8
28
  }
9
29
  export interface NotifyResult {
@@ -12,6 +32,22 @@ export interface NotifyResult {
12
32
  error?: string;
13
33
  }
14
34
  export declare function formatUrgentBlockMessage(block: OpenBlock): string;
15
- /** Build openclaw argv for urgent notify (exported for tests). */
16
- export declare function buildOpenClawNotifyArgs(text: string, options?: Pick<NotifyOptions, 'channel' | 'account' | 'target'>): string[];
17
- export declare function notifyUrgentBlock(block: OpenBlock, options?: NotifyOptions): Promise<NotifyResult>;
35
+ /**
36
+ * Build openclaw argv for a Telegram send (used by the openclaw-telegram
37
+ * provider and its tests). `target` is required — the recipient is always
38
+ * resolved by the caller, never defaulted to a hardcoded number here.
39
+ */
40
+ export declare function buildOpenClawNotifyArgs(text: string, opts: {
41
+ target: string;
42
+ channel?: string;
43
+ account?: string;
44
+ }): string[];
45
+ /**
46
+ * Deliver a message to the configured owner through the one channel seam.
47
+ * `channel`/`target` default to `notify.owner.{channel,to}`; `notify.transports`
48
+ * selects the provider per host. A missing owner config or a delivery failure
49
+ * (e.g. openclaw not on PATH) returns a clean `SendResult` error — never a raw
50
+ * ENOENT — so callers surface a consistent, best-effort failure.
51
+ */
52
+ export declare function sendToOwner(text: string, options?: OwnerNotifyOptions): Promise<SendResult>;
53
+ export declare function notifyUrgentBlock(block: OpenBlock, options?: OwnerNotifyOptions): Promise<NotifyResult>;