@phnx-labs/agents-cli 1.22.51 → 1.22.53
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 +238 -0
- package/README.md +1 -1
- package/dist/commands/accounts.js +1 -1
- package/dist/commands/attach.js +7 -0
- package/dist/commands/browser.js +118 -56
- package/dist/commands/daemon.d.ts +2 -0
- package/dist/commands/daemon.js +8 -4
- package/dist/commands/detach.js +1 -1
- package/dist/commands/exec.js +16 -9
- package/dist/commands/fleet-capture.js +7 -0
- package/dist/commands/focus.d.ts +1 -10
- package/dist/commands/focus.js +16 -79
- package/dist/commands/go.d.ts +26 -0
- package/dist/commands/go.js +65 -6
- package/dist/commands/monitors.js +1 -1
- package/dist/commands/repo.js +31 -3
- package/dist/commands/sessions-inject.js +8 -3
- package/dist/commands/sessions-picker.js +2 -1
- package/dist/commands/sessions-resume.d.ts +1 -0
- package/dist/commands/sessions-resume.js +13 -2
- package/dist/commands/sessions-stop.js +1 -1
- package/dist/commands/sessions.d.ts +23 -13
- package/dist/commands/sessions.js +69 -39
- package/dist/commands/setup-browser.d.ts +5 -2
- package/dist/commands/setup-browser.js +14 -29
- package/dist/commands/setup-preferences.d.ts +22 -3
- package/dist/commands/setup-preferences.js +25 -8
- package/dist/commands/share.js +12 -8
- package/dist/commands/ssh.js +35 -12
- package/dist/commands/status.js +5 -0
- package/dist/commands/sync.js +102 -2
- package/dist/commands/tmux.d.ts +8 -1
- package/dist/commands/tmux.js +167 -17
- package/dist/lib/account-registry.d.ts +15 -5
- package/dist/lib/account-registry.js +150 -50
- package/dist/lib/answer-router.js +2 -1
- package/dist/lib/browser/ipc.d.ts +44 -0
- package/dist/lib/browser/ipc.js +120 -8
- package/dist/lib/browser/profiles.d.ts +57 -17
- package/dist/lib/browser/profiles.js +77 -53
- package/dist/lib/browser/registry.d.ts +44 -14
- package/dist/lib/browser/registry.js +141 -45
- package/dist/lib/browser/runtime-state.d.ts +4 -2
- package/dist/lib/browser/runtime-state.js +4 -2
- package/dist/lib/browser/service.js +4 -3
- package/dist/lib/channels/owner-forward.d.ts +88 -0
- package/dist/lib/channels/owner-forward.js +116 -0
- package/dist/lib/channels/owner-sink.js +7 -0
- package/dist/lib/daemon/runner.js +10 -2
- package/dist/lib/device-config.js +3 -2
- package/dist/lib/devices/config-migration.js +147 -1
- package/dist/lib/devices/device-docs.d.ts +35 -0
- package/dist/lib/devices/device-docs.js +163 -0
- package/dist/lib/devices/discovery-policy.d.ts +14 -2
- package/dist/lib/devices/discovery-policy.js +31 -21
- package/dist/lib/devices/registry.d.ts +11 -5
- package/dist/lib/devices/registry.js +46 -18
- package/dist/lib/exec.d.ts +66 -28
- package/dist/lib/exec.js +71 -26
- package/dist/lib/feed/feed.d.ts +10 -2
- package/dist/lib/feed/feed.js +12 -1
- package/dist/lib/feed-broadcast.js +15 -1
- package/dist/lib/git.d.ts +93 -0
- package/dist/lib/git.js +232 -0
- package/dist/lib/hosts/dispatch.d.ts +4 -3
- package/dist/lib/hosts/dispatch.js +12 -8
- package/dist/lib/hosts/providers/local.d.ts +9 -3
- package/dist/lib/hosts/providers/local.js +23 -12
- package/dist/lib/hosts/reconnect.d.ts +7 -4
- package/dist/lib/hosts/reconnect.js +29 -25
- package/dist/lib/hosts/registry.js +4 -1
- package/dist/lib/hosts/remote-os.js +3 -1
- package/dist/lib/monitors/remote.d.ts +18 -1
- package/dist/lib/monitors/remote.js +15 -2
- package/dist/lib/notify.d.ts +7 -0
- package/dist/lib/notify.js +15 -1
- package/dist/lib/session/active.d.ts +10 -1
- package/dist/lib/session/active.js +7 -1
- package/dist/lib/session/actor-sidecar.d.ts +7 -0
- package/dist/lib/session/actor-sidecar.js +2 -0
- package/dist/lib/session/db.d.ts +1 -1
- package/dist/lib/session/db.js +39 -3
- package/dist/lib/session/discover.js +7 -12
- package/dist/lib/session/live-metadata.js +1 -0
- package/dist/lib/session/local-tmux-attach.d.ts +69 -0
- package/dist/lib/session/local-tmux-attach.js +164 -0
- package/dist/lib/session/pid-registry.d.ts +7 -0
- package/dist/lib/session/prompt.d.ts +15 -0
- package/dist/lib/session/prompt.js +21 -0
- package/dist/lib/session/remote-active.d.ts +8 -0
- package/dist/lib/session/remote-active.js +1 -0
- package/dist/lib/session/types.d.ts +17 -0
- package/dist/lib/session/types.js +10 -0
- package/dist/lib/share/publish.d.ts +8 -11
- package/dist/lib/share/publish.js +16 -20
- package/dist/lib/share/worker-template.js +104 -12
- package/dist/lib/state.d.ts +8 -0
- package/dist/lib/state.js +143 -11
- package/dist/lib/sync-status.d.ts +17 -0
- package/dist/lib/sync-status.js +21 -2
- package/dist/lib/terminal/resolve.d.ts +7 -0
- package/dist/lib/terminal/resolve.js +41 -2
- package/dist/lib/tmux/index.d.ts +1 -1
- package/dist/lib/tmux/index.js +1 -1
- package/dist/lib/tmux/session.d.ts +10 -0
- package/dist/lib/tmux/session.js +29 -0
- package/dist/lib/traces/insights.d.ts +67 -0
- package/dist/lib/traces/insights.js +178 -0
- package/dist/lib/traces/phenotype.d.ts +67 -0
- package/dist/lib/traces/phenotype.js +437 -0
- package/dist/lib/traces/segments.d.ts +133 -0
- package/dist/lib/traces/segments.js +301 -0
- package/dist/lib/traces/sync.d.ts +33 -0
- package/dist/lib/traces/sync.js +11 -2
- package/dist/lib/types.d.ts +47 -1
- package/dist/lib/watchdog/runner.js +18 -4
- package/package.json +1 -1
|
@@ -176,10 +176,12 @@ export declare function removeDevice(name: string): Promise<boolean>;
|
|
|
176
176
|
*/
|
|
177
177
|
export type { IgnoredDeviceEntry } from '../fleet/types.js';
|
|
178
178
|
/**
|
|
179
|
-
* The
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
179
|
+
* The EFFECTIVE ignore-list: the union of every box's device doc
|
|
180
|
+
* `fleet.ignored` (deduped by node name, newest `ignoredAt` winning) plus any
|
|
181
|
+
* lingering central-legacy `fleet.ignored` block (drained by the migration).
|
|
182
|
+
* Deterministic and order-independent. A malformed block is a hard error for the
|
|
183
|
+
* same reason the registry is: silently returning [] would let the next write
|
|
184
|
+
* wipe the user's dismissals.
|
|
183
185
|
*/
|
|
184
186
|
export declare function loadIgnoredEntries(meta?: Meta): IgnoredDeviceEntry[];
|
|
185
187
|
/** Load the set of ignored node names. Same corruption contract as
|
|
@@ -195,7 +197,11 @@ export declare function isIgnored(name: string): Promise<boolean>;
|
|
|
195
197
|
* lib/devices/config-migration.ts.
|
|
196
198
|
*/
|
|
197
199
|
export declare function withIgnoredAdded(meta: Meta, names: string[], ignoredAt: string): Meta;
|
|
198
|
-
/** Add a node name to
|
|
200
|
+
/** Add a node name to THIS box's ignore-list (device doc). Idempotent. Returns
|
|
201
|
+
* the resulting cross-box union of dismissed names. Reads only the device docs
|
|
202
|
+
* for the return value — a corrupt central-legacy block surfaces loudly on the
|
|
203
|
+
* effective read path ({@link loadIgnoredEntries}), never blocks a per-box
|
|
204
|
+
* write that does not touch central at all. */
|
|
199
205
|
export declare function addIgnored(name: string): Promise<Set<string>>;
|
|
200
206
|
/** Remove a node name from the ignore-list (un-ignore). Returns false if it was
|
|
201
207
|
* not ignored. */
|
|
@@ -20,6 +20,7 @@ import lockfile from 'proper-lockfile';
|
|
|
20
20
|
import { getDevicesRegistryPath, readMeta, updateMeta } from '../state.js';
|
|
21
21
|
import { atomicWriteJsonSync } from '../fs-atomic.js';
|
|
22
22
|
import { machineId } from '../machine-id.js';
|
|
23
|
+
import { addIgnoredEntry, unionDeviceIgnored } from './device-docs.js';
|
|
23
24
|
/**
|
|
24
25
|
* Whether a fan-out should dial this device, honouring the preference stated on
|
|
25
26
|
* {@link DeviceProfile.reachability}: the live SSH probe wins over the cached
|
|
@@ -287,19 +288,48 @@ export async function removeDevice(name) {
|
|
|
287
288
|
* []. A malformed block is a hard error for the same reason the registry is:
|
|
288
289
|
* silently returning [] would let the next write wipe the user's dismissals.
|
|
289
290
|
*/
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
if (raw === undefined)
|
|
293
|
-
return [];
|
|
291
|
+
/** Validate a raw ignore-list block, or throw with `where` naming the file. */
|
|
292
|
+
function assertIgnoredShape(raw, where) {
|
|
294
293
|
if (!Array.isArray(raw) ||
|
|
295
294
|
raw.some((e) => !e ||
|
|
296
295
|
typeof e.name !== 'string' ||
|
|
297
296
|
typeof e.ignoredAt !== 'string' ||
|
|
298
297
|
typeof e.ignoredOn !== 'string')) {
|
|
299
|
-
throw new Error(`Device ignore-list corrupted in
|
|
298
|
+
throw new Error(`Device ignore-list corrupted in ${where}: expected a list of { name, ignoredAt, ignoredOn } entries. Inspect and repair it.`);
|
|
300
299
|
}
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* THIS box's OWN dismissals — the writable slice in `meta.deviceFleet.ignored`
|
|
303
|
+
* (the device doc). `withIgnoredAdded`/`removeIgnored` operate on this so a box
|
|
304
|
+
* only ever edits its own folder (PHNX-3315). Absent => [].
|
|
305
|
+
*/
|
|
306
|
+
function loadOwnIgnoredEntries(meta) {
|
|
307
|
+
const raw = meta.deviceFleet?.ignored;
|
|
308
|
+
if (raw === undefined)
|
|
309
|
+
return [];
|
|
310
|
+
assertIgnoredShape(raw, `devices/<machine>/agents.yaml (fleet.ignored)`);
|
|
301
311
|
return raw;
|
|
302
312
|
}
|
|
313
|
+
/**
|
|
314
|
+
* The EFFECTIVE ignore-list: the union of every box's device doc
|
|
315
|
+
* `fleet.ignored` (deduped by node name, newest `ignoredAt` winning) plus any
|
|
316
|
+
* lingering central-legacy `fleet.ignored` block (drained by the migration).
|
|
317
|
+
* Deterministic and order-independent. A malformed block is a hard error for the
|
|
318
|
+
* same reason the registry is: silently returning [] would let the next write
|
|
319
|
+
* wipe the user's dismissals.
|
|
320
|
+
*/
|
|
321
|
+
export function loadIgnoredEntries(meta = readMeta()) {
|
|
322
|
+
const byName = new Map();
|
|
323
|
+
const central = meta.fleet?.ignored;
|
|
324
|
+
if (central !== undefined) {
|
|
325
|
+
assertIgnoredShape(central, `agents.yaml (fleet.ignored)`);
|
|
326
|
+
for (const e of central)
|
|
327
|
+
addIgnoredEntry(byName, e);
|
|
328
|
+
}
|
|
329
|
+
for (const e of unionDeviceIgnored())
|
|
330
|
+
addIgnoredEntry(byName, e);
|
|
331
|
+
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
332
|
+
}
|
|
303
333
|
/** Load the set of ignored node names. Same corruption contract as
|
|
304
334
|
* {@link loadIgnoredEntries}. */
|
|
305
335
|
export async function loadIgnored() {
|
|
@@ -317,40 +347,38 @@ export async function isIgnored(name) {
|
|
|
317
347
|
* lib/devices/config-migration.ts.
|
|
318
348
|
*/
|
|
319
349
|
export function withIgnoredAdded(meta, names, ignoredAt) {
|
|
320
|
-
const entries =
|
|
350
|
+
const entries = loadOwnIgnoredEntries(meta); // throws on a corrupted block — never wipe it
|
|
321
351
|
const have = new Set(entries.map((e) => e.name));
|
|
322
352
|
const fresh = names.filter((n) => !have.has(n));
|
|
323
353
|
if (fresh.length === 0)
|
|
324
354
|
return meta;
|
|
325
|
-
const fleet = (meta.fleet ?? { devices: {} });
|
|
326
355
|
const ignored = [
|
|
327
356
|
...entries,
|
|
328
357
|
...fresh.map((name) => ({ name, ignoredAt, ignoredOn: machineId() })),
|
|
329
358
|
].sort((a, b) => a.name.localeCompare(b.name));
|
|
330
|
-
|
|
331
|
-
return { ...meta, fleet: nextFleet };
|
|
359
|
+
return { ...meta, deviceFleet: { ...meta.deviceFleet, ignored } };
|
|
332
360
|
}
|
|
333
|
-
/** Add a node name to
|
|
361
|
+
/** Add a node name to THIS box's ignore-list (device doc). Idempotent. Returns
|
|
362
|
+
* the resulting cross-box union of dismissed names. Reads only the device docs
|
|
363
|
+
* for the return value — a corrupt central-legacy block surfaces loudly on the
|
|
364
|
+
* effective read path ({@link loadIgnoredEntries}), never blocks a per-box
|
|
365
|
+
* write that does not touch central at all. */
|
|
334
366
|
export async function addIgnored(name) {
|
|
335
367
|
assertValidDeviceName(name);
|
|
336
|
-
|
|
337
|
-
return new Set(
|
|
368
|
+
updateMeta((m) => withIgnoredAdded(m, [name], new Date().toISOString()));
|
|
369
|
+
return new Set(unionDeviceIgnored().map((e) => e.name));
|
|
338
370
|
}
|
|
339
371
|
/** Remove a node name from the ignore-list (un-ignore). Returns false if it was
|
|
340
372
|
* not ignored. */
|
|
341
373
|
export async function removeIgnored(name) {
|
|
342
374
|
let removed = false;
|
|
343
375
|
updateMeta((m) => {
|
|
344
|
-
const
|
|
345
|
-
if (!fleet?.ignored)
|
|
346
|
-
return m;
|
|
347
|
-
const entries = loadIgnoredEntries(m);
|
|
376
|
+
const entries = loadOwnIgnoredEntries(m); // only this box's own dismissals are ours to drop
|
|
348
377
|
const next = entries.filter((e) => e.name !== name);
|
|
349
378
|
if (next.length === entries.length)
|
|
350
379
|
return m;
|
|
351
380
|
removed = true;
|
|
352
|
-
|
|
353
|
-
return { ...m, fleet: nextFleet };
|
|
381
|
+
return { ...m, deviceFleet: { ...m.deviceFleet, ignored: next } };
|
|
354
382
|
});
|
|
355
383
|
return removed;
|
|
356
384
|
}
|
package/dist/lib/exec.d.ts
CHANGED
|
@@ -93,6 +93,14 @@ export type ExecEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'auto';
|
|
|
93
93
|
/** Options for spawning an agent process. Omitting `prompt` launches the CLI interactively. */
|
|
94
94
|
export interface ExecOptions {
|
|
95
95
|
agent: AgentId;
|
|
96
|
+
/**
|
|
97
|
+
* Custom harness / profile name when this run was launched via
|
|
98
|
+
* `agents run <profile>` (e.g. `deepseek`). `agent` stays the HOST CLI
|
|
99
|
+
* that actually executes. Stamped onto `AGENTS_AGENT_NAME`, the pid
|
|
100
|
+
* registry, and the session-actor sidecar so listings can tell the
|
|
101
|
+
* profile apart from a native host run (PHNX-2935).
|
|
102
|
+
*/
|
|
103
|
+
harnessName?: string;
|
|
96
104
|
version?: string;
|
|
97
105
|
/** Version home whose native auth/config is overlaid onto this run's binary. */
|
|
98
106
|
configVersion?: string;
|
|
@@ -177,6 +185,18 @@ export interface ExecOptions {
|
|
|
177
185
|
*/
|
|
178
186
|
raw?: boolean;
|
|
179
187
|
}
|
|
188
|
+
/**
|
|
189
|
+
* Identity a custom-harness run stamps on env / pid-registry / sidecars.
|
|
190
|
+
* `agent` is the host CLI; `harnessName` is the profile the user launched.
|
|
191
|
+
* Empty/whitespace harness names fall back to the host so a blank stamp
|
|
192
|
+
* never hides a real agent.
|
|
193
|
+
*/
|
|
194
|
+
export declare function stampedAgentName(options: Pick<ExecOptions, 'agent' | 'harnessName'>): string;
|
|
195
|
+
/**
|
|
196
|
+
* Profile name when it differs from the host agent. Undefined for a native
|
|
197
|
+
* run, so pid-registry / sidecar records stay sparse.
|
|
198
|
+
*/
|
|
199
|
+
export declare function customHarnessName(options: Pick<ExecOptions, 'agent' | 'harnessName'>): string | undefined;
|
|
180
200
|
/**
|
|
181
201
|
* Resolve interactive vs headless. Explicit flags are definitive and win over
|
|
182
202
|
* inference: `--interactive` forces interactive, `--headless` forces headless.
|
|
@@ -406,25 +426,39 @@ export interface TmuxWrapContext {
|
|
|
406
426
|
* True when this run was dispatched onto this box over SSH by `--device`
|
|
407
427
|
* (the launcher exports {@link REMOTE_INTERACTIVE_ENV}).
|
|
408
428
|
*
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
*
|
|
413
|
-
*
|
|
414
|
-
*
|
|
415
|
-
*
|
|
416
|
-
* (
|
|
417
|
-
*
|
|
429
|
+
* Since PHNX-3316 this flag no longer forces the wrap: `tmux.enabled` gates
|
|
430
|
+
* local and remote runs alike, because the operator reading "tmux disabled"
|
|
431
|
+
* expects NO tmux anywhere. A followed remote run left bare is protected by
|
|
432
|
+
* reconnect-and-resume (lib/hosts/reconnect.ts): a dropped link costs the
|
|
433
|
+
* in-flight turn, and the harness session resumes from disk.
|
|
434
|
+
*
|
|
435
|
+
* The flag still matters for one case: a followed remote run whose LAUNCHER
|
|
436
|
+
* has no TTY (CI, a script, another agent driving the CLI — `sshStream`
|
|
437
|
+
* allocates the peer's TTY from `process.stdin.isTTY`, dispatch.ts) gets no
|
|
438
|
+
* TTY on the peer either, so there is nothing to attach to and the detached
|
|
439
|
+
* pane is the run's only interface — it wraps regardless of `configEnabled`
|
|
440
|
+
* (and is refused as `undurable` when tmux is missing).
|
|
418
441
|
*/
|
|
419
442
|
remoteDispatch: boolean;
|
|
420
443
|
/** Whether a tmux binary is on PATH. */
|
|
421
444
|
tmuxAvailable: boolean;
|
|
445
|
+
/**
|
|
446
|
+
* True when this process has a real TTY to attach (`stdout.isTTY`).
|
|
447
|
+
* A piped `agents run --interactive` (session-tracker tests, CI) has none:
|
|
448
|
+
* wrapping then treating the failed attach as Ctrl-b d leaked live panes
|
|
449
|
+
* for a week on yosemite-s0 (PHNX-3293). A remote dispatch still wraps
|
|
450
|
+
* without a TTY — a launcher that has none (CI, scripts, another agent)
|
|
451
|
+
* gives the peer nothing to attach to, so the detached pane is the run's
|
|
452
|
+
* only interface.
|
|
453
|
+
*/
|
|
454
|
+
hasTty: boolean;
|
|
422
455
|
}
|
|
423
456
|
/**
|
|
424
|
-
* What to do with an interactive spawn. Three outcomes, not two: a run
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
*
|
|
457
|
+
* What to do with an interactive spawn. Three outcomes, not two: a remote run
|
|
458
|
+
* that WOULD wrap (the operator opted in, or a `--no-follow` run whose only
|
|
459
|
+
* interface is the detached pane) on a box with no tmux is neither "wrap" nor
|
|
460
|
+
* "spawn bare" — it is a launch that should not happen, because a bare remote
|
|
461
|
+
* spawn looks fine right up until the link blinks and the agent dies with it.
|
|
428
462
|
*/
|
|
429
463
|
export type TmuxWrapDecision =
|
|
430
464
|
/** Run the agent in a detached tmux session and attach this TTY. */
|
|
@@ -435,7 +469,7 @@ export type TmuxWrapDecision =
|
|
|
435
469
|
| {
|
|
436
470
|
kind: 'bare';
|
|
437
471
|
}
|
|
438
|
-
/** Remote-dispatched and tmux is missing
|
|
472
|
+
/** Remote-dispatched, wants the wrap, and tmux is missing: refuse, don't pretend. */
|
|
439
473
|
| {
|
|
440
474
|
kind: 'undurable';
|
|
441
475
|
};
|
|
@@ -443,20 +477,24 @@ export type TmuxWrapDecision =
|
|
|
443
477
|
* Decide whether to run an interactive agent INSIDE a detached tmux session on
|
|
444
478
|
* the shared socket (then attach the current TTY) instead of a bare spawn.
|
|
445
479
|
*
|
|
446
|
-
*
|
|
447
|
-
*
|
|
448
|
-
*
|
|
449
|
-
*
|
|
450
|
-
*
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
*
|
|
480
|
+
* The wrap is opt-in via this device's `tmux.enabled` (`configEnabled`): a
|
|
481
|
+
* unique `%pane` handle so `agents sessions --active` can tell co-located
|
|
482
|
+
* agents apart and `agents focus` re-attaches without forking, plus scrollback
|
|
483
|
+
* and mouse at the operator's keyboard. Off means OFF, for local and remote
|
|
484
|
+
* runs alike (PHNX-3316) — a followed `--device` run left bare is protected by
|
|
485
|
+
* reconnect-and-resume (lib/hosts/reconnect.ts), which rejoins the live pane
|
|
486
|
+
* when one exists and resumes the harness session from disk when it does not.
|
|
487
|
+
* The RUSH-3125 forced remote wrap conflated durability with that preference
|
|
488
|
+
* and surprised every operator who had explicitly left tmux off.
|
|
489
|
+
*
|
|
490
|
+
* One case still wraps regardless: a followed remote run whose launcher has
|
|
491
|
+
* no TTY (CI, scripts, another agent) gives the peer nothing to attach to —
|
|
492
|
+
* the detached pane is the run's only interface, not an ergonomics choice.
|
|
493
|
+
*
|
|
494
|
+
* The per-run opt-outs bind everything: `--raw` / `--no-tmux` /
|
|
495
|
+
* `AGENTS_NO_TMUX=1` are explicit "I want the bare process" requests, and an
|
|
496
|
+
* escape hatch that silently stopped applying over `--device` would be worse
|
|
497
|
+
* than the bare run the user asked for.
|
|
460
498
|
*
|
|
461
499
|
* Pure, so the gate is unit-tested independently of the (side-effecting) spawn.
|
|
462
500
|
*/
|
package/dist/lib/exec.js
CHANGED
|
@@ -181,6 +181,26 @@ export function defaultModeFor(agent) {
|
|
|
181
181
|
export function implicitModeFor(agent) {
|
|
182
182
|
return agent === 'codex' ? 'edit' : 'plan';
|
|
183
183
|
}
|
|
184
|
+
/**
|
|
185
|
+
* Identity a custom-harness run stamps on env / pid-registry / sidecars.
|
|
186
|
+
* `agent` is the host CLI; `harnessName` is the profile the user launched.
|
|
187
|
+
* Empty/whitespace harness names fall back to the host so a blank stamp
|
|
188
|
+
* never hides a real agent.
|
|
189
|
+
*/
|
|
190
|
+
export function stampedAgentName(options) {
|
|
191
|
+
const harness = options.harnessName?.trim();
|
|
192
|
+
return harness || options.agent;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Profile name when it differs from the host agent. Undefined for a native
|
|
196
|
+
* run, so pid-registry / sidecar records stay sparse.
|
|
197
|
+
*/
|
|
198
|
+
export function customHarnessName(options) {
|
|
199
|
+
const harness = options.harnessName?.trim();
|
|
200
|
+
if (!harness || harness === options.agent)
|
|
201
|
+
return undefined;
|
|
202
|
+
return harness;
|
|
203
|
+
}
|
|
184
204
|
/**
|
|
185
205
|
* Resolve interactive vs headless. Explicit flags are definitive and win over
|
|
186
206
|
* inference: `--interactive` forces interactive, `--headless` forces headless.
|
|
@@ -346,8 +366,11 @@ export function buildExecEnv(options) {
|
|
|
346
366
|
result.AGENTS_RUN_MODE = resolveHeadlessMode(options.agent, normalizeMode(options.mode), resolveInteractive(options), options.modeWarningContext, options.modeWarningState);
|
|
347
367
|
result.AGENTS_HISTORY_DIR = getHistoryDir();
|
|
348
368
|
// So activity / feed posts stamp the right harness without re-detecting.
|
|
369
|
+
// A custom-harness run (`agents run deepseek`) must stamp the PROFILE name,
|
|
370
|
+
// not the host CLI (`claude`) — otherwise sessions and feed posts cannot
|
|
371
|
+
// tell the two apart (PHNX-2935).
|
|
349
372
|
if (options.agent) {
|
|
350
|
-
result.AGENTS_AGENT_NAME = options
|
|
373
|
+
result.AGENTS_AGENT_NAME = stampedAgentName(options);
|
|
351
374
|
}
|
|
352
375
|
if (options.cwd) {
|
|
353
376
|
result.AGENTS_CWD = options.cwd;
|
|
@@ -1176,41 +1199,52 @@ export function isPaneKnownAliveFromQueryResult(code, stdout) {
|
|
|
1176
1199
|
* Decide whether to run an interactive agent INSIDE a detached tmux session on
|
|
1177
1200
|
* the shared socket (then attach the current TTY) instead of a bare spawn.
|
|
1178
1201
|
*
|
|
1179
|
-
*
|
|
1202
|
+
* The wrap is opt-in via this device's `tmux.enabled` (`configEnabled`): a
|
|
1203
|
+
* unique `%pane` handle so `agents sessions --active` can tell co-located
|
|
1204
|
+
* agents apart and `agents focus` re-attaches without forking, plus scrollback
|
|
1205
|
+
* and mouse at the operator's keyboard. Off means OFF, for local and remote
|
|
1206
|
+
* runs alike (PHNX-3316) — a followed `--device` run left bare is protected by
|
|
1207
|
+
* reconnect-and-resume (lib/hosts/reconnect.ts), which rejoins the live pane
|
|
1208
|
+
* when one exists and resumes the harness session from disk when it does not.
|
|
1209
|
+
* The RUSH-3125 forced remote wrap conflated durability with that preference
|
|
1210
|
+
* and surprised every operator who had explicitly left tmux off.
|
|
1180
1211
|
*
|
|
1181
|
-
*
|
|
1182
|
-
*
|
|
1183
|
-
*
|
|
1184
|
-
* operator turns it on once tmux's mouse/clipboard/scrollback suits them.
|
|
1185
|
-
* - **Durability** (`remoteDispatch`): a run that arrived over SSH must outlive
|
|
1186
|
-
* the SSH client, because a bare remote spawn dies of SIGHUP the moment the
|
|
1187
|
-
* link blinks. That is not a preference, so it does not consult
|
|
1188
|
-
* `configEnabled` (RUSH-3125 — see {@link TmuxWrapContext.remoteDispatch}).
|
|
1212
|
+
* One case still wraps regardless: a followed remote run whose launcher has
|
|
1213
|
+
* no TTY (CI, scripts, another agent) gives the peer nothing to attach to —
|
|
1214
|
+
* the detached pane is the run's only interface, not an ergonomics choice.
|
|
1189
1215
|
*
|
|
1190
|
-
* The per-run opt-outs bind
|
|
1191
|
-
* explicit "I want the bare process" requests, and
|
|
1192
|
-
*
|
|
1216
|
+
* The per-run opt-outs bind everything: `--raw` / `--no-tmux` /
|
|
1217
|
+
* `AGENTS_NO_TMUX=1` are explicit "I want the bare process" requests, and an
|
|
1218
|
+
* escape hatch that silently stopped applying over `--device` would be worse
|
|
1219
|
+
* than the bare run the user asked for.
|
|
1193
1220
|
*
|
|
1194
1221
|
* Pure, so the gate is unit-tested independently of the (side-effecting) spawn.
|
|
1195
1222
|
*/
|
|
1196
1223
|
export function resolveTmuxWrap(ctx) {
|
|
1197
1224
|
// A headless `-p` run has no TTY to attach, Windows has no tmux path, and
|
|
1198
|
-
// nesting tmux-in-tmux is pointless
|
|
1199
|
-
// is a remote interactive agent whose life depends on it.
|
|
1225
|
+
// nesting tmux-in-tmux is pointless.
|
|
1200
1226
|
if (!ctx.interactive)
|
|
1201
1227
|
return { kind: 'bare' };
|
|
1202
1228
|
if (ctx.platform === 'win32')
|
|
1203
1229
|
return { kind: 'bare' };
|
|
1204
1230
|
if (ctx.inTmux)
|
|
1205
1231
|
return { kind: 'bare' };
|
|
1206
|
-
// Explicit opt-outs win over
|
|
1207
|
-
// get the unwrapped process
|
|
1208
|
-
// remote box would be worse than an undurable run the user asked for.
|
|
1232
|
+
// Explicit opt-outs win over every other rule: `--raw` exists precisely to
|
|
1233
|
+
// get the unwrapped process.
|
|
1209
1234
|
if (ctx.raw)
|
|
1210
1235
|
return { kind: 'bare' };
|
|
1211
1236
|
if (ctx.noTmuxEnv)
|
|
1212
1237
|
return { kind: 'bare' };
|
|
1213
|
-
|
|
1238
|
+
// Local interactive with no TTY cannot attach. Wrapping anyway creates a
|
|
1239
|
+
// detached pane, attach returns immediately, and resolveAfterAttach treats
|
|
1240
|
+
// the still-alive pane as Ctrl-b d — the session-tracker test leak.
|
|
1241
|
+
if (!ctx.hasTty && !ctx.remoteDispatch)
|
|
1242
|
+
return { kind: 'bare' };
|
|
1243
|
+
// tmux.enabled gates the wrap for local AND followed remote runs. The one
|
|
1244
|
+
// exception: a followed remote run whose launcher has no TTY (CI, scripts)
|
|
1245
|
+
// gives the peer nothing to attach to — the detached pane is its only
|
|
1246
|
+
// interface, infrastructure rather than ergonomics.
|
|
1247
|
+
if (!ctx.configEnabled && !(ctx.remoteDispatch && !ctx.hasTty))
|
|
1214
1248
|
return { kind: 'bare' };
|
|
1215
1249
|
// Fail loud rather than launch a remote agent that a blink would kill: the
|
|
1216
1250
|
// caller refuses the run instead of starting work that cannot be recovered.
|
|
@@ -1514,6 +1548,7 @@ async function runInTmux(options, executable, args) {
|
|
|
1514
1548
|
writePidSessionEntry({
|
|
1515
1549
|
pid: panePid,
|
|
1516
1550
|
agent: options.agent,
|
|
1551
|
+
harness: customHarnessName(options),
|
|
1517
1552
|
sessionId: options.sessionId,
|
|
1518
1553
|
cwd,
|
|
1519
1554
|
actor: resolveActor().id,
|
|
@@ -1532,6 +1567,7 @@ async function runInTmux(options, executable, args) {
|
|
|
1532
1567
|
sessionId: options.sessionId,
|
|
1533
1568
|
actor: resolveActor().id,
|
|
1534
1569
|
initiatedBy: resolveActor().kind,
|
|
1570
|
+
harness: customHarnessName(options),
|
|
1535
1571
|
startedAtMs: Date.now(),
|
|
1536
1572
|
});
|
|
1537
1573
|
}
|
|
@@ -1656,9 +1692,10 @@ async function spawnAgent(options) {
|
|
|
1656
1692
|
args: redactArgs(args.slice(0, 10)),
|
|
1657
1693
|
});
|
|
1658
1694
|
// Interactive spawn-wrap: run the agent INSIDE a shared-socket tmux session
|
|
1659
|
-
// (then attach this TTY) so it gets a unique, addressable %pane
|
|
1660
|
-
//
|
|
1661
|
-
//
|
|
1695
|
+
// (then attach this TTY) so it gets a unique, addressable %pane. Opt-in via
|
|
1696
|
+
// this device's tmux.enabled, local and remote alike (PHNX-3316); a followed
|
|
1697
|
+
// remote run left bare relies on reconnect-and-resume, not a pane. Every
|
|
1698
|
+
// failed guard keeps the bare spawn below.
|
|
1662
1699
|
const tmuxWrap = resolveTmuxWrap({
|
|
1663
1700
|
interactive,
|
|
1664
1701
|
platform: process.platform,
|
|
@@ -1668,6 +1705,7 @@ async function spawnAgent(options) {
|
|
|
1668
1705
|
configEnabled: isTmuxEnabled(),
|
|
1669
1706
|
remoteDispatch: process.env[REMOTE_INTERACTIVE_ENV] === '1',
|
|
1670
1707
|
tmuxAvailable: isTmuxInstalled(),
|
|
1708
|
+
hasTty: !!process.stdout.isTTY,
|
|
1671
1709
|
});
|
|
1672
1710
|
if (tmuxWrap.kind === 'undurable') {
|
|
1673
1711
|
// Refuse rather than start work a blink would destroy. This is the ONLY
|
|
@@ -1676,10 +1714,15 @@ async function spawnAgent(options) {
|
|
|
1676
1714
|
// there is no launcher-side probe ahead of this one. Failing here still
|
|
1677
1715
|
// fails before the agent starts, which is what matters; a pre-dispatch
|
|
1678
1716
|
// probe would only move the message earlier, at the cost of a round trip on
|
|
1679
|
-
// every launch.
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1717
|
+
// every launch. Reached only when the wrap was wanted: the device opted in
|
|
1718
|
+
// via tmux.enabled, or a followed run whose launcher had no TTY (CI,
|
|
1719
|
+
// scripts) left the peer nothing to attach to, so the pane is its stdout.
|
|
1720
|
+
// The config tip uses the ssh form because tmux.enabled is machine-local:
|
|
1721
|
+
// setting it from the launcher throws (assertLocalTarget).
|
|
1722
|
+
const msg = `agents: ${machineId()} has no tmux, so this --device run cannot get the pane it needs.\n`
|
|
1723
|
+
+ ` install it: (apt|dnf|brew) install tmux\n`
|
|
1724
|
+
+ ` or turn the wrap off: agents ssh ${machineId()} 'agents devices config ${machineId()} tmux.enabled off'\n`
|
|
1725
|
+
+ ` or accept a bare run: agents run … --device ${machineId()} --raw\n`;
|
|
1683
1726
|
process.stderr.write(`\x1b[31m${msg}\x1b[0m`);
|
|
1684
1727
|
timer.end({ exitCode: 1, status: 'failed', error: 'remote interactive run has no tmux for durability' });
|
|
1685
1728
|
return { exitCode: 1, stdout: '', stderr: msg };
|
|
@@ -1730,6 +1773,7 @@ async function spawnAgent(options) {
|
|
|
1730
1773
|
writePidSessionEntry({
|
|
1731
1774
|
pid: child.pid ?? 0,
|
|
1732
1775
|
agent: options.agent,
|
|
1776
|
+
harness: customHarnessName(options),
|
|
1733
1777
|
sessionId: options.sessionId,
|
|
1734
1778
|
cwd: options.cwd || process.cwd(),
|
|
1735
1779
|
actor: resolveActor().id,
|
|
@@ -1744,6 +1788,7 @@ async function spawnAgent(options) {
|
|
|
1744
1788
|
sessionId: options.sessionId,
|
|
1745
1789
|
actor: resolveActor().id,
|
|
1746
1790
|
initiatedBy: resolveActor().kind,
|
|
1791
|
+
harness: customHarnessName(options),
|
|
1747
1792
|
startedAtMs: Date.now(),
|
|
1748
1793
|
});
|
|
1749
1794
|
}
|
package/dist/lib/feed/feed.d.ts
CHANGED
|
@@ -76,7 +76,12 @@ export interface OpenBlock {
|
|
|
76
76
|
source?: AttentionSource;
|
|
77
77
|
/** Lifecycle state. Derived from the answer/continue markers when absent — see {@link deriveBlockState}. */
|
|
78
78
|
state?: AttentionState;
|
|
79
|
-
/**
|
|
79
|
+
/**
|
|
80
|
+
* Where in the source this block's generation sits — stamped at write time
|
|
81
|
+
* (`buildDeclaredBlock`, the feed-publish hook) and carried onto its resolution
|
|
82
|
+
* tombstone. Without a write-time cursor a new generation is suppressed whenever
|
|
83
|
+
* `session.lastActivityMs` is unresolvable (cloud / remote / index-lag).
|
|
84
|
+
*/
|
|
80
85
|
sourceCursor?: SourceCursor;
|
|
81
86
|
/** Indexed launch origin, added at read time when the live session is known. */
|
|
82
87
|
origin?: 'cli' | 'routine';
|
|
@@ -319,6 +324,9 @@ export interface DeclareBlockInput {
|
|
|
319
324
|
* `costOfDelay: high` because a declared block is, by definition, an agent that
|
|
320
325
|
* has already stopped making progress — that is what makes it worth interrupting
|
|
321
326
|
* someone over, and what `feed --dispatch`'s urgency filter keys off.
|
|
327
|
+
*
|
|
328
|
+
* `sourceCursor` is stamped from `ts` at write time so a fresh generation is
|
|
329
|
+
* comparable even when the live session's `lastActivityMs` is unresolvable.
|
|
322
330
|
*/
|
|
323
331
|
export declare function buildDeclaredBlock(agent: DeclaringAgent, input: DeclareBlockInput): OpenBlock;
|
|
324
332
|
/** Atomic write a block record to the feed store. Clears stale lifecycle state. */
|
|
@@ -334,7 +342,7 @@ export declare function removeBlock(blockId: string, root?: string): boolean;
|
|
|
334
342
|
* Embedded so it ships with the compiled CLI and can be installed to the
|
|
335
343
|
* CLI-writable user hooks dir without a separate file in the npm tarball.
|
|
336
344
|
*/
|
|
337
|
-
export declare const FEED_PUBLISH_HOOK_SCRIPT = "#!/usr/bin/env python3\n\"\"\"Publish and clear open-block records for `agents feed`.\n\nThe manifest invokes this script for top-level AskUserQuestion calls, waiting\nnotifications, question answers, and session lifecycle events. One atomic file\nper session means a new block replaces the previous block. Answer/resume/stop\nevents remove it so `agents feed` only lists decisions that are still open.\n\nSub-agent gate: when the PreToolUse payload carries `agent_type`, this is a\nTask/Agent subagent -- skip. Only the top-level agent publishes. Verified on\nClaude Code 2.1.170 (2026-07).\n\nFail-open: ANY error is swallowed so a feed hiccup never blocks a tool call.\n\"\"\"\nimport os\nimport sys\nimport json\nimport re\nimport socket\nimport tempfile\nfrom datetime import datetime, timezone\n\nWAITING_NOTIFICATION_TYPES = {\n \"permission_prompt\",\n \"idle_prompt\",\n \"elicitation_dialog\",\n}\nCLEAR_EVENTS = {\n \"PostToolUse\",\n \"Stop\",\n \"SessionEnd\",\n}\n# Codex emits a PermissionRequest event (not Claude's Notification) when it\n# blocks on an approval prompt. Claude never fires PermissionRequest, so the\n# same script handles both: PermissionRequest maps to an approval-class block\n# with a high cost-of-delay so 'agents feed --dispatch' pages it as urgent.\n\n\ndef read_json(path):\n try:\n with open(path) as f:\n return json.load(f)\n except Exception:\n return None\n\n\ndef write_json(path, value):\n dir_name = os.path.dirname(path)\n os.makedirs(dir_name, exist_ok=True)\n fd, tmp = tempfile.mkstemp(dir=dir_name, suffix=\".tmp\")\n try:\n with os.fdopen(fd, \"w\") as f:\n json.dump(value, f, indent=2)\n os.replace(tmp, path)\n except Exception:\n try:\n os.unlink(tmp)\n except Exception:\n pass\n\n\ndef project_from_cwd(cwd):\n \"\"\"Basename of cwd, with worktree paths resolved to their repo name.\"\"\"\n if not cwd:\n return None\n norm = cwd.replace(\"\\\\\", \"/\").rstrip(\"/\")\n if not norm:\n return None\n marker = \"/.agents/worktrees/\"\n idx = norm.find(marker)\n if idx > 0:\n repo_path = norm[:idx]\n base = repo_path[repo_path.rfind(\"/\") + 1:]\n if base:\n return base\n base = norm[norm.rfind(\"/\") + 1:]\n return base or None\n\n\ndef main():\n raw = sys.stdin.read()\n try:\n payload = json.loads(raw) if raw.strip() else {}\n except Exception:\n return\n\n # Sub-agent gate.\n if payload.get(\"agent_type\"):\n return\n\n session_id = payload.get(\"session_id\", \"\")\n if not session_id:\n return\n\n safe_session_id = re.sub(r\"[^A-Za-z0-9._-]\", \"-\", session_id)\n block_id = f\"block-{safe_session_id}\"\n home = os.environ.get(\"HOME\") or os.path.expanduser(\"~\")\n feed_dir = os.path.join(home, \".agents\", \".history\", \"feed\")\n answered_dir = os.path.join(feed_dir, \"answered\")\n asks_dir = os.path.join(feed_dir, \"asks\")\n target = os.path.join(feed_dir, f\"{block_id}.json\")\n hook_event = payload.get(\"hook_event_name\", \"PreToolUse\")\n\n if hook_event in CLEAR_EVENTS:\n # A declared block (`agents feed post --blocked`) is the agent explicitly\n # saying it is stuck. Unlike a question/notification/approval block -- which\n # tracks an in-flight harness prompt that a lifecycle event resolves -- a\n # declared block stays open until it is actually ANSWERED. So while it is\n # still UNANSWERED, Stop/SessionEnd/PostToolUse must never silently drop it:\n # otherwise the needs-you record vanishes the moment the agent parks the block\n # and its turn ends -- exactly when the owner still needs to see and answer it.\n # Once it IS answered (an answered marker exists), it clears like any other\n # block by falling through below -- which frees that marker too, so a later\n # `--blocked` in the same session is not falsely locked as already-answered\n # (recordAnswer creates the marker with O_EXCL).\n try:\n with open(target) as existing_file:\n existing = json.load(existing_file)\n answered = os.path.exists(os.path.join(answered_dir, f\"{block_id}.json\"))\n if existing.get(\"kind\") == \"declared\" and not answered:\n return\n except Exception:\n pass\n # A matcher-less PostToolUse clear (registered for Codex so an approved\n # tool clears its approval card) must NOT wipe an open AskUserQuestion\n # while an unrelated tool runs mid-question -- those are cleared only by\n # the AskUserQuestion-matched PostToolUse. So on PostToolUse, keep a\n # 'question' block; approval/notification blocks clear once the tool runs.\n if hook_event == \"PostToolUse\":\n try:\n with open(target) as existing_file:\n existing = json.load(existing_file)\n if existing.get(\"kind\") == \"question\" and payload.get(\"tool_name\") != \"AskUserQuestion\":\n return\n except Exception:\n pass\n try:\n os.unlink(target)\n except FileNotFoundError:\n pass\n except Exception:\n pass\n # Also clear the answered marker so a future question for this session\n # is not permanently locked.\n try:\n os.unlink(os.path.join(answered_dir, f\"{block_id}.json\"))\n except FileNotFoundError:\n pass\n except Exception:\n pass\n return\n\n # Terminal answers (human typed in the TUI) record an answered marker and\n # remove the block file so the feed stops showing it within one poll cycle.\n # The marker stays behind so a concurrent surface cannot double-answer.\n if hook_event == \"UserPromptSubmit\":\n os.makedirs(answered_dir, exist_ok=True)\n marker = os.path.join(answered_dir, f\"{block_id}.json\")\n try:\n fd = os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)\n record = {\n \"answeredAt\": datetime.now(timezone.utc).isoformat(),\n \"answeredFrom\": \"terminal\",\n }\n with os.fdopen(fd, \"w\") as f:\n json.dump(record, f, indent=2)\n except FileExistsError:\n pass\n except Exception:\n pass\n # Remove the visible block so the feed drops the answered question.\n try:\n os.unlink(target)\n except FileNotFoundError:\n pass\n except Exception:\n pass\n return\n\n notification_type = None\n codex_approval = False\n if hook_event == \"Notification\":\n notification_type = payload.get(\"notification_type\", \"\")\n if notification_type not in WAITING_NOTIFICATION_TYPES:\n return\n # Claude emits a generic permission notification after presenting an\n # AskUserQuestion. Keep the structured questions and options already\n # published for this session instead of replacing them with that less\n # useful notification text.\n try:\n with open(target) as existing_file:\n existing = json.load(existing_file)\n if existing.get(\"kind\") == \"question\":\n return\n except Exception:\n pass\n message = payload.get(\"message\", \"\")\n if not message:\n return\n normalized_questions = [{\n \"text\": message,\n \"header\": payload.get(\"title\") or notification_type.replace(\"_\", \" \").title(),\n \"multiSelect\": False,\n }]\n kind = \"notification\"\n elif hook_event == \"PermissionRequest\":\n # Codex approval prompt. The payload mirrors PreToolUse (tool_name,\n # tool_input) but carries no questions -- Codex is asking to run a tool,\n # not asking the operator a multiple-choice question. Publish it as a\n # notification-kind approval block naming the tool so the feed and the\n # phone notifier can surface it, and so AGI EXT can bridge\n # it to a VS Code notification.\n tool_name = payload.get(\"tool_name\") or \"a tool\"\n tool_input = payload.get(\"tool_input\", {})\n command = \"\"\n if isinstance(tool_input, dict):\n command = (\n tool_input.get(\"command\")\n or tool_input.get(\"cmd\")\n or tool_input.get(\"path\")\n or \"\"\n )\n if isinstance(command, list):\n command = \" \".join(str(c) for c in command)\n detail = f\": {command}\" if command else \"\"\n normalized_questions = [{\n \"text\": f\"Codex needs approval to run {tool_name}{detail}\",\n \"header\": \"Approval needed\",\n \"multiSelect\": False,\n }]\n kind = \"notification\"\n notification_type = \"permission_prompt\"\n codex_approval = True\n else:\n tool_input = payload.get(\"tool_input\", {})\n questions = tool_input.get(\"questions\", [])\n if not questions:\n return\n normalized_questions = []\n for q in questions:\n if not isinstance(q, dict):\n continue\n question = {\n \"text\": q.get(\"question\", q.get(\"header\", \"\")),\n \"header\": q.get(\"header\"),\n \"multiSelect\": q.get(\"multiSelect\", False),\n }\n raw_opts = q.get(\"options\", [])\n if raw_opts:\n question[\"options\"] = [\n {\"label\": o.get(\"label\", \"\"), \"description\": o.get(\"description\")}\n for o in raw_opts\n if isinstance(o, dict)\n ]\n normalized_questions.append(question)\n if not normalized_questions:\n return\n kind = \"question\"\n\n # Identity from env (set by agents-cli at spawn).\n mailbox_id = os.path.basename(\n os.environ.get(\"AGENTS_MAILBOX_DIR\", \"\").rstrip(\"/\")\n ) or session_id\n\n now_iso = datetime.now(timezone.utc).isoformat()\n stats_path = os.path.join(asks_dir, f\"{safe_session_id}.json\")\n stats = read_json(stats_path) or {}\n recent = stats.get(\"recentAskTimestamps\") if isinstance(stats, dict) else []\n if not isinstance(recent, list):\n recent = []\n recent.append(now_iso)\n # Keep enough history for rolling one-hour needy detection without unbounded\n # per-session files. The TypeScript reader applies the exact time window.\n recent = recent[-200:]\n write_json(stats_path, {\n \"sessionId\": session_id,\n \"mailboxId\": mailbox_id,\n \"firstAskAt\": stats.get(\"firstAskAt\") or now_iso,\n \"lastAskAt\": now_iso,\n \"totalAskCount\": int(stats.get(\"totalAskCount\") or 0) + 1,\n \"recentAskTimestamps\": recent,\n })\n\n hostname = os.environ.get(\"AGENTS_SYNC_MACHINE_ID\") or socket.gethostname()\n host = hostname.split(\".\")[0].strip().lower()\n host = re.sub(r\"[^a-z0-9_-]\", \"-\", host) or \"unknown\"\n\n runtime = os.environ.get(\"AGENTS_RUNTIME\", \"headless\")\n cwd = payload.get(\"cwd\") or os.environ.get(\"AGENTS_CWD\")\n project = project_from_cwd(cwd)\n\n block = {\n \"blockId\": block_id,\n \"sessionId\": session_id,\n \"mailboxId\": mailbox_id,\n \"host\": host,\n \"runtime\": runtime,\n \"ts\": now_iso,\n \"questions\": normalized_questions,\n \"kind\": kind,\n }\n if project:\n block[\"project\"] = project\n if notification_type:\n block[\"notificationType\"] = notification_type\n\n # A Codex PermissionRequest is a real approval gate: mark it approval-class\n # with a high cost-of-delay so 'agents feed --dispatch' classifies it urgent\n # (isPhoneUrgent gates on costOfDelay >= phoneNotifyThreshold, default\n # 'medium') and pages the phone. A plain 'deny' is the safe default.\n if codex_approval:\n block[\"blockClass\"] = \"approval\"\n block[\"costOfDelay\"] = \"high\"\n block[\"safeDefault\"] = \"deny\"\n\n # Optional multi-operator control metadata passed by the agent in the\n # AskUserQuestion tool_input. Defaults keep the existing behavior. A Codex\n # PermissionRequest carries tool ARGS in tool_input (command/path), not\n # operator controls, so it is excluded here -- its class/cost is stamped\n # above from codex_approval.\n controls = payload.get(\"tool_input\", {}) if hook_event not in (\"Notification\", \"PermissionRequest\") else {}\n block_class = controls.get(\"blockClass\") if isinstance(controls, dict) else None\n if block_class in (\"approval\", \"decision\"):\n block[\"blockClass\"] = block_class\n consequence = controls.get(\"consequence\") if isinstance(controls, dict) else None\n if consequence:\n block[\"consequence\"] = consequence\n allowed = controls.get(\"allowedOperators\") if isinstance(controls, dict) else None\n if isinstance(allowed, list):\n block[\"allowedOperators\"] = [str(a) for a in allowed]\n timeout = controls.get(\"timeoutMinutes\") if isinstance(controls, dict) else None\n if isinstance(timeout, (int, float)) and timeout > 0:\n block[\"timeoutMinutes\"] = int(timeout)\n safe_default = controls.get(\"safeDefault\") if isinstance(controls, dict) else None\n if isinstance(safe_default, str):\n block[\"safeDefault\"] = safe_default\n cost = controls.get(\"costOfDelay\") if isinstance(controls, dict) else None\n if cost in (\"low\", \"medium\", \"high\"):\n block[\"costOfDelay\"] = cost\n\n # Publishing a new question clears any stale answered marker from the\n # previous question in this session.\n try:\n os.unlink(os.path.join(answered_dir, f\"{block_id}.json\"))\n except FileNotFoundError:\n pass\n except Exception:\n pass\n\n # Python's expanduser() ignores HOME on Windows, while agents-cli honors a\n # HOME override on every platform. Use the same anchor so hooks and the CLI\n # always read/write one feed store (including temp-home and sandbox runs).\n os.makedirs(feed_dir, exist_ok=True)\n\n fd, tmp = tempfile.mkstemp(dir=feed_dir, suffix=\".tmp\")\n try:\n with os.fdopen(fd, \"w\") as f:\n json.dump(block, f, indent=2)\n os.replace(tmp, target)\n except Exception:\n try:\n os.unlink(tmp)\n except Exception:\n pass\n\n\nif __name__ == \"__main__\":\n try:\n main()\n except Exception:\n pass # fail open\n";
|
|
345
|
+
export declare const FEED_PUBLISH_HOOK_SCRIPT = "#!/usr/bin/env python3\n\"\"\"Publish and clear open-block records for `agents feed`.\n\nThe manifest invokes this script for top-level AskUserQuestion calls, waiting\nnotifications, question answers, and session lifecycle events. One atomic file\nper session means a new block replaces the previous block. Answer/resume/stop\nevents remove it so `agents feed` only lists decisions that are still open.\n\nSub-agent gate: when the PreToolUse payload carries `agent_type`, this is a\nTask/Agent subagent -- skip. Only the top-level agent publishes. Verified on\nClaude Code 2.1.170 (2026-07).\n\nFail-open: ANY error is swallowed so a feed hiccup never blocks a tool call.\n\"\"\"\nimport os\nimport sys\nimport json\nimport re\nimport socket\nimport tempfile\nfrom datetime import datetime, timezone\n\nWAITING_NOTIFICATION_TYPES = {\n \"permission_prompt\",\n \"idle_prompt\",\n \"elicitation_dialog\",\n}\nCLEAR_EVENTS = {\n \"PostToolUse\",\n \"Stop\",\n \"SessionEnd\",\n}\n# Codex emits a PermissionRequest event (not Claude's Notification) when it\n# blocks on an approval prompt. Claude never fires PermissionRequest, so the\n# same script handles both: PermissionRequest maps to an approval-class block\n# with a high cost-of-delay so 'agents feed --dispatch' pages it as urgent.\n\n\ndef read_json(path):\n try:\n with open(path) as f:\n return json.load(f)\n except Exception:\n return None\n\n\ndef write_json(path, value):\n dir_name = os.path.dirname(path)\n os.makedirs(dir_name, exist_ok=True)\n fd, tmp = tempfile.mkstemp(dir=dir_name, suffix=\".tmp\")\n try:\n with os.fdopen(fd, \"w\") as f:\n json.dump(value, f, indent=2)\n os.replace(tmp, path)\n except Exception:\n try:\n os.unlink(tmp)\n except Exception:\n pass\n\n\ndef project_from_cwd(cwd):\n \"\"\"Basename of cwd, with worktree paths resolved to their repo name.\"\"\"\n if not cwd:\n return None\n norm = cwd.replace(\"\\\\\", \"/\").rstrip(\"/\")\n if not norm:\n return None\n marker = \"/.agents/worktrees/\"\n idx = norm.find(marker)\n if idx > 0:\n repo_path = norm[:idx]\n base = repo_path[repo_path.rfind(\"/\") + 1:]\n if base:\n return base\n base = norm[norm.rfind(\"/\") + 1:]\n return base or None\n\n\ndef main():\n raw = sys.stdin.read()\n try:\n payload = json.loads(raw) if raw.strip() else {}\n except Exception:\n return\n\n # Sub-agent gate.\n if payload.get(\"agent_type\"):\n return\n\n session_id = payload.get(\"session_id\", \"\")\n if not session_id:\n return\n\n safe_session_id = re.sub(r\"[^A-Za-z0-9._-]\", \"-\", session_id)\n block_id = f\"block-{safe_session_id}\"\n home = os.environ.get(\"HOME\") or os.path.expanduser(\"~\")\n feed_dir = os.path.join(home, \".agents\", \".history\", \"feed\")\n answered_dir = os.path.join(feed_dir, \"answered\")\n asks_dir = os.path.join(feed_dir, \"asks\")\n target = os.path.join(feed_dir, f\"{block_id}.json\")\n hook_event = payload.get(\"hook_event_name\", \"PreToolUse\")\n\n if hook_event in CLEAR_EVENTS:\n # A declared block (`agents feed post --blocked`) is the agent explicitly\n # saying it is stuck. Unlike a question/notification/approval block -- which\n # tracks an in-flight harness prompt that a lifecycle event resolves -- a\n # declared block stays open until it is actually ANSWERED. So while it is\n # still UNANSWERED, Stop/SessionEnd/PostToolUse must never silently drop it:\n # otherwise the needs-you record vanishes the moment the agent parks the block\n # and its turn ends -- exactly when the owner still needs to see and answer it.\n # Once it IS answered (an answered marker exists), it clears like any other\n # block by falling through below -- which frees that marker too, so a later\n # `--blocked` in the same session is not falsely locked as already-answered\n # (recordAnswer creates the marker with O_EXCL).\n try:\n with open(target) as existing_file:\n existing = json.load(existing_file)\n answered = os.path.exists(os.path.join(answered_dir, f\"{block_id}.json\"))\n if existing.get(\"kind\") == \"declared\" and not answered:\n return\n except Exception:\n pass\n # A matcher-less PostToolUse clear (registered for Codex so an approved\n # tool clears its approval card) must NOT wipe an open AskUserQuestion\n # while an unrelated tool runs mid-question -- those are cleared only by\n # the AskUserQuestion-matched PostToolUse. So on PostToolUse, keep a\n # 'question' block; approval/notification blocks clear once the tool runs.\n if hook_event == \"PostToolUse\":\n try:\n with open(target) as existing_file:\n existing = json.load(existing_file)\n if existing.get(\"kind\") == \"question\" and payload.get(\"tool_name\") != \"AskUserQuestion\":\n return\n except Exception:\n pass\n try:\n os.unlink(target)\n except FileNotFoundError:\n pass\n except Exception:\n pass\n # Also clear the answered marker so a future question for this session\n # is not permanently locked.\n try:\n os.unlink(os.path.join(answered_dir, f\"{block_id}.json\"))\n except FileNotFoundError:\n pass\n except Exception:\n pass\n return\n\n # Terminal answers (human typed in the TUI) record an answered marker and\n # remove the block file so the feed stops showing it within one poll cycle.\n # The marker stays behind so a concurrent surface cannot double-answer.\n if hook_event == \"UserPromptSubmit\":\n os.makedirs(answered_dir, exist_ok=True)\n marker = os.path.join(answered_dir, f\"{block_id}.json\")\n try:\n fd = os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)\n record = {\n \"answeredAt\": datetime.now(timezone.utc).isoformat(),\n \"answeredFrom\": \"terminal\",\n }\n with os.fdopen(fd, \"w\") as f:\n json.dump(record, f, indent=2)\n except FileExistsError:\n pass\n except Exception:\n pass\n # Remove the visible block so the feed drops the answered question.\n try:\n os.unlink(target)\n except FileNotFoundError:\n pass\n except Exception:\n pass\n return\n\n notification_type = None\n codex_approval = False\n if hook_event == \"Notification\":\n notification_type = payload.get(\"notification_type\", \"\")\n if notification_type not in WAITING_NOTIFICATION_TYPES:\n return\n # Claude emits a generic permission notification after presenting an\n # AskUserQuestion. Keep the structured questions and options already\n # published for this session instead of replacing them with that less\n # useful notification text.\n try:\n with open(target) as existing_file:\n existing = json.load(existing_file)\n if existing.get(\"kind\") == \"question\":\n return\n except Exception:\n pass\n message = payload.get(\"message\", \"\")\n if not message:\n return\n normalized_questions = [{\n \"text\": message,\n \"header\": payload.get(\"title\") or notification_type.replace(\"_\", \" \").title(),\n \"multiSelect\": False,\n }]\n kind = \"notification\"\n elif hook_event == \"PermissionRequest\":\n # Codex approval prompt. The payload mirrors PreToolUse (tool_name,\n # tool_input) but carries no questions -- Codex is asking to run a tool,\n # not asking the operator a multiple-choice question. Publish it as a\n # notification-kind approval block naming the tool so the feed and the\n # phone notifier can surface it, and so AGI EXT can bridge\n # it to a VS Code notification.\n tool_name = payload.get(\"tool_name\") or \"a tool\"\n tool_input = payload.get(\"tool_input\", {})\n command = \"\"\n if isinstance(tool_input, dict):\n command = (\n tool_input.get(\"command\")\n or tool_input.get(\"cmd\")\n or tool_input.get(\"path\")\n or \"\"\n )\n if isinstance(command, list):\n command = \" \".join(str(c) for c in command)\n detail = f\": {command}\" if command else \"\"\n normalized_questions = [{\n \"text\": f\"Codex needs approval to run {tool_name}{detail}\",\n \"header\": \"Approval needed\",\n \"multiSelect\": False,\n }]\n kind = \"notification\"\n notification_type = \"permission_prompt\"\n codex_approval = True\n else:\n tool_input = payload.get(\"tool_input\", {})\n questions = tool_input.get(\"questions\", [])\n if not questions:\n return\n normalized_questions = []\n for q in questions:\n if not isinstance(q, dict):\n continue\n question = {\n \"text\": q.get(\"question\", q.get(\"header\", \"\")),\n \"header\": q.get(\"header\"),\n \"multiSelect\": q.get(\"multiSelect\", False),\n }\n raw_opts = q.get(\"options\", [])\n if raw_opts:\n question[\"options\"] = [\n {\"label\": o.get(\"label\", \"\"), \"description\": o.get(\"description\")}\n for o in raw_opts\n if isinstance(o, dict)\n ]\n normalized_questions.append(question)\n if not normalized_questions:\n return\n kind = \"question\"\n\n # Identity from env (set by agents-cli at spawn).\n mailbox_id = os.path.basename(\n os.environ.get(\"AGENTS_MAILBOX_DIR\", \"\").rstrip(\"/\")\n ) or session_id\n\n now = datetime.now(timezone.utc)\n now_iso = now.isoformat()\n now_ms = int(now.timestamp() * 1000)\n stats_path = os.path.join(asks_dir, f\"{safe_session_id}.json\")\n stats = read_json(stats_path) or {}\n recent = stats.get(\"recentAskTimestamps\") if isinstance(stats, dict) else []\n if not isinstance(recent, list):\n recent = []\n recent.append(now_iso)\n # Keep enough history for rolling one-hour needy detection without unbounded\n # per-session files. The TypeScript reader applies the exact time window.\n recent = recent[-200:]\n write_json(stats_path, {\n \"sessionId\": session_id,\n \"mailboxId\": mailbox_id,\n \"firstAskAt\": stats.get(\"firstAskAt\") or now_iso,\n \"lastAskAt\": now_iso,\n \"totalAskCount\": int(stats.get(\"totalAskCount\") or 0) + 1,\n \"recentAskTimestamps\": recent,\n })\n\n hostname = os.environ.get(\"AGENTS_SYNC_MACHINE_ID\") or socket.gethostname()\n host = hostname.split(\".\")[0].strip().lower()\n host = re.sub(r\"[^a-z0-9_-]\", \"-\", host) or \"unknown\"\n\n runtime = os.environ.get(\"AGENTS_RUNTIME\", \"headless\")\n cwd = payload.get(\"cwd\") or os.environ.get(\"AGENTS_CWD\")\n project = project_from_cwd(cwd)\n\n block = {\n \"blockId\": block_id,\n \"sessionId\": session_id,\n \"mailboxId\": mailbox_id,\n \"host\": host,\n \"runtime\": runtime,\n \"ts\": now_iso,\n # Write-time cursor so a new generation is not suppressed when\n # session.lastActivityMs is unresolvable (cloud / remote / index-lag).\n \"sourceCursor\": {\"lastActivityMs\": now_ms},\n \"questions\": normalized_questions,\n \"kind\": kind,\n }\n if project:\n block[\"project\"] = project\n if notification_type:\n block[\"notificationType\"] = notification_type\n\n # A Codex PermissionRequest is a real approval gate: mark it approval-class\n # with a high cost-of-delay so 'agents feed --dispatch' classifies it urgent\n # (isPhoneUrgent gates on costOfDelay >= phoneNotifyThreshold, default\n # 'medium') and pages the phone. A plain 'deny' is the safe default.\n if codex_approval:\n block[\"blockClass\"] = \"approval\"\n block[\"costOfDelay\"] = \"high\"\n block[\"safeDefault\"] = \"deny\"\n\n # Optional multi-operator control metadata passed by the agent in the\n # AskUserQuestion tool_input. Defaults keep the existing behavior. A Codex\n # PermissionRequest carries tool ARGS in tool_input (command/path), not\n # operator controls, so it is excluded here -- its class/cost is stamped\n # above from codex_approval.\n controls = payload.get(\"tool_input\", {}) if hook_event not in (\"Notification\", \"PermissionRequest\") else {}\n block_class = controls.get(\"blockClass\") if isinstance(controls, dict) else None\n if block_class in (\"approval\", \"decision\"):\n block[\"blockClass\"] = block_class\n consequence = controls.get(\"consequence\") if isinstance(controls, dict) else None\n if consequence:\n block[\"consequence\"] = consequence\n allowed = controls.get(\"allowedOperators\") if isinstance(controls, dict) else None\n if isinstance(allowed, list):\n block[\"allowedOperators\"] = [str(a) for a in allowed]\n timeout = controls.get(\"timeoutMinutes\") if isinstance(controls, dict) else None\n if isinstance(timeout, (int, float)) and timeout > 0:\n block[\"timeoutMinutes\"] = int(timeout)\n safe_default = controls.get(\"safeDefault\") if isinstance(controls, dict) else None\n if isinstance(safe_default, str):\n block[\"safeDefault\"] = safe_default\n cost = controls.get(\"costOfDelay\") if isinstance(controls, dict) else None\n if cost in (\"low\", \"medium\", \"high\"):\n block[\"costOfDelay\"] = cost\n\n # Publishing a new question clears any stale answered marker from the\n # previous question in this session.\n try:\n os.unlink(os.path.join(answered_dir, f\"{block_id}.json\"))\n except FileNotFoundError:\n pass\n except Exception:\n pass\n\n # Python's expanduser() ignores HOME on Windows, while agents-cli honors a\n # HOME override on every platform. Use the same anchor so hooks and the CLI\n # always read/write one feed store (including temp-home and sandbox runs).\n os.makedirs(feed_dir, exist_ok=True)\n\n fd, tmp = tempfile.mkstemp(dir=feed_dir, suffix=\".tmp\")\n try:\n with os.fdopen(fd, \"w\") as f:\n json.dump(block, f, indent=2)\n os.replace(tmp, target)\n except Exception:\n try:\n os.unlink(tmp)\n except Exception:\n pass\n\n\nif __name__ == \"__main__\":\n try:\n main()\n except Exception:\n pass # fail open\n";
|
|
338
346
|
/** Manifest entry for the feed-publish hook, matching the ManifestHook shape. */
|
|
339
347
|
export declare const FEED_PUBLISH_HOOK_MANIFEST: {
|
|
340
348
|
name: string;
|
package/dist/lib/feed/feed.js
CHANGED
|
@@ -374,6 +374,9 @@ export function clearBlockLifecycle(blockId, root) {
|
|
|
374
374
|
* `costOfDelay: high` because a declared block is, by definition, an agent that
|
|
375
375
|
* has already stopped making progress — that is what makes it worth interrupting
|
|
376
376
|
* someone over, and what `feed --dispatch`'s urgency filter keys off.
|
|
377
|
+
*
|
|
378
|
+
* `sourceCursor` is stamped from `ts` at write time so a fresh generation is
|
|
379
|
+
* comparable even when the live session's `lastActivityMs` is unresolvable.
|
|
377
380
|
*/
|
|
378
381
|
export function buildDeclaredBlock(agent, input) {
|
|
379
382
|
const text = input.text.trim().replace(/\s+/g, ' ');
|
|
@@ -399,6 +402,9 @@ export function buildDeclaredBlock(agent, input) {
|
|
|
399
402
|
generation: ts,
|
|
400
403
|
source: 'declared',
|
|
401
404
|
state: 'open',
|
|
405
|
+
// Write-time cursor so a new generation is not suppressed when
|
|
406
|
+
// session.lastActivityMs is unresolvable (cloud / remote / index-lag).
|
|
407
|
+
sourceCursor: { lastActivityMs: Date.parse(ts) },
|
|
402
408
|
kind: 'declared',
|
|
403
409
|
questions: [{ text, header: 'Needs you', ...(options.length ? { options } : {}) }],
|
|
404
410
|
blockClass: input.safeDefault ? 'approval' : 'decision',
|
|
@@ -769,7 +775,9 @@ def main():
|
|
|
769
775
|
os.environ.get("AGENTS_MAILBOX_DIR", "").rstrip("/")
|
|
770
776
|
) or session_id
|
|
771
777
|
|
|
772
|
-
|
|
778
|
+
now = datetime.now(timezone.utc)
|
|
779
|
+
now_iso = now.isoformat()
|
|
780
|
+
now_ms = int(now.timestamp() * 1000)
|
|
773
781
|
stats_path = os.path.join(asks_dir, f"{safe_session_id}.json")
|
|
774
782
|
stats = read_json(stats_path) or {}
|
|
775
783
|
recent = stats.get("recentAskTimestamps") if isinstance(stats, dict) else []
|
|
@@ -803,6 +811,9 @@ def main():
|
|
|
803
811
|
"host": host,
|
|
804
812
|
"runtime": runtime,
|
|
805
813
|
"ts": now_iso,
|
|
814
|
+
# Write-time cursor so a new generation is not suppressed when
|
|
815
|
+
# session.lastActivityMs is unresolvable (cloud / remote / index-lag).
|
|
816
|
+
"sourceCursor": {"lastActivityMs": now_ms},
|
|
806
817
|
"questions": normalized_questions,
|
|
807
818
|
"kind": kind,
|
|
808
819
|
}
|