@phnx-labs/agents-cli 1.22.52 → 1.22.54
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 +336 -0
- package/README.md +42 -9
- package/dist/bootstrap.js +55 -154
- package/dist/cli/command-registry.d.ts +5 -0
- package/dist/cli/command-registry.js +8 -1
- package/dist/commands/accounts.js +220 -174
- package/dist/commands/apply.js +6 -3
- package/dist/commands/auth-mint.d.ts +8 -0
- package/dist/commands/auth-mint.js +96 -0
- package/dist/commands/auth.js +5 -1
- package/dist/commands/browser.js +1 -1
- package/dist/commands/cost.js +8 -2
- package/dist/commands/daemon.js +2 -2
- package/dist/commands/doctor.js +6 -1
- package/dist/commands/exec.js +26 -17
- package/dist/commands/fleet-capture.js +7 -0
- package/dist/commands/focus.d.ts +1 -0
- package/dist/commands/focus.js +4 -2
- package/dist/commands/go.d.ts +5 -4
- package/dist/commands/go.js +8 -7
- package/dist/commands/insights.js +9 -0
- package/dist/commands/monitors.js +85 -30
- package/dist/commands/output.js +8 -2
- package/dist/commands/repo.js +18 -0
- package/dist/commands/secrets.js +33 -14
- package/dist/commands/sessions-inject.js +8 -3
- package/dist/commands/sessions-picker.js +2 -1
- package/dist/commands/sessions.d.ts +20 -12
- package/dist/commands/sessions.js +94 -40
- package/dist/commands/setup-accounts.d.ts +8 -0
- package/dist/commands/setup-accounts.js +47 -0
- package/dist/commands/setup.d.ts +1 -1
- package/dist/commands/setup.js +11 -2
- package/dist/commands/share.d.ts +52 -3
- package/dist/commands/share.js +262 -18
- package/dist/commands/ssh.d.ts +7 -0
- package/dist/commands/ssh.js +53 -14
- package/dist/commands/status.js +14 -0
- package/dist/commands/sync.js +44 -0
- package/dist/commands/view.d.ts +3 -1
- package/dist/commands/view.js +5 -4
- package/dist/lib/account-registry.d.ts +15 -5
- package/dist/lib/account-registry.js +165 -53
- package/dist/lib/accounting/rotate.d.ts +20 -6
- package/dist/lib/accounting/rotate.js +38 -7
- package/dist/lib/accounting/usage.d.ts +37 -1
- package/dist/lib/accounting/usage.js +71 -6
- package/dist/lib/agent-spec/agents.d.ts +5 -2
- package/dist/lib/agent-spec/agents.js +25 -7
- package/dist/lib/analytics/mix-commands.js +12 -6
- package/dist/lib/answer-router.js +2 -1
- package/dist/lib/auth-mint.d.ts +150 -0
- package/dist/lib/auth-mint.js +434 -0
- package/dist/lib/browser/profiles.d.ts +18 -0
- package/dist/lib/browser/profiles.js +26 -1
- package/dist/lib/browser/registry.d.ts +44 -14
- package/dist/lib/browser/registry.js +141 -45
- package/dist/lib/browser/remote-control.d.ts +9 -7
- package/dist/lib/browser/remote-control.js +9 -7
- package/dist/lib/claude-account-token.d.ts +10 -0
- package/dist/lib/claude-account-token.js +14 -4
- package/dist/lib/config-drift.d.ts +37 -0
- package/dist/lib/config-drift.js +72 -0
- package/dist/lib/daemon/auth-sync-service.d.ts +19 -0
- package/dist/lib/daemon/auth-sync-service.js +34 -0
- package/dist/lib/daemon/daemon.js +30 -4
- package/dist/lib/daemon/runner.js +10 -2
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +5 -0
- package/dist/lib/device-config.d.ts +3 -3
- package/dist/lib/device-config.js +8 -7
- package/dist/lib/devices/config-migration.js +147 -1
- package/dist/lib/devices/connect.d.ts +26 -0
- package/dist/lib/devices/connect.js +48 -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/doctor-findings.d.ts +5 -1
- package/dist/lib/devices/doctor-findings.js +19 -1
- package/dist/lib/devices/registry.d.ts +11 -5
- package/dist/lib/devices/registry.js +46 -18
- package/dist/lib/exec.d.ts +88 -30
- package/dist/lib/exec.js +138 -34
- package/dist/lib/feed/feed.d.ts +10 -2
- package/dist/lib/feed/feed.js +35 -2
- package/dist/lib/feed-broadcast.js +1 -1
- package/dist/lib/fleet/apply.d.ts +11 -0
- package/dist/lib/fleet/apply.js +23 -3
- package/dist/lib/fleet/auth-sync.js +5 -3
- package/dist/lib/help.d.ts +9 -0
- package/dist/lib/help.js +29 -1
- package/dist/lib/hosts/dispatch.d.ts +4 -3
- package/dist/lib/hosts/dispatch.js +12 -8
- package/dist/lib/hosts/passthrough.d.ts +1 -10
- package/dist/lib/hosts/passthrough.js +1 -13
- 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/installations/versions.js +9 -1
- package/dist/lib/linux-userns.d.ts +58 -0
- package/dist/lib/linux-userns.js +116 -0
- package/dist/lib/memory.d.ts +26 -0
- package/dist/lib/memory.js +80 -1
- package/dist/lib/monitors/config.d.ts +11 -0
- package/dist/lib/monitors/config.js +8 -0
- package/dist/lib/monitors/engine.js +8 -1
- package/dist/lib/monitors/state.d.ts +37 -1
- package/dist/lib/monitors/state.js +79 -4
- package/dist/lib/permissions-registry.d.ts +2 -0
- package/dist/lib/permissions-registry.js +116 -14
- package/dist/lib/permissions.d.ts +5 -3
- package/dist/lib/permissions.js +25 -27
- package/dist/lib/profiles.d.ts +8 -7
- package/dist/lib/profiles.js +12 -0
- package/dist/lib/project-key.d.ts +9 -0
- package/dist/lib/project-key.js +11 -0
- package/dist/lib/secrets/bundles.d.ts +35 -0
- package/dist/lib/secrets/bundles.js +78 -1
- package/dist/lib/secrets/push.d.ts +3 -8
- package/dist/lib/secrets/push.js +18 -14
- package/dist/lib/secrets/remote.d.ts +9 -18
- package/dist/lib/secrets/remote.js +11 -26
- package/dist/lib/secrets/reserved-sync.d.ts +65 -0
- package/dist/lib/secrets/reserved-sync.js +129 -0
- package/dist/lib/self-heal/checks/hook-manifest.d.ts +2 -0
- package/dist/lib/self-heal/checks/hook-manifest.js +56 -0
- package/dist/lib/self-heal/registry.js +4 -0
- package/dist/lib/self-heal/types.d.ts +1 -1
- package/dist/lib/session/active.d.ts +10 -1
- package/dist/lib/session/active.js +8 -5
- 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 +39 -4
- package/dist/lib/session/db.js +168 -31
- package/dist/lib/session/discover.d.ts +32 -4
- package/dist/lib/session/discover.js +126 -37
- package/dist/lib/session/insights.d.ts +14 -0
- package/dist/lib/session/insights.js +25 -2
- package/dist/lib/session/linear.js +1 -1
- package/dist/lib/session/live-metadata.js +1 -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/shell-programs.d.ts +17 -0
- package/dist/lib/session/shell-programs.js +21 -0
- package/dist/lib/session/state.js +2 -1
- package/dist/lib/session/stream-render.js +2 -1
- package/dist/lib/session/tool-calls.js +2 -5
- package/dist/lib/session/trajectory-html.js +2 -1
- package/dist/lib/session/trajectory.js +3 -12
- package/dist/lib/session/types.d.ts +25 -0
- package/dist/lib/session/types.js +10 -0
- package/dist/lib/share/publish.d.ts +53 -5
- package/dist/lib/share/publish.js +99 -17
- package/dist/lib/share/worker-template.js +594 -64
- package/dist/lib/startup/root-command.js +2 -1
- package/dist/lib/state.d.ts +24 -0
- package/dist/lib/state.js +318 -54
- package/dist/lib/sync-status.d.ts +4 -0
- package/dist/lib/sync-status.js +3 -0
- package/dist/lib/terminal/resolve.d.ts +7 -0
- package/dist/lib/terminal/resolve.js +41 -2
- package/dist/lib/traces/classify.js +24 -19
- 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/usage-refresh.js +2 -1
- package/dist/lib/view-types.d.ts +2 -0
- package/dist/lib/watchdog/runner.js +18 -4
- package/package.json +2 -1
package/dist/lib/exec.js
CHANGED
|
@@ -32,6 +32,7 @@ import { isTmuxEnabled, selfConfiguredDeviceRole } from './device-config.js';
|
|
|
32
32
|
import { machineId } from './machine-id.js';
|
|
33
33
|
import { shellQuote } from './ssh-exec.js';
|
|
34
34
|
import { codexEditWritableRoots, codexPolicyArgs } from './codex-policy.js';
|
|
35
|
+
import { probeUnprivilegedUserns } from './linux-userns.js';
|
|
35
36
|
import { resolveClaudeSetupToken } from './claude-account-token.js';
|
|
36
37
|
import { applyAddDirs } from './add-dir.js';
|
|
37
38
|
import { applyActiveRulesPresetAtRun } from './rules/run-sync.js';
|
|
@@ -181,6 +182,69 @@ export function defaultModeFor(agent) {
|
|
|
181
182
|
export function implicitModeFor(agent) {
|
|
182
183
|
return agent === 'codex' ? 'edit' : 'plan';
|
|
183
184
|
}
|
|
185
|
+
/**
|
|
186
|
+
* Preflight for Codex's Linux sandbox. Codex ≥0.146 sandboxes `read-only` and
|
|
187
|
+
* `workspace-write` runs with a bundled bubblewrap that needs an unprivileged
|
|
188
|
+
* user namespace; on a box that restricts it (Ubuntu 24.04
|
|
189
|
+
* `apparmor_restrict_unprivileged_userns=1`) bwrap dies with "setting up uid map:
|
|
190
|
+
* Permission denied" and a HEADLESS codex run lands zero tools — no file writes,
|
|
191
|
+
* no shell — while still reporting a completed turn. That silent under-delivery
|
|
192
|
+
* is what breaks `agents teams` codex teammates (always headless + workspace-write)
|
|
193
|
+
* and headless `agents run codex` alike on the fleet. Returns a loud, actionable
|
|
194
|
+
* message to fail the launch with instead of spawning that doomed run; returns
|
|
195
|
+
* null when the run is fine to proceed.
|
|
196
|
+
*
|
|
197
|
+
* Deliberately scoped: only `codex`, only Linux, only a HEADLESS run (an
|
|
198
|
+
* interactive TUI surfaces the bwrap error to the operator itself), and only a
|
|
199
|
+
* SANDBOXED mode — `skip` is codex `--dangerously-bypass-approvals-and-sandbox`,
|
|
200
|
+
* which uses no bwrap and is unaffected. The intended auto=workspace-write config
|
|
201
|
+
* is never weakened here; the run fails loud rather than silently downgrading.
|
|
202
|
+
* PHNX-3285.
|
|
203
|
+
*/
|
|
204
|
+
export function codexSandboxPreflight(args) {
|
|
205
|
+
if (args.agent !== 'codex')
|
|
206
|
+
return null;
|
|
207
|
+
if (args.platform !== 'linux')
|
|
208
|
+
return null;
|
|
209
|
+
if (args.interactive)
|
|
210
|
+
return null;
|
|
211
|
+
if (args.mode === 'skip')
|
|
212
|
+
return null;
|
|
213
|
+
if (args.userns.state !== 'blocked')
|
|
214
|
+
return null;
|
|
215
|
+
const reason = args.userns.reason ?? 'unprivileged user namespaces are restricted';
|
|
216
|
+
return (`codex's Linux sandbox can't start on ${args.machine}: ${reason}, so codex's ` +
|
|
217
|
+
`bundled bubblewrap fails with "bwrap: setting up uid map: Permission denied" ` +
|
|
218
|
+
`and a headless run lands zero tools. (PHNX-3285)\n` +
|
|
219
|
+
`\n` +
|
|
220
|
+
`Enable it once on this box — keeps codex's workspace-write sandbox intact:\n` +
|
|
221
|
+
` echo 'kernel.apparmor_restrict_unprivileged_userns=0' | sudo tee /etc/sysctl.d/60-codex-userns.conf\n` +
|
|
222
|
+
` sudo sysctl --system\n` +
|
|
223
|
+
` # verify: unshare --user --map-root-user true (exit 0 = fixed)\n` +
|
|
224
|
+
`(agents-cli ships cli/scripts/enable-codex-sandbox.sh to apply + verify this.)\n` +
|
|
225
|
+
`\n` +
|
|
226
|
+
`Or run codex WITHOUT a filesystem sandbox instead: add --mode skip.`);
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Identity a custom-harness run stamps on env / pid-registry / sidecars.
|
|
230
|
+
* `agent` is the host CLI; `harnessName` is the profile the user launched.
|
|
231
|
+
* Empty/whitespace harness names fall back to the host so a blank stamp
|
|
232
|
+
* never hides a real agent.
|
|
233
|
+
*/
|
|
234
|
+
export function stampedAgentName(options) {
|
|
235
|
+
const harness = options.harnessName?.trim();
|
|
236
|
+
return harness || options.agent;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Profile name when it differs from the host agent. Undefined for a native
|
|
240
|
+
* run, so pid-registry / sidecar records stay sparse.
|
|
241
|
+
*/
|
|
242
|
+
export function customHarnessName(options) {
|
|
243
|
+
const harness = options.harnessName?.trim();
|
|
244
|
+
if (!harness || harness === options.agent)
|
|
245
|
+
return undefined;
|
|
246
|
+
return harness;
|
|
247
|
+
}
|
|
184
248
|
/**
|
|
185
249
|
* Resolve interactive vs headless. Explicit flags are definitive and win over
|
|
186
250
|
* inference: `--interactive` forces interactive, `--headless` forces headless.
|
|
@@ -346,8 +410,11 @@ export function buildExecEnv(options) {
|
|
|
346
410
|
result.AGENTS_RUN_MODE = resolveHeadlessMode(options.agent, normalizeMode(options.mode), resolveInteractive(options), options.modeWarningContext, options.modeWarningState);
|
|
347
411
|
result.AGENTS_HISTORY_DIR = getHistoryDir();
|
|
348
412
|
// So activity / feed posts stamp the right harness without re-detecting.
|
|
413
|
+
// A custom-harness run (`agents run deepseek`) must stamp the PROFILE name,
|
|
414
|
+
// not the host CLI (`claude`) — otherwise sessions and feed posts cannot
|
|
415
|
+
// tell the two apart (PHNX-2935).
|
|
349
416
|
if (options.agent) {
|
|
350
|
-
result.AGENTS_AGENT_NAME = options
|
|
417
|
+
result.AGENTS_AGENT_NAME = stampedAgentName(options);
|
|
351
418
|
}
|
|
352
419
|
if (options.cwd) {
|
|
353
420
|
result.AGENTS_CWD = options.cwd;
|
|
@@ -774,8 +841,13 @@ export function buildExecCommand(options) {
|
|
|
774
841
|
// that maps to reasoning effort on a single-model harness (e.g. Grok, where the
|
|
775
842
|
// tier IS the effort dial). `modelVersion` is null when no version resolves;
|
|
776
843
|
// `tierModel` is the concrete model a tier resolved to (null => drop the flag).
|
|
844
|
+
// Prefer the user's explicit --model. Codex falls back to ~/.codex/config.toml
|
|
845
|
+
// (per-version CODEX_HOME may not carry that setting). OpenCode has no model
|
|
846
|
+
// env var — a custom-harness pin lives in OPENCODE_MODEL and must become
|
|
847
|
+
// `--model` here, or the host silently uses its configured default (PHNX-2577).
|
|
777
848
|
const effectiveModel = options.model
|
|
778
|
-
?? (options.agent === 'codex' ? readCodexConfiguredModel() : undefined)
|
|
849
|
+
?? (options.agent === 'codex' ? readCodexConfiguredModel() : undefined)
|
|
850
|
+
?? (options.agent === 'opencode' ? options.env?.OPENCODE_MODEL : undefined);
|
|
779
851
|
const modelVersion = effectiveModel && template.modelFlag
|
|
780
852
|
? (options.version || resolveVersion(options.agent, options.cwd || process.cwd()))
|
|
781
853
|
: null;
|
|
@@ -883,12 +955,8 @@ export function buildExecCommand(options) {
|
|
|
883
955
|
else if (options.sessionId && options.agent === 'claude') {
|
|
884
956
|
cmd.push('--session-id', options.sessionId);
|
|
885
957
|
}
|
|
886
|
-
// Add model.
|
|
887
|
-
//
|
|
888
|
-
// Codex runs under a per-version CODEX_HOME (see buildExecEnv) that may not
|
|
889
|
-
// carry that setting, so without this it silently defaults to gpt-5.3-codex,
|
|
890
|
-
// which a ChatGPT-tier account can't use (HTTP 400). Forwarding keeps the
|
|
891
|
-
// user's default model setup for both `agents run` and `agents teams`.
|
|
958
|
+
// Add model. `effectiveModel` already preferred --model, then Codex's
|
|
959
|
+
// configured default, then an OpenCode custom-harness pin (OPENCODE_MODEL).
|
|
892
960
|
if (effectiveModel && template.modelFlag) {
|
|
893
961
|
if (tierResolved) {
|
|
894
962
|
// Cost tier (cheap|default|best|ultra) -> a concrete model this harness+
|
|
@@ -1176,36 +1244,38 @@ export function isPaneKnownAliveFromQueryResult(code, stdout) {
|
|
|
1176
1244
|
* Decide whether to run an interactive agent INSIDE a detached tmux session on
|
|
1177
1245
|
* the shared socket (then attach the current TTY) instead of a bare spawn.
|
|
1178
1246
|
*
|
|
1179
|
-
*
|
|
1247
|
+
* The wrap is opt-in via this device's `tmux.enabled` (`configEnabled`): a
|
|
1248
|
+
* unique `%pane` handle so `agents sessions --active` can tell co-located
|
|
1249
|
+
* agents apart and `agents focus` re-attaches without forking, plus scrollback
|
|
1250
|
+
* and mouse at the operator's keyboard. Off means OFF, for local and remote
|
|
1251
|
+
* runs alike (PHNX-3316) — a followed `--device` run left bare is protected by
|
|
1252
|
+
* reconnect-and-resume (lib/hosts/reconnect.ts), which rejoins the live pane
|
|
1253
|
+
* when one exists and resumes the harness session from disk when it does not.
|
|
1254
|
+
* The RUSH-3125 forced remote wrap conflated durability with that preference
|
|
1255
|
+
* and surprised every operator who had explicitly left tmux off.
|
|
1180
1256
|
*
|
|
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}).
|
|
1257
|
+
* One case still wraps regardless: a followed remote run whose launcher has
|
|
1258
|
+
* no TTY (CI, scripts, another agent) gives the peer nothing to attach to —
|
|
1259
|
+
* the detached pane is the run's only interface, not an ergonomics choice.
|
|
1189
1260
|
*
|
|
1190
|
-
* The per-run opt-outs bind
|
|
1191
|
-
* explicit "I want the bare process" requests, and
|
|
1192
|
-
*
|
|
1261
|
+
* The per-run opt-outs bind everything: `--raw` / `--no-tmux` /
|
|
1262
|
+
* `AGENTS_NO_TMUX=1` are explicit "I want the bare process" requests, and an
|
|
1263
|
+
* escape hatch that silently stopped applying over `--device` would be worse
|
|
1264
|
+
* than the bare run the user asked for.
|
|
1193
1265
|
*
|
|
1194
1266
|
* Pure, so the gate is unit-tested independently of the (side-effecting) spawn.
|
|
1195
1267
|
*/
|
|
1196
1268
|
export function resolveTmuxWrap(ctx) {
|
|
1197
1269
|
// 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.
|
|
1270
|
+
// nesting tmux-in-tmux is pointless.
|
|
1200
1271
|
if (!ctx.interactive)
|
|
1201
1272
|
return { kind: 'bare' };
|
|
1202
1273
|
if (ctx.platform === 'win32')
|
|
1203
1274
|
return { kind: 'bare' };
|
|
1204
1275
|
if (ctx.inTmux)
|
|
1205
1276
|
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.
|
|
1277
|
+
// Explicit opt-outs win over every other rule: `--raw` exists precisely to
|
|
1278
|
+
// get the unwrapped process.
|
|
1209
1279
|
if (ctx.raw)
|
|
1210
1280
|
return { kind: 'bare' };
|
|
1211
1281
|
if (ctx.noTmuxEnv)
|
|
@@ -1213,10 +1283,13 @@ export function resolveTmuxWrap(ctx) {
|
|
|
1213
1283
|
// Local interactive with no TTY cannot attach. Wrapping anyway creates a
|
|
1214
1284
|
// detached pane, attach returns immediately, and resolveAfterAttach treats
|
|
1215
1285
|
// the still-alive pane as Ctrl-b d — the session-tracker test leak.
|
|
1216
|
-
// Remote dispatch is the exception: --no-follow *intends* a detached pane.
|
|
1217
1286
|
if (!ctx.hasTty && !ctx.remoteDispatch)
|
|
1218
1287
|
return { kind: 'bare' };
|
|
1219
|
-
|
|
1288
|
+
// tmux.enabled gates the wrap for local AND followed remote runs. The one
|
|
1289
|
+
// exception: a followed remote run whose launcher has no TTY (CI, scripts)
|
|
1290
|
+
// gives the peer nothing to attach to — the detached pane is its only
|
|
1291
|
+
// interface, infrastructure rather than ergonomics.
|
|
1292
|
+
if (!ctx.configEnabled && !(ctx.remoteDispatch && !ctx.hasTty))
|
|
1220
1293
|
return { kind: 'bare' };
|
|
1221
1294
|
// Fail loud rather than launch a remote agent that a blink would kill: the
|
|
1222
1295
|
// caller refuses the run instead of starting work that cannot be recovered.
|
|
@@ -1520,6 +1593,7 @@ async function runInTmux(options, executable, args) {
|
|
|
1520
1593
|
writePidSessionEntry({
|
|
1521
1594
|
pid: panePid,
|
|
1522
1595
|
agent: options.agent,
|
|
1596
|
+
harness: customHarnessName(options),
|
|
1523
1597
|
sessionId: options.sessionId,
|
|
1524
1598
|
cwd,
|
|
1525
1599
|
actor: resolveActor().id,
|
|
@@ -1538,6 +1612,7 @@ async function runInTmux(options, executable, args) {
|
|
|
1538
1612
|
sessionId: options.sessionId,
|
|
1539
1613
|
actor: resolveActor().id,
|
|
1540
1614
|
initiatedBy: resolveActor().kind,
|
|
1615
|
+
harness: customHarnessName(options),
|
|
1541
1616
|
startedAtMs: Date.now(),
|
|
1542
1617
|
});
|
|
1543
1618
|
}
|
|
@@ -1623,6 +1698,27 @@ async function spawnAgent(options) {
|
|
|
1623
1698
|
const timeoutMs = options.timeout ? parseTimeout(options.timeout) : undefined;
|
|
1624
1699
|
const piped = !process.stdout.isTTY;
|
|
1625
1700
|
const interactive = resolveInteractive(options);
|
|
1701
|
+
// Fail loud before spawning a headless codex whose Linux sandbox can't start —
|
|
1702
|
+
// otherwise it burns a turn and lands zero tools (PHNX-3285). Cheap gate first
|
|
1703
|
+
// so the userns probe (one `unshare`) runs only for the exact case it applies to
|
|
1704
|
+
// (codex + Linux + headless + a sandboxed mode), never for every spawn.
|
|
1705
|
+
if (options.agent === 'codex' && process.platform === 'linux' && !interactive) {
|
|
1706
|
+
const codexMode = options.mode ? normalizeMode(options.mode) : defaultModeFor(options.agent);
|
|
1707
|
+
if (codexMode !== 'skip') {
|
|
1708
|
+
const message = codexSandboxPreflight({
|
|
1709
|
+
agent: options.agent,
|
|
1710
|
+
platform: process.platform,
|
|
1711
|
+
interactive,
|
|
1712
|
+
mode: codexMode,
|
|
1713
|
+
userns: probeUnprivilegedUserns(),
|
|
1714
|
+
machine: machineId(),
|
|
1715
|
+
});
|
|
1716
|
+
if (message) {
|
|
1717
|
+
process.stderr.write(`\x1b[31m${message}\x1b[0m\n`);
|
|
1718
|
+
return { exitCode: 1, stdout: '', stderr: message };
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1626
1722
|
// Budget live kill-switch (issue #346). For headless runs we incrementally
|
|
1627
1723
|
// parse stream-json usage off stdout, accumulate cost, and kill the child the
|
|
1628
1724
|
// moment a configured cap is crossed — exactly like the --timeout path, but
|
|
@@ -1662,9 +1758,10 @@ async function spawnAgent(options) {
|
|
|
1662
1758
|
args: redactArgs(args.slice(0, 10)),
|
|
1663
1759
|
});
|
|
1664
1760
|
// Interactive spawn-wrap: run the agent INSIDE a shared-socket tmux session
|
|
1665
|
-
// (then attach this TTY) so it gets a unique, addressable %pane
|
|
1666
|
-
//
|
|
1667
|
-
//
|
|
1761
|
+
// (then attach this TTY) so it gets a unique, addressable %pane. Opt-in via
|
|
1762
|
+
// this device's tmux.enabled, local and remote alike (PHNX-3316); a followed
|
|
1763
|
+
// remote run left bare relies on reconnect-and-resume, not a pane. Every
|
|
1764
|
+
// failed guard keeps the bare spawn below.
|
|
1668
1765
|
const tmuxWrap = resolveTmuxWrap({
|
|
1669
1766
|
interactive,
|
|
1670
1767
|
platform: process.platform,
|
|
@@ -1683,10 +1780,15 @@ async function spawnAgent(options) {
|
|
|
1683
1780
|
// there is no launcher-side probe ahead of this one. Failing here still
|
|
1684
1781
|
// fails before the agent starts, which is what matters; a pre-dispatch
|
|
1685
1782
|
// probe would only move the message earlier, at the cost of a round trip on
|
|
1686
|
-
// every launch.
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1783
|
+
// every launch. Reached only when the wrap was wanted: the device opted in
|
|
1784
|
+
// via tmux.enabled, or a followed run whose launcher had no TTY (CI,
|
|
1785
|
+
// scripts) left the peer nothing to attach to, so the pane is its stdout.
|
|
1786
|
+
// The config tip uses the ssh form because tmux.enabled is machine-local:
|
|
1787
|
+
// setting it from the launcher throws (assertLocalTarget).
|
|
1788
|
+
const msg = `agents: ${machineId()} has no tmux, so this --device run cannot get the pane it needs.\n`
|
|
1789
|
+
+ ` install it: (apt|dnf|brew) install tmux\n`
|
|
1790
|
+
+ ` or turn the wrap off: agents ssh ${machineId()} 'agents devices config ${machineId()} tmux.enabled off'\n`
|
|
1791
|
+
+ ` or accept a bare run: agents run … --device ${machineId()} --raw\n`;
|
|
1690
1792
|
process.stderr.write(`\x1b[31m${msg}\x1b[0m`);
|
|
1691
1793
|
timer.end({ exitCode: 1, status: 'failed', error: 'remote interactive run has no tmux for durability' });
|
|
1692
1794
|
return { exitCode: 1, stdout: '', stderr: msg };
|
|
@@ -1737,6 +1839,7 @@ async function spawnAgent(options) {
|
|
|
1737
1839
|
writePidSessionEntry({
|
|
1738
1840
|
pid: child.pid ?? 0,
|
|
1739
1841
|
agent: options.agent,
|
|
1842
|
+
harness: customHarnessName(options),
|
|
1740
1843
|
sessionId: options.sessionId,
|
|
1741
1844
|
cwd: options.cwd || process.cwd(),
|
|
1742
1845
|
actor: resolveActor().id,
|
|
@@ -1751,6 +1854,7 @@ async function spawnAgent(options) {
|
|
|
1751
1854
|
sessionId: options.sessionId,
|
|
1752
1855
|
actor: resolveActor().id,
|
|
1753
1856
|
initiatedBy: resolveActor().kind,
|
|
1857
|
+
harness: customHarnessName(options),
|
|
1754
1858
|
startedAtMs: Date.now(),
|
|
1755
1859
|
});
|
|
1756
1860
|
}
|
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 # A resolution tombstone is written BEFORE unlink (matching TS recordAnswer)\n # so a stale lifecycle re-read cannot resurrect this generation.\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 now_iso = datetime.now(timezone.utc).isoformat()\n try:\n fd = os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)\n record = {\n \"answeredAt\": now_iso,\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 # Tombstone first, then drop the open-block view. A missing/corrupt\n # block means there is nothing to resolve; fail open.\n existing = read_json(target)\n if isinstance(existing, dict):\n generation = existing.get(\"generation\") or existing.get(\"ts\")\n if generation:\n tombstone = {\n \"blockId\": block_id,\n \"generation\": generation,\n \"resolvedAt\": now_iso,\n \"reason\": \"answered\",\n }\n source_cursor = existing.get(\"sourceCursor\")\n if source_cursor:\n tombstone[\"sourceCursor\"] = source_cursor\n write_json(\n os.path.join(feed_dir, \"resolutions\", f\"{block_id}.json\"),\n tombstone,\n )\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',
|
|
@@ -660,13 +666,16 @@ def main():
|
|
|
660
666
|
# Terminal answers (human typed in the TUI) record an answered marker and
|
|
661
667
|
# remove the block file so the feed stops showing it within one poll cycle.
|
|
662
668
|
# The marker stays behind so a concurrent surface cannot double-answer.
|
|
669
|
+
# A resolution tombstone is written BEFORE unlink (matching TS recordAnswer)
|
|
670
|
+
# so a stale lifecycle re-read cannot resurrect this generation.
|
|
663
671
|
if hook_event == "UserPromptSubmit":
|
|
664
672
|
os.makedirs(answered_dir, exist_ok=True)
|
|
665
673
|
marker = os.path.join(answered_dir, f"{block_id}.json")
|
|
674
|
+
now_iso = datetime.now(timezone.utc).isoformat()
|
|
666
675
|
try:
|
|
667
676
|
fd = os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
|
|
668
677
|
record = {
|
|
669
|
-
"answeredAt":
|
|
678
|
+
"answeredAt": now_iso,
|
|
670
679
|
"answeredFrom": "terminal",
|
|
671
680
|
}
|
|
672
681
|
with os.fdopen(fd, "w") as f:
|
|
@@ -675,6 +684,25 @@ def main():
|
|
|
675
684
|
pass
|
|
676
685
|
except Exception:
|
|
677
686
|
pass
|
|
687
|
+
# Tombstone first, then drop the open-block view. A missing/corrupt
|
|
688
|
+
# block means there is nothing to resolve; fail open.
|
|
689
|
+
existing = read_json(target)
|
|
690
|
+
if isinstance(existing, dict):
|
|
691
|
+
generation = existing.get("generation") or existing.get("ts")
|
|
692
|
+
if generation:
|
|
693
|
+
tombstone = {
|
|
694
|
+
"blockId": block_id,
|
|
695
|
+
"generation": generation,
|
|
696
|
+
"resolvedAt": now_iso,
|
|
697
|
+
"reason": "answered",
|
|
698
|
+
}
|
|
699
|
+
source_cursor = existing.get("sourceCursor")
|
|
700
|
+
if source_cursor:
|
|
701
|
+
tombstone["sourceCursor"] = source_cursor
|
|
702
|
+
write_json(
|
|
703
|
+
os.path.join(feed_dir, "resolutions", f"{block_id}.json"),
|
|
704
|
+
tombstone,
|
|
705
|
+
)
|
|
678
706
|
# Remove the visible block so the feed drops the answered question.
|
|
679
707
|
try:
|
|
680
708
|
os.unlink(target)
|
|
@@ -769,7 +797,9 @@ def main():
|
|
|
769
797
|
os.environ.get("AGENTS_MAILBOX_DIR", "").rstrip("/")
|
|
770
798
|
) or session_id
|
|
771
799
|
|
|
772
|
-
|
|
800
|
+
now = datetime.now(timezone.utc)
|
|
801
|
+
now_iso = now.isoformat()
|
|
802
|
+
now_ms = int(now.timestamp() * 1000)
|
|
773
803
|
stats_path = os.path.join(asks_dir, f"{safe_session_id}.json")
|
|
774
804
|
stats = read_json(stats_path) or {}
|
|
775
805
|
recent = stats.get("recentAskTimestamps") if isinstance(stats, dict) else []
|
|
@@ -803,6 +833,9 @@ def main():
|
|
|
803
833
|
"host": host,
|
|
804
834
|
"runtime": runtime,
|
|
805
835
|
"ts": now_iso,
|
|
836
|
+
# Write-time cursor so a new generation is not suppressed when
|
|
837
|
+
# session.lastActivityMs is unresolvable (cloud / remote / index-lag).
|
|
838
|
+
"sourceCursor": {"lastActivityMs": now_ms},
|
|
806
839
|
"questions": normalized_questions,
|
|
807
840
|
"kind": kind,
|
|
808
841
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* a GitHub issue) hears nothing at all. So a post can also be mirrored outward.
|
|
8
8
|
*
|
|
9
9
|
* Sinks are **argv templates from config**, never hardcoded integrations. This
|
|
10
|
-
* CLI ships Apache-2.0 and must not depend on one person's tracker or messaging
|
|
10
|
+
* CLI ships under FSL-1.1-Apache-2.0 and must not depend on one person's tracker or messaging
|
|
11
11
|
* stack; declaring `[linear, update, "{ticket}", --comment, "{text}"]` in
|
|
12
12
|
* `agents.yaml` keeps the coupling in the operator's config where it belongs,
|
|
13
13
|
* and lets someone else point the same mechanism at `jira`, `gh issue comment`,
|
|
@@ -45,6 +45,13 @@ export interface SourceAuth {
|
|
|
45
45
|
/** The captured file payloads, keyed by agent. */
|
|
46
46
|
filesByAgent: Map<string, AuthFilePayload[]>;
|
|
47
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Bundles `fleet apply` considers for push. The reserved file-backed `auth`
|
|
50
|
+
* bundle is always included when it exists locally — it is the fleet-shared
|
|
51
|
+
* setup-token store and must not wait on `--provision-secrets` / a manifest
|
|
52
|
+
* list (PHNX-2371).
|
|
53
|
+
*/
|
|
54
|
+
export declare function fleetSecretsBundles(declared: string[] | undefined): string[];
|
|
48
55
|
export interface DiffContext {
|
|
49
56
|
/** agents-cli version the source is on — the fleet target version. */
|
|
50
57
|
targetCliVersion: string;
|
|
@@ -95,6 +102,10 @@ export interface SecretPushDecision {
|
|
|
95
102
|
* auto-provisions its OWN machine-local key — so each box ends up with an
|
|
96
103
|
* unshared at-rest key and NO passphrase is forwarded. That is the direct
|
|
97
104
|
* alternative to the fleet-wide shared secret this ticket exists to remove.
|
|
105
|
+
*
|
|
106
|
+
* The reserved `auth` bundle is the exception: it is always file-backed
|
|
107
|
+
* (SEC-GAP-3) and is pushed even without `--provision-secrets`, because it is
|
|
108
|
+
* the one fleet-shared setup-token store (PHNX-2371).
|
|
98
109
|
*/
|
|
99
110
|
export declare function decideSecretPush(bundle: string, desired: DeviceDesired, probe: DeviceProbe, ctx: DiffContext): SecretPushDecision;
|
|
100
111
|
export interface ProbeOptions {
|
package/dist/lib/fleet/apply.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import * as os from 'os';
|
|
11
11
|
import { pushBundleToHost } from '../secrets/push.js';
|
|
12
|
+
import { AUTH_BUNDLE_NAME, inspectReservedAuthBundle, isReservedBundleName } from '../secrets/bundles.js';
|
|
12
13
|
import { deviceIdentityArgs, sshTargetFor } from '../devices/connect.js';
|
|
13
14
|
import { readyProbe, bootstrapAgentsCli } from '../hosts/ready.js';
|
|
14
15
|
import { buildRemoteAgentsInvocation } from '../hosts/remote-cmd.js';
|
|
@@ -88,6 +89,20 @@ export function parseInstalledVersions(stdout) {
|
|
|
88
89
|
return undefined;
|
|
89
90
|
}
|
|
90
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* Bundles `fleet apply` considers for push. The reserved file-backed `auth`
|
|
94
|
+
* bundle is always included when it exists locally — it is the fleet-shared
|
|
95
|
+
* setup-token store and must not wait on `--provision-secrets` / a manifest
|
|
96
|
+
* list (PHNX-2371).
|
|
97
|
+
*/
|
|
98
|
+
export function fleetSecretsBundles(declared) {
|
|
99
|
+
const out = [...(declared ?? [])];
|
|
100
|
+
const auth = inspectReservedAuthBundle();
|
|
101
|
+
if (auth.exists && auth.ok && !out.includes(AUTH_BUNDLE_NAME)) {
|
|
102
|
+
out.push(AUTH_BUNDLE_NAME);
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
91
106
|
/** Pure: desired vs probed -> per-device diff + flat action list. */
|
|
92
107
|
export function diffFleet(desired, probes, ctx) {
|
|
93
108
|
const devices = [];
|
|
@@ -208,12 +223,17 @@ export function diffFleet(desired, probes, ctx) {
|
|
|
208
223
|
* auto-provisions its OWN machine-local key — so each box ends up with an
|
|
209
224
|
* unshared at-rest key and NO passphrase is forwarded. That is the direct
|
|
210
225
|
* alternative to the fleet-wide shared secret this ticket exists to remove.
|
|
226
|
+
*
|
|
227
|
+
* The reserved `auth` bundle is the exception: it is always file-backed
|
|
228
|
+
* (SEC-GAP-3) and is pushed even without `--provision-secrets`, because it is
|
|
229
|
+
* the one fleet-shared setup-token store (PHNX-2371).
|
|
211
230
|
*/
|
|
212
231
|
export function decideSecretPush(bundle, desired, probe, ctx) {
|
|
213
232
|
const device = desired.device;
|
|
214
|
-
const
|
|
233
|
+
const reserved = isReservedBundleName(bundle);
|
|
234
|
+
const backend = reserved || probe.platform === 'linux' ? 'file' : 'keychain';
|
|
215
235
|
const manual = `recreate secrets bundle '${bundle}' (\`agents ssh ${device} -- secrets create ${bundle}\`)`;
|
|
216
|
-
if (!ctx.provisionSecrets) {
|
|
236
|
+
if (!ctx.provisionSecrets && !reserved) {
|
|
217
237
|
return { push: false, backend, reason: `${manual} — or re-run with --provision-secrets to push it` };
|
|
218
238
|
}
|
|
219
239
|
if (!probe.reachable) {
|
|
@@ -416,7 +436,7 @@ export function reconcileDevice(row, device, ctx) {
|
|
|
416
436
|
ok = false;
|
|
417
437
|
continue;
|
|
418
438
|
}
|
|
419
|
-
const backend = device.platform === 'linux' ? 'file' : 'keychain';
|
|
439
|
+
const backend = isReservedBundleName(bundle) || device.platform === 'linux' ? 'file' : 'keychain';
|
|
420
440
|
try {
|
|
421
441
|
const out = pushBundleToHost(bundle, target, {
|
|
422
442
|
remoteBackend: backend,
|
|
@@ -136,9 +136,11 @@ export const FLEET_LOGIN_FLOWS = {
|
|
|
136
136
|
successFile: successFileFor('opencode'),
|
|
137
137
|
},
|
|
138
138
|
// claude: interactive `/login` inside the TUI is a loopback browser flow, and
|
|
139
|
-
// the token is keychain-bound on macOS.
|
|
140
|
-
//
|
|
141
|
-
//
|
|
139
|
+
// the token is keychain-bound on macOS. That native login stays per-box and
|
|
140
|
+
// is never copied. The shareable `claude setup-token` mint is a different
|
|
141
|
+
// credential class — wired as `agents accounts mint claude` / `agents auth mint`
|
|
142
|
+
// (`lib/auth-mint.ts`), not as a fleet-login device-code flow (a setup-token
|
|
143
|
+
// is account-scoped, not per-machine).
|
|
142
144
|
claude: {
|
|
143
145
|
loginCommand: 'claude',
|
|
144
146
|
flowType: 'loopback',
|
package/dist/lib/help.d.ts
CHANGED
|
@@ -18,6 +18,15 @@ export interface CommandGroup {
|
|
|
18
18
|
* any group fall back to a plain "Commands:" section below the groups.
|
|
19
19
|
*/
|
|
20
20
|
export declare function registerCommandGroups(parent: Command, groups: readonly CommandGroup[]): void;
|
|
21
|
+
/**
|
|
22
|
+
* Front-door command groups shown on `agents --help`. Derived from measured
|
|
23
|
+
* reach: tier 1 = setup/run/sessions/view, tier 2 = teams/browser/secrets/
|
|
24
|
+
* devices/accounts/add. The remaining groups stay discoverable through the
|
|
25
|
+
* pointer rendered below these groups.
|
|
26
|
+
*/
|
|
27
|
+
export declare const FRONT_DOOR_COMMAND_GROUPS: readonly CommandGroup[];
|
|
28
|
+
/** Mark the root program so its help only renders front-door groups + a pointer. */
|
|
29
|
+
export declare function setCompactRootHelp(program: Command): void;
|
|
21
30
|
/** Examples + Notes blocks attached to a command via setHelpSections. */
|
|
22
31
|
export interface HelpSections {
|
|
23
32
|
examples?: string;
|