@phnx-labs/agents-cli 1.20.91 → 1.20.93
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 +276 -0
- package/README.md +1 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/activity.d.ts +72 -6
- package/dist/commands/activity.js +198 -49
- package/dist/commands/beta.js +1 -0
- package/dist/commands/doctor.js +4 -2
- package/dist/commands/events.js +91 -1
- package/dist/commands/exec.d.ts +14 -0
- package/dist/commands/exec.js +144 -14
- package/dist/commands/projects.d.ts +22 -0
- package/dist/commands/projects.js +539 -0
- package/dist/commands/secrets.d.ts +17 -0
- package/dist/commands/secrets.js +198 -7
- package/dist/commands/send.d.ts +14 -12
- package/dist/commands/send.js +105 -35
- package/dist/commands/sessions-picker.d.ts +15 -0
- package/dist/commands/sessions-picker.js +37 -12
- package/dist/commands/sessions-resume.d.ts +2 -0
- package/dist/commands/sessions-resume.js +9 -1
- package/dist/commands/sessions.d.ts +10 -5
- package/dist/commands/sessions.js +65 -27
- package/dist/commands/sync.js +9 -3
- package/dist/commands/view.js +4 -0
- package/dist/index.js +18 -1
- package/dist/lib/activity.d.ts +77 -12
- package/dist/lib/activity.js +424 -74
- package/dist/lib/beta.d.ts +1 -1
- package/dist/lib/beta.js +1 -1
- package/dist/lib/channels/send.d.ts +83 -0
- package/dist/lib/channels/send.js +112 -0
- package/dist/lib/devices/registry.d.ts +14 -0
- package/dist/lib/devices/registry.js +37 -0
- package/dist/lib/events-ingest.d.ts +46 -0
- package/dist/lib/events-ingest.js +182 -0
- package/dist/lib/events.d.ts +15 -3
- package/dist/lib/events.js +55 -3
- package/dist/lib/feed-post.js +8 -2
- package/dist/lib/hosts/remote-cmd.js +4 -0
- package/dist/lib/linear-project-counts.d.ts +62 -0
- package/dist/lib/linear-project-counts.js +122 -0
- package/dist/lib/linear-projects.d.ts +50 -0
- package/dist/lib/linear-projects.js +114 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.d.ts +14 -4
- package/dist/lib/menubar/install-menubar.js +20 -6
- package/dist/lib/menubar/notify-desktop.d.ts +17 -2
- package/dist/lib/menubar/notify-desktop.js +8 -2
- package/dist/lib/project-key.d.ts +44 -0
- package/dist/lib/project-key.js +79 -0
- package/dist/lib/project-probe.d.ts +75 -0
- package/dist/lib/project-probe.js +160 -0
- package/dist/lib/project-resources.d.ts +8 -0
- package/dist/lib/project-resources.js +31 -3
- package/dist/lib/project-root.js +16 -0
- package/dist/lib/project-status.d.ts +100 -0
- package/dist/lib/project-status.js +182 -0
- package/dist/lib/projects.d.ts +144 -0
- package/dist/lib/projects.js +313 -0
- package/dist/lib/remote-agents-json.d.ts +9 -0
- package/dist/lib/remote-agents-json.js +11 -5
- package/dist/lib/routine-notify.d.ts +11 -0
- package/dist/lib/routine-notify.js +22 -0
- package/dist/lib/run-notify.js +3 -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/audit.d.ts +1 -1
- package/dist/lib/secrets/audit.js +53 -10
- package/dist/lib/secrets/list-filter.d.ts +20 -5
- package/dist/lib/secrets/list-filter.js +22 -6
- package/dist/lib/secrets/usage-db.d.ts +106 -0
- package/dist/lib/secrets/usage-db.js +236 -0
- package/dist/lib/session/bash-command.d.ts +53 -0
- package/dist/lib/session/bash-command.js +364 -0
- package/dist/lib/session/digest.d.ts +6 -0
- package/dist/lib/session/digest.js +19 -0
- package/dist/lib/session/relative-time.d.ts +23 -0
- package/dist/lib/session/relative-time.js +60 -8
- package/dist/lib/session/remote-active.d.ts +5 -1
- package/dist/lib/session/remote-active.js +4 -1
- package/dist/lib/session/remote-list.js +5 -2
- package/dist/lib/session/render.d.ts +2 -9
- package/dist/lib/session/render.js +25 -56
- package/dist/lib/sqlite.js +28 -1
- package/dist/lib/ssh-exec.d.ts +6 -0
- package/dist/lib/ssh-exec.js +10 -1
- 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 +14 -0
- package/dist/lib/state.js +19 -0
- package/dist/lib/terminal/backends/index.d.ts +10 -2
- package/dist/lib/terminal/backends/index.js +14 -2
- package/dist/lib/terminal/backends/terminal-app.d.ts +13 -0
- package/dist/lib/terminal/backends/terminal-app.js +73 -0
- package/dist/lib/terminal/index.d.ts +2 -1
- package/dist/lib/terminal/index.js +2 -1
- package/dist/lib/terminal/preferred.d.ts +89 -0
- package/dist/lib/terminal/preferred.js +87 -0
- package/dist/lib/terminal/run-surface.d.ts +82 -0
- package/dist/lib/terminal/run-surface.js +146 -0
- package/dist/lib/terminal/types.d.ts +1 -1
- package/dist/lib/types.d.ts +6 -5
- package/dist/lib/versions.d.ts +6 -0
- package/dist/lib/versions.js +6 -4
- package/package.json +2 -1
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-project Linear issue counts for the `agents projects status` card.
|
|
3
|
+
*
|
|
4
|
+
* When a project definition carries `linear.projectId` (set via
|
|
5
|
+
* `agents projects link <name> --linear`), the card shows one outcome line —
|
|
6
|
+
* `12/30 done · 5 in progress` — counted from the Linear GraphQL API by state
|
|
7
|
+
* TYPE (triage / backlog / unstarted / started / completed / canceled), never
|
|
8
|
+
* hardcoded state names, same convention as `auto-dispatch-linear.ts`.
|
|
9
|
+
*
|
|
10
|
+
* This is a best-effort card enrichment, not an explicit command: every failure
|
|
11
|
+
* (no credential, offline, API error, timeout) degrades to `undefined` and the
|
|
12
|
+
* card simply omits the line — never a hang, never a throw. `--no-remote`
|
|
13
|
+
* skips it (it's network). The API key resolves through the same chain the rest
|
|
14
|
+
* of the stack uses: $LINEAR_API_KEY → macOS Keychain (`resolveLinearApiKey`)
|
|
15
|
+
* → the linear-cli config (`~/.linear-cli/config.json` `apiKey`).
|
|
16
|
+
*
|
|
17
|
+
* Paging is capped (10 × 250 issues) so a pathological project can't burn the
|
|
18
|
+
* budget; a capped fetch reports `truncated: true` and the card renders the
|
|
19
|
+
* total as a lower bound (`2500+ done`), never as the complete count.
|
|
20
|
+
*/
|
|
21
|
+
/** The counts the card renders. `total` counts every issue in the project. */
|
|
22
|
+
export interface LinearProjectCounts {
|
|
23
|
+
/** Issues in a `completed`-type state. */
|
|
24
|
+
done: number;
|
|
25
|
+
/** All issues in the project (any state type, including canceled). */
|
|
26
|
+
total: number;
|
|
27
|
+
/** Issues in a `started`-type state. */
|
|
28
|
+
inProgress: number;
|
|
29
|
+
/**
|
|
30
|
+
* True when the page cap cut the fetch short — `total` is then a LOWER
|
|
31
|
+
* bound (rendered `2500+`), never presented as the complete count.
|
|
32
|
+
*/
|
|
33
|
+
truncated?: boolean;
|
|
34
|
+
}
|
|
35
|
+
/** The GraphQL response shape this module consumes (recorded for the tests). */
|
|
36
|
+
export interface LinearIssuesResponse {
|
|
37
|
+
issues?: {
|
|
38
|
+
nodes?: Array<{
|
|
39
|
+
state?: {
|
|
40
|
+
type?: string;
|
|
41
|
+
} | null;
|
|
42
|
+
}>;
|
|
43
|
+
pageInfo?: {
|
|
44
|
+
hasNextPage?: boolean;
|
|
45
|
+
endCursor?: string | null;
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Pure mapping: a Linear issues response → card counts, grouping by state
|
|
51
|
+
* type. Defensive at the boundary — a missing `issues`/`nodes` yields zeros,
|
|
52
|
+
* an issue with no state still counts toward `total`.
|
|
53
|
+
*/
|
|
54
|
+
export declare function countsFromIssuesResponse(data: LinearIssuesResponse): LinearProjectCounts;
|
|
55
|
+
/**
|
|
56
|
+
* Fetch issue counts for one Linear project, paging `issues` filtered by
|
|
57
|
+
* project id. One shared AbortController bounds the WHOLE paged fetch at ~8s;
|
|
58
|
+
* any failure (no key, network, API error, abort) returns undefined so the
|
|
59
|
+
* card just omits the line. `fetchPage` is injectable for tests — the
|
|
60
|
+
* accumulator (cursor hand-off, cap) is the risky logic, not the HTTP.
|
|
61
|
+
*/
|
|
62
|
+
export declare function fetchLinearProjectCounts(projectId: string, fetchPage?: (projectId: string, after: string | undefined, signal: AbortSignal) => Promise<LinearIssuesResponse | undefined>): Promise<LinearProjectCounts | undefined>;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-project Linear issue counts for the `agents projects status` card.
|
|
3
|
+
*
|
|
4
|
+
* When a project definition carries `linear.projectId` (set via
|
|
5
|
+
* `agents projects link <name> --linear`), the card shows one outcome line —
|
|
6
|
+
* `12/30 done · 5 in progress` — counted from the Linear GraphQL API by state
|
|
7
|
+
* TYPE (triage / backlog / unstarted / started / completed / canceled), never
|
|
8
|
+
* hardcoded state names, same convention as `auto-dispatch-linear.ts`.
|
|
9
|
+
*
|
|
10
|
+
* This is a best-effort card enrichment, not an explicit command: every failure
|
|
11
|
+
* (no credential, offline, API error, timeout) degrades to `undefined` and the
|
|
12
|
+
* card simply omits the line — never a hang, never a throw. `--no-remote`
|
|
13
|
+
* skips it (it's network). The API key resolves through the same chain the rest
|
|
14
|
+
* of the stack uses: $LINEAR_API_KEY → macOS Keychain (`resolveLinearApiKey`)
|
|
15
|
+
* → the linear-cli config (`~/.linear-cli/config.json` `apiKey`).
|
|
16
|
+
*
|
|
17
|
+
* Paging is capped (10 × 250 issues) so a pathological project can't burn the
|
|
18
|
+
* budget; a capped fetch reports `truncated: true` and the card renders the
|
|
19
|
+
* total as a lower bound (`2500+ done`), never as the complete count.
|
|
20
|
+
*/
|
|
21
|
+
import * as fs from 'fs';
|
|
22
|
+
import * as os from 'os';
|
|
23
|
+
import * as path from 'path';
|
|
24
|
+
import { resolveLinearApiKey } from './auto-dispatch-linear.js';
|
|
25
|
+
const LINEAR_API = 'https://api.linear.app/graphql';
|
|
26
|
+
/** Overall budget across all pages — the card must never hang on Linear. */
|
|
27
|
+
const TIMEOUT_MS = 8_000;
|
|
28
|
+
const PAGE_SIZE = 250;
|
|
29
|
+
/** Hard page cap so a pathological project can't page forever within the budget. */
|
|
30
|
+
const MAX_PAGES = 10;
|
|
31
|
+
/**
|
|
32
|
+
* Pure mapping: a Linear issues response → card counts, grouping by state
|
|
33
|
+
* type. Defensive at the boundary — a missing `issues`/`nodes` yields zeros,
|
|
34
|
+
* an issue with no state still counts toward `total`.
|
|
35
|
+
*/
|
|
36
|
+
export function countsFromIssuesResponse(data) {
|
|
37
|
+
const nodes = data.issues?.nodes ?? [];
|
|
38
|
+
let done = 0;
|
|
39
|
+
let inProgress = 0;
|
|
40
|
+
for (const n of nodes) {
|
|
41
|
+
const type = n?.state?.type;
|
|
42
|
+
if (type === 'completed')
|
|
43
|
+
done++;
|
|
44
|
+
else if (type === 'started')
|
|
45
|
+
inProgress++;
|
|
46
|
+
}
|
|
47
|
+
return { done, total: nodes.length, inProgress };
|
|
48
|
+
}
|
|
49
|
+
/** $LINEAR_API_KEY → macOS Keychain → ~/.linear-cli/config.json. Null if none. */
|
|
50
|
+
function resolveApiKey() {
|
|
51
|
+
const fromChain = resolveLinearApiKey();
|
|
52
|
+
if (fromChain)
|
|
53
|
+
return fromChain;
|
|
54
|
+
try {
|
|
55
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.linear-cli', 'config.json'), 'utf8'));
|
|
56
|
+
return cfg.apiKey?.trim() || null;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Fetch issue counts for one Linear project, paging `issues` filtered by
|
|
64
|
+
* project id. One shared AbortController bounds the WHOLE paged fetch at ~8s;
|
|
65
|
+
* any failure (no key, network, API error, abort) returns undefined so the
|
|
66
|
+
* card just omits the line. `fetchPage` is injectable for tests — the
|
|
67
|
+
* accumulator (cursor hand-off, cap) is the risky logic, not the HTTP.
|
|
68
|
+
*/
|
|
69
|
+
export async function fetchLinearProjectCounts(projectId, fetchPage = fetchLinearIssuesPage) {
|
|
70
|
+
const ctrl = new AbortController();
|
|
71
|
+
const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
|
|
72
|
+
try {
|
|
73
|
+
const all = [];
|
|
74
|
+
let after;
|
|
75
|
+
let truncated = false;
|
|
76
|
+
for (let page = 0;; page++) {
|
|
77
|
+
const data = await fetchPage(projectId, after, ctrl.signal);
|
|
78
|
+
if (!data)
|
|
79
|
+
return undefined;
|
|
80
|
+
all.push(...(data.issues?.nodes ?? []));
|
|
81
|
+
const pi = data.issues?.pageInfo;
|
|
82
|
+
if (!pi?.hasNextPage || !pi.endCursor)
|
|
83
|
+
break;
|
|
84
|
+
if (page + 1 >= MAX_PAGES) {
|
|
85
|
+
// The cap cut the fetch short — total is a lower bound, say so.
|
|
86
|
+
truncated = true;
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
after = pi.endCursor;
|
|
90
|
+
}
|
|
91
|
+
return { ...countsFromIssuesResponse({ issues: { nodes: all } }), ...(truncated ? { truncated } : {}) };
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** One real GraphQL page; undefined on any HTTP/API-level failure. */
|
|
101
|
+
async function fetchLinearIssuesPage(projectId, after, signal) {
|
|
102
|
+
const apiKey = resolveApiKey();
|
|
103
|
+
if (!apiKey)
|
|
104
|
+
return undefined;
|
|
105
|
+
const res = await fetch(LINEAR_API, {
|
|
106
|
+
method: 'POST',
|
|
107
|
+
headers: { Authorization: apiKey, 'Content-Type': 'application/json' },
|
|
108
|
+
body: JSON.stringify({
|
|
109
|
+
query: 'query($p:ID!, $after:String){ issues(filter:{ project:{ id:{ eq:$p } } }, first:' +
|
|
110
|
+
PAGE_SIZE +
|
|
111
|
+
', after:$after){ nodes{ state{ type } } pageInfo{ hasNextPage endCursor } } }',
|
|
112
|
+
variables: { p: projectId, after: after ?? null },
|
|
113
|
+
}),
|
|
114
|
+
signal,
|
|
115
|
+
});
|
|
116
|
+
if (!res.ok)
|
|
117
|
+
return undefined;
|
|
118
|
+
const json = (await res.json());
|
|
119
|
+
if (json.errors?.length || !json.data)
|
|
120
|
+
return undefined;
|
|
121
|
+
return json.data;
|
|
122
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** The minimal Linear project shape the link flow needs (id + name + url). */
|
|
2
|
+
export interface LinearProjectLite {
|
|
3
|
+
id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
/** Project URL, when the `linear` CLI JSON carries one. Never fabricated. */
|
|
6
|
+
url?: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Collapse a Linear name / repo slug / folder path to one comparison key:
|
|
10
|
+
* lowercase, keep only the last path segment, strip separators.
|
|
11
|
+
* "Agents CLI" -> "agentscli"
|
|
12
|
+
* "phnx-labs/agents-cli" -> "agentscli"
|
|
13
|
+
* "~/src/.../agents-cli" -> "agentscli"
|
|
14
|
+
*/
|
|
15
|
+
export declare function normalizeProjectKey(s: string): string;
|
|
16
|
+
/**
|
|
17
|
+
* Find the Linear project that best matches a repo slug or folder name.
|
|
18
|
+
* Exact normalized match first, then a containment fallback (either direction),
|
|
19
|
+
* so "agents-cli-web" still suggests "Agents CLI" when no exact peer exists.
|
|
20
|
+
*
|
|
21
|
+
* Kept for parity with the Factory original — the `link` command uses
|
|
22
|
+
* {@link pickLinearProject} instead: this one returns the FIRST match (silent
|
|
23
|
+
* on duplicate names), which is fine for a UI suggestion but never for a write
|
|
24
|
+
* path.
|
|
25
|
+
*/
|
|
26
|
+
export declare function matchLinearProject(slugOrName: string, projects: LinearProjectLite[]): LinearProjectLite | undefined;
|
|
27
|
+
/** The outcome of picking one Linear project out of the workspace list. */
|
|
28
|
+
export type LinearPick = {
|
|
29
|
+
kind: 'match';
|
|
30
|
+
project: LinearProjectLite;
|
|
31
|
+
} | {
|
|
32
|
+
kind: 'candidates';
|
|
33
|
+
projects: LinearProjectLite[];
|
|
34
|
+
} | {
|
|
35
|
+
kind: 'none';
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Pick the Linear project a query refers to. An exact id or exact normalized
|
|
39
|
+
* name match is confident enough to write; anything weaker (several exact-name
|
|
40
|
+
* peers, or only containment matches) comes back as a candidate LIST for the
|
|
41
|
+
* user to disambiguate — the link command never guesses on a weak signal.
|
|
42
|
+
*/
|
|
43
|
+
export declare function pickLinearProject(query: string, projects: LinearProjectLite[]): LinearPick;
|
|
44
|
+
/**
|
|
45
|
+
* List the workspace's Linear projects via the `linear` CLI on PATH. Throws a
|
|
46
|
+
* clear error when the binary is missing, errors, or returns a shape we can't
|
|
47
|
+
* use — this backs an explicit user command, so a silent empty list would send
|
|
48
|
+
* the user down a wrong "no matches" path.
|
|
49
|
+
*/
|
|
50
|
+
export declare function listLinearProjects(): LinearProjectLite[];
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Linear project matching for `agents projects link --linear`.
|
|
2
|
+
//
|
|
3
|
+
// A project's identity shows up three ways — a Linear project name ("Agents CLI"),
|
|
4
|
+
// a GitHub repo slug ("phnx-labs/agents-cli"), and a filesystem folder
|
|
5
|
+
// (".../agents-cli"). normalizeProjectKey() collapses all three to one comparison
|
|
6
|
+
// key so they compare equal, and matchLinearProject() binds a repo/folder to the
|
|
7
|
+
// Linear project the user most likely means.
|
|
8
|
+
//
|
|
9
|
+
// Ported from apps/factory/src/core/linearProjects.ts (no cross-package imports —
|
|
10
|
+
// repo rule); keep the two in sync. The matcher half is PURE so it unit-tests
|
|
11
|
+
// without a live `linear` binary; the `linear projects --json` shell-out lives at
|
|
12
|
+
// the bottom (listLinearProjects) and fails LOUD — it's behind an explicit user
|
|
13
|
+
// command, not a best-effort card enrichment.
|
|
14
|
+
import { execFileSync } from 'child_process';
|
|
15
|
+
/**
|
|
16
|
+
* Collapse a Linear name / repo slug / folder path to one comparison key:
|
|
17
|
+
* lowercase, keep only the last path segment, strip separators.
|
|
18
|
+
* "Agents CLI" -> "agentscli"
|
|
19
|
+
* "phnx-labs/agents-cli" -> "agentscli"
|
|
20
|
+
* "~/src/.../agents-cli" -> "agentscli"
|
|
21
|
+
*/
|
|
22
|
+
export function normalizeProjectKey(s) {
|
|
23
|
+
const last = s.toLowerCase().split('/').filter(Boolean).pop() ?? '';
|
|
24
|
+
return last.replace(/[-_\s.]/g, '');
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Find the Linear project that best matches a repo slug or folder name.
|
|
28
|
+
* Exact normalized match first, then a containment fallback (either direction),
|
|
29
|
+
* so "agents-cli-web" still suggests "Agents CLI" when no exact peer exists.
|
|
30
|
+
*
|
|
31
|
+
* Kept for parity with the Factory original — the `link` command uses
|
|
32
|
+
* {@link pickLinearProject} instead: this one returns the FIRST match (silent
|
|
33
|
+
* on duplicate names), which is fine for a UI suggestion but never for a write
|
|
34
|
+
* path.
|
|
35
|
+
*/
|
|
36
|
+
export function matchLinearProject(slugOrName, projects) {
|
|
37
|
+
const key = normalizeProjectKey(slugOrName);
|
|
38
|
+
if (!key)
|
|
39
|
+
return undefined;
|
|
40
|
+
const exact = projects.find((p) => normalizeProjectKey(p.name) === key);
|
|
41
|
+
if (exact)
|
|
42
|
+
return exact;
|
|
43
|
+
return projects.find((p) => {
|
|
44
|
+
const pk = normalizeProjectKey(p.name);
|
|
45
|
+
return pk.length > 0 && (pk.includes(key) || key.includes(pk));
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Pick the Linear project a query refers to. An exact id or exact normalized
|
|
50
|
+
* name match is confident enough to write; anything weaker (several exact-name
|
|
51
|
+
* peers, or only containment matches) comes back as a candidate LIST for the
|
|
52
|
+
* user to disambiguate — the link command never guesses on a weak signal.
|
|
53
|
+
*/
|
|
54
|
+
export function pickLinearProject(query, projects) {
|
|
55
|
+
const q = query.trim();
|
|
56
|
+
if (!q)
|
|
57
|
+
return { kind: 'none' };
|
|
58
|
+
const byId = projects.find((p) => p.id === q);
|
|
59
|
+
if (byId)
|
|
60
|
+
return { kind: 'match', project: byId };
|
|
61
|
+
const key = normalizeProjectKey(q);
|
|
62
|
+
if (!key)
|
|
63
|
+
return { kind: 'none' };
|
|
64
|
+
const exact = projects.filter((p) => normalizeProjectKey(p.name) === key);
|
|
65
|
+
if (exact.length === 1)
|
|
66
|
+
return { kind: 'match', project: exact[0] };
|
|
67
|
+
if (exact.length > 1)
|
|
68
|
+
return { kind: 'candidates', projects: exact };
|
|
69
|
+
const containment = projects.filter((p) => {
|
|
70
|
+
const pk = normalizeProjectKey(p.name);
|
|
71
|
+
return pk.length > 0 && (pk.includes(key) || key.includes(pk));
|
|
72
|
+
});
|
|
73
|
+
return containment.length > 0 ? { kind: 'candidates', projects: containment } : { kind: 'none' };
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* List the workspace's Linear projects via the `linear` CLI on PATH. Throws a
|
|
77
|
+
* clear error when the binary is missing, errors, or returns a shape we can't
|
|
78
|
+
* use — this backs an explicit user command, so a silent empty list would send
|
|
79
|
+
* the user down a wrong "no matches" path.
|
|
80
|
+
*/
|
|
81
|
+
export function listLinearProjects() {
|
|
82
|
+
let out;
|
|
83
|
+
try {
|
|
84
|
+
out = execFileSync('linear', ['projects', '--json'], {
|
|
85
|
+
encoding: 'utf8',
|
|
86
|
+
timeout: 8000,
|
|
87
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
throw new Error('Could not list Linear projects — is the `linear` CLI installed and logged in? (`brew install linear-cli`, `linear auth login`)');
|
|
92
|
+
}
|
|
93
|
+
let parsed;
|
|
94
|
+
try {
|
|
95
|
+
parsed = JSON.parse(out);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
throw new Error('`linear projects --json` returned invalid JSON');
|
|
99
|
+
}
|
|
100
|
+
if (!Array.isArray(parsed))
|
|
101
|
+
throw new Error('`linear projects --json` did not return a list');
|
|
102
|
+
return parsed.flatMap((x) => {
|
|
103
|
+
if (x && typeof x === 'object' && !Array.isArray(x)) {
|
|
104
|
+
const o = x;
|
|
105
|
+
if (typeof o.id === 'string' && typeof o.name === 'string') {
|
|
106
|
+
const p = { id: o.id, name: o.name };
|
|
107
|
+
if (typeof o.url === 'string')
|
|
108
|
+
p.url = o.url;
|
|
109
|
+
return [p];
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return [];
|
|
113
|
+
});
|
|
114
|
+
}
|
|
Binary file
|
|
@@ -30,7 +30,16 @@ export declare function menubarServiceInstalled(): boolean;
|
|
|
30
30
|
/** True when the bundle carries a signature the kernel will accept at launch. */
|
|
31
31
|
export declare function codesignVerifies(appPath: string): boolean;
|
|
32
32
|
/**
|
|
33
|
-
*
|
|
33
|
+
* True when Gatekeeper will let the bundle execute on this machine.
|
|
34
|
+
* A Developer-ID-signed but un-notarized app is rejected by `spctl --assess`,
|
|
35
|
+
* which macOS surfaces as "the app is damaged" and can crash AppKit during
|
|
36
|
+
* launch. This is separate from `codesign --verify`: a signature can be valid
|
|
37
|
+
* while Gatekeeper still refuses to run it.
|
|
38
|
+
*/
|
|
39
|
+
export declare function gatekeeperAssesses(appPath: string): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Guarantee the installed bundle has a signature Gatekeeper will accept on THIS
|
|
42
|
+
* machine.
|
|
34
43
|
*
|
|
35
44
|
* npm's pack/extract strips the ad-hoc/linker signature the release baked into
|
|
36
45
|
* the helper, leaving `code object is not signed at all`. On macOS 26+ the
|
|
@@ -42,9 +51,10 @@ export declare function codesignVerifies(appPath: string): boolean;
|
|
|
42
51
|
* matching cdhash, which the kernel accepts.
|
|
43
52
|
*
|
|
44
53
|
* A Developer-ID-signed helper survives npm untouched — its embedded signature
|
|
45
|
-
* still verifies —
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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.
|
|
48
58
|
*/
|
|
49
59
|
export declare function ensureValidSignature(appPath: string): boolean;
|
|
50
60
|
/**
|
|
@@ -177,7 +177,19 @@ export function codesignVerifies(appPath) {
|
|
|
177
177
|
return r.status === 0;
|
|
178
178
|
}
|
|
179
179
|
/**
|
|
180
|
-
*
|
|
180
|
+
* True when Gatekeeper will let the bundle execute on this machine.
|
|
181
|
+
* A Developer-ID-signed but un-notarized app is rejected by `spctl --assess`,
|
|
182
|
+
* which macOS surfaces as "the app is damaged" and can crash AppKit during
|
|
183
|
+
* launch. This is separate from `codesign --verify`: a signature can be valid
|
|
184
|
+
* while Gatekeeper still refuses to run it.
|
|
185
|
+
*/
|
|
186
|
+
export function gatekeeperAssesses(appPath) {
|
|
187
|
+
const r = spawnSync('spctl', ['--assess', '--type', 'exec', appPath], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
188
|
+
return r.status === 0;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Guarantee the installed bundle has a signature Gatekeeper will accept on THIS
|
|
192
|
+
* machine.
|
|
181
193
|
*
|
|
182
194
|
* npm's pack/extract strips the ad-hoc/linker signature the release baked into
|
|
183
195
|
* the helper, leaving `code object is not signed at all`. On macOS 26+ the
|
|
@@ -189,15 +201,17 @@ export function codesignVerifies(appPath) {
|
|
|
189
201
|
* matching cdhash, which the kernel accepts.
|
|
190
202
|
*
|
|
191
203
|
* A Developer-ID-signed helper survives npm untouched — its embedded signature
|
|
192
|
-
* still verifies —
|
|
193
|
-
*
|
|
194
|
-
*
|
|
204
|
+
* still verifies — but if the release was not notarized, Gatekeeper rejects it.
|
|
205
|
+
* In that case we strip the quarantine xattr and re-sign ad-hoc so the helper
|
|
206
|
+
* can launch locally. The stable fix is to notarize the release build; this
|
|
207
|
+
* fallback just prevents a crash-loop while the user is on an un-notarized cut.
|
|
195
208
|
*/
|
|
196
209
|
export function ensureValidSignature(appPath) {
|
|
197
|
-
if (codesignVerifies(appPath))
|
|
210
|
+
if (codesignVerifies(appPath) && gatekeeperAssesses(appPath))
|
|
198
211
|
return true;
|
|
199
212
|
// Drop any quarantine/xattrs the tarball round-trip added (they can break
|
|
200
|
-
// codesign), then re-sign ad-hoc under the helper's
|
|
213
|
+
// both codesign and Gatekeeper), then re-sign ad-hoc under the helper's
|
|
214
|
+
// stable bundle identifier.
|
|
201
215
|
spawnSync('xattr', ['-cr', appPath], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
202
216
|
spawnSync('codesign', ['--force', '--sign', '-', '--identifier', SERVICE_LABEL, appPath], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
203
217
|
return codesignVerifies(appPath);
|
|
@@ -4,8 +4,12 @@
|
|
|
4
4
|
* The one place the daemon (overdue routines, heal, routine start/finish/output)
|
|
5
5
|
* emits a native desktop notification. On macOS it routes through the installed
|
|
6
6
|
* `MenubarHelper.app` companion — a one-shot `MenubarHelper --notify` invocation —
|
|
7
|
-
* so the notification is attributed to that bundle and carries the agents-cli
|
|
8
|
-
*
|
|
7
|
+
* so the notification is attributed to that bundle and carries the agents-cli
|
|
8
|
+
* mark instead of the generic AppleScript icon. A banner carries two images: the
|
|
9
|
+
* agents-cli app icon on the LEFT (the sender) and, when the event belongs to one
|
|
10
|
+
* harness, that agent's avatar on the RIGHT (`agent`, rendered by
|
|
11
|
+
* AgentAvatar.swift) — the same layout macOS gives a YouTube notification, app on
|
|
12
|
+
* the left and the channel on the right. When the companion
|
|
9
13
|
* app is not installed (menu bar disabled, a Linux package, a dev checkout), it
|
|
10
14
|
* degrades to `osascript` so an overdue/heal notice is never silently lost — the
|
|
11
15
|
* generic icon is the acceptable cost of preserving delivery, not a bug hidden by
|
|
@@ -32,6 +36,17 @@ export interface DesktopNotification {
|
|
|
32
36
|
* macOS-only (osascript / notify-send have no click target). See routine-notify.ts.
|
|
33
37
|
*/
|
|
34
38
|
action?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Harness the notification is ABOUT (`claude`, `codex`, … — an `AgentId`).
|
|
41
|
+
* macOS draws two images on a banner: the sending bundle's app icon on the
|
|
42
|
+
* LEFT and `contentImage` on the RIGHT. The companion renders this id as the
|
|
43
|
+
* right-hand avatar (AgentAvatar.swift), so a banner reads "agents-cli, about
|
|
44
|
+
* Claude" the way a YouTube notification reads "YouTube, from this channel".
|
|
45
|
+
* Omit it when no single harness owns the event (a daemon heal, a fan-out
|
|
46
|
+
* across several agents) — the right slot then stays empty rather than
|
|
47
|
+
* repeating the left one. macOS-only; osascript / notify-send carry no image.
|
|
48
|
+
*/
|
|
49
|
+
agent?: string;
|
|
35
50
|
}
|
|
36
51
|
/** Argv for the MenubarHelper one-shot notify mode. Exported for tests. */
|
|
37
52
|
export declare function buildMenubarNotifyArgs(n: DesktopNotification): string[];
|
|
@@ -4,8 +4,12 @@
|
|
|
4
4
|
* The one place the daemon (overdue routines, heal, routine start/finish/output)
|
|
5
5
|
* emits a native desktop notification. On macOS it routes through the installed
|
|
6
6
|
* `MenubarHelper.app` companion — a one-shot `MenubarHelper --notify` invocation —
|
|
7
|
-
* so the notification is attributed to that bundle and carries the agents-cli
|
|
8
|
-
*
|
|
7
|
+
* so the notification is attributed to that bundle and carries the agents-cli
|
|
8
|
+
* mark instead of the generic AppleScript icon. A banner carries two images: the
|
|
9
|
+
* agents-cli app icon on the LEFT (the sender) and, when the event belongs to one
|
|
10
|
+
* harness, that agent's avatar on the RIGHT (`agent`, rendered by
|
|
11
|
+
* AgentAvatar.swift) — the same layout macOS gives a YouTube notification, app on
|
|
12
|
+
* the left and the channel on the right. When the companion
|
|
9
13
|
* app is not installed (menu bar disabled, a Linux package, a dev checkout), it
|
|
10
14
|
* degrades to `osascript` so an overdue/heal notice is never silently lost — the
|
|
11
15
|
* generic icon is the acceptable cost of preserving delivery, not a bug hidden by
|
|
@@ -35,6 +39,8 @@ export function buildMenubarNotifyArgs(n) {
|
|
|
35
39
|
args.push('--subtitle', n.subtitle);
|
|
36
40
|
if (n.action)
|
|
37
41
|
args.push('--action', n.action);
|
|
42
|
+
if (n.agent)
|
|
43
|
+
args.push('--agent', n.agent);
|
|
38
44
|
return args;
|
|
39
45
|
}
|
|
40
46
|
/** AppleScript for the osascript degradation path. Exported for tests. */
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one worktree-aware cwd -> project fold.
|
|
3
|
+
*
|
|
4
|
+
* Several surfaces bucket work "by project": the `agents sessions` overview,
|
|
5
|
+
* the `agents activity` timeline, and anything else that has a cwd and needs a
|
|
6
|
+
* stable repo-level key. They must agree, or the same session shows up under
|
|
7
|
+
* `agents-cli` in one view and `my-branch-slug` in another — so the rule lives
|
|
8
|
+
* here and every caller delegates.
|
|
9
|
+
*
|
|
10
|
+
* The rule: a worktree cwd (`…/<repo>/.agents/worktrees/<slug>[/sub]`) folds to
|
|
11
|
+
* the REPO directory name, so a worktree groups with the repo it branched from;
|
|
12
|
+
* any other path resolves to its own basename. {@link projectKeyFromCwd} is pure
|
|
13
|
+
* — no filesystem, no git, so it works identically for a remote peer's events as
|
|
14
|
+
* for local ones; {@link resolveProjectKey} adds the filesystem repo-root walk
|
|
15
|
+
* for paths this machine can see.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Resolve a stable project key from a working directory, or `undefined` when
|
|
19
|
+
* the path carries nothing usable (empty, `/`, whitespace).
|
|
20
|
+
*/
|
|
21
|
+
export declare function projectKeyFromCwd(cwd?: string | null): string | undefined;
|
|
22
|
+
/**
|
|
23
|
+
* The git working-tree root containing `dir`, by walking up for a `.git` entry
|
|
24
|
+
* — a directory in a normal checkout, a file in a linked worktree, so one
|
|
25
|
+
* `existsSync` covers both. Filesystem-only: no `git` process per lookup, which
|
|
26
|
+
* matters because a timeline can hold dozens of distinct cwds.
|
|
27
|
+
*
|
|
28
|
+
* Returns `undefined` when `dir` is not inside a repo, when it does not exist
|
|
29
|
+
* (a path from another machine), or when the only repo found IS the home
|
|
30
|
+
* directory — a dotfiles repo at `$HOME` would otherwise swallow every
|
|
31
|
+
* non-project directory under it into one bogus "project".
|
|
32
|
+
*/
|
|
33
|
+
export declare function repoRootForCwd(dir: string, home?: string): string | undefined;
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the project key for a cwd **on this machine**: the repository it
|
|
36
|
+
* belongs to when there is one (so a monorepo subdir like `<repo>/apps/cli`
|
|
37
|
+
* groups under `<repo>`, not `cli`), else the directory itself.
|
|
38
|
+
*
|
|
39
|
+
* Each machine resolves its own paths — a peer answering a fan-out stamps the
|
|
40
|
+
* project for its events before they cross the wire — so this is never asked
|
|
41
|
+
* about a path it cannot see. {@link projectKeyFromCwd} is the pure fold for
|
|
42
|
+
* everything else.
|
|
43
|
+
*/
|
|
44
|
+
export declare function resolveProjectKey(cwd?: string | null, home?: string): string | undefined;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one worktree-aware cwd -> project fold.
|
|
3
|
+
*
|
|
4
|
+
* Several surfaces bucket work "by project": the `agents sessions` overview,
|
|
5
|
+
* the `agents activity` timeline, and anything else that has a cwd and needs a
|
|
6
|
+
* stable repo-level key. They must agree, or the same session shows up under
|
|
7
|
+
* `agents-cli` in one view and `my-branch-slug` in another — so the rule lives
|
|
8
|
+
* here and every caller delegates.
|
|
9
|
+
*
|
|
10
|
+
* The rule: a worktree cwd (`…/<repo>/.agents/worktrees/<slug>[/sub]`) folds to
|
|
11
|
+
* the REPO directory name, so a worktree groups with the repo it branched from;
|
|
12
|
+
* any other path resolves to its own basename. {@link projectKeyFromCwd} is pure
|
|
13
|
+
* — no filesystem, no git, so it works identically for a remote peer's events as
|
|
14
|
+
* for local ones; {@link resolveProjectKey} adds the filesystem repo-root walk
|
|
15
|
+
* for paths this machine can see.
|
|
16
|
+
*/
|
|
17
|
+
import * as fs from 'fs';
|
|
18
|
+
import * as os from 'os';
|
|
19
|
+
import * as path from 'path';
|
|
20
|
+
const WORKTREE_SEGMENT = '/.agents/worktrees/';
|
|
21
|
+
/**
|
|
22
|
+
* Resolve a stable project key from a working directory, or `undefined` when
|
|
23
|
+
* the path carries nothing usable (empty, `/`, whitespace).
|
|
24
|
+
*/
|
|
25
|
+
export function projectKeyFromCwd(cwd) {
|
|
26
|
+
if (!cwd)
|
|
27
|
+
return undefined;
|
|
28
|
+
const norm = cwd.replace(/\\/g, '/').replace(/\/+$/, '').trim();
|
|
29
|
+
if (!norm)
|
|
30
|
+
return undefined;
|
|
31
|
+
const wtIdx = norm.indexOf(WORKTREE_SEGMENT);
|
|
32
|
+
if (wtIdx > 0) {
|
|
33
|
+
const repoPath = norm.slice(0, wtIdx);
|
|
34
|
+
const base = repoPath.slice(repoPath.lastIndexOf('/') + 1);
|
|
35
|
+
if (base)
|
|
36
|
+
return base;
|
|
37
|
+
}
|
|
38
|
+
const base = norm.slice(norm.lastIndexOf('/') + 1);
|
|
39
|
+
return base || undefined;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The git working-tree root containing `dir`, by walking up for a `.git` entry
|
|
43
|
+
* — a directory in a normal checkout, a file in a linked worktree, so one
|
|
44
|
+
* `existsSync` covers both. Filesystem-only: no `git` process per lookup, which
|
|
45
|
+
* matters because a timeline can hold dozens of distinct cwds.
|
|
46
|
+
*
|
|
47
|
+
* Returns `undefined` when `dir` is not inside a repo, when it does not exist
|
|
48
|
+
* (a path from another machine), or when the only repo found IS the home
|
|
49
|
+
* directory — a dotfiles repo at `$HOME` would otherwise swallow every
|
|
50
|
+
* non-project directory under it into one bogus "project".
|
|
51
|
+
*/
|
|
52
|
+
export function repoRootForCwd(dir, home = os.homedir()) {
|
|
53
|
+
const stop = path.resolve(home);
|
|
54
|
+
let current = path.resolve(dir);
|
|
55
|
+
for (;;) {
|
|
56
|
+
if (fs.existsSync(path.join(current, '.git')))
|
|
57
|
+
return current === stop ? undefined : current;
|
|
58
|
+
const parent = path.dirname(current);
|
|
59
|
+
if (parent === current)
|
|
60
|
+
return undefined;
|
|
61
|
+
current = parent;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Resolve the project key for a cwd **on this machine**: the repository it
|
|
66
|
+
* belongs to when there is one (so a monorepo subdir like `<repo>/apps/cli`
|
|
67
|
+
* groups under `<repo>`, not `cli`), else the directory itself.
|
|
68
|
+
*
|
|
69
|
+
* Each machine resolves its own paths — a peer answering a fan-out stamps the
|
|
70
|
+
* project for its events before they cross the wire — so this is never asked
|
|
71
|
+
* about a path it cannot see. {@link projectKeyFromCwd} is the pure fold for
|
|
72
|
+
* everything else.
|
|
73
|
+
*/
|
|
74
|
+
export function resolveProjectKey(cwd, home) {
|
|
75
|
+
if (!cwd)
|
|
76
|
+
return undefined;
|
|
77
|
+
const root = repoRootForCwd(cwd, home);
|
|
78
|
+
return projectKeyFromCwd(root ?? cwd);
|
|
79
|
+
}
|