@phnx-labs/agents-cli 1.22.22 → 1.22.24
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 +402 -0
- package/README.md +13 -6
- package/dist/bin/agents +0 -0
- package/dist/commands/attach.d.ts +2 -0
- package/dist/commands/attach.js +32 -7
- package/dist/commands/defaults.js +2 -0
- package/dist/commands/doctor.js +20 -7
- package/dist/commands/exec.js +100 -34
- package/dist/commands/feed.d.ts +18 -0
- package/dist/commands/feed.js +44 -1
- package/dist/commands/focus.d.ts +108 -5
- package/dist/commands/focus.js +455 -32
- package/dist/commands/fork.d.ts +20 -2
- package/dist/commands/fork.js +91 -64
- package/dist/commands/go.d.ts +39 -1
- package/dist/commands/go.js +112 -7
- package/dist/commands/harness-wizard.d.ts +206 -0
- package/dist/commands/harness-wizard.js +403 -0
- package/dist/commands/harness.d.ts +12 -0
- package/dist/commands/harness.js +97 -101
- package/dist/commands/insights.js +6 -2
- package/dist/commands/inspect.js +39 -5
- package/dist/commands/menubar.js +6 -1
- package/dist/commands/models.js +1 -0
- package/dist/commands/modes.d.ts +12 -0
- package/dist/commands/modes.js +147 -0
- package/dist/commands/resume.js +10 -3
- package/dist/commands/secrets-sync.js +11 -13
- package/dist/commands/secrets.d.ts +2 -0
- package/dist/commands/secrets.js +79 -47
- package/dist/commands/sessions-browser.d.ts +35 -0
- package/dist/commands/sessions-browser.js +140 -14
- package/dist/commands/sessions-resume.d.ts +23 -3
- package/dist/commands/sessions-resume.js +73 -19
- package/dist/commands/sessions.d.ts +91 -5
- package/dist/commands/sessions.js +359 -125
- package/dist/commands/setup-secrets.js +1 -1
- package/dist/commands/sync.js +246 -42
- package/dist/commands/view.js +2 -0
- package/dist/commands/watchdog.js +13 -2
- package/dist/index.js +2 -1
- package/dist/lib/agent-modes.d.ts +49 -0
- package/dist/lib/agent-modes.js +70 -0
- package/dist/lib/agents.d.ts +1 -1
- package/dist/lib/agents.js +132 -0
- package/dist/lib/codex-policy.d.ts +17 -0
- package/dist/lib/codex-policy.js +48 -0
- package/dist/lib/crabbox/lease.d.ts +25 -0
- package/dist/lib/crabbox/lease.js +62 -0
- package/dist/lib/daemon.js +70 -0
- package/dist/lib/doctor-diff.d.ts +3 -0
- package/dist/lib/doctor-diff.js +15 -13
- package/dist/lib/event-stream.d.ts +3 -1
- package/dist/lib/event-stream.js +14 -1
- package/dist/lib/exec.d.ts +16 -0
- package/dist/lib/exec.js +175 -62
- package/dist/lib/feed-broadcast.d.ts +1 -20
- package/dist/lib/feed-broadcast.js +31 -1
- package/dist/lib/hooks/cache.js +36 -3
- package/dist/lib/hooks.d.ts +19 -7
- package/dist/lib/hooks.js +100 -40
- package/dist/lib/hosts/session-index.d.ts +4 -0
- package/dist/lib/hosts/session-index.js +7 -0
- package/dist/lib/manifest.d.ts +12 -2
- package/dist/lib/manifest.js +60 -5
- package/dist/lib/mcp.js +44 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.d.ts +52 -2
- package/dist/lib/menubar/install-menubar.js +128 -6
- package/dist/lib/models.d.ts +0 -5
- package/dist/lib/models.js +48 -0
- package/dist/lib/plugin-marketplace.js +9 -0
- package/dist/lib/pricing/prices.json +119 -92
- package/dist/lib/pricing/table.js +13 -0
- package/dist/lib/refresh.d.ts +5 -0
- package/dist/lib/refresh.js +37 -33
- package/dist/lib/remote-agents-json.d.ts +29 -1
- package/dist/lib/remote-agents-json.js +47 -10
- package/dist/lib/resource-inventory.d.ts +79 -0
- package/dist/lib/resource-inventory.js +122 -0
- package/dist/lib/resources/mcp.js +2 -0
- package/dist/lib/resources/permissions.js +3 -0
- package/dist/lib/resources/types.d.ts +2 -1
- package/dist/lib/resources.js +8 -5
- package/dist/lib/run-defaults.d.ts +2 -0
- package/dist/lib/run-defaults.js +23 -2
- package/dist/lib/runner.js +50 -36
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/bundles.js +157 -65
- package/dist/lib/secrets/filestore.d.ts +5 -3
- package/dist/lib/secrets/filestore.js +12 -8
- package/dist/lib/secrets/index.d.ts +15 -1
- package/dist/lib/secrets/index.js +148 -53
- package/dist/lib/secrets/reaper.d.ts +97 -0
- package/dist/lib/secrets/reaper.js +219 -0
- package/dist/lib/secrets/remote.d.ts +29 -0
- package/dist/lib/secrets/remote.js +37 -1
- package/dist/lib/secrets/sync-passphrase.d.ts +27 -0
- package/dist/lib/secrets/sync-passphrase.js +78 -0
- package/dist/lib/session/active.d.ts +36 -1
- package/dist/lib/session/active.js +60 -19
- package/dist/lib/session/actor-sidecar.d.ts +14 -0
- package/dist/lib/session/actor-sidecar.js +67 -4
- package/dist/lib/session/db.d.ts +1 -1
- package/dist/lib/session/db.js +32 -1
- package/dist/lib/session/discover.js +168 -0
- package/dist/lib/session/parse.d.ts +10 -0
- package/dist/lib/session/parse.js +98 -0
- package/dist/lib/session/recovery.d.ts +37 -0
- package/dist/lib/session/recovery.js +95 -0
- package/dist/lib/session/remote-list.d.ts +10 -1
- package/dist/lib/session/remote-list.js +2 -8
- package/dist/lib/session/remote.d.ts +57 -3
- package/dist/lib/session/remote.js +90 -26
- package/dist/lib/session/resume-command.d.ts +6 -0
- package/dist/lib/session/resume-command.js +8 -0
- package/dist/lib/session/session-cache.d.ts +173 -0
- package/dist/lib/session/session-cache.js +399 -0
- package/dist/lib/session/types.d.ts +1 -1
- package/dist/lib/session/types.js +1 -1
- package/dist/lib/session/width.d.ts +1 -1
- package/dist/lib/session/width.js +12 -2
- package/dist/lib/shims.d.ts +2 -2
- package/dist/lib/shims.js +62 -8
- package/dist/lib/staleness/writers/hooks.js +1 -1
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/sync-umbrella.d.ts +5 -0
- package/dist/lib/sync-umbrella.js +5 -4
- package/dist/lib/tmux/session.d.ts +4 -2
- package/dist/lib/tmux/session.js +5 -5
- package/dist/lib/types.d.ts +3 -1
- package/dist/lib/usage.d.ts +13 -0
- package/dist/lib/usage.js +215 -0
- package/dist/lib/versions.js +13 -2
- package/package.json +1 -1
package/dist/lib/doctor-diff.js
CHANGED
|
@@ -33,7 +33,8 @@ import { shouldInstallCommandAsSkill, commandSkillMatches, commandSkillName } fr
|
|
|
33
33
|
import { gooseCommandMatches, gooseCommandsDir } from './goose-commands.js';
|
|
34
34
|
import { supports } from './capabilities.js';
|
|
35
35
|
import { listSkillsInVersionHome, getVersionSkillsDir } from './skills.js';
|
|
36
|
-
import {
|
|
36
|
+
import { listHookEntriesFromDir } from './hooks.js';
|
|
37
|
+
import { getResourceInventory } from './resource-inventory.js';
|
|
37
38
|
const RULES_DOC_FILENAME = 'README.md';
|
|
38
39
|
const ALL_KINDS = [
|
|
39
40
|
'commands',
|
|
@@ -300,11 +301,10 @@ function diffSkills(agent, version, cwd, excludeProject = false) {
|
|
|
300
301
|
return rows.sort((a, b) => a.name.localeCompare(b.name));
|
|
301
302
|
}
|
|
302
303
|
// ─── hooks ────────────────────────────────────────────────────────────────────
|
|
303
|
-
function diffHooks(agent, version, cwd) {
|
|
304
|
+
function diffHooks(agent, version, cwd, inventory) {
|
|
304
305
|
if (!AGENTS[agent].supportsHooks)
|
|
305
306
|
return [];
|
|
306
|
-
const
|
|
307
|
-
const installedByName = new Map(installedEntries.map((e) => [e.name, e]));
|
|
307
|
+
const installedByName = new Map(inventory.onDisk.map((e) => [e.name, e]));
|
|
308
308
|
// Sync intentionally excludes project/.agents/hooks/ — mirror that.
|
|
309
309
|
const layerBases = buildLayerBases(cwd, 'hooks', { excludeProject: true });
|
|
310
310
|
// Group source files the same way the hook installer does (basename across
|
|
@@ -329,14 +329,14 @@ function diffHooks(agent, version, cwd) {
|
|
|
329
329
|
continue;
|
|
330
330
|
}
|
|
331
331
|
const a = readSafe(src.entry.scriptPath);
|
|
332
|
-
const b = readSafe(installed.
|
|
332
|
+
const b = readSafe(installed.path);
|
|
333
333
|
let matches = a != null && b != null && normalize(a) === normalize(b);
|
|
334
|
-
if (matches && src.entry.dataFile && installed.
|
|
334
|
+
if (matches && src.entry.dataFile && installed.detail) {
|
|
335
335
|
const ad = readSafe(src.entry.dataFile);
|
|
336
|
-
const bd = readSafe(installed.
|
|
336
|
+
const bd = readSafe(installed.detail);
|
|
337
337
|
matches = ad != null && bd != null && normalize(ad) === normalize(bd);
|
|
338
338
|
}
|
|
339
|
-
else if (matches && (!!src.entry.dataFile !== !!installed.
|
|
339
|
+
else if (matches && (!!src.entry.dataFile !== !!installed.detail)) {
|
|
340
340
|
matches = false;
|
|
341
341
|
}
|
|
342
342
|
rows.push({
|
|
@@ -345,13 +345,13 @@ function diffHooks(agent, version, cwd) {
|
|
|
345
345
|
status: matches ? 'ok' : 'diff',
|
|
346
346
|
source: src.layer,
|
|
347
347
|
sourcePath: src.entry.scriptPath,
|
|
348
|
-
homePath: installed.
|
|
348
|
+
homePath: installed.path,
|
|
349
349
|
});
|
|
350
350
|
}
|
|
351
351
|
for (const [name, installed] of installedByName) {
|
|
352
352
|
if (seen.has(name))
|
|
353
353
|
continue;
|
|
354
|
-
rows.push({ kind: 'hooks', name, status: 'extra', homePath: installed.
|
|
354
|
+
rows.push({ kind: 'hooks', name, status: 'extra', homePath: installed.path });
|
|
355
355
|
}
|
|
356
356
|
return rows.sort((a, b) => a.name.localeCompare(b.name));
|
|
357
357
|
}
|
|
@@ -609,12 +609,13 @@ export function diffVersionResources(agent, version, options = {}) {
|
|
|
609
609
|
empty.commands = diffCommands(agent, version, cwd, excludeProject);
|
|
610
610
|
if (requested.has('skills'))
|
|
611
611
|
empty.skills = diffSkills(agent, version, cwd, excludeProject);
|
|
612
|
-
|
|
613
|
-
|
|
612
|
+
const hookInventory = requested.has('hooks') ? getResourceInventory(agent, version, 'hooks', { cwd }) : undefined;
|
|
613
|
+
if (hookInventory)
|
|
614
|
+
empty.hooks = diffHooks(agent, version, cwd, hookInventory);
|
|
614
615
|
// Wiring check: a hook FILE can be present and byte-identical to source (ok
|
|
615
616
|
// above) yet never referenced in settings.json, so it never fires. Only
|
|
616
617
|
// meaningful when hooks are in scope.
|
|
617
|
-
const hookWiring =
|
|
618
|
+
const hookWiring = hookInventory?.wiring;
|
|
618
619
|
if (requested.has('rules'))
|
|
619
620
|
empty.rules = diffRules(agent, version, cwd, excludeProject);
|
|
620
621
|
if (requested.has('mcp'))
|
|
@@ -654,6 +655,7 @@ export function diffVersionResources(agent, version, options = {}) {
|
|
|
654
655
|
kinds: empty,
|
|
655
656
|
summary: { ok, diff, missing, extra },
|
|
656
657
|
...(hookWiring ? { hookWiring } : {}),
|
|
658
|
+
...(hookInventory ? { hookInventory } : {}),
|
|
657
659
|
};
|
|
658
660
|
}
|
|
659
661
|
export const DOCTOR_ALL_KINDS = ALL_KINDS;
|
|
@@ -36,6 +36,8 @@ export interface UnifiedQuery {
|
|
|
36
36
|
* Read a unified, newest-first event stream. Operational events come from
|
|
37
37
|
* events.ts `query()`; agent-semantic events from the activity logs, normalized
|
|
38
38
|
* to the same record shape and filtered identically. `limit` caps the merged
|
|
39
|
-
* result (each source is fetched up to `limit
|
|
39
|
+
* result (each source is fetched up to `limit` *after* its primary filters —
|
|
40
|
+
* eventTypes for activity, eventTypes/module/bundle for ops — so the top-N is
|
|
41
|
+
* exact for those filters).
|
|
40
42
|
*/
|
|
41
43
|
export declare function readUnifiedEvents(q?: UnifiedQuery): EventRecord[];
|
package/dist/lib/event-stream.js
CHANGED
|
@@ -46,7 +46,9 @@ function matches(r, q) {
|
|
|
46
46
|
* Read a unified, newest-first event stream. Operational events come from
|
|
47
47
|
* events.ts `query()`; agent-semantic events from the activity logs, normalized
|
|
48
48
|
* to the same record shape and filtered identically. `limit` caps the merged
|
|
49
|
-
* result (each source is fetched up to `limit
|
|
49
|
+
* result (each source is fetched up to `limit` *after* its primary filters —
|
|
50
|
+
* eventTypes for activity, eventTypes/module/bundle for ops — so the top-N is
|
|
51
|
+
* exact for those filters).
|
|
50
52
|
*/
|
|
51
53
|
export function readUnifiedEvents(q = {}) {
|
|
52
54
|
// `bundle` is filtered inside query()'s scan (before its limit cutoff) so a
|
|
@@ -66,10 +68,21 @@ export function readUnifiedEvents(q = {}) {
|
|
|
66
68
|
});
|
|
67
69
|
if (q.includeActivity === false)
|
|
68
70
|
return ops;
|
|
71
|
+
// Activity events always stamp module: 'activity'. A non-activity module
|
|
72
|
+
// filter can never match them — skip the activity scan entirely.
|
|
73
|
+
if (q.module != null && q.module !== 'activity')
|
|
74
|
+
return ops;
|
|
75
|
+
// Push eventTypes into the activity reader so `limit` is applied AFTER the
|
|
76
|
+
// event-type filter (readRecentActivity already does this for `events`).
|
|
77
|
+
// Without this, a rare match older than the newest-`limit` window of routine
|
|
78
|
+
// churn is silently dropped — the same class of bug as the ops-side bundle
|
|
79
|
+
// pre-filter above (RUSH-2093). Remaining filters (agent, sessionId, …) still
|
|
80
|
+
// run via matches() for fields activity.ts does not pre-filter.
|
|
69
81
|
const acts = readActivityAsEventRecords({
|
|
70
82
|
sinceMs: q.startDate?.getTime(),
|
|
71
83
|
limit: q.limit,
|
|
72
84
|
root: q.activityRoot,
|
|
85
|
+
events: q.eventTypes,
|
|
73
86
|
}).filter((r) => matches(r, q));
|
|
74
87
|
const merged = [...ops, ...acts].sort((a, b) => Date.parse(b.ts) - Date.parse(a.ts));
|
|
75
88
|
return typeof q.limit === 'number' ? merged.slice(0, q.limit) : merged;
|
package/dist/lib/exec.d.ts
CHANGED
|
@@ -86,6 +86,8 @@ export interface ModeWarningState {
|
|
|
86
86
|
* also maps an unsupported `'plan'` request onto this same value.
|
|
87
87
|
*/
|
|
88
88
|
export declare function defaultModeFor(agent: AgentId): Mode;
|
|
89
|
+
/** Safe mode used when the user did not provide --mode or a configured default. */
|
|
90
|
+
export declare function implicitModeFor(agent: AgentId): ExecMode;
|
|
89
91
|
/** Reasoning effort levels passed to agents that support them. 'auto' defers to the agent's default. */
|
|
90
92
|
export type ExecEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'auto';
|
|
91
93
|
/** Options for spawning an agent process. Omitting `prompt` launches the CLI interactively. */
|
|
@@ -97,6 +99,8 @@ export interface ExecOptions {
|
|
|
97
99
|
/** Force interactive mode even when a prompt is provided. Wins over `headless`. */
|
|
98
100
|
interactive?: boolean;
|
|
99
101
|
mode: ExecMode;
|
|
102
|
+
/** True when the caller omitted --mode; fallback agents resolve their own safe default. */
|
|
103
|
+
modeWasImplicit?: boolean;
|
|
100
104
|
effort: ExecEffort;
|
|
101
105
|
cwd?: string;
|
|
102
106
|
/** Force headless mode even when no prompt is provided (e.g. piping via stdin). */
|
|
@@ -385,7 +389,19 @@ export declare function shouldWrapInTmux(ctx: TmuxWrapContext): boolean;
|
|
|
385
389
|
*/
|
|
386
390
|
export declare function buildTmuxAgentCommand(executable: string, args: string[], env: NodeJS.ProcessEnv, opts?: {
|
|
387
391
|
redactEnvValues?: boolean;
|
|
392
|
+
envFile?: string;
|
|
388
393
|
}): string;
|
|
394
|
+
/**
|
|
395
|
+
* Serialize the pane env to a shell-sourceable file, created 0600 and exclusively
|
|
396
|
+
* (`wx`) so it can never adopt a pre-existing file's mode or content.
|
|
397
|
+
*
|
|
398
|
+
* EVERY key goes in the file, not a curated "secret-bearing" subset: a denylist
|
|
399
|
+
* has to be updated for each new credential and is wrong the moment someone
|
|
400
|
+
* forgets, whereas routing all of it through the file makes the guarantee hold by
|
|
401
|
+
* construction. Keys are filtered to valid shell identifiers for the same reason
|
|
402
|
+
* `env` needed it — an exported function (`BASH_FUNC_x%%`) is not assignable.
|
|
403
|
+
*/
|
|
404
|
+
export declare function writeTmuxEnvFile(env: NodeJS.ProcessEnv, filePath: string): void;
|
|
389
405
|
/**
|
|
390
406
|
* Trim a raw `tmux capture-pane` dump to its last `maxLines` non-empty lines
|
|
391
407
|
* (right-stripping each). Used by runInTmux to recap a fast-failed agent's
|
package/dist/lib/exec.js
CHANGED
|
@@ -17,11 +17,11 @@ import { isTierToken, resolveTier } from './model-tiers.js';
|
|
|
17
17
|
import { createTimer, redactPrompt, redactArgs } from './events.js';
|
|
18
18
|
import { sanitizeProcessEnv } from './secrets/bundles.js';
|
|
19
19
|
import { resolveActor, actorEnv } from './actor.js';
|
|
20
|
-
import { getShimsDir, getHistoryDir,
|
|
20
|
+
import { getShimsDir, getHistoryDir, getRuntimeStateDir } from './state.js';
|
|
21
21
|
import { resolveCodexHome } from './codex-home.js';
|
|
22
22
|
import { readCodexConfiguredModel } from './shims.js';
|
|
23
23
|
import { writePidSessionEntry, extractSessionIdArg } from './session/pid-registry.js';
|
|
24
|
-
import { writeSessionActorRecord } from './session/actor-sidecar.js';
|
|
24
|
+
import { writeSessionActorRecord, writeSessionAliasRecord } from './session/actor-sidecar.js';
|
|
25
25
|
import { loadHookSessionIndex, resolveHookSessionId } from './session/hook-sessions.js';
|
|
26
26
|
import { sessionIdMarkerLine } from './hosts/session-marker.js';
|
|
27
27
|
import { recordRunName } from './session/run-names.js';
|
|
@@ -30,6 +30,7 @@ import { composeWin32CommandLine } from './platform/index.js';
|
|
|
30
30
|
import { isTmuxInstalled } from './tmux/binary.js';
|
|
31
31
|
import { shellQuote } from './ssh-exec.js';
|
|
32
32
|
import { resolveClaudeSetupToken } from './claude-account-token.js';
|
|
33
|
+
import { codexEditWritableRoots, codexPolicyArgs } from './codex-policy.js';
|
|
33
34
|
/**
|
|
34
35
|
* Map a raw mode string (CLI flag, YAML field, env var) to the canonical Mode.
|
|
35
36
|
*
|
|
@@ -172,6 +173,10 @@ export function resolveHeadlessMode(agent, requested, interactive, warningContex
|
|
|
172
173
|
export function defaultModeFor(agent) {
|
|
173
174
|
return AGENTS[agent].capabilities.modes[0];
|
|
174
175
|
}
|
|
176
|
+
/** Safe mode used when the user did not provide --mode or a configured default. */
|
|
177
|
+
export function implicitModeFor(agent) {
|
|
178
|
+
return agent === 'codex' ? 'edit' : 'plan';
|
|
179
|
+
}
|
|
175
180
|
/**
|
|
176
181
|
* Resolve interactive vs headless. Explicit flags are definitive and win over
|
|
177
182
|
* inference: `--interactive` forces interactive, `--headless` forces headless.
|
|
@@ -357,6 +362,28 @@ export function buildExecEnv(options) {
|
|
|
357
362
|
delete result.CODEX_HOME;
|
|
358
363
|
delete result.COPILOT_HOME;
|
|
359
364
|
}
|
|
365
|
+
else if (options.agent === 'muse') {
|
|
366
|
+
// Muse has no MUSE_CONFIG_DIR. Config is XDG-based:
|
|
367
|
+
// $XDG_CONFIG_HOME/muse (settings, skills, hooks, auth)
|
|
368
|
+
// $XDG_DATA_HOME/muse (sessions, plugins)
|
|
369
|
+
// Pin both into the version home so multi-version isolation matches
|
|
370
|
+
// Claude's CLAUDE_CONFIG_DIR / Codex's CODEX_HOME, and so Muse never
|
|
371
|
+
// resolves through the adopt-time ~/.config/muse symlink (SymlinkOrReparse).
|
|
372
|
+
const cwd = options.cwd || process.cwd();
|
|
373
|
+
const resolvedVersion = options.version ?? resolveVersion('muse', cwd);
|
|
374
|
+
const version = options.version
|
|
375
|
+
? resolvedVersion
|
|
376
|
+
: (resolvedVersion && isVersionInstalled('muse', resolvedVersion) ? resolvedVersion : null);
|
|
377
|
+
if (version) {
|
|
378
|
+
const versionHome = getVersionHomePath('muse', version);
|
|
379
|
+
result.XDG_CONFIG_HOME = path.join(versionHome, '.config');
|
|
380
|
+
result.XDG_DATA_HOME = path.join(versionHome, '.local', 'share');
|
|
381
|
+
}
|
|
382
|
+
delete result.CLAUDE_CONFIG_DIR;
|
|
383
|
+
delete result.CODEX_HOME;
|
|
384
|
+
delete result.COPILOT_HOME;
|
|
385
|
+
delete result.KIMI_CODE_HOME;
|
|
386
|
+
}
|
|
360
387
|
else {
|
|
361
388
|
delete result.CLAUDE_CONFIG_DIR;
|
|
362
389
|
delete result.CODEX_HOME;
|
|
@@ -441,11 +468,11 @@ export const AGENT_COMMANDS = {
|
|
|
441
468
|
promptFlag: 'positional',
|
|
442
469
|
resume: { subcommand: 'resume' },
|
|
443
470
|
modeFlags: {
|
|
444
|
-
|
|
445
|
-
//
|
|
446
|
-
//
|
|
447
|
-
|
|
448
|
-
edit: [
|
|
471
|
+
// Native Codex modes are assembled by codexPolicyArgs below. Named
|
|
472
|
+
// permission profiles keep filesystem access and network access
|
|
473
|
+
// independent; legacy --sandbox flags cannot express that combination.
|
|
474
|
+
plan: [],
|
|
475
|
+
edit: [],
|
|
449
476
|
// skip = codex --yolo: drops the sandbox entirely and approves anything.
|
|
450
477
|
skip: ['--dangerously-bypass-approvals-and-sandbox'],
|
|
451
478
|
},
|
|
@@ -637,6 +664,29 @@ export const AGENT_COMMANDS = {
|
|
|
637
664
|
},
|
|
638
665
|
modelFlag: '--model',
|
|
639
666
|
},
|
|
667
|
+
// Meta Muse Code (`muse exec` headless, `muse` TUI). Flags from
|
|
668
|
+
// `muse --help` / `muse exec --help` (v0.1.0): prompt is positional;
|
|
669
|
+
// --model selects muse-spark-*; --reasoning-effort is the effort dial;
|
|
670
|
+
// --disable-write approximates plan (no non-shell writes); --disable-approval
|
|
671
|
+
// keeps the sandbox (auto); --yolo drops approval+sandbox and trusts the
|
|
672
|
+
// workspace (skip). --json emits JSONL on stdout. Interactive resume is
|
|
673
|
+
// `muse resume <id>`; headless resume is `muse exec --session-id <id>` —
|
|
674
|
+
// see the muse resume branch in buildExecCommand.
|
|
675
|
+
muse: {
|
|
676
|
+
base: ['muse', 'exec'],
|
|
677
|
+
promptFlag: 'positional',
|
|
678
|
+
modeFlags: {
|
|
679
|
+
plan: ['--disable-write'],
|
|
680
|
+
edit: [],
|
|
681
|
+
auto: ['--disable-approval'],
|
|
682
|
+
skip: ['--yolo'],
|
|
683
|
+
},
|
|
684
|
+
jsonFlags: ['--json'],
|
|
685
|
+
modelFlag: '--model',
|
|
686
|
+
// Flag form covers headless (`--session-id`). Interactive uses the
|
|
687
|
+
// `resume` subcommand — special-cased in buildExecCommand.
|
|
688
|
+
resume: { flag: '--session-id' },
|
|
689
|
+
},
|
|
640
690
|
};
|
|
641
691
|
/**
|
|
642
692
|
* Whether `agent` has a native resume form (Tier 1). Derived solely from the
|
|
@@ -665,12 +715,13 @@ export function buildExecCommand(options) {
|
|
|
665
715
|
const template = AGENT_COMMANDS[options.agent];
|
|
666
716
|
const cmd = [...template.base];
|
|
667
717
|
const interactive = resolveInteractive(options);
|
|
668
|
-
// For Codex and
|
|
669
|
-
// is. Drop it for interactive mode so we launch the TUI (`codex` /
|
|
670
|
-
// `
|
|
671
|
-
//
|
|
718
|
+
// For Codex, Droid, and Muse, 'exec' is the headless subcommand; for OpenCode,
|
|
719
|
+
// 'run' is. Drop it for interactive mode so we launch the TUI (`codex` /
|
|
720
|
+
// `droid` / `muse` / `opencode`) instead of the one-shot headless subcommand
|
|
721
|
+
// ('codex exec' / 'droid exec' / 'muse exec' / 'opencode run').
|
|
672
722
|
if (interactive) {
|
|
673
|
-
if ((options.agent === 'codex' || options.agent === 'droid'
|
|
723
|
+
if ((options.agent === 'codex' || options.agent === 'droid' || options.agent === 'muse') &&
|
|
724
|
+
cmd[1] === 'exec') {
|
|
674
725
|
cmd.splice(1, 1);
|
|
675
726
|
}
|
|
676
727
|
else if (options.agent === 'opencode' && cmd[1] === 'run') {
|
|
@@ -682,8 +733,18 @@ export function buildExecCommand(options) {
|
|
|
682
733
|
// interactive drop above, `codex` -> `codex resume` (TUI). The session id is
|
|
683
734
|
// pushed later as the first positional (before any prompt). `{ flag }` agents
|
|
684
735
|
// (claude) need no base change — the flag is appended with the id below.
|
|
736
|
+
//
|
|
737
|
+
// Muse interactive resume is also a subcommand (`muse resume <id>`), but the
|
|
738
|
+
// session id MUST sit immediately after the verb — mode/model flags come
|
|
739
|
+
// after. So for interactive muse we push both `resume` and the id here, and
|
|
740
|
+
// skip the later generic resume flag path.
|
|
685
741
|
const resumeSpec = options.resume ? template.resume : undefined;
|
|
686
|
-
|
|
742
|
+
let museInteractiveResumeDone = false;
|
|
743
|
+
if (options.agent === 'muse' && options.resume && interactive && options.sessionId) {
|
|
744
|
+
cmd.push('resume', options.sessionId);
|
|
745
|
+
museInteractiveResumeDone = true;
|
|
746
|
+
}
|
|
747
|
+
else if (resumeSpec && 'subcommand' in resumeSpec) {
|
|
687
748
|
cmd.push(resumeSpec.subcommand);
|
|
688
749
|
}
|
|
689
750
|
// Use versioned alias if a specific version was requested (e.g., claude@2.1.98).
|
|
@@ -764,40 +825,26 @@ export function buildExecCommand(options) {
|
|
|
764
825
|
// narrower than --yolo/-f, which also bypasses permission checks.
|
|
765
826
|
cmd.push('--trust');
|
|
766
827
|
}
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
// inner writes fail with EROFS (e.g. `agents ssh` dies before connecting),
|
|
774
|
-
// which is why a remote `agents run codex` couldn't reach the fleet. Grant it
|
|
775
|
-
// implicitly whenever codex runs workspace-write — far narrower than --mode skip
|
|
776
|
-
// (danger-full-access). Fresh runs take --add-dir (below); resume forms reject
|
|
777
|
-
// --add-dir, so they take the same root via -c writable_roots here.
|
|
778
|
-
const codexWorkspaceWrite = options.agent === 'codex' && resolvedMode === 'edit';
|
|
779
|
-
if (resumeSpec && 'subcommand' in resumeSpec) {
|
|
828
|
+
if (options.agent === 'codex') {
|
|
829
|
+
const policyMode = resolvedMode === 'plan' || resolvedMode === 'skip' ? resolvedMode : 'edit';
|
|
830
|
+
const writableRoots = [...codexEditWritableRoots(), ...(options.addDirs ?? [])];
|
|
831
|
+
cmd.push(...codexPolicyArgs(policyMode, writableRoots));
|
|
832
|
+
}
|
|
833
|
+
else if (resumeSpec && 'subcommand' in resumeSpec) {
|
|
780
834
|
if (resolvedMode === 'skip') {
|
|
781
835
|
// skip = yolo on resume too; both `codex resume` (TUI) and
|
|
782
836
|
// `codex exec resume` accept the bypass flag.
|
|
783
837
|
cmd.push('--dangerously-bypass-approvals-and-sandbox');
|
|
784
838
|
}
|
|
785
839
|
else if (interactive) {
|
|
786
|
-
// `codex resume` (TUI) accepts the same -s/--sandbox flags as a fresh run.
|
|
787
840
|
cmd.push(...modeFlags);
|
|
788
|
-
if (codexWorkspaceWrite)
|
|
789
|
-
cmd.push('-c', codexWritableRootsConfig(getUserAgentsDir()));
|
|
790
841
|
}
|
|
791
842
|
else {
|
|
792
843
|
// `codex exec resume` rejects `--sandbox <mode>` (verified against
|
|
793
844
|
// `codex exec resume --help` on 0.142.5), but takes -c config overrides —
|
|
794
845
|
// map the mode through sandbox_mode so a non-skip resume never gets the
|
|
795
846
|
// approval/sandbox bypass.
|
|
796
|
-
cmd.push(
|
|
797
|
-
if (resolvedMode !== 'plan') {
|
|
798
|
-
cmd.push('-c', 'sandbox_workspace_write.network_access=true');
|
|
799
|
-
cmd.push('-c', codexWritableRootsConfig(getUserAgentsDir()));
|
|
800
|
-
}
|
|
847
|
+
cmd.push(...modeFlags);
|
|
801
848
|
}
|
|
802
849
|
}
|
|
803
850
|
else if (options.agent === 'kimi' && !interactive) {
|
|
@@ -835,7 +882,7 @@ export function buildExecCommand(options) {
|
|
|
835
882
|
// agents (codex) already pushed the verb above, so the id is the first
|
|
836
883
|
// positional here — placed before the prompt positional appended later. Without
|
|
837
884
|
// `resume`, the legacy claude-only `--session-id` CREATES a session with that id.
|
|
838
|
-
if (options.resume && options.sessionId && resumeSpec) {
|
|
885
|
+
if (options.resume && options.sessionId && resumeSpec && !museInteractiveResumeDone) {
|
|
839
886
|
if ('flag' in resumeSpec) {
|
|
840
887
|
const flag = interactive
|
|
841
888
|
? (resumeSpec.interactiveFlag ?? resumeSpec.flag)
|
|
@@ -913,21 +960,10 @@ export function buildExecCommand(options) {
|
|
|
913
960
|
cmd.push(template.promptFlag, options.prompt);
|
|
914
961
|
}
|
|
915
962
|
}
|
|
916
|
-
//
|
|
917
|
-
//
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
// the approval/sandbox bypass. Codex's resume forms reject --add-dir, so
|
|
921
|
-
// skip it there (claude's flag-based resume accepts it).
|
|
922
|
-
//
|
|
923
|
-
// On top of any user-supplied dirs, a fresh codex workspace-write run
|
|
924
|
-
// implicitly gets ~/.agents (deduped) so the CLI's own tooling — askpass,
|
|
925
|
-
// secrets, sessions, tunings — can write from inside the sandbox. Resume forms
|
|
926
|
-
// get the same root via -c writable_roots above (see codexWorkspaceWrite).
|
|
927
|
-
const codexImplicitDirs = codexWorkspaceWrite && !options.resume ? [getUserAgentsDir()] : [];
|
|
928
|
-
const addDirs = [...new Set([...(options.addDirs ?? []), ...codexImplicitDirs])];
|
|
929
|
-
if (addDirs.length > 0 &&
|
|
930
|
-
(options.agent === 'claude' || (options.agent === 'codex' && !options.resume))) {
|
|
963
|
+
// Codex add-dirs are folded into its named edit profile above, including on
|
|
964
|
+
// resume where the native CLI rejects --add-dir. Claude keeps its native flag.
|
|
965
|
+
const addDirs = [...new Set(options.addDirs ?? [])];
|
|
966
|
+
if (addDirs.length > 0 && options.agent === 'claude') {
|
|
931
967
|
for (const dir of addDirs) {
|
|
932
968
|
cmd.push('--add-dir', dir);
|
|
933
969
|
}
|
|
@@ -1024,8 +1060,11 @@ export async function execShimPassthrough(agent, rawArgs, cwd, pinnedVersion) {
|
|
|
1024
1060
|
if (fs.existsSync(cmdPath))
|
|
1025
1061
|
binary = cmdPath;
|
|
1026
1062
|
}
|
|
1027
|
-
//
|
|
1028
|
-
|
|
1063
|
+
// Match the POSIX shim: direct Codex launches default to the safe writable
|
|
1064
|
+
// profile, while later user arguments can still override native settings.
|
|
1065
|
+
const launchArgs = agent === 'codex'
|
|
1066
|
+
? ['-c', 'check_for_update_on_startup=false', ...codexPolicyArgs('edit')]
|
|
1067
|
+
: [];
|
|
1029
1068
|
// Mint a launch id and export it as AGENT_LAUNCH_ID so the agent's SessionStart
|
|
1030
1069
|
// hook records the same id — the join key that maps this launch to its exact
|
|
1031
1070
|
// session even though the recorded pid here is the cmd.exe wrapper (Windows) or
|
|
@@ -1150,13 +1189,53 @@ export function shouldWrapInTmux(ctx) {
|
|
|
1150
1189
|
* real values; the stored/informational copy uses the redacted form (RUSH-1758).
|
|
1151
1190
|
*/
|
|
1152
1191
|
export function buildTmuxAgentCommand(executable, args, env, opts = {}) {
|
|
1192
|
+
const agentCmd = [executable, ...args].map(shellQuote).join(' ');
|
|
1193
|
+
// envFile: source the values instead of inlining them, so no VALUE ever lands
|
|
1194
|
+
// in the pane's argv. `exec env K=V …` put every resolved secret into the
|
|
1195
|
+
// process table, readable by any process of this user — on one fleet box six
|
|
1196
|
+
// live processes carried the secrets-store master passphrase, which decrypts
|
|
1197
|
+
// every file-backed bundle including the Claude OAuth tokens (RUSH-2100).
|
|
1198
|
+
// `set -a` exports what the file assigns; the file is unlinked before `exec`,
|
|
1199
|
+
// so it exists only for the sourcing itself. A missing file aborts the pane
|
|
1200
|
+
// rather than silently launching with a half-built env.
|
|
1201
|
+
if (opts.envFile) {
|
|
1202
|
+
const f = shellQuote(opts.envFile);
|
|
1203
|
+
// Remove the file whether or not sourcing succeeds — a bare `. f || exit 1`
|
|
1204
|
+
// strands the plaintext env (incl. the secrets-store master passphrase) on
|
|
1205
|
+
// disk on any source failure, worse than the argv leak this replaces
|
|
1206
|
+
// (RUSH-2100). Capture the source rc, unlink, then honor it.
|
|
1207
|
+
return `set -a; . ${f}; __agents_rc=$?; set +a; rm -f ${f}; [ "$__agents_rc" -eq 0 ] || exit 1; exec ${agentCmd}`;
|
|
1208
|
+
}
|
|
1153
1209
|
const envPrefix = Object.entries(env)
|
|
1154
1210
|
.filter(([k, v]) => v !== undefined && EXEC_ENV_KEY_PATTERN.test(k))
|
|
1155
1211
|
.map(([k, v]) => `${k}=${opts.redactEnvValues ? '<redacted>' : shellQuote(String(v))}`)
|
|
1156
1212
|
.join(' ');
|
|
1157
|
-
const agentCmd = [executable, ...args].map(shellQuote).join(' ');
|
|
1158
1213
|
return `exec env ${envPrefix} ${agentCmd}`;
|
|
1159
1214
|
}
|
|
1215
|
+
/**
|
|
1216
|
+
* Serialize the pane env to a shell-sourceable file, created 0600 and exclusively
|
|
1217
|
+
* (`wx`) so it can never adopt a pre-existing file's mode or content.
|
|
1218
|
+
*
|
|
1219
|
+
* EVERY key goes in the file, not a curated "secret-bearing" subset: a denylist
|
|
1220
|
+
* has to be updated for each new credential and is wrong the moment someone
|
|
1221
|
+
* forgets, whereas routing all of it through the file makes the guarantee hold by
|
|
1222
|
+
* construction. Keys are filtered to valid shell identifiers for the same reason
|
|
1223
|
+
* `env` needed it — an exported function (`BASH_FUNC_x%%`) is not assignable.
|
|
1224
|
+
*/
|
|
1225
|
+
export function writeTmuxEnvFile(env, filePath) {
|
|
1226
|
+
const body = Object.entries(env)
|
|
1227
|
+
.filter(([k, v]) => v !== undefined && EXEC_ENV_KEY_PATTERN.test(k))
|
|
1228
|
+
.map(([k, v]) => `${k}=${shellQuote(String(v))}`)
|
|
1229
|
+
.join('\n');
|
|
1230
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
1231
|
+
const fd = fs.openSync(filePath, 'wx', 0o600);
|
|
1232
|
+
try {
|
|
1233
|
+
fs.writeSync(fd, `${body}\n`);
|
|
1234
|
+
}
|
|
1235
|
+
finally {
|
|
1236
|
+
fs.closeSync(fd);
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1160
1239
|
/**
|
|
1161
1240
|
* Trim a raw `tmux capture-pane` dump to its last `maxLines` non-empty lines
|
|
1162
1241
|
* (right-stripping each). Used by runInTmux to recap a fast-failed agent's
|
|
@@ -1192,24 +1271,57 @@ export function formatPaneTail(raw, maxLines = 30) {
|
|
|
1192
1271
|
* (Ctrl-b d) — return 0 and LEAVE the session for `agents focus` to re-attach.
|
|
1193
1272
|
*/
|
|
1194
1273
|
async function runInTmux(options, executable, args) {
|
|
1195
|
-
const { createSession, killSession, paneExitStatus, setSessionHook, slugifyName, agentPaneDiedHook, markSessionHookSchema } = await import('./tmux/session.js');
|
|
1274
|
+
const { createSession, hasSession, killSession, paneExitStatus, setSessionHook, slugifyName, agentPaneDiedHook, markSessionHookSchema } = await import('./tmux/session.js');
|
|
1196
1275
|
const { getDefaultSocketPath } = await import('./tmux/paths.js');
|
|
1197
1276
|
const { attachTmux, runTmux } = await import('./tmux/binary.js');
|
|
1198
1277
|
const socket = getDefaultSocketPath();
|
|
1199
1278
|
const cwd = options.cwd || process.cwd();
|
|
1200
1279
|
const idSeed = (options.sessionId ?? randomUUID()).slice(0, 8);
|
|
1201
1280
|
const name = slugifyName(`ag-${options.agent}-${idSeed}`);
|
|
1202
|
-
|
|
1203
|
-
//
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1281
|
+
// A native resume must not create a competing wrapper for a live session.
|
|
1282
|
+
// A retained dead pane is reaped before the harness resumes normally.
|
|
1283
|
+
if (options.resume && await hasSession(name, socket)) {
|
|
1284
|
+
const existing = await createSession({ name, cwd, socket, source: 'cli', attachExisting: true });
|
|
1285
|
+
if (options.sessionId)
|
|
1286
|
+
writeSessionAliasRecord(options.sessionId, name);
|
|
1287
|
+
if (!existing.pane || !(await paneExitStatus(existing.pane, socket)).dead) {
|
|
1288
|
+
await attachTmux({ socket, args: ['attach-session', '-t', name] });
|
|
1289
|
+
return { exitCode: 0, stderr: '', stdout: '' };
|
|
1290
|
+
}
|
|
1291
|
+
await killSession(name, socket);
|
|
1292
|
+
}
|
|
1293
|
+
// SessionStart learns some harness IDs only after launch. Carry the wrapper
|
|
1294
|
+
// name into that hook so it can bind both identities durably.
|
|
1295
|
+
const execEnv = { ...buildExecEnv(options), AGENT_TMUX_SESSION_NAME: name };
|
|
1296
|
+
// The pane sources its env from a 0600 file it unlinks before exec, so no
|
|
1297
|
+
// resolved secret VALUE reaches the process table (RUSH-2100). SessionMeta.cmd
|
|
1298
|
+
// keeps the value-redacted inline form — the human-readable record of what ran,
|
|
1299
|
+
// which never carried real values anyway (RUSH-1758).
|
|
1300
|
+
const envFile = path.join(getRuntimeStateDir(), 'tmux-env', `${name}-${randomUUID().slice(0, 8)}.env`);
|
|
1301
|
+
writeTmuxEnvFile(execEnv, envFile);
|
|
1302
|
+
const cmd = buildTmuxAgentCommand(executable, args, execEnv, { envFile });
|
|
1207
1303
|
const metaCmd = buildTmuxAgentCommand(executable, args, execEnv, { redactEnvValues: true });
|
|
1208
1304
|
const labels = { agent: options.agent };
|
|
1209
1305
|
if (options.sessionId)
|
|
1210
1306
|
labels.sessionId = options.sessionId;
|
|
1211
|
-
|
|
1307
|
+
// Only a launched pane sources-and-unlinks the env file. If createSession
|
|
1308
|
+
// throws, the pane never runs, so the resolved secrets (incl. the master
|
|
1309
|
+
// passphrase) would linger on disk — remove it on that failure path
|
|
1310
|
+
// (RUSH-2100). On success the detached pane owns the unlink.
|
|
1311
|
+
let meta;
|
|
1312
|
+
try {
|
|
1313
|
+
meta = await createSession({ name, cmd, metaCmd, cwd, socket, source: 'cli', labels });
|
|
1314
|
+
}
|
|
1315
|
+
catch (err) {
|
|
1316
|
+
try {
|
|
1317
|
+
fs.rmSync(envFile, { force: true });
|
|
1318
|
+
}
|
|
1319
|
+
catch { /* best-effort */ }
|
|
1320
|
+
throw err;
|
|
1321
|
+
}
|
|
1212
1322
|
const pane = meta.pane;
|
|
1323
|
+
if (options.sessionId)
|
|
1324
|
+
writeSessionAliasRecord(options.sessionId, name);
|
|
1213
1325
|
if (pane) {
|
|
1214
1326
|
// When the AGENT pane dies, detach the client (don't kill) so the session
|
|
1215
1327
|
// survives just long enough to read the dead pane's exit status below. The
|
|
@@ -1284,7 +1396,7 @@ async function runInTmux(options, executable, args) {
|
|
|
1284
1396
|
};
|
|
1285
1397
|
// The agent could exit before we attach (fast failure). Don't attach to an
|
|
1286
1398
|
// already-dead pane — surface its output + status directly and tear down.
|
|
1287
|
-
const before = pane ? await paneExitStatus(pane, socket) : { dead: false };
|
|
1399
|
+
const before = pane ? await paneExitStatus(pane, socket) : { found: false, dead: false, status: undefined };
|
|
1288
1400
|
if (before.dead) {
|
|
1289
1401
|
// F2 (RUSH-2185 / EXEC-23a): for interactive runs, ALWAYS recap — a clean
|
|
1290
1402
|
// exit-0 before attach means the harness has no interactive REPL and the
|
|
@@ -1310,7 +1422,7 @@ async function runInTmux(options, executable, args) {
|
|
|
1310
1422
|
return false;
|
|
1311
1423
|
}
|
|
1312
1424
|
};
|
|
1313
|
-
const after = pane ? await paneExitStatus(pane, socket) : { dead: false };
|
|
1425
|
+
const after = pane ? await paneExitStatus(pane, socket) : { found: false, dead: false, status: undefined };
|
|
1314
1426
|
if (after.dead) {
|
|
1315
1427
|
// Nonzero exit after attach → the agent crashed rather than the user
|
|
1316
1428
|
// detaching cleanly (a clean detach leaves the pane ALIVE, handled below).
|
|
@@ -1873,6 +1985,7 @@ export async function runWithFallback(options) {
|
|
|
1873
1985
|
...options,
|
|
1874
1986
|
agent,
|
|
1875
1987
|
version,
|
|
1988
|
+
mode: options.modeWasImplicit ? implicitModeFor(agent) : options.mode,
|
|
1876
1989
|
prompt,
|
|
1877
1990
|
env: envOverride ? { ...(options.env ?? {}), ...envOverride } : options.env,
|
|
1878
1991
|
sessionId: pinnedSessionId ?? (i === 0 ? options.sessionId : undefined),
|
|
@@ -141,26 +141,7 @@ export declare function scrubOutboundDashes(text: string): string;
|
|
|
141
141
|
* host last. Skip the uninformative default label `agent`.
|
|
142
142
|
*/
|
|
143
143
|
export declare function composeBroadcastFooter(ctx: FeedBroadcastContext): string | undefined;
|
|
144
|
-
|
|
145
|
-
* Human-facing body for a messaging sink (`{message}`).
|
|
146
|
-
*
|
|
147
|
-
* ```
|
|
148
|
-
* Title in a few words
|
|
149
|
-
*
|
|
150
|
-
* Body of what happened or the ask.
|
|
151
|
-
* Options: publish / wait (blocks with choices)
|
|
152
|
-
* Default in 15 min: wait (blocks with a safe default)
|
|
153
|
-
*
|
|
154
|
-
* Sent from grok/a02da0e2 on mac-mini
|
|
155
|
-
* https://… (optional attach URL)
|
|
156
|
-
* ```
|
|
157
|
-
*
|
|
158
|
-
* Title first (scannable subject). Blank line. Body. Then the phone-actionable
|
|
159
|
-
* choices + default (a block that has stopped for the human), then footer
|
|
160
|
-
* provenance. No `agents focus <id>` line: a CLI command is unusable from a phone,
|
|
161
|
-
* so the safe default is the fallback and the message carries it. Prefer `{message}`
|
|
162
|
-
* over bare `{text}` in messaging sinks.
|
|
163
|
-
*/
|
|
144
|
+
export declare function truncateBroadcastBody(body: string): string;
|
|
164
145
|
export declare function composeBroadcastMessage(ctx: FeedBroadcastContext): string;
|
|
165
146
|
/**
|
|
166
147
|
* Substitute `{placeholder}` tokens in an argv template. Returns undefined when
|
|
@@ -202,9 +202,39 @@ export function composeBroadcastFooter(ctx) {
|
|
|
202
202
|
* so the safe default is the fallback and the message carries it. Prefer `{message}`
|
|
203
203
|
* over bare `{text}` in messaging sinks.
|
|
204
204
|
*/
|
|
205
|
+
/**
|
|
206
|
+
* The phone copy is a text, not a report. A long body reaches the owner's phone
|
|
207
|
+
* as an unreadable wall, and the `feed post` forwarding path is where that has to
|
|
208
|
+
* be shaped: every sink (owner alias, in-process `channel:`, spawned `command:`)
|
|
209
|
+
* funnels through {@link composeBroadcastMessage}, which is the only seam a shell
|
|
210
|
+
* hook cannot reach. Keep the title (the scannable headline) and cap the BODY to a
|
|
211
|
+
* short excerpt, marking the cut with a plain "(full in feed)" — NOT a CLI command
|
|
212
|
+
* (unusable from a phone, see the note on composeBroadcastMessage). Nothing is
|
|
213
|
+
* lost: this shapes only the outbound sink text; the full post stays in the feed.
|
|
214
|
+
*/
|
|
215
|
+
const PHONE_BODY_MAX_CHARS = 500;
|
|
216
|
+
const PHONE_BODY_MAX_LINES = 8;
|
|
217
|
+
export function truncateBroadcastBody(body) {
|
|
218
|
+
if (!body)
|
|
219
|
+
return body;
|
|
220
|
+
let out = body;
|
|
221
|
+
let cut = false;
|
|
222
|
+
const lines = out.split('\n');
|
|
223
|
+
if (lines.length > PHONE_BODY_MAX_LINES) {
|
|
224
|
+
out = lines.slice(0, PHONE_BODY_MAX_LINES).join('\n');
|
|
225
|
+
cut = true;
|
|
226
|
+
}
|
|
227
|
+
if (out.length > PHONE_BODY_MAX_CHARS) {
|
|
228
|
+
out = out.slice(0, PHONE_BODY_MAX_CHARS);
|
|
229
|
+
cut = true;
|
|
230
|
+
}
|
|
231
|
+
if (!cut)
|
|
232
|
+
return body;
|
|
233
|
+
return `${out.trimEnd()}\n… (full in feed)`;
|
|
234
|
+
}
|
|
205
235
|
export function composeBroadcastMessage(ctx) {
|
|
206
236
|
const title = scrubOutboundDashes(ctx.title ?? '');
|
|
207
|
-
const body = scrubOutboundDashes(ctx.text ?? '');
|
|
237
|
+
const body = truncateBroadcastBody(scrubOutboundDashes(ctx.text ?? ''));
|
|
208
238
|
// Title preferred; if an older post has no title, body alone still sends.
|
|
209
239
|
const head = title || body;
|
|
210
240
|
const mid = title && body && title !== body ? body : undefined;
|