@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.
- package/CHANGELOG.md +198 -0
- package/README.md +2 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/doctor.js +5 -2
- package/dist/commands/feed.js +63 -40
- package/dist/commands/hooks.js +9 -45
- package/dist/commands/menubar.js +24 -24
- package/dist/commands/message.js +23 -3
- package/dist/commands/monitors.js +8 -6
- 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/activity.d.ts +6 -0
- package/dist/lib/activity.js +3 -0
- 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/channels/providers/openclaw-telegram.js +1 -1
- package/dist/lib/channels/resolve.d.ts +18 -1
- package/dist/lib/channels/resolve.js +15 -5
- 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 +93 -12
- package/dist/lib/feed-broadcast.js +235 -30
- package/dist/lib/feed-post.d.ts +16 -2
- package/dist/lib/feed-post.js +35 -7
- package/dist/lib/feed.js +1 -1
- 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.d.ts +16 -1
- package/dist/lib/hooks.js +133 -18
- 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/monitors/config.d.ts +1 -1
- package/dist/lib/monitors/dispatch.d.ts +6 -2
- package/dist/lib/monitors/dispatch.js +10 -15
- package/dist/lib/notify.d.ts +42 -6
- package/dist/lib/notify.js +41 -32
- 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 +14 -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
|
@@ -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;
|
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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', `
|
|
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
|
|
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:
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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 {
|
|
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
|
|
67
|
-
|
|
66
|
+
const result = await sendToOwner(event.summary, {
|
|
67
|
+
...(meta ? { meta } : {}),
|
|
68
|
+
...(action.notifyChannel ? { channel: action.notifyChannel } : {}),
|
|
68
69
|
});
|
|
69
|
-
|
|
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)
|
package/dist/lib/notify.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
16
|
-
|
|
17
|
-
|
|
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>;
|