@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.
- package/CHANGELOG.md +172 -0
- package/README.md +1 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/doctor.js +5 -2
- package/dist/commands/feed.js +28 -19
- package/dist/commands/hooks.js +9 -45
- package/dist/commands/menubar.js +24 -24
- package/dist/commands/message.js +23 -3
- package/dist/commands/perf.d.ts +13 -0
- package/dist/commands/perf.js +80 -23
- package/dist/commands/projects.d.ts +11 -0
- package/dist/commands/projects.js +153 -21
- package/dist/commands/routines.js +46 -1
- package/dist/commands/ssh.js +69 -0
- package/dist/commands/trends.d.ts +2 -0
- package/dist/commands/trends.js +158 -0
- package/dist/commands/usage.d.ts +4 -4
- package/dist/commands/view.d.ts +6 -0
- package/dist/commands/view.js +90 -45
- package/dist/index.js +14 -1
- package/dist/lib/agents.js +2 -2
- package/dist/lib/analytics/dashboard.d.ts +11 -0
- package/dist/lib/analytics/dashboard.js +31 -0
- package/dist/lib/analytics/recipes.d.ts +32 -0
- package/dist/lib/analytics/recipes.js +316 -0
- package/dist/lib/analytics/usage-db.d.ts +84 -0
- package/dist/lib/analytics/usage-db.js +301 -0
- package/dist/lib/browser/service.js +18 -0
- package/dist/lib/cli-resources.d.ts +20 -0
- package/dist/lib/cli-resources.js +48 -1
- package/dist/lib/daemon.js +51 -14
- package/dist/lib/devices/health-report.d.ts +5 -0
- package/dist/lib/devices/health-report.js +3 -0
- package/dist/lib/feed-broadcast.d.ts +52 -7
- package/dist/lib/feed-broadcast.js +125 -18
- package/dist/lib/fleet-cache.d.ts +37 -0
- package/dist/lib/fleet-cache.js +40 -0
- package/dist/lib/fleet-status.d.ts +53 -0
- package/dist/lib/fleet-status.js +120 -0
- package/dist/lib/friction-heuristics.d.ts +32 -0
- package/dist/lib/friction-heuristics.js +47 -0
- package/dist/lib/hooks/cache.js +28 -6
- package/dist/lib/hooks/profile.d.ts +8 -0
- package/dist/lib/hooks/profile.js +14 -4
- package/dist/lib/hooks.js +72 -17
- package/dist/lib/linear-cache.d.ts +63 -0
- package/dist/lib/linear-cache.js +146 -0
- package/dist/lib/linear-project-counts.d.ts +35 -5
- package/dist/lib/linear-project-counts.js +61 -16
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +3 -1
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.d.ts +7 -0
- package/dist/lib/menubar/install-menubar.js +36 -6
- package/dist/lib/perf/db.d.ts +6 -1
- package/dist/lib/perf/db.js +35 -5
- package/dist/lib/perf/types.d.ts +10 -0
- package/dist/lib/project-doctor.d.ts +36 -0
- package/dist/lib/project-doctor.js +45 -0
- package/dist/lib/project-import.d.ts +11 -1
- package/dist/lib/project-import.js +17 -3
- package/dist/lib/project-status.d.ts +25 -5
- package/dist/lib/project-status.js +48 -6
- package/dist/lib/rotate.d.ts +27 -0
- package/dist/lib/rotate.js +44 -17
- package/dist/lib/routines.d.ts +16 -0
- package/dist/lib/routines.js +39 -0
- package/dist/lib/runner.js +34 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/usage-db.d.ts +3 -63
- package/dist/lib/secrets/usage-db.js +46 -186
- package/dist/lib/session/db.d.ts +2 -1
- package/dist/lib/session/db.js +14 -3
- package/dist/lib/session/discover.d.ts +3 -0
- package/dist/lib/session/discover.js +8 -0
- package/dist/lib/session/types.d.ts +1 -0
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/state.d.ts +31 -3
- package/dist/lib/state.js +53 -10
- package/dist/lib/types.d.ts +8 -4
- package/dist/lib/usage-refresh.d.ts +106 -0
- package/dist/lib/usage-refresh.js +238 -0
- package/dist/lib/usage.d.ts +152 -17
- package/dist/lib/usage.js +393 -79
- 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 (
|
|
260
|
-
*
|
|
261
|
-
* as a side effect when `cache:`
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
//
|
|
278
|
-
//
|
|
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
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
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
|
-
? [
|
|
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
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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());
|
|
Binary file
|
|
@@ -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>
|
|
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>
|
|
Binary file
|
|
@@ -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;
|