@phnx-labs/agents-cli 1.20.92 → 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 +121 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/events.js +91 -1
- package/dist/commands/projects.d.ts +10 -0
- package/dist/commands/projects.js +189 -8
- 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/sync.js +9 -3
- package/dist/commands/view.js +4 -0
- package/dist/index.js +16 -0
- package/dist/lib/activity.d.ts +8 -0
- package/dist/lib/activity.js +7 -0
- package/dist/lib/channels/send.d.ts +83 -0
- package/dist/lib/channels/send.js +112 -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/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/notify-desktop.d.ts +17 -2
- package/dist/lib/menubar/notify-desktop.js +8 -2
- 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-status.d.ts +32 -1
- package/dist/lib/project-status.js +82 -1
- package/dist/lib/projects.d.ts +6 -0
- package/dist/lib/projects.js +12 -0
- 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/remote-active.d.ts +5 -1
- package/dist/lib/session/remote-active.js +4 -1
- package/dist/lib/sqlite.js +28 -1
- package/dist/lib/state.d.ts +12 -0
- package/dist/lib/state.js +14 -0
- package/dist/lib/types.d.ts +5 -4
- package/dist/lib/versions.d.ts +6 -0
- package/dist/lib/versions.js +6 -4
- package/package.json +1 -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
|
|
@@ -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,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project workspace probing — the drift signal behind `projects status --fleet`.
|
|
3
|
+
*
|
|
4
|
+
* Projects are natively multi-device: the same definition (home-relative paths)
|
|
5
|
+
* re-roots on every fleet machine, and the question is whether the project's
|
|
6
|
+
* repos are PRESENT on each box, on which branch, how far ahead/behind their
|
|
7
|
+
* upstream, and whether they carry uncommitted changes. This module is the pure
|
|
8
|
+
* local half: given a set of home-relative paths it probes each one with a
|
|
9
|
+
* handful of read-only git calls. Drift is measured against the LAST-FETCHED
|
|
10
|
+
* upstream (`@{upstream}`) — deliberately no `git fetch`, so a probe is fast
|
|
11
|
+
* and offline-safe. The fleet half (`--fleet`) just runs this probe on every
|
|
12
|
+
* peer via the canonical `remote-agents-json` SSH fan-out.
|
|
13
|
+
*/
|
|
14
|
+
import type { ProjectDef } from './projects.js';
|
|
15
|
+
/** The on-disk state of one workspace repo on one machine. */
|
|
16
|
+
export interface RepoWorkspaceStatus {
|
|
17
|
+
/** The probed path, echoed home-relative (re-roots per machine). */
|
|
18
|
+
path: string;
|
|
19
|
+
/** `.git` exists (a directory, or a FILE for a linked worktree). */
|
|
20
|
+
present: boolean;
|
|
21
|
+
branch?: string;
|
|
22
|
+
/** The configured upstream ref (e.g. `origin/main`); absent → no upstream. */
|
|
23
|
+
upstream?: string;
|
|
24
|
+
/** Commits on HEAD not on the upstream. Undefined without an upstream. */
|
|
25
|
+
ahead?: number;
|
|
26
|
+
/** Commits on the upstream not on HEAD. Undefined without an upstream. */
|
|
27
|
+
behind?: number;
|
|
28
|
+
/** Uncommitted (incl. untracked) paths from `git status --porcelain`. */
|
|
29
|
+
dirty?: number;
|
|
30
|
+
/** ISO 8601 committer date of HEAD. */
|
|
31
|
+
lastCommit?: string;
|
|
32
|
+
/** `.git` exists but git could not read it — never looks silently clean. */
|
|
33
|
+
error?: string;
|
|
34
|
+
}
|
|
35
|
+
/** A probe result tagged with the machine that answered (the fleet view). */
|
|
36
|
+
export interface HostWorkspaceStatus extends RepoWorkspaceStatus {
|
|
37
|
+
host: string;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Probe one workspace repo. A missing path yields `{present: false}` and no
|
|
41
|
+
* git call is made. On a present repo every signal is best-effort: whatever
|
|
42
|
+
* succeeded is reported, and a repo whose `.git` exists yet every git call
|
|
43
|
+
* failed surfaces as present-with-error rather than silently clean.
|
|
44
|
+
*/
|
|
45
|
+
export declare function probeRepoWorkspace(absPath: string): RepoWorkspaceStatus;
|
|
46
|
+
/** Probe each home-relative path (expanded against the local home), in order. */
|
|
47
|
+
export declare function probeProjectWorkspaces(paths: string[]): RepoWorkspaceStatus[];
|
|
48
|
+
/**
|
|
49
|
+
* The home-relative paths to probe for a project definition: its `root` plus
|
|
50
|
+
* each `repos[].path` (the opt-in for additional repos), deduped. Every target
|
|
51
|
+
* is normalized through the same `toHomeRelative(expandLocalHome(...))` the
|
|
52
|
+
* probe echoes, so a hand-edited def (absolute path under home, trailing
|
|
53
|
+
* slash) matches its probe rows exactly — `writeProjectDef` normalizes on
|
|
54
|
+
* write, but defs are hand-editable YAML and never silently drop a row.
|
|
55
|
+
*/
|
|
56
|
+
export declare function workspaceTargetsForDef(def: ProjectDef): string[];
|
|
57
|
+
/**
|
|
58
|
+
* Parse a peer's `projects probe --json` stdout, tagging each row with the
|
|
59
|
+
* machine that answered. Defensive against version skew / partial output, the
|
|
60
|
+
* same boundary contract as `parseRemoteActive`: non-JSON or a non-array
|
|
61
|
+
* yields `[]`, and rows without a `path`/`present` core are dropped.
|
|
62
|
+
*/
|
|
63
|
+
export declare function parseRemoteProbe(stdout: string, machine: string): HostWorkspaceStatus[];
|
|
64
|
+
/**
|
|
65
|
+
* One workspace's compact state: `✓ clean · main`, `⚠ 12 dirty · ↑3 ↓1 ·
|
|
66
|
+
* feature/x`, `✗ missing`, or `⚠ error: …`. Pure — chalk styling only.
|
|
67
|
+
*/
|
|
68
|
+
export declare function formatWorkspaceLine(s: RepoWorkspaceStatus): string;
|
|
69
|
+
/**
|
|
70
|
+
* The fleet view of one project's workspaces: one content line per probed
|
|
71
|
+
* path (host-sorted `host: state` cells joined by ` · `), labelled with the
|
|
72
|
+
* path when a project probes more than one. Pure — the caller adds the
|
|
73
|
+
* `fleet` row label.
|
|
74
|
+
*/
|
|
75
|
+
export declare function formatFleetWorkspaces(statuses: HostWorkspaceStatus[]): string[];
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project workspace probing — the drift signal behind `projects status --fleet`.
|
|
3
|
+
*
|
|
4
|
+
* Projects are natively multi-device: the same definition (home-relative paths)
|
|
5
|
+
* re-roots on every fleet machine, and the question is whether the project's
|
|
6
|
+
* repos are PRESENT on each box, on which branch, how far ahead/behind their
|
|
7
|
+
* upstream, and whether they carry uncommitted changes. This module is the pure
|
|
8
|
+
* local half: given a set of home-relative paths it probes each one with a
|
|
9
|
+
* handful of read-only git calls. Drift is measured against the LAST-FETCHED
|
|
10
|
+
* upstream (`@{upstream}`) — deliberately no `git fetch`, so a probe is fast
|
|
11
|
+
* and offline-safe. The fleet half (`--fleet`) just runs this probe on every
|
|
12
|
+
* peer via the canonical `remote-agents-json` SSH fan-out.
|
|
13
|
+
*/
|
|
14
|
+
import * as fs from 'fs';
|
|
15
|
+
import * as path from 'path';
|
|
16
|
+
import { execFileSync } from 'child_process';
|
|
17
|
+
import chalk from 'chalk';
|
|
18
|
+
import { expandLocalHome, toHomeRelative } from './project-root.js';
|
|
19
|
+
/** Per-call git budget. A read-only git call taking >3s is wedged by any
|
|
20
|
+
* definition (NFS stall, index lock) — and the fleet fan-out SIGKILLs the SSH
|
|
21
|
+
* hop at 12s, so a probe must fit inside that budget to avoid a slow peer
|
|
22
|
+
* being misreported as unreachable: 3s × 5 calls leaves headroom even when
|
|
23
|
+
* one repo is genuinely stuck. */
|
|
24
|
+
const GIT_TIMEOUT_MS = 3_000;
|
|
25
|
+
/** One read-only git call against `absPath`; undefined on any failure. */
|
|
26
|
+
function git(absPath, args) {
|
|
27
|
+
try {
|
|
28
|
+
return execFileSync('git', ['-C', absPath, ...args], {
|
|
29
|
+
encoding: 'utf8',
|
|
30
|
+
timeout: GIT_TIMEOUT_MS,
|
|
31
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
32
|
+
}).trim();
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Probe one workspace repo. A missing path yields `{present: false}` and no
|
|
40
|
+
* git call is made. On a present repo every signal is best-effort: whatever
|
|
41
|
+
* succeeded is reported, and a repo whose `.git` exists yet every git call
|
|
42
|
+
* failed surfaces as present-with-error rather than silently clean.
|
|
43
|
+
*/
|
|
44
|
+
export function probeRepoWorkspace(absPath) {
|
|
45
|
+
const status = { path: toHomeRelative(absPath), present: false };
|
|
46
|
+
if (!fs.existsSync(path.join(absPath, '.git')))
|
|
47
|
+
return status;
|
|
48
|
+
status.present = true;
|
|
49
|
+
const branch = git(absPath, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
50
|
+
const upstream = git(absPath, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}']);
|
|
51
|
+
// `--left-right --count A...B` prints "<left>\t<right>" — left is
|
|
52
|
+
// upstream-only (we are BEHIND by that much), right is HEAD-only (AHEAD).
|
|
53
|
+
const counts = upstream !== undefined
|
|
54
|
+
? git(absPath, ['rev-list', '--left-right', '--count', '@{upstream}...HEAD'])
|
|
55
|
+
: undefined;
|
|
56
|
+
const dirtyOut = git(absPath, ['status', '--porcelain']);
|
|
57
|
+
const lastCommit = git(absPath, ['log', '-1', '--format=%cI']);
|
|
58
|
+
if (branch === undefined && dirtyOut === undefined && lastCommit === undefined) {
|
|
59
|
+
status.error = '.git exists but git could not read this repo';
|
|
60
|
+
return status;
|
|
61
|
+
}
|
|
62
|
+
if (branch !== undefined)
|
|
63
|
+
status.branch = branch;
|
|
64
|
+
if (upstream !== undefined)
|
|
65
|
+
status.upstream = upstream;
|
|
66
|
+
if (counts !== undefined) {
|
|
67
|
+
const [behind, ahead] = counts.split(/\s+/).map(Number);
|
|
68
|
+
if (Number.isFinite(behind) && Number.isFinite(ahead)) {
|
|
69
|
+
status.behind = behind;
|
|
70
|
+
status.ahead = ahead;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (dirtyOut !== undefined)
|
|
74
|
+
status.dirty = dirtyOut === '' ? 0 : dirtyOut.split('\n').length;
|
|
75
|
+
if (lastCommit !== undefined)
|
|
76
|
+
status.lastCommit = lastCommit;
|
|
77
|
+
return status;
|
|
78
|
+
}
|
|
79
|
+
/** Probe each home-relative path (expanded against the local home), in order. */
|
|
80
|
+
export function probeProjectWorkspaces(paths) {
|
|
81
|
+
return paths.map((p) => probeRepoWorkspace(expandLocalHome(p)));
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The home-relative paths to probe for a project definition: its `root` plus
|
|
85
|
+
* each `repos[].path` (the opt-in for additional repos), deduped. Every target
|
|
86
|
+
* is normalized through the same `toHomeRelative(expandLocalHome(...))` the
|
|
87
|
+
* probe echoes, so a hand-edited def (absolute path under home, trailing
|
|
88
|
+
* slash) matches its probe rows exactly — `writeProjectDef` normalizes on
|
|
89
|
+
* write, but defs are hand-editable YAML and never silently drop a row.
|
|
90
|
+
*/
|
|
91
|
+
export function workspaceTargetsForDef(def) {
|
|
92
|
+
const targets = [def.root, ...(def.repos ?? []).map((r) => r.path)]
|
|
93
|
+
.filter((p) => typeof p === 'string' && p.length > 0)
|
|
94
|
+
.map((p) => toHomeRelative(expandLocalHome(p)));
|
|
95
|
+
return [...new Set(targets)];
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Parse a peer's `projects probe --json` stdout, tagging each row with the
|
|
99
|
+
* machine that answered. Defensive against version skew / partial output, the
|
|
100
|
+
* same boundary contract as `parseRemoteActive`: non-JSON or a non-array
|
|
101
|
+
* yields `[]`, and rows without a `path`/`present` core are dropped.
|
|
102
|
+
*/
|
|
103
|
+
export function parseRemoteProbe(stdout, machine) {
|
|
104
|
+
let parsed;
|
|
105
|
+
try {
|
|
106
|
+
parsed = JSON.parse(stdout);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
if (!Array.isArray(parsed))
|
|
112
|
+
return [];
|
|
113
|
+
return parsed.flatMap((x) => {
|
|
114
|
+
if (x && typeof x === 'object' && !Array.isArray(x)) {
|
|
115
|
+
const o = x;
|
|
116
|
+
if (typeof o.path === 'string' && typeof o.present === 'boolean') {
|
|
117
|
+
return [{ ...o, host: machine }];
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return [];
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* One workspace's compact state: `✓ clean · main`, `⚠ 12 dirty · ↑3 ↓1 ·
|
|
125
|
+
* feature/x`, `✗ missing`, or `⚠ error: …`. Pure — chalk styling only.
|
|
126
|
+
*/
|
|
127
|
+
export function formatWorkspaceLine(s) {
|
|
128
|
+
if (!s.present)
|
|
129
|
+
return chalk.red('✗ missing');
|
|
130
|
+
if (s.error)
|
|
131
|
+
return chalk.yellow(`⚠ error: ${s.error}`);
|
|
132
|
+
const parts = [];
|
|
133
|
+
if (s.dirty !== undefined && s.dirty > 0)
|
|
134
|
+
parts.push(`${s.dirty} dirty`);
|
|
135
|
+
const drift = [
|
|
136
|
+
s.ahead !== undefined && s.ahead > 0 ? `↑${s.ahead}` : '',
|
|
137
|
+
s.behind !== undefined && s.behind > 0 ? `↓${s.behind}` : '',
|
|
138
|
+
].filter(Boolean).join(' ');
|
|
139
|
+
if (drift)
|
|
140
|
+
parts.push(drift);
|
|
141
|
+
const head = parts.length > 0 ? chalk.yellow(`⚠ ${parts.join(' · ')}`) : chalk.green('✓ clean');
|
|
142
|
+
return s.branch ? `${head} ${chalk.dim('·')} ${s.branch}` : head;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* The fleet view of one project's workspaces: one content line per probed
|
|
146
|
+
* path (host-sorted `host: state` cells joined by ` · `), labelled with the
|
|
147
|
+
* path when a project probes more than one. Pure — the caller adds the
|
|
148
|
+
* `fleet` row label.
|
|
149
|
+
*/
|
|
150
|
+
export function formatFleetWorkspaces(statuses) {
|
|
151
|
+
const paths = [...new Set(statuses.map((s) => s.path))];
|
|
152
|
+
const multi = paths.length > 1;
|
|
153
|
+
return paths.map((p) => {
|
|
154
|
+
const rows = statuses
|
|
155
|
+
.filter((s) => s.path === p)
|
|
156
|
+
.sort((a, b) => a.host.localeCompare(b.host));
|
|
157
|
+
const body = rows.map((r) => `${chalk.cyan(r.host)}: ${formatWorkspaceLine(r)}`).join(chalk.dim(' · '));
|
|
158
|
+
return multi ? `${chalk.dim(`${p} · `)}${body}` : body;
|
|
159
|
+
});
|
|
160
|
+
}
|
|
@@ -5,3 +5,11 @@ export interface ProjectResourceSyncResult {
|
|
|
5
5
|
}
|
|
6
6
|
export declare function projectAgentRoot(projectRoot: string, agent: AgentId): string;
|
|
7
7
|
export declare function syncProjectResourcesToAgent(agent: AgentId, version: string, projectAgentsDir: string): ProjectResourceSyncResult;
|
|
8
|
+
/**
|
|
9
|
+
* One human line for the files a project sync left alone because you already
|
|
10
|
+
* wrote them. This is the normal steady state — every sync of a project whose
|
|
11
|
+
* `.claude/commands/` you hand-authored hits it — so it is a single grouped
|
|
12
|
+
* line, not one wrapped warning per file, and it says "yours" rather than the
|
|
13
|
+
* internal "user-owned". Returns null when nothing was skipped.
|
|
14
|
+
*/
|
|
15
|
+
export declare function formatKeptProjectResources(skipped: string[]): string | null;
|