@phnx-labs/agents-cli 1.20.93 → 1.21.0
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 +264 -0
- package/README.md +3 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/beta.js +3 -5
- package/dist/commands/doctor.js +23 -0
- package/dist/commands/exec.js +13 -0
- package/dist/commands/mailboxes.js +39 -1
- package/dist/commands/message.js +12 -1
- package/dist/commands/packages.js +14 -5
- package/dist/commands/projects.d.ts +28 -0
- package/dist/commands/projects.js +173 -47
- package/dist/commands/routines.js +27 -1
- package/dist/commands/rules.js +6 -0
- package/dist/commands/sessions.d.ts +17 -0
- package/dist/commands/sessions.js +16 -8
- package/dist/commands/setup-browser.js +5 -1
- package/dist/commands/setup-fleet.js +5 -0
- package/dist/commands/setup-preferences.d.ts +53 -0
- package/dist/commands/setup-preferences.js +142 -0
- package/dist/commands/setup.js +8 -9
- package/dist/commands/ssh.js +220 -10
- package/dist/commands/sync.d.ts +2 -2
- package/dist/commands/sync.js +4 -9
- package/dist/commands/watchdog.js +26 -3
- package/dist/index.js +22 -6
- package/dist/lib/activity.d.ts +7 -0
- package/dist/lib/activity.js +19 -2
- package/dist/lib/auto-dispatch.d.ts +6 -1
- package/dist/lib/auto-dispatch.js +7 -2
- package/dist/lib/beta.d.ts +1 -1
- package/dist/lib/beta.js +1 -1
- package/dist/lib/daemon.d.ts +13 -0
- package/dist/lib/daemon.js +80 -60
- package/dist/lib/device-config.d.ts +82 -0
- package/dist/lib/device-config.js +296 -0
- package/dist/lib/event-provenance.d.ts +19 -0
- package/dist/lib/event-provenance.js +48 -0
- package/dist/lib/events.d.ts +2 -2
- package/dist/lib/events.js +4 -53
- package/dist/lib/feed.d.ts +1 -1
- package/dist/lib/feed.js +2 -0
- package/dist/lib/git.d.ts +13 -2
- package/dist/lib/git.js +38 -6
- package/dist/lib/linear-project-counts.d.ts +72 -5
- package/dist/lib/linear-project-counts.js +90 -6
- package/dist/lib/linear-projects.d.ts +12 -0
- package/dist/lib/linear-projects.js +30 -0
- package/dist/lib/mailbox-gc.js +30 -7
- package/dist/lib/mailbox.d.ts +14 -1
- package/dist/lib/mailbox.js +35 -3
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.d.ts +5 -21
- package/dist/lib/menubar/install-menubar.js +35 -44
- package/dist/lib/migrate.js +1 -1
- package/dist/lib/models.js +21 -11
- package/dist/lib/overdue.d.ts +1 -1
- package/dist/lib/overdue.js +54 -19
- package/dist/lib/project-import.d.ts +96 -0
- package/dist/lib/project-import.js +171 -0
- package/dist/lib/remote-agents-json.d.ts +14 -1
- package/dist/lib/remote-agents-json.js +21 -3
- package/dist/lib/routines.d.ts +53 -0
- package/dist/lib/routines.js +105 -4
- package/dist/lib/rules/run-sync.d.ts +18 -0
- package/dist/lib/rules/run-sync.js +92 -0
- package/dist/lib/scheduler.js +12 -2
- 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/index.d.ts +4 -5
- package/dist/lib/secrets/index.js +4 -5
- package/dist/lib/session/active.d.ts +16 -2
- package/dist/lib/session/active.js +8 -4
- package/dist/lib/session/bundle.js +5 -1
- package/dist/lib/session/remote-list.d.ts +1 -15
- package/dist/lib/session/remote-list.js +22 -111
- package/dist/lib/session/sync/config.d.ts +13 -5
- package/dist/lib/session/sync/config.js +21 -13
- package/dist/lib/startup/command-registry.d.ts +0 -2
- package/dist/lib/startup/command-registry.js +1 -5
- package/dist/lib/state.d.ts +7 -2
- package/dist/lib/state.js +85 -10
- package/dist/lib/sync-umbrella.d.ts +11 -23
- package/dist/lib/sync-umbrella.js +14 -45
- package/dist/lib/teams/agents.d.ts +35 -3
- package/dist/lib/teams/agents.js +52 -8
- package/dist/lib/teams/scheduler.d.ts +29 -2
- package/dist/lib/teams/scheduler.js +60 -16
- package/dist/lib/types.d.ts +17 -1
- package/package.json +1 -2
- package/dist/commands/drive.d.ts +0 -10
- package/dist/commands/drive.js +0 -183
- package/dist/commands/hq.d.ts +0 -2
- package/dist/commands/hq.js +0 -58
- package/dist/commands/sessions-sync.d.ts +0 -17
- package/dist/commands/sessions-sync.js +0 -135
- package/dist/commands/sync-provision.d.ts +0 -23
- package/dist/commands/sync-provision.js +0 -107
- package/dist/lib/drive-sync.d.ts +0 -45
- package/dist/lib/drive-sync.js +0 -238
- package/dist/lib/hq/floor.d.ts +0 -87
- package/dist/lib/hq/floor.js +0 -243
- package/dist/lib/session/sync/crdt.d.ts +0 -44
- package/dist/lib/session/sync/crdt.js +0 -119
- package/dist/lib/session/sync/manifest.d.ts +0 -62
- package/dist/lib/session/sync/manifest.js +0 -100
- package/dist/lib/session/sync/provision.d.ts +0 -49
- package/dist/lib/session/sync/provision.js +0 -91
- package/dist/lib/session/sync/r2.d.ts +0 -32
- package/dist/lib/session/sync/r2.js +0 -121
- package/dist/lib/session/sync/sync.d.ts +0 -106
- package/dist/lib/session/sync/sync.js +0 -374
|
@@ -7,6 +7,12 @@
|
|
|
7
7
|
* TYPE (triage / backlog / unstarted / started / completed / canceled), never
|
|
8
8
|
* hardcoded state names, same convention as `auto-dispatch-linear.ts`.
|
|
9
9
|
*
|
|
10
|
+
* The same fetch also yields the **next milestone** — the earliest-dated
|
|
11
|
+
* milestone with unfinished issues — because each issue node carries its
|
|
12
|
+
* `projectMilestone`. A percentage tells you how far along a project is; the
|
|
13
|
+
* milestone tells you what it is due to hit next, which is the thing a person
|
|
14
|
+
* actually plans around. Deriving it here costs no extra request.
|
|
15
|
+
*
|
|
10
16
|
* This is a best-effort card enrichment, not an explicit command: every failure
|
|
11
17
|
* (no credential, offline, API error, timeout) degrades to `undefined` and the
|
|
12
18
|
* card simply omits the line — never a hang, never a throw. `--no-remote`
|
|
@@ -18,6 +24,32 @@
|
|
|
18
24
|
* budget; a capped fetch reports `truncated: true` and the card renders the
|
|
19
25
|
* total as a lower bound (`2500+ done`), never as the complete count.
|
|
20
26
|
*/
|
|
27
|
+
/**
|
|
28
|
+
* The next checkpoint the project is working toward: the earliest-dated
|
|
29
|
+
* milestone that still has unfinished issues. A percentage says how far along
|
|
30
|
+
* the project is; this says what it is due to hit next.
|
|
31
|
+
*/
|
|
32
|
+
export interface LinearMilestone {
|
|
33
|
+
name: string;
|
|
34
|
+
/** `YYYY-MM-DD` as Linear stores it. Absent when the milestone has no date. */
|
|
35
|
+
targetDate?: string;
|
|
36
|
+
/** Issues in this milestone in a `completed`-type state. */
|
|
37
|
+
done: number;
|
|
38
|
+
/**
|
|
39
|
+
* Issues assigned to this milestone. Legitimately `0` — a milestone can be
|
|
40
|
+
* declared with a date long before any issue is filed under it (that is the
|
|
41
|
+
* state of every milestone in this repo's own Linear project), and such a
|
|
42
|
+
* milestone is still the next checkpoint. The card omits the fraction rather
|
|
43
|
+
* than printing a meaningless `0/0`.
|
|
44
|
+
*/
|
|
45
|
+
total: number;
|
|
46
|
+
}
|
|
47
|
+
/** A milestone as the project declares it, independent of any issue. */
|
|
48
|
+
export interface LinearMilestoneNode {
|
|
49
|
+
id?: string;
|
|
50
|
+
name?: string;
|
|
51
|
+
targetDate?: string | null;
|
|
52
|
+
}
|
|
21
53
|
/** The counts the card renders. `total` counts every issue in the project. */
|
|
22
54
|
export interface LinearProjectCounts {
|
|
23
55
|
/** Issues in a `completed`-type state. */
|
|
@@ -31,20 +63,40 @@ export interface LinearProjectCounts {
|
|
|
31
63
|
* bound (rendered `2500+`), never presented as the complete count.
|
|
32
64
|
*/
|
|
33
65
|
truncated?: boolean;
|
|
66
|
+
/**
|
|
67
|
+
* The next unfinished milestone, when the project has one. Derived from the
|
|
68
|
+
* SAME paged issue fetch as the counts — a milestone is only ever a grouping
|
|
69
|
+
* of these issues, so asking Linear again would spend a second round trip to
|
|
70
|
+
* learn what the first already said.
|
|
71
|
+
*/
|
|
72
|
+
nextMilestone?: LinearMilestone;
|
|
73
|
+
}
|
|
74
|
+
/** One issue node as the query selects it. */
|
|
75
|
+
export interface LinearIssueNode {
|
|
76
|
+
state?: {
|
|
77
|
+
type?: string;
|
|
78
|
+
} | null;
|
|
79
|
+
projectMilestone?: {
|
|
80
|
+
id?: string;
|
|
81
|
+
name?: string;
|
|
82
|
+
targetDate?: string | null;
|
|
83
|
+
} | null;
|
|
34
84
|
}
|
|
35
85
|
/** The GraphQL response shape this module consumes (recorded for the tests). */
|
|
36
86
|
export interface LinearIssuesResponse {
|
|
37
87
|
issues?: {
|
|
38
|
-
nodes?:
|
|
39
|
-
state?: {
|
|
40
|
-
type?: string;
|
|
41
|
-
} | null;
|
|
42
|
-
}>;
|
|
88
|
+
nodes?: LinearIssueNode[];
|
|
43
89
|
pageInfo?: {
|
|
44
90
|
hasNextPage?: boolean;
|
|
45
91
|
endCursor?: string | null;
|
|
46
92
|
};
|
|
47
93
|
};
|
|
94
|
+
/** Only the FIRST page asks for this — the milestone list does not paginate. */
|
|
95
|
+
project?: {
|
|
96
|
+
projectMilestones?: {
|
|
97
|
+
nodes?: LinearMilestoneNode[];
|
|
98
|
+
};
|
|
99
|
+
} | null;
|
|
48
100
|
}
|
|
49
101
|
/**
|
|
50
102
|
* Pure mapping: a Linear issues response → card counts, grouping by state
|
|
@@ -52,6 +104,21 @@ export interface LinearIssuesResponse {
|
|
|
52
104
|
* an issue with no state still counts toward `total`.
|
|
53
105
|
*/
|
|
54
106
|
export declare function countsFromIssuesResponse(data: LinearIssuesResponse): LinearProjectCounts;
|
|
107
|
+
/**
|
|
108
|
+
* Pick the milestone the project is working toward next.
|
|
109
|
+
*
|
|
110
|
+
* The **declared** list is authoritative for which milestones exist, their
|
|
111
|
+
* names, and their dates; issues only supply progress. Deriving the list from
|
|
112
|
+
* issues instead looks tempting (it costs no extra request) and is wrong: a
|
|
113
|
+
* milestone with nothing filed under it yet would be invisible, and that is the
|
|
114
|
+
* common case — every milestone in this repo's own Linear project has zero
|
|
115
|
+
* issues assigned, so an issue-derived list showed nothing at all.
|
|
116
|
+
*
|
|
117
|
+
* Next = the earliest-dated milestone that is not finished. A milestone with no
|
|
118
|
+
* issues counts as unfinished (it is upcoming work, not completed work). Undated
|
|
119
|
+
* milestones sort last, ties break by declaration order, so the answer is stable.
|
|
120
|
+
*/
|
|
121
|
+
export declare function nextMilestone(declared: LinearMilestoneNode[], nodes: LinearIssueNode[]): LinearMilestone | undefined;
|
|
55
122
|
/**
|
|
56
123
|
* Fetch issue counts for one Linear project, paging `issues` filtered by
|
|
57
124
|
* project id. One shared AbortController bounds the WHOLE paged fetch at ~8s;
|
|
@@ -7,6 +7,12 @@
|
|
|
7
7
|
* TYPE (triage / backlog / unstarted / started / completed / canceled), never
|
|
8
8
|
* hardcoded state names, same convention as `auto-dispatch-linear.ts`.
|
|
9
9
|
*
|
|
10
|
+
* The same fetch also yields the **next milestone** — the earliest-dated
|
|
11
|
+
* milestone with unfinished issues — because each issue node carries its
|
|
12
|
+
* `projectMilestone`. A percentage tells you how far along a project is; the
|
|
13
|
+
* milestone tells you what it is due to hit next, which is the thing a person
|
|
14
|
+
* actually plans around. Deriving it here costs no extra request.
|
|
15
|
+
*
|
|
10
16
|
* This is a best-effort card enrichment, not an explicit command: every failure
|
|
11
17
|
* (no credential, offline, API error, timeout) degrades to `undefined` and the
|
|
12
18
|
* card simply omits the line — never a hang, never a throw. `--no-remote`
|
|
@@ -44,7 +50,65 @@ export function countsFromIssuesResponse(data) {
|
|
|
44
50
|
else if (type === 'started')
|
|
45
51
|
inProgress++;
|
|
46
52
|
}
|
|
47
|
-
|
|
53
|
+
const counts = { done, total: nodes.length, inProgress };
|
|
54
|
+
const next = nextMilestone(data.project?.projectMilestones?.nodes ?? [], nodes);
|
|
55
|
+
if (next)
|
|
56
|
+
counts.nextMilestone = next;
|
|
57
|
+
return counts;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Pick the milestone the project is working toward next.
|
|
61
|
+
*
|
|
62
|
+
* The **declared** list is authoritative for which milestones exist, their
|
|
63
|
+
* names, and their dates; issues only supply progress. Deriving the list from
|
|
64
|
+
* issues instead looks tempting (it costs no extra request) and is wrong: a
|
|
65
|
+
* milestone with nothing filed under it yet would be invisible, and that is the
|
|
66
|
+
* common case — every milestone in this repo's own Linear project has zero
|
|
67
|
+
* issues assigned, so an issue-derived list showed nothing at all.
|
|
68
|
+
*
|
|
69
|
+
* Next = the earliest-dated milestone that is not finished. A milestone with no
|
|
70
|
+
* issues counts as unfinished (it is upcoming work, not completed work). Undated
|
|
71
|
+
* milestones sort last, ties break by declaration order, so the answer is stable.
|
|
72
|
+
*/
|
|
73
|
+
export function nextMilestone(declared, nodes) {
|
|
74
|
+
// Progress per milestone id, from whatever issues do carry one.
|
|
75
|
+
const progress = new Map();
|
|
76
|
+
for (const n of nodes) {
|
|
77
|
+
const id = n?.projectMilestone?.id;
|
|
78
|
+
if (!id)
|
|
79
|
+
continue;
|
|
80
|
+
const p = progress.get(id) ?? { done: 0, total: 0 };
|
|
81
|
+
p.total++;
|
|
82
|
+
if (n.state?.type === 'completed')
|
|
83
|
+
p.done++;
|
|
84
|
+
progress.set(id, p);
|
|
85
|
+
}
|
|
86
|
+
const candidates = declared
|
|
87
|
+
.map((d, order) => {
|
|
88
|
+
if (!d?.id || typeof d.name !== 'string' || !d.name)
|
|
89
|
+
return undefined;
|
|
90
|
+
const p = progress.get(d.id) ?? { done: 0, total: 0 };
|
|
91
|
+
const m = { name: d.name, done: p.done, total: p.total, order };
|
|
92
|
+
if (d.targetDate)
|
|
93
|
+
m.targetDate = d.targetDate;
|
|
94
|
+
return m;
|
|
95
|
+
})
|
|
96
|
+
.filter((m) => m !== undefined)
|
|
97
|
+
// total 0 means "declared, nothing filed yet" — unfinished, not done.
|
|
98
|
+
.filter((m) => m.total === 0 || m.done < m.total);
|
|
99
|
+
if (candidates.length === 0)
|
|
100
|
+
return undefined;
|
|
101
|
+
candidates.sort((a, b) => {
|
|
102
|
+
if (a.targetDate && b.targetDate)
|
|
103
|
+
return a.targetDate < b.targetDate ? -1 : a.targetDate > b.targetDate ? 1 : a.order - b.order;
|
|
104
|
+
if (a.targetDate)
|
|
105
|
+
return -1;
|
|
106
|
+
if (b.targetDate)
|
|
107
|
+
return 1;
|
|
108
|
+
return a.order - b.order;
|
|
109
|
+
});
|
|
110
|
+
const { order: _order, ...m } = candidates[0];
|
|
111
|
+
return m;
|
|
48
112
|
}
|
|
49
113
|
/** $LINEAR_API_KEY → macOS Keychain → ~/.linear-cli/config.json. Null if none. */
|
|
50
114
|
function resolveApiKey() {
|
|
@@ -71,12 +135,16 @@ export async function fetchLinearProjectCounts(projectId, fetchPage = fetchLinea
|
|
|
71
135
|
const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
|
|
72
136
|
try {
|
|
73
137
|
const all = [];
|
|
138
|
+
// Declared on the project, not on its issues — only page 0 asks for it.
|
|
139
|
+
let declared = [];
|
|
74
140
|
let after;
|
|
75
141
|
let truncated = false;
|
|
76
142
|
for (let page = 0;; page++) {
|
|
77
143
|
const data = await fetchPage(projectId, after, ctrl.signal);
|
|
78
144
|
if (!data)
|
|
79
145
|
return undefined;
|
|
146
|
+
if (page === 0)
|
|
147
|
+
declared = data.project?.projectMilestones?.nodes ?? [];
|
|
80
148
|
all.push(...(data.issues?.nodes ?? []));
|
|
81
149
|
const pi = data.issues?.pageInfo;
|
|
82
150
|
if (!pi?.hasNextPage || !pi.endCursor)
|
|
@@ -88,7 +156,13 @@ export async function fetchLinearProjectCounts(projectId, fetchPage = fetchLinea
|
|
|
88
156
|
}
|
|
89
157
|
after = pi.endCursor;
|
|
90
158
|
}
|
|
91
|
-
return {
|
|
159
|
+
return {
|
|
160
|
+
...countsFromIssuesResponse({
|
|
161
|
+
issues: { nodes: all },
|
|
162
|
+
project: { projectMilestones: { nodes: declared } },
|
|
163
|
+
}),
|
|
164
|
+
...(truncated ? { truncated } : {}),
|
|
165
|
+
};
|
|
92
166
|
}
|
|
93
167
|
catch {
|
|
94
168
|
return undefined;
|
|
@@ -102,14 +176,24 @@ async function fetchLinearIssuesPage(projectId, after, signal) {
|
|
|
102
176
|
const apiKey = resolveApiKey();
|
|
103
177
|
if (!apiKey)
|
|
104
178
|
return undefined;
|
|
179
|
+
// The declared-milestone list rides along on the FIRST page only — it does
|
|
180
|
+
// not paginate, and re-requesting it per page would spend up to MAX_PAGES
|
|
181
|
+
// copies of the same answer.
|
|
182
|
+
const issuesSelection = 'issues(filter:{ project:{ id:{ eq:$p } } }, first:' +
|
|
183
|
+
PAGE_SIZE +
|
|
184
|
+
', after:$after){ nodes{ state{ type } projectMilestone{ id } } pageInfo{ hasNextPage endCursor } }';
|
|
185
|
+
const milestonesSelection = 'project(id:$pid){ projectMilestones(first:50){ nodes{ id name targetDate } } }';
|
|
186
|
+
const first = after === undefined;
|
|
105
187
|
const res = await fetch(LINEAR_API, {
|
|
106
188
|
method: 'POST',
|
|
107
189
|
headers: { Authorization: apiKey, 'Content-Type': 'application/json' },
|
|
108
190
|
body: JSON.stringify({
|
|
109
|
-
query:
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
variables:
|
|
191
|
+
query: first
|
|
192
|
+
? `query($p:ID!, $pid:String!, $after:String){ ${issuesSelection} ${milestonesSelection} }`
|
|
193
|
+
: `query($p:ID!, $after:String){ ${issuesSelection} }`,
|
|
194
|
+
variables: first
|
|
195
|
+
? { p: projectId, pid: projectId, after: null }
|
|
196
|
+
: { p: projectId, after },
|
|
113
197
|
}),
|
|
114
198
|
signal,
|
|
115
199
|
});
|
|
@@ -24,6 +24,18 @@ export declare function normalizeProjectKey(s: string): string;
|
|
|
24
24
|
* path.
|
|
25
25
|
*/
|
|
26
26
|
export declare function matchLinearProject(slugOrName: string, projects: LinearProjectLite[]): LinearProjectLite | undefined;
|
|
27
|
+
/**
|
|
28
|
+
* Find the local checkout directory a Linear project name refers to, on EXACT
|
|
29
|
+
* key equality only — never containment. `matchLinearProject`'s containment
|
|
30
|
+
* fallback is right for suggesting a link a human then confirms; this backs
|
|
31
|
+
* `projects import --from-linear`, which writes `root`/`repo` with nobody
|
|
32
|
+
* looking, and "Agents CLI" must not silently bind `agents-cli-web`.
|
|
33
|
+
*
|
|
34
|
+
* Returns `undefined` when nothing matches, and also when SEVERAL dirs key to
|
|
35
|
+
* the same value (`agents-cli` and `agents_cli`) — an ambiguous match is not a
|
|
36
|
+
* match.
|
|
37
|
+
*/
|
|
38
|
+
export declare function matchLocalCheckoutExact(name: string, dirNames: string[]): string | undefined;
|
|
27
39
|
/** The outcome of picking one Linear project out of the workspace list. */
|
|
28
40
|
export type LinearPick = {
|
|
29
41
|
kind: 'match';
|
|
@@ -45,6 +45,36 @@ export function matchLinearProject(slugOrName, projects) {
|
|
|
45
45
|
return pk.length > 0 && (pk.includes(key) || key.includes(pk));
|
|
46
46
|
});
|
|
47
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Collapse a Linear **display name** or a directory basename to one comparison
|
|
50
|
+
* key. Deliberately NOT `normalizeProjectKey`: that one keeps only the segment
|
|
51
|
+
* after the last `/`, which is right for an `owner/repo` slug or a filesystem
|
|
52
|
+
* path and wrong for a display name, where `/` is ordinary punctuation. Keying
|
|
53
|
+
* "Rush / Web" the path way yields `web`, which exact-matches an unrelated
|
|
54
|
+
* `web/` checkout — the precise silent mis-binding this whole match path exists
|
|
55
|
+
* to prevent.
|
|
56
|
+
*/
|
|
57
|
+
function checkoutMatchKey(s) {
|
|
58
|
+
return s.toLowerCase().replace(/[^a-z0-9]+/g, '');
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Find the local checkout directory a Linear project name refers to, on EXACT
|
|
62
|
+
* key equality only — never containment. `matchLinearProject`'s containment
|
|
63
|
+
* fallback is right for suggesting a link a human then confirms; this backs
|
|
64
|
+
* `projects import --from-linear`, which writes `root`/`repo` with nobody
|
|
65
|
+
* looking, and "Agents CLI" must not silently bind `agents-cli-web`.
|
|
66
|
+
*
|
|
67
|
+
* Returns `undefined` when nothing matches, and also when SEVERAL dirs key to
|
|
68
|
+
* the same value (`agents-cli` and `agents_cli`) — an ambiguous match is not a
|
|
69
|
+
* match.
|
|
70
|
+
*/
|
|
71
|
+
export function matchLocalCheckoutExact(name, dirNames) {
|
|
72
|
+
const key = checkoutMatchKey(name);
|
|
73
|
+
if (!key)
|
|
74
|
+
return undefined;
|
|
75
|
+
const hits = dirNames.filter((d) => checkoutMatchKey(d) === key);
|
|
76
|
+
return hits.length === 1 ? hits[0] : undefined;
|
|
77
|
+
}
|
|
48
78
|
/**
|
|
49
79
|
* Pick the Linear project a query refers to. An exact id or exact normalized
|
|
50
80
|
* name match is confident enough to write; anything weaker (several exact-name
|
package/dist/lib/mailbox-gc.js
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import * as fs from 'fs';
|
|
10
10
|
import * as path from 'path';
|
|
11
|
-
import { getMailboxRootDir } from './state.js';
|
|
11
|
+
import { getFeedDir, getMailboxRootDir } from './state.js';
|
|
12
12
|
import { mailboxDir, isValidMailboxId, readMessage, sweepExpired, } from './mailbox.js';
|
|
13
|
-
import { listBlocks, removeBlock } from './feed.js';
|
|
13
|
+
import { listBlocks, removeBlock, recordMessageReceipt } from './feed.js';
|
|
14
14
|
const DEFAULT_MAX_CONSUMED_AGE_MINUTES = 24 * 60;
|
|
15
15
|
function consumedAgeMinutes(file, now) {
|
|
16
16
|
try {
|
|
@@ -31,7 +31,7 @@ function jsonFiles(dir) {
|
|
|
31
31
|
}
|
|
32
32
|
return names.filter((n) => n.endsWith('.json')).sort();
|
|
33
33
|
}
|
|
34
|
-
function archiveAllPending(boxDir, reason) {
|
|
34
|
+
function archiveAllPending(boxDir, reason, feedRoot) {
|
|
35
35
|
let n = 0;
|
|
36
36
|
for (const dir of [path.join(boxDir, 'inbox'), path.join(boxDir, 'processing')]) {
|
|
37
37
|
for (const name of jsonFiles(dir)) {
|
|
@@ -45,6 +45,14 @@ function archiveAllPending(boxDir, reason) {
|
|
|
45
45
|
fs.writeFileSync(tmp, JSON.stringify(msg, null, 2), 'utf-8');
|
|
46
46
|
fs.renameSync(tmp, dest);
|
|
47
47
|
fs.unlinkSync(src);
|
|
48
|
+
if (msg.blockId) {
|
|
49
|
+
try {
|
|
50
|
+
recordMessageReceipt(msg.blockId, { msgId: msg.msgId, status: reason === 'expired' ? 'expired' : 'dropped', at: new Date().toISOString(), from: msg.from }, feedRoot);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// Receipt surfacing is best-effort; never stall GC.
|
|
54
|
+
}
|
|
55
|
+
}
|
|
48
56
|
}
|
|
49
57
|
else {
|
|
50
58
|
fs.renameSync(src, dest);
|
|
@@ -58,6 +66,16 @@ function archiveAllPending(boxDir, reason) {
|
|
|
58
66
|
}
|
|
59
67
|
return n;
|
|
60
68
|
}
|
|
69
|
+
function blockAgeMinutes(blockId, feedRoot, now) {
|
|
70
|
+
const file = path.join(feedRoot ?? getFeedDir(), `${blockId}.json`);
|
|
71
|
+
try {
|
|
72
|
+
const stat = fs.statSync(file);
|
|
73
|
+
return (now.getTime() - stat.mtimeMs) / 60_000;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return Number.POSITIVE_INFINITY;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
61
79
|
function pruneConsumed(boxDir, maxAgeMinutes, now) {
|
|
62
80
|
let n = 0;
|
|
63
81
|
const consumed = path.join(boxDir, 'consumed');
|
|
@@ -115,7 +133,7 @@ export function gcMailbox(activeBoxIds, options = {}) {
|
|
|
115
133
|
const boxDir = mailboxDir(name, root);
|
|
116
134
|
if (!activeBoxIds.has(name)) {
|
|
117
135
|
result.deadBoxes++;
|
|
118
|
-
result.messagesDroppedDead += archiveAllPending(boxDir, 'dead');
|
|
136
|
+
result.messagesDroppedDead += archiveAllPending(boxDir, 'dead', feedRoot);
|
|
119
137
|
result.consumedPruned += pruneConsumed(boxDir, maxConsumedAgeMinutes, now);
|
|
120
138
|
// Also prune the empty box dir if it is now empty.
|
|
121
139
|
try {
|
|
@@ -136,13 +154,18 @@ export function gcMailbox(activeBoxIds, options = {}) {
|
|
|
136
154
|
else {
|
|
137
155
|
// Live box: archive expired messages (same path as drain/peek) so GC does
|
|
138
156
|
// not leave expired files in inbox/processing while only bumping metrics.
|
|
139
|
-
result.messagesDroppedExpired += sweepExpired(boxDir, name, now);
|
|
157
|
+
result.messagesDroppedExpired += sweepExpired(boxDir, name, now, feedRoot);
|
|
140
158
|
result.consumedPruned += pruneConsumed(boxDir, maxConsumedAgeMinutes, now);
|
|
141
159
|
}
|
|
142
160
|
}
|
|
143
161
|
for (const blockId of blocksToRemove) {
|
|
144
|
-
|
|
145
|
-
|
|
162
|
+
// A dead box's block is kept for up to maxConsumedAgeMinutes after a bounce
|
|
163
|
+
// receipt is written so the operator/sender can see the failure. Stale blocks
|
|
164
|
+
// (older than the prune window) are removed.
|
|
165
|
+
if (blockAgeMinutes(blockId, feedRoot, now) >= maxConsumedAgeMinutes) {
|
|
166
|
+
if (removeBlock(blockId, feedRoot)) {
|
|
167
|
+
result.blocksRemoved++;
|
|
168
|
+
}
|
|
146
169
|
}
|
|
147
170
|
}
|
|
148
171
|
return result;
|
package/dist/lib/mailbox.d.ts
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
/** Default delivery TTL for messages enqueued without an explicit one (24 hours). */
|
|
2
|
+
export declare const DEFAULT_TTL_SECONDS: number;
|
|
3
|
+
/** Env var that overrides {@link DEFAULT_TTL_SECONDS} with a duration like "24h" or "3600". */
|
|
4
|
+
export declare const MAILBOX_TTL_ENV = "AGENTS_MAILBOX_TTL";
|
|
5
|
+
/**
|
|
6
|
+
* Resolve the default mailbox TTL in seconds. Honors `AGENTS_MAILBOX_TTL`
|
|
7
|
+
* (parsed by {@link parseDuration}); falls back to 24h. A malformed env value
|
|
8
|
+
* fails loud instead of silently disabling expiry.
|
|
9
|
+
*/
|
|
10
|
+
export declare function resolveDefaultTtlSeconds(): number;
|
|
1
11
|
/** A single mailbox message. `text` may embed `host:/path` clip tokens. */
|
|
2
12
|
export interface MailboxMessage {
|
|
3
13
|
/** Unique, time-sortable id. Also the on-disk filename stem. */
|
|
@@ -54,8 +64,11 @@ export declare function isExpired(msg: MailboxMessage, now?: Date): boolean;
|
|
|
54
64
|
/**
|
|
55
65
|
* Move expired messages from inbox/ and processing/ into consumed/ with a
|
|
56
66
|
* `dropped: expired` marker. Called by drain/peek before returning messages.
|
|
67
|
+
*
|
|
68
|
+
* When a dropped message carries a `blockId`, a failure receipt is surfaced
|
|
69
|
+
* back to the feed store so the sender sees the bounce.
|
|
57
70
|
*/
|
|
58
|
-
export declare function sweepExpired(boxDir: string, boxId?: string, now?: Date): number;
|
|
71
|
+
export declare function sweepExpired(boxDir: string, boxId?: string, now?: Date, feedRoot?: string): number;
|
|
59
72
|
/**
|
|
60
73
|
* Drain the box: return every pending message addressed to it, in FIFO order,
|
|
61
74
|
* removing them from the queue. Claim-first (inbox → processing → consumed) so
|
package/dist/lib/mailbox.js
CHANGED
|
@@ -20,6 +20,26 @@ import * as path from 'path';
|
|
|
20
20
|
import { randomUUID } from 'crypto';
|
|
21
21
|
import { getMailboxRootDir } from './state.js';
|
|
22
22
|
import { recordMessageReceipt } from './feed.js';
|
|
23
|
+
import { parseDuration } from './hooks/cache.js';
|
|
24
|
+
/** Default delivery TTL for messages enqueued without an explicit one (24 hours). */
|
|
25
|
+
export const DEFAULT_TTL_SECONDS = 24 * 60 * 60;
|
|
26
|
+
/** Env var that overrides {@link DEFAULT_TTL_SECONDS} with a duration like "24h" or "3600". */
|
|
27
|
+
export const MAILBOX_TTL_ENV = 'AGENTS_MAILBOX_TTL';
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the default mailbox TTL in seconds. Honors `AGENTS_MAILBOX_TTL`
|
|
30
|
+
* (parsed by {@link parseDuration}); falls back to 24h. A malformed env value
|
|
31
|
+
* fails loud instead of silently disabling expiry.
|
|
32
|
+
*/
|
|
33
|
+
export function resolveDefaultTtlSeconds() {
|
|
34
|
+
const raw = process.env[MAILBOX_TTL_ENV];
|
|
35
|
+
if (raw == null || raw === '')
|
|
36
|
+
return DEFAULT_TTL_SECONDS;
|
|
37
|
+
const parsed = parseDuration(raw);
|
|
38
|
+
if (parsed == null || parsed <= 0) {
|
|
39
|
+
throw new Error(`Invalid ${MAILBOX_TTL_ENV}=${JSON.stringify(raw)}: expected a positive duration (e.g. 24h, 30m, 3600).`);
|
|
40
|
+
}
|
|
41
|
+
return parsed;
|
|
42
|
+
}
|
|
23
43
|
/**
|
|
24
44
|
* A mailboxId must be a single, separator-free path segment. Real ids (session
|
|
25
45
|
* UUID / teams agentId / loop runId) already satisfy this; rejecting anything
|
|
@@ -78,8 +98,9 @@ export function enqueue(boxDir, msg) {
|
|
|
78
98
|
text: msg.text,
|
|
79
99
|
blockId: msg.blockId,
|
|
80
100
|
};
|
|
81
|
-
|
|
82
|
-
|
|
101
|
+
const ttlSeconds = msg.ttlSeconds ?? resolveDefaultTtlSeconds();
|
|
102
|
+
if (ttlSeconds > 0) {
|
|
103
|
+
record.expiresAt = new Date(now.getTime() + ttlSeconds * 1000).toISOString();
|
|
83
104
|
}
|
|
84
105
|
const target = path.join(inboxDir(boxDir), `${msgId}.json`);
|
|
85
106
|
const tmp = `${target}.${process.pid}.tmp`;
|
|
@@ -139,8 +160,11 @@ function archiveDropped(boxDir, name, reason) {
|
|
|
139
160
|
/**
|
|
140
161
|
* Move expired messages from inbox/ and processing/ into consumed/ with a
|
|
141
162
|
* `dropped: expired` marker. Called by drain/peek before returning messages.
|
|
163
|
+
*
|
|
164
|
+
* When a dropped message carries a `blockId`, a failure receipt is surfaced
|
|
165
|
+
* back to the feed store so the sender sees the bounce.
|
|
142
166
|
*/
|
|
143
|
-
export function sweepExpired(boxDir, boxId = path.basename(boxDir), now = new Date()) {
|
|
167
|
+
export function sweepExpired(boxDir, boxId = path.basename(boxDir), now = new Date(), feedRoot) {
|
|
144
168
|
ensureDirs(boxDir);
|
|
145
169
|
let n = 0;
|
|
146
170
|
for (const dir of [inboxDir(boxDir), processingDir(boxDir)]) {
|
|
@@ -154,6 +178,14 @@ export function sweepExpired(boxDir, boxId = path.basename(boxDir), now = new Da
|
|
|
154
178
|
fs.writeFileSync(tmp, JSON.stringify(msg, null, 2), 'utf-8');
|
|
155
179
|
fs.renameSync(tmp, dest);
|
|
156
180
|
fs.unlinkSync(path.join(dir, name));
|
|
181
|
+
if (msg.blockId) {
|
|
182
|
+
try {
|
|
183
|
+
recordMessageReceipt(msg.blockId, { msgId: msg.msgId, status: 'expired', at: new Date().toISOString(), from: msg.from }, feedRoot ?? process.env.AGENTS_FEED_DIR);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
// Receipt surfacing is best-effort; never stall expiry.
|
|
187
|
+
}
|
|
188
|
+
}
|
|
157
189
|
n++;
|
|
158
190
|
}
|
|
159
191
|
catch {
|
|
Binary file
|
|
Binary file
|
|
@@ -34,29 +34,12 @@ export declare function codesignVerifies(appPath: string): boolean;
|
|
|
34
34
|
* A Developer-ID-signed but un-notarized app is rejected by `spctl --assess`,
|
|
35
35
|
* which macOS surfaces as "the app is damaged" and can crash AppKit during
|
|
36
36
|
* launch. This is separate from `codesign --verify`: a signature can be valid
|
|
37
|
-
* while Gatekeeper still refuses to run it.
|
|
37
|
+
* while Gatekeeper still refuses to run it. The release notarizes + staples the
|
|
38
|
+
* helper (menubar/scripts/build.sh, gated by verify-menubar-helper.sh), so a
|
|
39
|
+
* shipped bundle passes this; the launch guards use it to fail loud rather than
|
|
40
|
+
* bootstrap a helper macOS would reject.
|
|
38
41
|
*/
|
|
39
42
|
export declare function gatekeeperAssesses(appPath: string): boolean;
|
|
40
|
-
/**
|
|
41
|
-
* Guarantee the installed bundle has a signature Gatekeeper will accept on THIS
|
|
42
|
-
* machine.
|
|
43
|
-
*
|
|
44
|
-
* npm's pack/extract strips the ad-hoc/linker signature the release baked into
|
|
45
|
-
* the helper, leaving `code object is not signed at all`. On macOS 26+ the
|
|
46
|
-
* kernel's code-signing monitor SIGKILLs an unsigned/invalid binary at launch
|
|
47
|
-
* (`SIGKILL (Code Signature Invalid)`), so under the launchd `KeepAlive` service
|
|
48
|
-
* an ad-hoc release helper crash-loops forever and its unstable identity makes
|
|
49
|
-
* the Accessibility grant (needed for the clip→paste keystroke in Clip.swift)
|
|
50
|
-
* re-prompt every time. A fresh ad-hoc re-sign gives the on-disk bytes a
|
|
51
|
-
* matching cdhash, which the kernel accepts.
|
|
52
|
-
*
|
|
53
|
-
* A Developer-ID-signed helper survives npm untouched — its embedded signature
|
|
54
|
-
* still verifies — but if the release was not notarized, Gatekeeper rejects it.
|
|
55
|
-
* In that case we strip the quarantine xattr and re-sign ad-hoc so the helper
|
|
56
|
-
* can launch locally. The stable fix is to notarize the release build; this
|
|
57
|
-
* fallback just prevents a crash-loop while the user is on an un-notarized cut.
|
|
58
|
-
*/
|
|
59
|
-
export declare function ensureValidSignature(appPath: string): boolean;
|
|
60
43
|
/**
|
|
61
44
|
* Copy the bundled `.app` to the stable user path (idempotent unless forced).
|
|
62
45
|
* Returns the installed executable path, or null if no source bundle ships
|
|
@@ -65,6 +48,7 @@ export declare function ensureValidSignature(appPath: string): boolean;
|
|
|
65
48
|
export declare function ensureMenubarAppInstalled(opts?: {
|
|
66
49
|
forceReinstall?: boolean;
|
|
67
50
|
}): string | null;
|
|
51
|
+
export declare function generateServicePlist(execPath: string): string;
|
|
68
52
|
/**
|
|
69
53
|
* Restart the menu-bar launchd agent from a clean state.
|
|
70
54
|
*
|