@phnx-labs/agents-cli 1.21.1 → 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 (87) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/README.md +1 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/doctor.js +5 -2
  5. package/dist/commands/feed.js +28 -19
  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/perf.d.ts +13 -0
  10. package/dist/commands/perf.js +80 -23
  11. package/dist/commands/projects.d.ts +11 -0
  12. package/dist/commands/projects.js +153 -21
  13. package/dist/commands/routines.js +46 -1
  14. package/dist/commands/ssh.js +69 -0
  15. package/dist/commands/trends.d.ts +2 -0
  16. package/dist/commands/trends.js +158 -0
  17. package/dist/commands/usage.d.ts +4 -4
  18. package/dist/commands/view.d.ts +6 -0
  19. package/dist/commands/view.js +90 -45
  20. package/dist/index.js +14 -1
  21. package/dist/lib/agents.js +2 -2
  22. package/dist/lib/analytics/dashboard.d.ts +11 -0
  23. package/dist/lib/analytics/dashboard.js +31 -0
  24. package/dist/lib/analytics/recipes.d.ts +32 -0
  25. package/dist/lib/analytics/recipes.js +316 -0
  26. package/dist/lib/analytics/usage-db.d.ts +84 -0
  27. package/dist/lib/analytics/usage-db.js +301 -0
  28. package/dist/lib/browser/service.js +18 -0
  29. package/dist/lib/cli-resources.d.ts +20 -0
  30. package/dist/lib/cli-resources.js +48 -1
  31. package/dist/lib/daemon.js +51 -14
  32. package/dist/lib/devices/health-report.d.ts +5 -0
  33. package/dist/lib/devices/health-report.js +3 -0
  34. package/dist/lib/feed-broadcast.d.ts +52 -7
  35. package/dist/lib/feed-broadcast.js +125 -18
  36. package/dist/lib/fleet-cache.d.ts +37 -0
  37. package/dist/lib/fleet-cache.js +40 -0
  38. package/dist/lib/fleet-status.d.ts +53 -0
  39. package/dist/lib/fleet-status.js +120 -0
  40. package/dist/lib/friction-heuristics.d.ts +32 -0
  41. package/dist/lib/friction-heuristics.js +47 -0
  42. package/dist/lib/hooks/cache.js +28 -6
  43. package/dist/lib/hooks/profile.d.ts +8 -0
  44. package/dist/lib/hooks/profile.js +14 -4
  45. package/dist/lib/hooks.js +72 -17
  46. package/dist/lib/linear-cache.d.ts +63 -0
  47. package/dist/lib/linear-cache.js +146 -0
  48. package/dist/lib/linear-project-counts.d.ts +35 -5
  49. package/dist/lib/linear-project-counts.js +61 -16
  50. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  51. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +3 -1
  52. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  53. package/dist/lib/menubar/install-menubar.d.ts +7 -0
  54. package/dist/lib/menubar/install-menubar.js +36 -6
  55. package/dist/lib/perf/db.d.ts +6 -1
  56. package/dist/lib/perf/db.js +35 -5
  57. package/dist/lib/perf/types.d.ts +10 -0
  58. package/dist/lib/project-doctor.d.ts +36 -0
  59. package/dist/lib/project-doctor.js +45 -0
  60. package/dist/lib/project-import.d.ts +11 -1
  61. package/dist/lib/project-import.js +17 -3
  62. package/dist/lib/project-status.d.ts +25 -5
  63. package/dist/lib/project-status.js +48 -6
  64. package/dist/lib/rotate.d.ts +27 -0
  65. package/dist/lib/rotate.js +44 -17
  66. package/dist/lib/routines.d.ts +16 -0
  67. package/dist/lib/routines.js +39 -0
  68. package/dist/lib/runner.js +34 -0
  69. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  70. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  71. package/dist/lib/secrets/usage-db.d.ts +3 -63
  72. package/dist/lib/secrets/usage-db.js +46 -186
  73. package/dist/lib/session/db.d.ts +2 -1
  74. package/dist/lib/session/db.js +14 -3
  75. package/dist/lib/session/discover.d.ts +3 -0
  76. package/dist/lib/session/discover.js +8 -0
  77. package/dist/lib/session/types.d.ts +1 -0
  78. package/dist/lib/startup/command-registry.d.ts +1 -0
  79. package/dist/lib/startup/command-registry.js +2 -0
  80. package/dist/lib/state.d.ts +31 -3
  81. package/dist/lib/state.js +53 -10
  82. package/dist/lib/types.d.ts +8 -4
  83. package/dist/lib/usage-refresh.d.ts +106 -0
  84. package/dist/lib/usage-refresh.js +238 -0
  85. package/dist/lib/usage.d.ts +152 -17
  86. package/dist/lib/usage.js +393 -79
  87. package/package.json +1 -1
package/dist/lib/hooks.js CHANGED
@@ -15,7 +15,7 @@ import * as yaml from 'yaml';
15
15
  import * as TOML from 'smol-toml';
16
16
  import { AGENTS, agentConfigDirName, isAgentHardDeprecated } from './agents.js';
17
17
  import { supports, explainSkip, capableAgents } from './capabilities.js';
18
- import { getHooksDir as getSystemHooksDir, getUserHooksDir, getUserAgentsDir, getSystemAgentsDir, getProjectAgentsDir, getTrashHooksDir, getEnabledExtraRepos, getResolvedRulesDir, getUserRulesDir } from './state.js';
18
+ import { getHooksDir as getSystemHooksDir, getUserHooksDir, getUserAgentsDir, getSystemAgentsDir, getProjectAgentsDir, getTrashHooksDir, getEnabledExtraRepos, getResolvedRulesDir, getUserRulesDir, getPerfDir } from './state.js';
19
19
  import { collectSubruleHooksFromState } from './rules/compose.js';
20
20
  function getCentralHooksDir() { return getUserHooksDir(); }
21
21
  /**
@@ -256,12 +256,20 @@ export function inspectDuplicateVersionHooks(cwd = process.cwd()) {
256
256
  /**
257
257
  * Resolve the command path to register for a hook.
258
258
  *
259
- * Returns either the raw script path (neither `cache:` nor `matches:` set,
260
- * legacy behavior) or the path to a generated wrapper shim. The shim is written
261
- * as a side effect when `cache:` and/or `matches:` is configured — it enforces
262
- * the `matches:` gate at fire time and layers the caching/timing machinery when
263
- * `cache:` is set. The agent-native settings file gets the same shape either
264
- * way — just a different command path.
259
+ * Returns either the raw script path (no `cache:`, `matches:`, or `matcher:`
260
+ * set a bare lifecycle hook with nothing to gate or time) or the path to a
261
+ * generated wrapper shim. The shim is written as a side effect when `cache:`
262
+ * and/or `matches:` is configured it enforces the `matches:` gate at fire
263
+ * time and layers the caching/timing machinery when `cache:` is set.
264
+ *
265
+ * A hook that declares only `matcher:` (e.g. git-guard/rm-guard scoped to the
266
+ * `Bash` tool, no `cache:`/`matches:`) also gets a shim now — a pass-through
267
+ * one with no gate and no cache, whose only job is the trailing timing sample
268
+ * (see PASSTHROUGH_TAIL in hooks/cache.ts). Before this, a matcher-only hook
269
+ * took the raw-path branch and fired completely uninstrumented: `agents perf
270
+ * hooks` showed zero samples for it no matter how often it ran. The agent-
271
+ * native settings file gets the same shape either way — just a different
272
+ * command path.
265
273
  */
266
274
  function resolveHookCommand(name, hookDef, resolveScript) {
267
275
  const scriptPath = resolveScript(hookDef.script);
@@ -272,17 +280,20 @@ function resolveHookCommand(name, hookDef, resolveScript) {
272
280
  const cache = parseCacheConfig(hookDef.cache);
273
281
  const matches = hookDef.matches;
274
282
  const hasMatches = matches != null && Object.keys(matches).length > 0;
275
- if (!cache && !hasMatches) {
276
- // No caching and no matches: gate opted in — make sure a previously
277
- // generated shim from an earlier `cache:`/`matches:` config is gone so the
278
- // JSONL doesn't keep claiming hits.
283
+ const hasMatcher = !!hookDef.matcher;
284
+ if (!cache && !hasMatches && !hasMatcher) {
285
+ // Nothing to gate, cache, or time: make sure a previously generated shim
286
+ // from an earlier cache:/matches:/matcher config is gone so the JSONL
287
+ // doesn't keep claiming hits.
279
288
  removeHookShim(name);
280
289
  return toPortableCommand(scriptPath);
281
290
  }
282
- // A shim is generated when the hook opts into caching and/or declares
283
- // `matches:` predicates. The shim enforces the `matches:` gate at fire time
284
- // (skipping the script when predicates don't hold) and, when `cache:` is set,
285
- // layers the cache/timing machinery on top.
291
+ // A shim is generated when the hook opts into caching, declares `matches:`
292
+ // predicates, or declares a `matcher:` (even alone see the doc comment
293
+ // above). The shim enforces the `matches:` gate at fire time (skipping the
294
+ // script when predicates don't hold) and, when `cache:` is set, layers the
295
+ // cache/timing machinery on top; with neither, it is a pure pass-through
296
+ // timing wrapper.
286
297
  return toPortableCommand(generateHookShim({ name, scriptPath, cache, matches }));
287
298
  }
288
299
  /**
@@ -645,7 +656,7 @@ export function checkVersionHookWiring(agent, version) {
645
656
  return null;
646
657
  const cache = parseCacheConfig(hookDef.cache);
647
658
  const hasMatches = hookDef.matches != null && Object.keys(hookDef.matches).length > 0;
648
- if (!cache && !hasMatches)
659
+ if (!cache && !hasMatches && !hookDef.matcher)
649
660
  return toPortableCommand(scriptPath);
650
661
  return toPortableCommand(getHookShimPath(name));
651
662
  };
@@ -1264,7 +1275,17 @@ export function registerHooksToSettings(agentId, versionHome, hookManifest, agen
1264
1275
  return resolveHookScriptPath(script);
1265
1276
  };
1266
1277
  const managedPrefixes = overrideRoots
1267
- ? [path.join(overrideRoots[0], 'hooks') + path.sep]
1278
+ ? [
1279
+ path.join(overrideRoots[0], 'hooks') + path.sep,
1280
+ // The shim dir is one global location regardless of which hooks
1281
+ // source (agentsDirOverride vs the normal user/system dirs) resolved
1282
+ // the underlying script, so it belongs in every managedPrefixes
1283
+ // shape — omitting it here left a shim path unrecognized as managed
1284
+ // under the override branch, so a hook's matcher/event change never
1285
+ // GC'd its stale shim-path entry (only reachable via a caller that
1286
+ // passes agentsDirOverride; no production call site does today).
1287
+ getHookShimsDir() + path.sep,
1288
+ ]
1268
1289
  : [
1269
1290
  ...getManagedHookPrefixes(),
1270
1291
  ...(localHooksDir ? [localHooksDir + path.sep] : []),
@@ -1373,9 +1394,42 @@ function registerHooksForOpenCode(versionHome, manifest, resolveScript) {
1373
1394
  }
1374
1395
  const serializedDirect = JSON.stringify(Object.fromEntries(direct), null, 2);
1375
1396
  const serializedLifecycle = JSON.stringify(Object.fromEntries(lifecycle), null, 2);
1397
+ // Same disposable perf spool the bash shims (hooks/cache.ts) append to — see
1398
+ // the timedOut branch below for why OpenCode needs its own writer.
1399
+ const perfSpoolPath = path.join(getPerfDir(), 'spool.jsonl');
1376
1400
  const pluginSource = `// Generated by agents-cli. Re-run agents sync to update.
1401
+ import fs from "node:fs"
1402
+ import os from "node:os"
1403
+
1377
1404
  const directHooks = ${serializedDirect}
1378
1405
  const lifecycleHooks = ${serializedLifecycle}
1406
+ const PERF_SPOOL = ${JSON.stringify(perfSpoolPath)}
1407
+
1408
+ function recordTimeoutSample(hook, payload) {
1409
+ // hook.command already ran through a generated shim (hooks/cache.ts) that
1410
+ // writes its own hook.fire sample on exit — but Bun.spawn's child.kill()
1411
+ // below (SIGTERM) tears the shim down before it reaches that trailing
1412
+ // printf, so a timed-out fire would otherwise leave ZERO trace in the
1413
+ // warehouse. Write the sample ourselves from the side that knows it timed out.
1414
+ try {
1415
+ fs.mkdirSync(PERF_SPOOL.slice(0, PERF_SPOOL.lastIndexOf("/")), { recursive: true })
1416
+ const line = JSON.stringify({
1417
+ ts_ms: Date.now(),
1418
+ kind: "hook.fire",
1419
+ label: hook.name,
1420
+ duration_ms: hook.timeout * 1000,
1421
+ cache: "none",
1422
+ exit_code: null,
1423
+ status: "timeout",
1424
+ cwd: payload && typeof payload.cwd === "string" ? payload.cwd : undefined,
1425
+ session_id: payload && typeof payload.session_id === "string" ? payload.session_id : undefined,
1426
+ hostname: (() => { try { return os.hostname() } catch { return "unknown" } })(),
1427
+ }) + "\\n"
1428
+ fs.appendFileSync(PERF_SPOOL, line)
1429
+ } catch {
1430
+ // best effort — never let sample recording break the timeout error path
1431
+ }
1432
+ }
1379
1433
 
1380
1434
  function matches(hook, tool) {
1381
1435
  if (!hook.matcher) return true
@@ -1415,6 +1469,7 @@ async function runHooks(hooks, payload, $, matchTool = false) {
1415
1469
  const exitCode = await child.exited.finally(() => clearTimeout(timer))
1416
1470
  const stderr = await new Response(child.stderr).text()
1417
1471
  if (timedOut) {
1472
+ recordTimeoutSample(hook, payload)
1418
1473
  throw new Error(\`\${hook.name} timed out after \${hook.timeout} seconds\`)
1419
1474
  }
1420
1475
  if (exitCode !== 0) {
@@ -0,0 +1,63 @@
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
+ /** Matches `SKILL_INDEX_TTL_MS` (`lib/registry.ts`) — the repo's TTL convention. */
36
+ export declare const LINEAR_CACHE_TTL_MS: number;
37
+ /** What a lookup found, and how much to trust it. */
38
+ export interface CacheHit<T> {
39
+ value: T;
40
+ /** Age in ms. Past the TTL the value is still returned, flagged stale. */
41
+ ageMs: number;
42
+ stale: boolean;
43
+ }
44
+ /** Look up a project's cached answer. Returns stale entries too — the caller decides. */
45
+ export declare function readCached<T>(projectId: string, nowMs: number): CacheHit<T> | undefined;
46
+ /** Store a freshly fetched answer. */
47
+ export declare function writeCached<T>(projectId: string, value: T, nowMs: number): void;
48
+ /** Drop one project's entry — used when `projects link` re-points a definition. */
49
+ export declare function invalidateCached(projectId: string): void;
50
+ /** True when a prior 429 said the budget is exhausted and has not yet reset. */
51
+ export declare function isRateLimited(nowMs: number): boolean;
52
+ /**
53
+ * Read a 429's `x-ratelimit-requests-reset` header into an epoch-ms instant.
54
+ * Linear sends epoch milliseconds; anything absent, non-numeric, or already in
55
+ * the past is not usable and the caller backs off a TTL instead. Pure, so the
56
+ * parsing is testable without a live 429.
57
+ */
58
+ export declare function parseRateLimitReset(header: string | null, nowMs: number): number | undefined;
59
+ /**
60
+ * Record a 429 so the next runs don't spend a request learning the same thing.
61
+ * `resetAtMs` comes from {@link parseRateLimitReset}; without it, back off one TTL.
62
+ */
63
+ export declare function noteRateLimited(resetAtMs: number | undefined, nowMs: number): void;
@@ -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;