@phnx-labs/agents-cli 1.20.29 → 1.20.31
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/dist/commands/computer-actions.js +6 -2
- package/dist/commands/computer.d.ts +12 -0
- package/dist/commands/computer.js +88 -13
- package/dist/commands/inspect.js +1 -1
- package/dist/commands/models.js +8 -2
- package/dist/commands/sessions-picker.js +35 -10
- package/dist/commands/sessions.js +164 -44
- package/dist/commands/setup.js +8 -0
- package/dist/commands/ssh.js +123 -15
- package/dist/commands/sync.js +70 -14
- package/dist/lib/agents.d.ts +0 -4
- package/dist/lib/agents.js +122 -22
- package/dist/lib/browser/drivers/ssh.js +4 -35
- package/dist/lib/computer-rpc.d.ts +6 -1
- package/dist/lib/computer-rpc.js +86 -3
- package/dist/lib/devices/registry.d.ts +11 -0
- package/dist/lib/devices/registry.js +53 -1
- package/dist/lib/devices/sync.d.ts +42 -0
- package/dist/lib/devices/sync.js +85 -0
- package/dist/lib/exec.js +14 -0
- package/dist/lib/models.js +138 -5
- package/dist/lib/runner.js +7 -7
- package/dist/lib/session/active.d.ts +15 -0
- package/dist/lib/session/active.js +108 -19
- package/dist/lib/session/cloud.js +2 -0
- package/dist/lib/session/db.d.ts +11 -0
- package/dist/lib/session/db.js +62 -5
- package/dist/lib/session/digest.d.ts +50 -0
- package/dist/lib/session/digest.js +170 -0
- package/dist/lib/session/discover.d.ts +5 -0
- package/dist/lib/session/discover.js +81 -0
- package/dist/lib/session/parse.d.ts +15 -0
- package/dist/lib/session/parse.js +22 -2
- package/dist/lib/session/remote.d.ts +1 -1
- package/dist/lib/session/remote.js +8 -3
- package/dist/lib/session/render.d.ts +2 -0
- package/dist/lib/session/render.js +83 -10
- package/dist/lib/session/state.d.ts +82 -0
- package/dist/lib/session/state.js +221 -0
- package/dist/lib/session/tail.d.ts +18 -0
- package/dist/lib/session/tail.js +57 -0
- package/dist/lib/session/types.d.ts +9 -0
- package/dist/lib/session/width.d.ts +29 -0
- package/dist/lib/session/width.js +91 -0
- package/dist/lib/shims.d.ts +17 -1
- package/dist/lib/shims.js +130 -6
- package/dist/lib/ssh-tunnel.d.ts +127 -0
- package/dist/lib/ssh-tunnel.js +346 -0
- package/dist/lib/state.d.ts +4 -0
- package/dist/lib/state.js +19 -1
- package/dist/lib/sync-umbrella.d.ts +5 -0
- package/dist/lib/sync-umbrella.js +10 -0
- package/dist/lib/teams/agents.d.ts +11 -1
- package/dist/lib/teams/agents.js +16 -2
- package/dist/lib/types.d.ts +1 -0
- package/dist/lib/versions.d.ts +19 -0
- package/dist/lib/versions.js +84 -24
- package/package.json +1 -1
|
@@ -20,10 +20,15 @@ export declare function loadDefaultPeers(): string[];
|
|
|
20
20
|
export declare function writeComputerPeers(allowedExecPaths: string[]): void;
|
|
21
21
|
export declare function resolveHelperExec(): string | null;
|
|
22
22
|
export declare function resolveHelperApp(): string | null;
|
|
23
|
+
export declare function resolveTcpEndpoint(): {
|
|
24
|
+
host: string;
|
|
25
|
+
port: number;
|
|
26
|
+
token: string | null;
|
|
27
|
+
} | null;
|
|
23
28
|
export declare function openComputerClient(): ComputerClient;
|
|
24
29
|
export declare const RPC_TIMEOUT_MS = 30000;
|
|
25
30
|
export declare function resolveRpcTimeoutMs(env: string | undefined): number;
|
|
26
31
|
export declare function describeTransport(): {
|
|
27
|
-
kind: 'socket' | 'stdio' | 'none';
|
|
32
|
+
kind: 'socket' | 'stdio' | 'tcp' | 'none';
|
|
28
33
|
path: string | null;
|
|
29
34
|
};
|
package/dist/lib/computer-rpc.js
CHANGED
|
@@ -187,9 +187,31 @@ export function resolveHelperApp() {
|
|
|
187
187
|
// exec = <bundle>/Contents/MacOS/ComputerHelper
|
|
188
188
|
return path.resolve(exec, '..', '..', '..');
|
|
189
189
|
}
|
|
190
|
-
//
|
|
191
|
-
//
|
|
190
|
+
// Resolve the TCP endpoint for the Windows daemon (computer-helper-win), if
|
|
191
|
+
// configured. The Windows helper binds loopback TCP (Program.cs) and the CLI
|
|
192
|
+
// reaches it over an `ssh -L` tunnel, so the endpoint is a local forwarded
|
|
193
|
+
// port. COMPUTER_HELPER_TCP is "host:port" (host defaults to 127.0.0.1);
|
|
194
|
+
// COMPUTER_HELPER_TOKEN is the shared secret sent in the first `auth` frame.
|
|
195
|
+
export function resolveTcpEndpoint() {
|
|
196
|
+
const raw = process.env.COMPUTER_HELPER_TCP;
|
|
197
|
+
if (!raw || raw.length === 0)
|
|
198
|
+
return null;
|
|
199
|
+
const [hostPart, portPart] = raw.includes(':') ? raw.split(':') : ['127.0.0.1', raw];
|
|
200
|
+
const port = Number(portPart);
|
|
201
|
+
if (!Number.isInteger(port) || port <= 0)
|
|
202
|
+
return null;
|
|
203
|
+
const token = process.env.COMPUTER_HELPER_TOKEN;
|
|
204
|
+
return { host: hostPart || '127.0.0.1', port, token: token && token.length > 0 ? token : null };
|
|
205
|
+
}
|
|
206
|
+
// Pick the best transport. Precedence:
|
|
207
|
+
// 1. COMPUTER_HELPER_TCP -> the Windows daemon over a (tunneled) TCP port.
|
|
208
|
+
// 2. the macOS launchd socket if it exists.
|
|
209
|
+
// 3. spawning the helper as a subprocess (legacy/dev fallback).
|
|
192
210
|
export function openComputerClient() {
|
|
211
|
+
const tcp = resolveTcpEndpoint();
|
|
212
|
+
if (tcp) {
|
|
213
|
+
return new TcpClient(tcp.host, tcp.port, tcp.token);
|
|
214
|
+
}
|
|
193
215
|
const sockPath = resolveSocketPath();
|
|
194
216
|
if (fs.existsSync(sockPath)) {
|
|
195
217
|
return new SocketClient(sockPath);
|
|
@@ -297,6 +319,61 @@ class SocketClient extends BaseClient {
|
|
|
297
319
|
});
|
|
298
320
|
}
|
|
299
321
|
}
|
|
322
|
+
// TCP transport for the Windows daemon (computer-helper-win). The daemon
|
|
323
|
+
// binds loopback only (Program.cs); the CLI reaches it over an `ssh -L`
|
|
324
|
+
// tunnel, so `host` is typically 127.0.0.1 + a forwarded port. When a token
|
|
325
|
+
// is configured the daemon accepts only an `auth` frame until authenticated,
|
|
326
|
+
// so we send that first and gate every other call on it.
|
|
327
|
+
class TcpClient extends BaseClient {
|
|
328
|
+
sock;
|
|
329
|
+
authReady;
|
|
330
|
+
constructor(host, port, token) {
|
|
331
|
+
super();
|
|
332
|
+
this.sock = createConnection({ host, port });
|
|
333
|
+
this.sock.setEncoding('utf8');
|
|
334
|
+
this.sock.on('data', (chunk) => this.handleChunk(chunk));
|
|
335
|
+
this.sock.on('error', (err) => {
|
|
336
|
+
this.closed = true;
|
|
337
|
+
this.failPending('socket_error', err.message);
|
|
338
|
+
});
|
|
339
|
+
this.sock.on('close', () => {
|
|
340
|
+
this.closed = true;
|
|
341
|
+
this.failPending('helper_exited', 'tcp connection closed before reply');
|
|
342
|
+
});
|
|
343
|
+
// Kick off the auth handshake synchronously so its frame (id 1) is the
|
|
344
|
+
// first thing written. No token → daemon is open (tunnel-gated).
|
|
345
|
+
this.authReady = token ? this.authenticate(token) : Promise.resolve();
|
|
346
|
+
}
|
|
347
|
+
async authenticate(token) {
|
|
348
|
+
const res = await super.call('auth', { token });
|
|
349
|
+
if (res.error)
|
|
350
|
+
throw new Error(`computer-helper auth failed: ${res.error.code}`);
|
|
351
|
+
}
|
|
352
|
+
async call(method, params) {
|
|
353
|
+
if (method !== 'auth') {
|
|
354
|
+
try {
|
|
355
|
+
await this.authReady;
|
|
356
|
+
}
|
|
357
|
+
catch (e) {
|
|
358
|
+
return { id: null, error: { code: 'auth_failed', message: e.message } };
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return super.call(method, params);
|
|
362
|
+
}
|
|
363
|
+
send(payload) {
|
|
364
|
+
this.sock.write(payload);
|
|
365
|
+
}
|
|
366
|
+
async close() {
|
|
367
|
+
if (this.closed)
|
|
368
|
+
return;
|
|
369
|
+
this.sock.end();
|
|
370
|
+
await new Promise((resolve) => {
|
|
371
|
+
if (this.closed)
|
|
372
|
+
return resolve();
|
|
373
|
+
this.sock.on('close', () => resolve());
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
}
|
|
300
377
|
class StdioClient extends BaseClient {
|
|
301
378
|
proc;
|
|
302
379
|
constructor(helperPath) {
|
|
@@ -324,8 +401,14 @@ class StdioClient extends BaseClient {
|
|
|
324
401
|
}
|
|
325
402
|
}
|
|
326
403
|
// Describe which transport is currently in use. Useful for diagnostics
|
|
327
|
-
// like `agents computer status`.
|
|
404
|
+
// like `agents computer status`. TCP takes precedence to match
|
|
405
|
+
// openComputerClient() — when COMPUTER_HELPER_TCP is set we drive a remote
|
|
406
|
+
// (Windows) daemon over a tunnel, so callers off macOS (no socket, no local
|
|
407
|
+
// .app) must not be told "no transport". `path` is null: the endpoint is an
|
|
408
|
+
// env-configured host:port, not an on-disk path.
|
|
328
409
|
export function describeTransport() {
|
|
410
|
+
if (resolveTcpEndpoint())
|
|
411
|
+
return { kind: 'tcp', path: null };
|
|
329
412
|
const sockPath = resolveSocketPath();
|
|
330
413
|
if (fs.existsSync(sockPath))
|
|
331
414
|
return { kind: 'socket', path: sockPath };
|
|
@@ -76,3 +76,14 @@ export interface DeviceInput {
|
|
|
76
76
|
export declare function upsertDevice(name: string, input: DeviceInput): Promise<DeviceProfile>;
|
|
77
77
|
/** Remove a device. Returns false if it was not registered. */
|
|
78
78
|
export declare function removeDevice(name: string): Promise<boolean>;
|
|
79
|
+
/** Load the set of ignored node names. Missing file => empty set. A malformed
|
|
80
|
+
* file is a hard error for the same reason the registry is: silently returning
|
|
81
|
+
* [] would let the next write wipe the user's dismissals. */
|
|
82
|
+
export declare function loadIgnored(): Promise<Set<string>>;
|
|
83
|
+
/** True if `name` is on the ignore-list. */
|
|
84
|
+
export declare function isIgnored(name: string): Promise<boolean>;
|
|
85
|
+
/** Add a node name to the ignore-list. Idempotent. Returns the resulting set. */
|
|
86
|
+
export declare function addIgnored(name: string): Promise<Set<string>>;
|
|
87
|
+
/** Remove a node name from the ignore-list (un-ignore). Returns false if it was
|
|
88
|
+
* not ignored. */
|
|
89
|
+
export declare function removeIgnored(name: string): Promise<boolean>;
|
|
@@ -18,7 +18,7 @@ import * as fsSync from 'fs';
|
|
|
18
18
|
import * as path from 'path';
|
|
19
19
|
import { randomBytes } from 'crypto';
|
|
20
20
|
import lockfile from 'proper-lockfile';
|
|
21
|
-
import { getDevicesRegistryPath } from '../state.js';
|
|
21
|
+
import { getDevicesRegistryPath, getDevicesIgnoredPath } from '../state.js';
|
|
22
22
|
function registryPath() {
|
|
23
23
|
return getDevicesRegistryPath();
|
|
24
24
|
}
|
|
@@ -166,3 +166,55 @@ export async function removeDevice(name) {
|
|
|
166
166
|
return true;
|
|
167
167
|
});
|
|
168
168
|
}
|
|
169
|
+
function ignoredPath() {
|
|
170
|
+
return getDevicesIgnoredPath();
|
|
171
|
+
}
|
|
172
|
+
/** Load the set of ignored node names. Missing file => empty set. A malformed
|
|
173
|
+
* file is a hard error for the same reason the registry is: silently returning
|
|
174
|
+
* [] would let the next write wipe the user's dismissals. */
|
|
175
|
+
export async function loadIgnored() {
|
|
176
|
+
const p = ignoredPath();
|
|
177
|
+
let raw;
|
|
178
|
+
try {
|
|
179
|
+
raw = await fs.readFile(p, 'utf-8');
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
if (err && err.code === 'ENOENT')
|
|
183
|
+
return new Set();
|
|
184
|
+
throw err;
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
const parsed = JSON.parse(raw);
|
|
188
|
+
return new Set(Array.isArray(parsed.ignored) ? parsed.ignored : []);
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
throw new Error(`Device ignore-list corrupted at ${p}: ${err?.message ?? err}. Inspect and restore from backup.`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/** True if `name` is on the ignore-list. */
|
|
195
|
+
export async function isIgnored(name) {
|
|
196
|
+
return (await loadIgnored()).has(name);
|
|
197
|
+
}
|
|
198
|
+
/** Add a node name to the ignore-list. Idempotent. Returns the resulting set. */
|
|
199
|
+
export async function addIgnored(name) {
|
|
200
|
+
assertValidDeviceName(name);
|
|
201
|
+
const p = ignoredPath();
|
|
202
|
+
return withRegistryLock(p, async () => {
|
|
203
|
+
const set = await loadIgnored();
|
|
204
|
+
set.add(name);
|
|
205
|
+
await atomicWriteJson(p, { ignored: [...set].sort(), updatedAt: new Date().toISOString() });
|
|
206
|
+
return set;
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
/** Remove a node name from the ignore-list (un-ignore). Returns false if it was
|
|
210
|
+
* not ignored. */
|
|
211
|
+
export async function removeIgnored(name) {
|
|
212
|
+
const p = ignoredPath();
|
|
213
|
+
return withRegistryLock(p, async () => {
|
|
214
|
+
const set = await loadIgnored();
|
|
215
|
+
if (!set.delete(name))
|
|
216
|
+
return false;
|
|
217
|
+
await atomicWriteJson(p, { ignored: [...set].sort(), updatedAt: new Date().toISOString() });
|
|
218
|
+
return true;
|
|
219
|
+
});
|
|
220
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type TailscaleNode } from './tailscale.js';
|
|
2
|
+
export interface DeviceSyncResult {
|
|
3
|
+
/** False when discovery could not run (e.g. tailscale absent) in soft mode. */
|
|
4
|
+
ok: boolean;
|
|
5
|
+
/** Number of tailscale nodes upserted into the registry. */
|
|
6
|
+
synced: number;
|
|
7
|
+
/** Node names discovered but neither registered-before nor ignored. */
|
|
8
|
+
pending: string[];
|
|
9
|
+
/** Populated when ok is false: why discovery was skipped. */
|
|
10
|
+
reason?: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Node names present on the tailnet but neither already in the registry nor on
|
|
14
|
+
* the ignore-list — i.e. genuinely new devices worth surfacing. Pure so the
|
|
15
|
+
* flag matrix is unit-testable without a live tailnet.
|
|
16
|
+
*/
|
|
17
|
+
export declare function computePendingDevices(nodes: TailscaleNode[], registered: Iterable<string>, ignored: Iterable<string>): string[];
|
|
18
|
+
/**
|
|
19
|
+
* Ingest `tailscale status --json` into the registry. In soft mode a missing
|
|
20
|
+
* tailscale binary / unreachable daemon resolves to `{ ok: false }` instead of
|
|
21
|
+
* throwing, so callers wiring this into setup/sync never abort the whole run.
|
|
22
|
+
* The `pending` list is computed against the registry state BEFORE this sync so
|
|
23
|
+
* "new" means "not previously registered and not ignored".
|
|
24
|
+
*/
|
|
25
|
+
export declare function runDeviceSync(opts?: {
|
|
26
|
+
soft?: boolean;
|
|
27
|
+
}): Promise<DeviceSyncResult>;
|
|
28
|
+
/**
|
|
29
|
+
* The register/remove/ignore decision for the interactive curation picker.
|
|
30
|
+
* Pure so the highest-risk reconcile logic is unit-testable without a tailnet
|
|
31
|
+
* or a live prompt. `keep` is the set the user left checked; everything else is
|
|
32
|
+
* dismissed. Checked => register (and un-ignore if it was ignored). Unchecked
|
|
33
|
+
* => remove from the registry if it was there, and ignore it so auto-sync never
|
|
34
|
+
* re-adds it.
|
|
35
|
+
*/
|
|
36
|
+
export interface DeviceReconciliation {
|
|
37
|
+
toRegister: string[];
|
|
38
|
+
toUnignore: string[];
|
|
39
|
+
toRemove: string[];
|
|
40
|
+
toIgnore: string[];
|
|
41
|
+
}
|
|
42
|
+
export declare function planDeviceReconciliation(allNames: Iterable<string>, keep: Iterable<string>, registered: Iterable<string>, ignored: Iterable<string>): DeviceReconciliation;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reusable device discovery.
|
|
3
|
+
*
|
|
4
|
+
* `agents devices sync` was the only thing that ever populated the registry,
|
|
5
|
+
* and it was purely user-invoked — so the registry sat empty until someone
|
|
6
|
+
* remembered to run it. This module extracts the ingest so it can be triggered
|
|
7
|
+
* automatically (from `agents sync` and `agents setup`) without duplicating the
|
|
8
|
+
* tailscale-parse-and-upsert loop, and exposes the pure pending-device diff the
|
|
9
|
+
* curation picker and the menu-bar probe both need.
|
|
10
|
+
*
|
|
11
|
+
* Two failure modes, one function:
|
|
12
|
+
* - hard (default): the CLI `agents devices sync` action wants a clear error
|
|
13
|
+
* and a non-zero exit when tailscale is missing.
|
|
14
|
+
* - soft (`soft: true`): auto-callers must never abort setup/sync because a
|
|
15
|
+
* machine has no tailscale — they get a result with `ok: false` instead.
|
|
16
|
+
*/
|
|
17
|
+
import { loadDevices, loadIgnored, upsertDevice, } from './registry.js';
|
|
18
|
+
import { nodeToDeviceInput, parseTailscaleStatus, tailscaleStatusJson, } from './tailscale.js';
|
|
19
|
+
/**
|
|
20
|
+
* Node names present on the tailnet but neither already in the registry nor on
|
|
21
|
+
* the ignore-list — i.e. genuinely new devices worth surfacing. Pure so the
|
|
22
|
+
* flag matrix is unit-testable without a live tailnet.
|
|
23
|
+
*/
|
|
24
|
+
export function computePendingDevices(nodes, registered, ignored) {
|
|
25
|
+
const known = new Set(registered);
|
|
26
|
+
const skip = new Set(ignored);
|
|
27
|
+
return nodes
|
|
28
|
+
.map((n) => n.name)
|
|
29
|
+
.filter((name) => !known.has(name) && !skip.has(name));
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Ingest `tailscale status --json` into the registry. In soft mode a missing
|
|
33
|
+
* tailscale binary / unreachable daemon resolves to `{ ok: false }` instead of
|
|
34
|
+
* throwing, so callers wiring this into setup/sync never abort the whole run.
|
|
35
|
+
* The `pending` list is computed against the registry state BEFORE this sync so
|
|
36
|
+
* "new" means "not previously registered and not ignored".
|
|
37
|
+
*/
|
|
38
|
+
export async function runDeviceSync(opts = {}) {
|
|
39
|
+
// Soft mode must be non-fatal for ANY failure, not just a missing tailscale:
|
|
40
|
+
// a corrupted registry/ignore file (both throw by design), a disk error, or
|
|
41
|
+
// registry lock contention (plausible when many agents SessionStart-autosync
|
|
42
|
+
// the same host at once) would otherwise abort the whole `agents sync`. The
|
|
43
|
+
// whole body is inside the guard so the "never a sync failure" promise holds.
|
|
44
|
+
try {
|
|
45
|
+
const nodes = parseTailscaleStatus(tailscaleStatusJson());
|
|
46
|
+
const [registeredBefore, ignored] = await Promise.all([loadDevices(), loadIgnored()]);
|
|
47
|
+
const pending = computePendingDevices(nodes, Object.keys(registeredBefore), ignored);
|
|
48
|
+
// Register/refresh every node the user has NOT dismissed. Skipping ignored
|
|
49
|
+
// nodes is what makes the "register all" default safe: a phone or someone
|
|
50
|
+
// else's laptop the user once dismissed never silently comes back.
|
|
51
|
+
let synced = 0;
|
|
52
|
+
for (const node of nodes) {
|
|
53
|
+
if (ignored.has(node.name))
|
|
54
|
+
continue;
|
|
55
|
+
await upsertDevice(node.name, nodeToDeviceInput(node));
|
|
56
|
+
synced++;
|
|
57
|
+
}
|
|
58
|
+
return { ok: true, synced, pending };
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
if (opts.soft) {
|
|
62
|
+
return { ok: false, synced: 0, pending: [], reason: err?.message ?? String(err) };
|
|
63
|
+
}
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export function planDeviceReconciliation(allNames, keep, registered, ignored) {
|
|
68
|
+
const keepSet = new Set(keep);
|
|
69
|
+
const regSet = new Set(registered);
|
|
70
|
+
const ignSet = new Set(ignored);
|
|
71
|
+
const out = { toRegister: [], toUnignore: [], toRemove: [], toIgnore: [] };
|
|
72
|
+
for (const name of allNames) {
|
|
73
|
+
if (keepSet.has(name)) {
|
|
74
|
+
out.toRegister.push(name);
|
|
75
|
+
if (ignSet.has(name))
|
|
76
|
+
out.toUnignore.push(name);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
if (regSet.has(name))
|
|
80
|
+
out.toRemove.push(name);
|
|
81
|
+
out.toIgnore.push(name);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
package/dist/lib/exec.js
CHANGED
|
@@ -534,6 +534,20 @@ export function buildExecCommand(options) {
|
|
|
534
534
|
cmd.push('--dangerously-bypass-approvals-and-sandbox');
|
|
535
535
|
}
|
|
536
536
|
}
|
|
537
|
+
else if (options.agent === 'kimi' && !interactive) {
|
|
538
|
+
// kimi's headless prompt mode (`-p`/`--prompt`) is self-contained and REFUSES
|
|
539
|
+
// to be combined with any startup-mode flag: `--plan`, `--auto`, and `--yolo`
|
|
540
|
+
// all abort with "Cannot combine --prompt with --X" (verified against the live
|
|
541
|
+
// kimi CLI). The write-capable modes (edit/auto/skip) all collapse to kimi's
|
|
542
|
+
// default `-p` behavior, which already auto-approves tool calls, so we emit no
|
|
543
|
+
// mode flag. Plan (read-only) has no headless equivalent, so fail closed rather
|
|
544
|
+
// than silently letting a plan-mode run mutate the workspace.
|
|
545
|
+
if (resolvedMode === 'plan') {
|
|
546
|
+
throw new Error('kimi has no headless read-only mode: `--prompt` cannot be combined with `--plan`. ' +
|
|
547
|
+
'Run kimi in plan mode interactively (omit the prompt), or use --mode edit, auto, or skip.');
|
|
548
|
+
}
|
|
549
|
+
// edit/auto/skip: emit no mode flag — `kimi -p` auto-runs.
|
|
550
|
+
}
|
|
537
551
|
else {
|
|
538
552
|
cmd.push(...modeFlags);
|
|
539
553
|
}
|
package/dist/lib/models.js
CHANGED
|
@@ -165,6 +165,26 @@ export function locateModelSource(agent, version) {
|
|
|
165
165
|
return { path: pathBin, kind: 'cli' };
|
|
166
166
|
return null;
|
|
167
167
|
}
|
|
168
|
+
if (agent === 'antigravity') {
|
|
169
|
+
// The `agy` shim under node_modules/.bin exposes `agy models`. We don't parse
|
|
170
|
+
// any bundle; the CLI produces its own (display-name-only) catalog.
|
|
171
|
+
const cli = path.join(versionDir, 'node_modules', '.bin', 'agy');
|
|
172
|
+
if (fs.existsSync(cli))
|
|
173
|
+
return { path: cli, kind: 'cli' };
|
|
174
|
+
const pathBin = findOnPath('agy');
|
|
175
|
+
if (pathBin)
|
|
176
|
+
return { path: pathBin, kind: 'cli' };
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
if (agent === 'kimi') {
|
|
180
|
+
const cli = path.join(versionDir, 'node_modules', '.bin', 'kimi');
|
|
181
|
+
if (fs.existsSync(cli))
|
|
182
|
+
return { path: cli, kind: 'cli' };
|
|
183
|
+
const pathBin = findOnPath('kimi');
|
|
184
|
+
if (pathBin)
|
|
185
|
+
return { path: pathBin, kind: 'cli' };
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
168
188
|
if (agent === 'cursor') {
|
|
169
189
|
// cursor-agent is installed via curl script, not agents-cli. Version argument
|
|
170
190
|
// is accepted for API symmetry but ignored -- cursor lives on PATH.
|
|
@@ -606,6 +626,114 @@ function extractOpenClawCatalog(binaryPath) {
|
|
|
606
626
|
}));
|
|
607
627
|
return { models, aliases: {} };
|
|
608
628
|
}
|
|
629
|
+
/**
|
|
630
|
+
* Extract Antigravity's catalog via `agy models`. Antigravity is unusual: it
|
|
631
|
+
* prints DISPLAY NAMES ONLY, one per line, with no machine ids and no --json:
|
|
632
|
+
* Gemini 3.5 Flash (Medium)
|
|
633
|
+
* Claude Sonnet 4.6 (Thinking)
|
|
634
|
+
* Verified (agy 1.0.11) that those display strings ARE the accepted `--model`
|
|
635
|
+
* values -- `agy --model "Claude Opus 4.6 (Thinking)"` routes to that model,
|
|
636
|
+
* and an unknown value silently falls back to the first row. So we use each
|
|
637
|
+
* display string as both id and displayName, and mark the first row default.
|
|
638
|
+
*/
|
|
639
|
+
function extractAntigravityCatalog(binaryPath) {
|
|
640
|
+
let stdout;
|
|
641
|
+
try {
|
|
642
|
+
stdout = execFileSync(binaryPath, ['models'], {
|
|
643
|
+
encoding: 'utf-8',
|
|
644
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
645
|
+
timeout: 15_000,
|
|
646
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
catch {
|
|
650
|
+
return { models: [], aliases: {} };
|
|
651
|
+
}
|
|
652
|
+
// Strip ANSI in case a spinner or color codes slip through.
|
|
653
|
+
// eslint-disable-next-line no-control-regex
|
|
654
|
+
const plain = stdout.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
|
|
655
|
+
const models = [];
|
|
656
|
+
const seen = new Set();
|
|
657
|
+
for (const raw of plain.split('\n')) {
|
|
658
|
+
const name = raw.trim();
|
|
659
|
+
if (!name)
|
|
660
|
+
continue;
|
|
661
|
+
// Guard against any stray banner/usage lines: real rows look like
|
|
662
|
+
// "<Vendor> <Model> (<Level>)". Require an alphanumeric start and a
|
|
663
|
+
// parenthesized suffix, which every observed model row has.
|
|
664
|
+
if (!/^[A-Za-z0-9].*\([^)]+\)\s*$/.test(name))
|
|
665
|
+
continue;
|
|
666
|
+
if (seen.has(name))
|
|
667
|
+
continue;
|
|
668
|
+
seen.add(name);
|
|
669
|
+
models.push({
|
|
670
|
+
id: name,
|
|
671
|
+
displayName: name,
|
|
672
|
+
// Antigravity's first listed model is its default (unknown --model values
|
|
673
|
+
// fall back to it), so flag the first row we accept.
|
|
674
|
+
isDefault: models.length === 0,
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
return { models, aliases: {} };
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Extract Kimi's catalog via `kimi provider list --json`, which emits the raw
|
|
681
|
+
* providers/models config. Model ids are the `models` object keys (e.g.
|
|
682
|
+
* `kimi-code/kimi-for-coding`). The default is reported on a separate plain
|
|
683
|
+
* `Default model: <id>` line by `kimi provider list` (no flags), so we run that
|
|
684
|
+
* too to flag the default row.
|
|
685
|
+
*/
|
|
686
|
+
function extractKimiCatalog(binaryPath) {
|
|
687
|
+
let jsonOut;
|
|
688
|
+
try {
|
|
689
|
+
jsonOut = execFileSync(binaryPath, ['provider', 'list', '--json'], {
|
|
690
|
+
encoding: 'utf-8',
|
|
691
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
692
|
+
timeout: 15_000,
|
|
693
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
catch {
|
|
697
|
+
return { models: [], aliases: {} };
|
|
698
|
+
}
|
|
699
|
+
const firstBrace = jsonOut.indexOf('{');
|
|
700
|
+
if (firstBrace === -1)
|
|
701
|
+
return { models: [], aliases: {} };
|
|
702
|
+
let parsed;
|
|
703
|
+
try {
|
|
704
|
+
parsed = JSON.parse(jsonOut.slice(firstBrace));
|
|
705
|
+
}
|
|
706
|
+
catch {
|
|
707
|
+
return { models: [], aliases: {} };
|
|
708
|
+
}
|
|
709
|
+
// Resolve the default model id from the plain listing's "Default model:" line.
|
|
710
|
+
let defaultId = null;
|
|
711
|
+
try {
|
|
712
|
+
const plain = execFileSync(binaryPath, ['provider', 'list'], {
|
|
713
|
+
encoding: 'utf-8',
|
|
714
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
715
|
+
timeout: 10_000,
|
|
716
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
717
|
+
});
|
|
718
|
+
const m = plain.match(/Default model:\s*(\S+)/);
|
|
719
|
+
if (m)
|
|
720
|
+
defaultId = m[1];
|
|
721
|
+
}
|
|
722
|
+
catch {
|
|
723
|
+
/* default flag is best-effort */
|
|
724
|
+
}
|
|
725
|
+
const modelsObj = parsed?.models && typeof parsed.models === 'object' ? parsed.models : {};
|
|
726
|
+
const models = [];
|
|
727
|
+
for (const id of Object.keys(modelsObj)) {
|
|
728
|
+
const info = modelsObj[id] ?? {};
|
|
729
|
+
models.push({
|
|
730
|
+
id,
|
|
731
|
+
displayName: typeof info.displayName === 'string' ? info.displayName : undefined,
|
|
732
|
+
isDefault: defaultId != null && id === defaultId,
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
return { models, aliases: {} };
|
|
736
|
+
}
|
|
609
737
|
/**
|
|
610
738
|
* Build (or load from cache) the model catalog for a specific (agent, version).
|
|
611
739
|
* Cache is keyed on source-file mtime (binary or js module), so re-extracts
|
|
@@ -654,6 +782,10 @@ export function getModelCatalog(agent, version) {
|
|
|
654
782
|
({ models, aliases } = extractCursorCatalog(src.path));
|
|
655
783
|
else if (agent === 'openclaw')
|
|
656
784
|
({ models, aliases } = extractOpenClawCatalog(src.path));
|
|
785
|
+
else if (agent === 'antigravity')
|
|
786
|
+
({ models, aliases } = extractAntigravityCatalog(src.path));
|
|
787
|
+
else if (agent === 'kimi')
|
|
788
|
+
({ models, aliases } = extractKimiCatalog(src.path));
|
|
657
789
|
}
|
|
658
790
|
const catalog = {
|
|
659
791
|
agent,
|
|
@@ -663,11 +795,12 @@ export function getModelCatalog(agent, version) {
|
|
|
663
795
|
models,
|
|
664
796
|
aliases,
|
|
665
797
|
};
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
//
|
|
669
|
-
//
|
|
670
|
-
|
|
798
|
+
// Never cache an empty extraction, regardless of source kind. A 0-model
|
|
799
|
+
// result is always suspect: the CLI may have been mid-install, network-
|
|
800
|
+
// dependent, or transiently failing, and a js/bundle/binary extractor that
|
|
801
|
+
// regex-misses would otherwise pin an empty catalog forever (mtime won't
|
|
802
|
+
// change until the source file does). Only persist a non-empty catalog.
|
|
803
|
+
if (models.length > 0) {
|
|
671
804
|
cache.entries[key] = { sourcePath: src.path, mtime, catalog };
|
|
672
805
|
saveCache();
|
|
673
806
|
}
|
package/dist/lib/runner.js
CHANGED
|
@@ -95,14 +95,14 @@ export function buildJobCommand(config, resolvedPrompt) {
|
|
|
95
95
|
appendModelAndReasoning(cmd, config);
|
|
96
96
|
}
|
|
97
97
|
if (config.agent === 'kimi') {
|
|
98
|
+
// kimi daemon jobs always run headless via `--prompt`, which cannot be
|
|
99
|
+
// combined with any startup-mode flag (--plan/--auto/--yolo all abort with
|
|
100
|
+
// "Cannot combine --prompt with --X"). edit/auto/skip reduce to kimi's default
|
|
101
|
+
// headless auto-run, so emit no flag; plan has no headless read-only
|
|
102
|
+
// equivalent, so fail closed rather than silently allowing writes.
|
|
98
103
|
if (mode === 'plan') {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
else if (mode === 'auto') {
|
|
102
|
-
cmd.push('--auto');
|
|
103
|
-
}
|
|
104
|
-
else if (mode === 'skip') {
|
|
105
|
-
cmd.push('--yolo');
|
|
104
|
+
throw new Error('kimi has no headless read-only mode: routine jobs cannot run kimi with --mode plan ' +
|
|
105
|
+
'(kimi rejects --prompt + --plan). Use --mode edit, auto, or skip.');
|
|
106
106
|
}
|
|
107
107
|
appendModelAndReasoning(cmd, config);
|
|
108
108
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type SessionActivity, type AwaitingReason, type DetectedPr, type DetectedWorktree, type DetectedTicket } from './state.js';
|
|
1
2
|
export type ActiveContext = 'terminal' | 'teams' | 'cloud' | 'headless';
|
|
2
3
|
export type ActiveStatus = 'running' | 'idle' | 'queued' | 'input_required';
|
|
3
4
|
export interface ActiveSession {
|
|
@@ -12,9 +13,23 @@ export interface ActiveSession {
|
|
|
12
13
|
label?: string;
|
|
13
14
|
/** First meaningful line of the initial prompt (extracted topic). */
|
|
14
15
|
topic?: string;
|
|
16
|
+
/** Live preview: the latest turn (agent message or tool action), from the state engine. */
|
|
17
|
+
preview?: string;
|
|
18
|
+
/** Inferred activity: working / waiting_input / idle (from the transcript tail). */
|
|
19
|
+
activity?: SessionActivity;
|
|
20
|
+
/** Why the agent is waiting, when activity is waiting_input. */
|
|
21
|
+
awaitingReason?: AwaitingReason;
|
|
22
|
+
/** PR opened during the session. */
|
|
23
|
+
pr?: DetectedPr;
|
|
24
|
+
/** Worktree the session runs in. */
|
|
25
|
+
worktree?: DetectedWorktree;
|
|
26
|
+
/** Tracker ticket the session is tied to. */
|
|
27
|
+
ticket?: DetectedTicket;
|
|
15
28
|
sessionFile?: string;
|
|
16
29
|
startedAtMs?: number;
|
|
17
30
|
status: ActiveStatus;
|
|
31
|
+
/** How many live PIDs resolve to this same session (subagents/forks). 1 unless collapsed. */
|
|
32
|
+
pidCount?: number;
|
|
18
33
|
teamName?: string;
|
|
19
34
|
agentId?: string;
|
|
20
35
|
cloudProvider?: string;
|