@phnx-labs/agents-cli 1.20.84 → 1.20.86
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 +98 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/events.d.ts +16 -0
- package/dist/commands/events.js +44 -5
- package/dist/commands/exec.js +15 -0
- package/dist/commands/feed.js +135 -31
- package/dist/commands/models.js +1 -1
- package/dist/commands/sessions-picker.js +5 -4
- package/dist/lib/activity.d.ts +8 -0
- package/dist/lib/activity.js +5 -0
- package/dist/lib/feed-broadcast.d.ts +66 -0
- package/dist/lib/feed-broadcast.js +131 -0
- package/dist/lib/hosts/remote-cmd.js +5 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -2
- package/dist/lib/models.d.ts +21 -0
- package/dist/lib/models.js +133 -4
- package/dist/lib/run-notify.d.ts +27 -0
- package/dist/lib/run-notify.js +54 -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/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +2 -2
- package/dist/lib/session/parse.d.ts +11 -0
- package/dist/lib/session/parse.js +24 -7
- package/dist/lib/session/state.d.ts +6 -5
- package/dist/lib/session/state.js +15 -9
- package/dist/lib/types.d.ts +10 -0
- package/package.json +1 -1
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fan an `agents feed post` out to the systems the operator actually watches.
|
|
3
|
+
*
|
|
4
|
+
* A post is already durable — it lands in the append-only activity log and shows
|
|
5
|
+
* up in `agents feed --filter updates`. But an operator who is away from every
|
|
6
|
+
* terminal never sees it, and the tracker that owns the work (a Linear ticket,
|
|
7
|
+
* a GitHub issue) hears nothing at all. So a post can also be mirrored outward.
|
|
8
|
+
*
|
|
9
|
+
* Sinks are **argv templates from config**, never hardcoded integrations. This
|
|
10
|
+
* CLI ships Apache-2.0 and must not depend on one person's tracker or messaging
|
|
11
|
+
* stack; declaring `[linear, update, "{ticket}", --comment, "{text}"]` in
|
|
12
|
+
* `agents.yaml` keeps the coupling in the operator's config where it belongs,
|
|
13
|
+
* and lets someone else point the same mechanism at `jira`, `gh issue comment`,
|
|
14
|
+
* or a webhook script.
|
|
15
|
+
*
|
|
16
|
+
* Two rules decide whether a sink runs, both derived from the post itself:
|
|
17
|
+
*
|
|
18
|
+
* - **Level.** `minLevel: important` keeps a sink for the posts worth
|
|
19
|
+
* interrupting someone over, so a routine "CI green" does not buzz a phone.
|
|
20
|
+
* - **Placeholders.** A template that references `{ticket}` is skipped when no
|
|
21
|
+
* ticket is known. The template declares what it needs; nothing has to
|
|
22
|
+
* restate it as a flag, and a sink can never fire with a hole in its argv.
|
|
23
|
+
*
|
|
24
|
+
* Delivery is best-effort and reported: a sink that fails prints a warning and
|
|
25
|
+
* the post still stands. Losing a mirror must never cost the operator the post.
|
|
26
|
+
*/
|
|
27
|
+
import { spawnSync } from 'child_process';
|
|
28
|
+
const LEVEL_RANK = { milestone: 0, important: 1 };
|
|
29
|
+
/** Parse a `--level` value; anything unrecognized is a usage error, not a default. */
|
|
30
|
+
export function parseFeedPostLevel(raw) {
|
|
31
|
+
const v = (raw ?? '').trim().toLowerCase();
|
|
32
|
+
if (!v || v === 'milestone')
|
|
33
|
+
return 'milestone';
|
|
34
|
+
if (v === 'important')
|
|
35
|
+
return 'important';
|
|
36
|
+
throw new Error(`Unknown --level '${raw}'. Use milestone or important.`);
|
|
37
|
+
}
|
|
38
|
+
const PLACEHOLDER = /\{([a-z]+)\}/g;
|
|
39
|
+
/**
|
|
40
|
+
* A human-facing one-liner for a messaging sink: what project, what happened,
|
|
41
|
+
* and the link to go read more. Leading with the project is deliberate — a
|
|
42
|
+
* message that opens with an agent name tells the reader nothing about which of
|
|
43
|
+
* their projects just moved.
|
|
44
|
+
*/
|
|
45
|
+
export function composeBroadcastMessage(ctx) {
|
|
46
|
+
const head = ctx.project ? `${ctx.project} · ${ctx.text}` : ctx.text;
|
|
47
|
+
const link = ctx.links?.find((l) => /^https?:\/\//i.test(l));
|
|
48
|
+
return link ? `${head}\n${link}` : head;
|
|
49
|
+
}
|
|
50
|
+
/** The values a template may reference, resolved once per post. */
|
|
51
|
+
function templateVars(ctx) {
|
|
52
|
+
return {
|
|
53
|
+
text: ctx.text,
|
|
54
|
+
ticket: ctx.ticket,
|
|
55
|
+
project: ctx.project,
|
|
56
|
+
agent: ctx.agent,
|
|
57
|
+
host: ctx.host,
|
|
58
|
+
session: ctx.session,
|
|
59
|
+
level: ctx.level,
|
|
60
|
+
links: ctx.links?.length ? ctx.links.join(' ') : undefined,
|
|
61
|
+
message: composeBroadcastMessage(ctx),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Substitute `{placeholder}` tokens in an argv template. Returns undefined when
|
|
66
|
+
* the template needs a value this post does not have — the sink is then skipped
|
|
67
|
+
* rather than run with an empty argument, which is how a `linear update --comment`
|
|
68
|
+
* would otherwise comment on nothing.
|
|
69
|
+
*/
|
|
70
|
+
export function renderSinkArgv(template, ctx) {
|
|
71
|
+
const vars = templateVars(ctx);
|
|
72
|
+
const argv = [];
|
|
73
|
+
for (const token of template) {
|
|
74
|
+
let missing = false;
|
|
75
|
+
const rendered = token.replace(PLACEHOLDER, (whole, key) => {
|
|
76
|
+
const value = vars[key];
|
|
77
|
+
if (value === undefined || value === '') {
|
|
78
|
+
missing = true;
|
|
79
|
+
return whole;
|
|
80
|
+
}
|
|
81
|
+
return value;
|
|
82
|
+
});
|
|
83
|
+
if (missing)
|
|
84
|
+
return undefined;
|
|
85
|
+
argv.push(rendered);
|
|
86
|
+
}
|
|
87
|
+
return argv.length > 0 ? argv : undefined;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Which sinks this post reaches, in config order. Pure — the dry-run listing and
|
|
91
|
+
* the real fan-out plan through here, so what `--dry-run` shows is what runs.
|
|
92
|
+
*/
|
|
93
|
+
export function planFeedBroadcast(config, ctx) {
|
|
94
|
+
if (!config)
|
|
95
|
+
return [];
|
|
96
|
+
const planned = [];
|
|
97
|
+
for (const [name, sink] of Object.entries(config)) {
|
|
98
|
+
if (!Array.isArray(sink?.command) || sink.command.length === 0)
|
|
99
|
+
continue;
|
|
100
|
+
const min = sink.minLevel ?? 'milestone';
|
|
101
|
+
if (LEVEL_RANK[ctx.level] < LEVEL_RANK[min])
|
|
102
|
+
continue;
|
|
103
|
+
const argv = renderSinkArgv(sink.command, ctx);
|
|
104
|
+
if (!argv)
|
|
105
|
+
continue;
|
|
106
|
+
planned.push({ name, argv });
|
|
107
|
+
}
|
|
108
|
+
return planned;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Run the planned sinks. Each is a direct spawn with a bounded lifetime; a sink
|
|
112
|
+
* that fails or is not installed is reported, never thrown — the post is already
|
|
113
|
+
* written and must not be undone by a mirror that could not be reached.
|
|
114
|
+
*/
|
|
115
|
+
export function runFeedBroadcast(planned, timeoutMs = 20_000) {
|
|
116
|
+
return planned.map(({ name, argv }) => {
|
|
117
|
+
const result = spawnSync(argv[0], argv.slice(1), {
|
|
118
|
+
encoding: 'utf-8',
|
|
119
|
+
timeout: timeoutMs,
|
|
120
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
121
|
+
});
|
|
122
|
+
if (result.error) {
|
|
123
|
+
return { name, ok: false, error: result.error.message };
|
|
124
|
+
}
|
|
125
|
+
if (result.status !== 0) {
|
|
126
|
+
const tail = (result.stderr || result.stdout || '').trim().split('\n').slice(-1)[0];
|
|
127
|
+
return { name, ok: false, error: tail || `exited ${result.status}` };
|
|
128
|
+
}
|
|
129
|
+
return { name, ok: true };
|
|
130
|
+
});
|
|
131
|
+
}
|
|
@@ -116,6 +116,11 @@ export const RUN_OPTION_FORWARDING = {
|
|
|
116
116
|
tailscale: 'local-only', // --tailscale/--no-tailscale gate the lease net mode; never forwarded
|
|
117
117
|
copyCreds: 'local-only', // copies creds TO the host before dispatch — local concern only
|
|
118
118
|
authCheck: 'local-only', // --no-auth-check gates the local interactive login preflight; --host runs skip that preflight entirely
|
|
119
|
+
// The notification must land on the box the PERSON is at — the one that
|
|
120
|
+
// dispatched — not on a headless worker with no desktop to post to. The local
|
|
121
|
+
// process follows the remote run to completion, so its exit handler fires at
|
|
122
|
+
// the right moment anyway.
|
|
123
|
+
notify: 'local-only',
|
|
119
124
|
// Deprecated alias for --device auto; resolved on the launching box before SSH.
|
|
120
125
|
smart: 'local-only',
|
|
121
126
|
};
|
|
Binary file
|
|
Binary file
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
<dict>
|
|
7
7
|
<key>Resources/AppIcon.icns</key>
|
|
8
8
|
<data>
|
|
9
|
-
|
|
9
|
+
DFq5H08EkhgWIC3UvGMR9B58BZw=
|
|
10
10
|
</data>
|
|
11
11
|
</dict>
|
|
12
12
|
<key>files2</key>
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
<dict>
|
|
16
16
|
<key>hash2</key>
|
|
17
17
|
<data>
|
|
18
|
-
|
|
18
|
+
mBSjM6jlvN7J1jQowsvqTl7CHM0pur0e6qsHMuzk5k0=
|
|
19
19
|
</data>
|
|
20
20
|
</dict>
|
|
21
21
|
</dict>
|
package/dist/lib/models.d.ts
CHANGED
|
@@ -57,6 +57,27 @@ export interface ModelSource {
|
|
|
57
57
|
* Returns null if nothing usable is found.
|
|
58
58
|
*/
|
|
59
59
|
export declare function locateModelSource(agent: AgentId, version: string): ModelSource | null;
|
|
60
|
+
/**
|
|
61
|
+
* Parse `grok models` stdout into a catalog. Exported for unit tests.
|
|
62
|
+
*
|
|
63
|
+
* Output shape (verified 0.2.118):
|
|
64
|
+
* You are logged in with grok.com.
|
|
65
|
+
*
|
|
66
|
+
* Default model: grok-4.5
|
|
67
|
+
*
|
|
68
|
+
* Available models:
|
|
69
|
+
* * grok-4.5 (default)
|
|
70
|
+
*
|
|
71
|
+
* The `Default model:` line is authoritative; rows may also carry a leading `*`
|
|
72
|
+
* and a `(default)` flag. Grok has no `--json` on this subcommand. Settings live
|
|
73
|
+
* in `config.toml` / `models_cache.json`, not `settings.json`, so the native
|
|
74
|
+
* settings.json reader cannot surface the default — the catalog is the source
|
|
75
|
+
* that makes `resolveConfiguredModel` return a cli-default for Grok.
|
|
76
|
+
*/
|
|
77
|
+
export declare function parseGrokModelsStdout(stdout: string): {
|
|
78
|
+
models: ModelInfo[];
|
|
79
|
+
aliases: Record<string, string>;
|
|
80
|
+
};
|
|
60
81
|
/**
|
|
61
82
|
* Build (or load from cache) the model catalog for a specific (agent, version).
|
|
62
83
|
* Cache is keyed on source-file mtime (binary or js module), so re-extracts
|
package/dist/lib/models.js
CHANGED
|
@@ -3,15 +3,15 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Each agent ships its model list differently -- Claude and Codex embed it in
|
|
5
5
|
* compiled bundles/binaries, Gemini exports it from a JS module, and OpenCode/
|
|
6
|
-
* Cursor/OpenClaw expose it via CLI commands. This
|
|
7
|
-
* `getModelCatalog()` and `resolveModel()` interface
|
|
8
|
-
* by a file-system cache keyed on source mtime.
|
|
6
|
+
* Cursor/OpenClaw/Antigravity/Kimi/Grok expose it via CLI commands. This
|
|
7
|
+
* module provides a unified `getModelCatalog()` and `resolveModel()` interface
|
|
8
|
+
* over all of them, backed by a file-system cache keyed on source mtime.
|
|
9
9
|
*/
|
|
10
10
|
import * as fs from 'fs';
|
|
11
11
|
import * as path from 'path';
|
|
12
12
|
import { execFileSync } from 'child_process';
|
|
13
13
|
import chalk from 'chalk';
|
|
14
|
-
import { getVersionDir, getVersionHomePath } from './versions.js';
|
|
14
|
+
import { getVersionDir, getVersionHomePath, getBinaryPath } from './versions.js';
|
|
15
15
|
import { getModelsCachePath } from './state.js';
|
|
16
16
|
import { agentConfigDirName } from './agents.js';
|
|
17
17
|
import { resolveRunDefaults } from './run-defaults.js';
|
|
@@ -188,6 +188,36 @@ export function locateModelSource(agent, version) {
|
|
|
188
188
|
return { path: pathBin, kind: 'cli' };
|
|
189
189
|
return null;
|
|
190
190
|
}
|
|
191
|
+
if (agent === 'grok') {
|
|
192
|
+
// Grok ships a native binary under the version home's `.grok/downloads/`,
|
|
193
|
+
// not node_modules/.bin. Prefer a real binary over a failed-download stub
|
|
194
|
+
// (a 99-byte placeholder sometimes left beside a prior good download).
|
|
195
|
+
const preferred = getBinaryPath('grok', version);
|
|
196
|
+
if (isUsableGrokBinary(preferred))
|
|
197
|
+
return { path: preferred, kind: 'cli' };
|
|
198
|
+
const downloads = path.join(getVersionHomePath('grok', version), '.grok', 'downloads');
|
|
199
|
+
try {
|
|
200
|
+
const candidates = fs
|
|
201
|
+
.readdirSync(downloads)
|
|
202
|
+
.filter((e) => e.startsWith('grok-'))
|
|
203
|
+
.map((e) => path.join(downloads, e))
|
|
204
|
+
.filter(isUsableGrokBinary)
|
|
205
|
+
.sort((a, b) => {
|
|
206
|
+
try {
|
|
207
|
+
return fs.statSync(b).size - fs.statSync(a).size;
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return 0;
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
if (candidates[0])
|
|
214
|
+
return { path: candidates[0], kind: 'cli' };
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
/* empty downloads */
|
|
218
|
+
}
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
191
221
|
if (agent === 'cursor') {
|
|
192
222
|
// cursor-agent is installed via curl script, not agents-cli. Version argument
|
|
193
223
|
// is accepted for API symmetry but ignored -- cursor lives on PATH.
|
|
@@ -198,6 +228,16 @@ export function locateModelSource(agent, version) {
|
|
|
198
228
|
}
|
|
199
229
|
return null;
|
|
200
230
|
}
|
|
231
|
+
/** Real Grok binaries are ~100MB+; failed-download stubs are tens of bytes. */
|
|
232
|
+
function isUsableGrokBinary(filePath) {
|
|
233
|
+
try {
|
|
234
|
+
const st = fs.statSync(filePath);
|
|
235
|
+
return st.isFile() && st.size > 1024 * 1024;
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
201
241
|
/** Search PATH for a command and return its absolute path, or null. */
|
|
202
242
|
function findOnPath(command) {
|
|
203
243
|
const pathEnv = process.env.PATH || '';
|
|
@@ -679,6 +719,93 @@ function extractAntigravityCatalog(binaryPath) {
|
|
|
679
719
|
}
|
|
680
720
|
return { models, aliases: {} };
|
|
681
721
|
}
|
|
722
|
+
/**
|
|
723
|
+
* Parse `grok models` stdout into a catalog. Exported for unit tests.
|
|
724
|
+
*
|
|
725
|
+
* Output shape (verified 0.2.118):
|
|
726
|
+
* You are logged in with grok.com.
|
|
727
|
+
*
|
|
728
|
+
* Default model: grok-4.5
|
|
729
|
+
*
|
|
730
|
+
* Available models:
|
|
731
|
+
* * grok-4.5 (default)
|
|
732
|
+
*
|
|
733
|
+
* The `Default model:` line is authoritative; rows may also carry a leading `*`
|
|
734
|
+
* and a `(default)` flag. Grok has no `--json` on this subcommand. Settings live
|
|
735
|
+
* in `config.toml` / `models_cache.json`, not `settings.json`, so the native
|
|
736
|
+
* settings.json reader cannot surface the default — the catalog is the source
|
|
737
|
+
* that makes `resolveConfiguredModel` return a cli-default for Grok.
|
|
738
|
+
*/
|
|
739
|
+
export function parseGrokModelsStdout(stdout) {
|
|
740
|
+
// Strip ANSI in case a spinner or color codes slip through.
|
|
741
|
+
// eslint-disable-next-line no-control-regex
|
|
742
|
+
const plain = stdout.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
|
|
743
|
+
let defaultId = null;
|
|
744
|
+
const defaultLine = plain.match(/Default model:\s*(\S+)/i);
|
|
745
|
+
if (defaultLine)
|
|
746
|
+
defaultId = defaultLine[1];
|
|
747
|
+
const models = [];
|
|
748
|
+
const seen = new Set();
|
|
749
|
+
for (const raw of plain.split('\n')) {
|
|
750
|
+
const line = raw.trim();
|
|
751
|
+
if (!line)
|
|
752
|
+
continue;
|
|
753
|
+
// Rows: "* grok-4.5 (default)" or "grok-4.5" or " grok-code-fast-1"
|
|
754
|
+
const m = line.match(/^\*?\s*([A-Za-z0-9][A-Za-z0-9._-]*)(?:\s+\(([^)]*)\))?\s*$/);
|
|
755
|
+
if (!m)
|
|
756
|
+
continue;
|
|
757
|
+
const id = m[1];
|
|
758
|
+
// Real model ids are grok-* (or match the Default model: line). Skip banner words.
|
|
759
|
+
if (!/^grok[-_]/i.test(id) && id !== defaultId)
|
|
760
|
+
continue;
|
|
761
|
+
if (seen.has(id))
|
|
762
|
+
continue;
|
|
763
|
+
seen.add(id);
|
|
764
|
+
const flags = (m[2] ?? '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
765
|
+
models.push({
|
|
766
|
+
id,
|
|
767
|
+
isDefault: (defaultId != null && id === defaultId) || flags.includes('default'),
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
// If Default model was set but did not appear as a row, still surface it.
|
|
771
|
+
if (defaultId && !seen.has(defaultId)) {
|
|
772
|
+
models.unshift({ id: defaultId, isDefault: true });
|
|
773
|
+
}
|
|
774
|
+
// Normalize: exactly one default when we know the Default model: id.
|
|
775
|
+
if (defaultId) {
|
|
776
|
+
for (const model of models)
|
|
777
|
+
model.isDefault = model.id === defaultId;
|
|
778
|
+
}
|
|
779
|
+
else if (models.length > 0 && !models.some((model) => model.isDefault)) {
|
|
780
|
+
models[0].isDefault = true;
|
|
781
|
+
}
|
|
782
|
+
return { models, aliases: {} };
|
|
783
|
+
}
|
|
784
|
+
/** Extract Grok's catalog via `grok models` (see parseGrokModelsStdout). */
|
|
785
|
+
function extractGrokCatalog(binaryPath) {
|
|
786
|
+
const env = { ...process.env };
|
|
787
|
+
// Point GROK_HOME at the version home that owns this binary so auth +
|
|
788
|
+
// models_cache come from the right install, not a host ~/.grok symlink.
|
|
789
|
+
// binary: <home>/.grok/downloads/grok-<ver>-...
|
|
790
|
+
const downloadsDir = path.dirname(binaryPath);
|
|
791
|
+
if (path.basename(downloadsDir) === 'downloads') {
|
|
792
|
+
env.GROK_HOME = path.dirname(downloadsDir);
|
|
793
|
+
}
|
|
794
|
+
let stdout;
|
|
795
|
+
try {
|
|
796
|
+
stdout = execFileSync(binaryPath, ['models'], {
|
|
797
|
+
encoding: 'utf-8',
|
|
798
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
799
|
+
timeout: 15_000,
|
|
800
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
801
|
+
env,
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
catch {
|
|
805
|
+
return { models: [], aliases: {} };
|
|
806
|
+
}
|
|
807
|
+
return parseGrokModelsStdout(stdout);
|
|
808
|
+
}
|
|
682
809
|
/**
|
|
683
810
|
* Extract Kimi's catalog via `kimi provider list --json`, which emits the raw
|
|
684
811
|
* providers/models config. Model ids are the `models` object keys (e.g.
|
|
@@ -789,6 +916,8 @@ export function getModelCatalog(agent, version) {
|
|
|
789
916
|
({ models, aliases } = extractAntigravityCatalog(src.path));
|
|
790
917
|
else if (agent === 'kimi')
|
|
791
918
|
({ models, aliases } = extractKimiCatalog(src.path));
|
|
919
|
+
else if (agent === 'grok')
|
|
920
|
+
({ models, aliases } = extractGrokCatalog(src.path));
|
|
792
921
|
}
|
|
793
922
|
const catalog = {
|
|
794
923
|
agent,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type DesktopNotification } from './menubar/notify-desktop.js';
|
|
2
|
+
export interface RunNotifyContext {
|
|
3
|
+
/** Agent that ran, e.g. `claude`. */
|
|
4
|
+
agent: string;
|
|
5
|
+
/** `--name` slug when the caller named the run; falls back to the agent. */
|
|
6
|
+
name?: string;
|
|
7
|
+
/** The prompt, used for a one-line reminder of what the run was about. */
|
|
8
|
+
prompt?: string;
|
|
9
|
+
/** Working directory the run was scoped to; its basename names the project. */
|
|
10
|
+
cwd?: string;
|
|
11
|
+
/** Machine the run executed on, when it was dispatched off-box. */
|
|
12
|
+
host?: string;
|
|
13
|
+
/** Clickable target — a PR/ticket URL the caller already knows. */
|
|
14
|
+
url?: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The finish notification for one run. Pure — the exit handler and the tests
|
|
18
|
+
* both build through here, so what ships is what is asserted.
|
|
19
|
+
*/
|
|
20
|
+
export declare function buildRunFinishNotification(ctx: RunNotifyContext, exitCode: number): DesktopNotification;
|
|
21
|
+
/**
|
|
22
|
+
* Post the finish notification when this process exits. Best-effort by
|
|
23
|
+
* construction: `notifyDesktop` swallows its own failures, and a run killed
|
|
24
|
+
* outright (SIGKILL) never reaches an exit handler — that is the documented
|
|
25
|
+
* limit, not a case to paper over.
|
|
26
|
+
*/
|
|
27
|
+
export declare function armRunFinishNotification(ctx: RunNotifyContext): void;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Desktop notification when a headless `agents run` finishes (`--notify`).
|
|
3
|
+
*
|
|
4
|
+
* The notifying process is the one that OWNS the run. That is the whole point:
|
|
5
|
+
* the menu bar's quick dispatch used to post its completion notice from the
|
|
6
|
+
* dispatching MenubarHelper's process-termination callback, so a helper that
|
|
7
|
+
* restarted (an upgrade, a crash) took the callback with it — the run kept
|
|
8
|
+
* going, reparented to launchd, and no notification could ever fire. Posting
|
|
9
|
+
* from the run process instead means the notice survives anything that happens
|
|
10
|
+
* to the menu bar, and `notifyDesktop` spawns a FRESH one-shot notifier, so it
|
|
11
|
+
* does not need a helper to have been running at dispatch time either.
|
|
12
|
+
*
|
|
13
|
+
* Armed once via `process.on('exit')` so it covers every way the run command
|
|
14
|
+
* terminates — local spawn, `--host` dispatch, `--lease` box, the error path —
|
|
15
|
+
* rather than being sprinkled over ~50 `process.exit` call sites where the next
|
|
16
|
+
* new exit path would silently miss it.
|
|
17
|
+
*/
|
|
18
|
+
import * as path from 'path';
|
|
19
|
+
import { notifyDesktop } from './menubar/notify-desktop.js';
|
|
20
|
+
/** Notification body cap: a banner truncates anyway, and a wall of text is noise. */
|
|
21
|
+
const BODY_MAX = 120;
|
|
22
|
+
function shorten(text, max = BODY_MAX) {
|
|
23
|
+
const flat = text.replace(/\s+/g, ' ').trim();
|
|
24
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The finish notification for one run. Pure — the exit handler and the tests
|
|
28
|
+
* both build through here, so what ships is what is asserted.
|
|
29
|
+
*/
|
|
30
|
+
export function buildRunFinishNotification(ctx, exitCode) {
|
|
31
|
+
const label = ctx.name?.trim() || ctx.agent;
|
|
32
|
+
const project = ctx.cwd ? path.basename(ctx.cwd) : undefined;
|
|
33
|
+
const where = [project, ctx.host].filter(Boolean).join(' · ');
|
|
34
|
+
const n = {
|
|
35
|
+
title: exitCode === 0 ? `${label} finished` : `${label} failed`,
|
|
36
|
+
body: shorten(ctx.prompt?.trim() || `${ctx.agent} run`),
|
|
37
|
+
};
|
|
38
|
+
if (where)
|
|
39
|
+
n.subtitle = where;
|
|
40
|
+
if (ctx.url)
|
|
41
|
+
n.action = `url:${ctx.url}`;
|
|
42
|
+
return n;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Post the finish notification when this process exits. Best-effort by
|
|
46
|
+
* construction: `notifyDesktop` swallows its own failures, and a run killed
|
|
47
|
+
* outright (SIGKILL) never reaches an exit handler — that is the documented
|
|
48
|
+
* limit, not a case to paper over.
|
|
49
|
+
*/
|
|
50
|
+
export function armRunFinishNotification(ctx) {
|
|
51
|
+
process.on('exit', (code) => {
|
|
52
|
+
notifyDesktop(buildRunFinishNotification(ctx, code));
|
|
53
|
+
});
|
|
54
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
<dict>
|
|
7
7
|
<key>Resources/AppIcon.icns</key>
|
|
8
8
|
<data>
|
|
9
|
-
|
|
9
|
+
DFq5H08EkhgWIC3UvGMR9B58BZw=
|
|
10
10
|
</data>
|
|
11
11
|
</dict>
|
|
12
12
|
<key>files2</key>
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
<dict>
|
|
16
16
|
<key>hash2</key>
|
|
17
17
|
<data>
|
|
18
|
-
|
|
18
|
+
mBSjM6jlvN7J1jQowsvqTl7CHM0pur0e6qsHMuzk5k0=
|
|
19
19
|
</data>
|
|
20
20
|
</dict>
|
|
21
21
|
<key>embedded.provisionprofile</key>
|
|
@@ -27,6 +27,17 @@ export declare function safeReadSessionFile(filePath: string, maxBytes?: number)
|
|
|
27
27
|
export declare function parseSession(filePath: string, agent?: SessionAgentId): SessionEvent[];
|
|
28
28
|
/** Infer the agent type from a session file path using known directory conventions. */
|
|
29
29
|
export declare function detectAgent(filePath: string): SessionAgentId | null;
|
|
30
|
+
/**
|
|
31
|
+
* Checklist-snapshot tool names across harnesses — each sends the WHOLE list on
|
|
32
|
+
* every write, so the last call is the current checklist. Claude `TodoWrite`,
|
|
33
|
+
* Kimi `TodoList`, Droid/OpenCode `todo_write`, Codex `update_plan`.
|
|
34
|
+
*/
|
|
35
|
+
export declare const SNAPSHOT_TODO_TOOLS: Set<string>;
|
|
36
|
+
/**
|
|
37
|
+
* Whether a harness's checklist status means "finished". Claude/Codex write
|
|
38
|
+
* `completed`; Kimi writes `done`.
|
|
39
|
+
*/
|
|
40
|
+
export declare function isCompletedTodoStatus(status: unknown): boolean;
|
|
30
41
|
/**
|
|
31
42
|
* Summarize a tool_use into a one-liner string.
|
|
32
43
|
*/
|
|
@@ -205,6 +205,19 @@ export function detectAgent(filePath) {
|
|
|
205
205
|
return 'gemini';
|
|
206
206
|
return null;
|
|
207
207
|
}
|
|
208
|
+
/**
|
|
209
|
+
* Checklist-snapshot tool names across harnesses — each sends the WHOLE list on
|
|
210
|
+
* every write, so the last call is the current checklist. Claude `TodoWrite`,
|
|
211
|
+
* Kimi `TodoList`, Droid/OpenCode `todo_write`, Codex `update_plan`.
|
|
212
|
+
*/
|
|
213
|
+
export const SNAPSHOT_TODO_TOOLS = new Set(['TodoWrite', 'TodoList', 'todo_write', 'update_plan']);
|
|
214
|
+
/**
|
|
215
|
+
* Whether a harness's checklist status means "finished". Claude/Codex write
|
|
216
|
+
* `completed`; Kimi writes `done`.
|
|
217
|
+
*/
|
|
218
|
+
export function isCompletedTodoStatus(status) {
|
|
219
|
+
return status === 'completed' || status === 'done';
|
|
220
|
+
}
|
|
208
221
|
/**
|
|
209
222
|
* Summarize a tool_use into a one-liner string.
|
|
210
223
|
*/
|
|
@@ -214,12 +227,13 @@ export function summarizeToolUse(tool, args) {
|
|
|
214
227
|
switch (tool) {
|
|
215
228
|
case 'Bash':
|
|
216
229
|
return `Bash: ${truncate(String(args.command || '').replace(/\n/g, ' ').trim(), 120)}`;
|
|
230
|
+
// `path` is the Kimi spelling of Claude's `file_path` for the same tools.
|
|
217
231
|
case 'Read':
|
|
218
|
-
return `Read ${shortenPath(args.file_path || '')}`;
|
|
232
|
+
return `Read ${shortenPath(args.file_path || args.path || '')}`;
|
|
219
233
|
case 'Write':
|
|
220
|
-
return `Write ${shortenPath(args.file_path || '')}`;
|
|
234
|
+
return `Write ${shortenPath(args.file_path || args.path || '')}`;
|
|
221
235
|
case 'Edit':
|
|
222
|
-
return `Edit ${shortenPath(args.file_path || '')}`;
|
|
236
|
+
return `Edit ${shortenPath(args.file_path || args.path || '')}`;
|
|
223
237
|
case 'Glob':
|
|
224
238
|
return `Glob ${args.pattern || ''}`;
|
|
225
239
|
case 'Grep':
|
|
@@ -234,14 +248,17 @@ export function summarizeToolUse(tool, args) {
|
|
|
234
248
|
const steps = Array.isArray(args.plan) ? args.plan.length : 0;
|
|
235
249
|
return `Plan: ${steps} step${steps === 1 ? '' : 's'}`;
|
|
236
250
|
}
|
|
237
|
-
//
|
|
238
|
-
|
|
251
|
+
// Live checklist: show progress + the current step, not a bare "TodoWrite".
|
|
252
|
+
// Claude writes `TodoWrite`, Kimi writes `TodoList`; both carry the whole list
|
|
253
|
+
// under `todos`, with Kimi spelling the item text `title` and "done" `done`.
|
|
254
|
+
case 'TodoWrite':
|
|
255
|
+
case 'TodoList': {
|
|
239
256
|
const todos = Array.isArray(args.todos) ? args.todos : [];
|
|
240
257
|
if (todos.length === 0)
|
|
241
258
|
return 'Plan: 0 steps';
|
|
242
|
-
const done = todos.filter((t) => t?.status
|
|
259
|
+
const done = todos.filter((t) => isCompletedTodoStatus(t?.status)).length;
|
|
243
260
|
const active = todos.find((t) => t?.status === 'in_progress');
|
|
244
|
-
const step = active?.activeForm || active?.content;
|
|
261
|
+
const step = active?.activeForm || active?.content || active?.title;
|
|
245
262
|
return step
|
|
246
263
|
? `Plan ${done}/${todos.length}: ${truncate(String(step), 80)}`
|
|
247
264
|
: `Plan: ${done}/${todos.length} done`;
|
|
@@ -119,11 +119,12 @@ export interface StateContext {
|
|
|
119
119
|
activeWindowMs?: number;
|
|
120
120
|
}
|
|
121
121
|
/**
|
|
122
|
-
* Derive live plan progress from a checklist tool call's args. Accepts
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
122
|
+
* Derive live plan progress from a checklist tool call's args. Accepts Claude's
|
|
123
|
+
* `TodoWrite` (`todos: [{content,status,activeForm}]`), Kimi's `TodoList`
|
|
124
|
+
* (`todos: [{title,status}]`, where finished is `done` rather than `completed`)
|
|
125
|
+
* and Codex's `update_plan` (`plan: [{step,status}]`) shapes, so the CLI is the
|
|
126
|
+
* single source of checklist state for every agent. Returns undefined when there
|
|
127
|
+
* is no usable list, so a session with no plan carries no `todos` field.
|
|
127
128
|
*/
|
|
128
129
|
export declare function extractTodoProgress(args?: Record<string, any>): TodoProgress | undefined;
|
|
129
130
|
/** Fold snapshot checklist tools and Claude TaskCreate/TaskUpdate event logs. */
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* shape + mtime — same function, driven off the normalized events.
|
|
16
16
|
*/
|
|
17
17
|
import * as path from 'path';
|
|
18
|
-
import { summarizeToolUse } from './parse.js';
|
|
18
|
+
import { isCompletedTodoStatus, SNAPSHOT_TODO_TOOLS, summarizeToolUse } from './parse.js';
|
|
19
19
|
/**
|
|
20
20
|
* Detect per-session rate-limit / usage-limit signals in assistant or error
|
|
21
21
|
* text (RUSH-1523). Matches the same shapes Factory's prewarm detectBlockingPrompt
|
|
@@ -48,15 +48,15 @@ const PROSE_QUESTION_FRESH_MS = 30 * 60_000;
|
|
|
48
48
|
/** Claude tool names that structurally mean "the agent handed control back to you". */
|
|
49
49
|
const PLAN_TOOL = 'ExitPlanMode';
|
|
50
50
|
const ASK_TOOL = 'AskUserQuestion';
|
|
51
|
-
const SNAPSHOT_TODO_TOOLS = new Set(['TodoWrite', 'todo_write', 'update_plan']);
|
|
52
51
|
const TASK_CREATE_TOOL = 'TaskCreate';
|
|
53
52
|
const TASK_UPDATE_TOOL = 'TaskUpdate';
|
|
54
53
|
/**
|
|
55
|
-
* Derive live plan progress from a checklist tool call's args. Accepts
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
54
|
+
* Derive live plan progress from a checklist tool call's args. Accepts Claude's
|
|
55
|
+
* `TodoWrite` (`todos: [{content,status,activeForm}]`), Kimi's `TodoList`
|
|
56
|
+
* (`todos: [{title,status}]`, where finished is `done` rather than `completed`)
|
|
57
|
+
* and Codex's `update_plan` (`plan: [{step,status}]`) shapes, so the CLI is the
|
|
58
|
+
* single source of checklist state for every agent. Returns undefined when there
|
|
59
|
+
* is no usable list, so a session with no plan carries no `todos` field.
|
|
60
60
|
*/
|
|
61
61
|
export function extractTodoProgress(args) {
|
|
62
62
|
const input = args?.input && typeof args.input === 'object' ? args.input : args;
|
|
@@ -76,10 +76,16 @@ export function extractTodoProgress(args) {
|
|
|
76
76
|
? t.text
|
|
77
77
|
: typeof t?.step === 'string' && t.step
|
|
78
78
|
? t.step
|
|
79
|
-
:
|
|
79
|
+
: typeof t?.title === 'string' && t.title
|
|
80
|
+
? t.title
|
|
81
|
+
: activeForm ?? '';
|
|
80
82
|
if (!content)
|
|
81
83
|
continue;
|
|
82
|
-
const status = t?.status
|
|
84
|
+
const status = isCompletedTodoStatus(t?.status)
|
|
85
|
+
? 'completed'
|
|
86
|
+
: t?.status === 'in_progress'
|
|
87
|
+
? 'in_progress'
|
|
88
|
+
: 'pending';
|
|
83
89
|
const description = typeof t?.description === 'string' && t.description ? t.description : undefined;
|
|
84
90
|
items.push({ content, status, ...(description ? { description } : {}), ...(activeForm ? { activeForm } : {}) });
|
|
85
91
|
}
|
package/dist/lib/types.d.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* formats for each supported agent.
|
|
7
7
|
*/
|
|
8
8
|
import type { CloudProviderId } from './cloud/types.js';
|
|
9
|
+
import type { FeedBroadcastConfig } from './feed-broadcast.js';
|
|
9
10
|
/** Unique identifier for a current or legacy AI coding agent. */
|
|
10
11
|
export type AgentId = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode' | 'openclaw' | 'copilot' | 'amp' | 'kiro' | 'goose' | 'antigravity' | 'grok' | 'kimi' | 'droid' | 'hermes';
|
|
11
12
|
/** How `agents run <agent>` chooses an installed version when none is pinned. */
|
|
@@ -762,6 +763,15 @@ export interface Meta {
|
|
|
762
763
|
};
|
|
763
764
|
/** Spend guardrails (issue #346). User-global caps; project agents.yaml overrides. */
|
|
764
765
|
budget?: BudgetConfig;
|
|
766
|
+
/**
|
|
767
|
+
* `agents feed post` fan-out. `broadcast` maps a sink name to the argv template
|
|
768
|
+
* run for each post, so mirroring to a tracker or a messaging CLI is the
|
|
769
|
+
* operator's config rather than an integration compiled into this CLI. See
|
|
770
|
+
* lib/feed-broadcast.ts and docs/06-observability.md.
|
|
771
|
+
*/
|
|
772
|
+
feed?: {
|
|
773
|
+
broadcast?: FeedBroadcastConfig;
|
|
774
|
+
};
|
|
765
775
|
beta?: {
|
|
766
776
|
enabled?: BetaFeatureName[];
|
|
767
777
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phnx-labs/agents-cli",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.86",
|
|
4
4
|
"description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|