@phnx-labs/agents-cli 1.22.112 → 1.22.113
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 +8 -0
- package/dist/commands/sessions.d.ts +1 -0
- package/dist/commands/sessions.js +12 -0
- package/dist/lib/daemon/daemon.d.ts +0 -8
- package/dist/lib/daemon/daemon.js +41 -22
- package/dist/lib/daemon/testdata/process-view-daemon.d.ts +1 -0
- package/dist/lib/daemon/testdata/process-view-daemon.js +14 -0
- package/dist/lib/daemon/testdata/process-view-start.d.ts +1 -0
- package/dist/lib/daemon/testdata/process-view-start.js +43 -0
- package/dist/lib/event-provenance.d.ts +2 -0
- package/dist/lib/event-provenance.js +4 -0
- package/dist/lib/exec.js +27 -7
- package/dist/lib/feed/activity-stream.js +9 -0
- package/dist/lib/feed/activity.d.ts +8 -0
- package/dist/lib/feed/activity.js +61 -18
- package/dist/lib/feed/watch.d.ts +9 -0
- package/dist/lib/feed/watch.js +91 -3
- package/dist/lib/feed-post.js +1 -1
- package/dist/lib/hosts/dispatch.d.ts +0 -10
- package/dist/lib/hosts/dispatch.js +2 -19
- package/dist/lib/hosts/remote-session-id.d.ts +5 -27
- package/dist/lib/hosts/remote-session-id.js +18 -61
- package/dist/lib/launch-identity.d.ts +10 -0
- package/dist/lib/launch-identity.js +31 -0
- package/dist/lib/session/active.js +9 -3
- package/dist/lib/session/db.d.ts +2 -1
- package/dist/lib/session/db.js +39 -4
- package/dist/lib/session/discover.js +7 -2
- package/dist/lib/session/hook-sessions.d.ts +10 -0
- package/dist/lib/session/hook-sessions.js +139 -6
- package/dist/lib/session/pid-registry.d.ts +18 -5
- package/dist/lib/session/pid-registry.js +164 -17
- package/dist/lib/session/process-view.d.ts +25 -0
- package/dist/lib/session/process-view.js +176 -0
- package/dist/lib/session/projection.d.ts +13 -0
- package/dist/lib/session/projection.js +101 -0
- package/dist/lib/session/remote/watch.d.ts +8 -0
- package/dist/lib/session/remote/watch.js +7 -3
- package/dist/lib/session/session-cache.js +15 -0
- package/dist/lib/session/testdata/pid-registry-namespace.d.ts +1 -0
- package/dist/lib/session/testdata/pid-registry-namespace.js +27 -0
- package/dist/lib/session/testdata/process-view-namespace.d.ts +1 -0
- package/dist/lib/session/testdata/process-view-namespace.js +56 -0
- package/dist/lib/session/testdata/process-view-private.d.ts +1 -0
- package/dist/lib/session/testdata/process-view-private.js +12 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.22.113
|
|
4
|
+
|
|
5
|
+
- **Remote sessions have one execution-owned row (PHNX-3939).** Fleet session and feed streams reconcile launcher observations with the worker's state, retaining terminal/viewing metadata separately. Reconnects and stale history cannot replace a genuine worker row. Registry cleanup retains records whose PID namespace cannot be verified; nested agent runs keep launch lineage without claiming their parent's editor terminal.
|
|
6
|
+
- **Codex forks retain their child identity.** Full and incremental scans keep the first native metadata record. Reindexing repairs cold files and removes a stale parent binding to child bytes even when the parent rollout is absent. Team descendants also keep their immediate parent lineage without inheriting its editor ownership.
|
|
7
|
+
- **Nested daemon starts preserve shared session state.** Legacy migration and reboot recovery require the initial host namespace or kernel-verified live daemon ownership before touching singleton state. Foreign snapshots remain read-only, and invisible processes are never treated as dead. Automatic reuse of a private-container HOME across namespaces is unsupported, including after the prior namespace exits; writers fail safely with a diagnostic.
|
|
8
|
+
|
|
9
|
+
- Repair session indexes whose schema version is current but whose identity columns are missing, preserving existing session metadata (PHNX-3940).
|
|
10
|
+
|
|
3
11
|
## 1.22.112
|
|
4
12
|
|
|
5
13
|
- **Runs expand portable home paths on the executing device (PHNX-3940).** A remote resume terminal could open successfully while its child failed to start because `--cwd '$HOME'` was treated as a literal directory. The shared run path now expands `$HOME` and `~` locally, while preserving those anchors until remote dispatch reaches its target. This fixes direct runs and resumed conversations alike.
|
|
@@ -31,6 +31,7 @@ interface SessionsOptions extends SessionFilterOptions {
|
|
|
31
31
|
resolve?: string;
|
|
32
32
|
/** Versioned internal peer protocol; old/unsafe peers must reject it. */
|
|
33
33
|
resolveSafeV1?: string;
|
|
34
|
+
resolveLaunchId?: string;
|
|
34
35
|
limit?: string;
|
|
35
36
|
sort?: string;
|
|
36
37
|
json?: boolean;
|
|
@@ -2367,6 +2367,17 @@ limitSource) {
|
|
|
2367
2367
|
process.stdout.write(JSON.stringify(getSessionRoots(), null, 2) + '\n');
|
|
2368
2368
|
return;
|
|
2369
2369
|
}
|
|
2370
|
+
if (options.resolveLaunchId !== undefined) {
|
|
2371
|
+
if (!options.json)
|
|
2372
|
+
throw new Error('--resolve-launch-id requires --json.');
|
|
2373
|
+
const launchId = options.resolveLaunchId.trim();
|
|
2374
|
+
if (!launchId)
|
|
2375
|
+
throw new Error('--resolve-launch-id requires a non-empty launch id.');
|
|
2376
|
+
const { loadHookSessionIndex } = await import('../lib/session/hook-sessions.js');
|
|
2377
|
+
const record = loadHookSessionIndex().byLaunchId.get(launchId);
|
|
2378
|
+
process.stdout.write(JSON.stringify({ launchId, sessionId: record?.session_id ?? null }) + '\n');
|
|
2379
|
+
return;
|
|
2380
|
+
}
|
|
2370
2381
|
// --resolve is the metadata-only contract for downstream context workflows.
|
|
2371
2382
|
// It reads indexed SessionMeta rows and never calls renderSession, buildPreview,
|
|
2372
2383
|
// parseSession, or any other transcript renderer/parser.
|
|
@@ -5160,6 +5171,7 @@ export function registerSessionsCommands(program) {
|
|
|
5160
5171
|
.option('--query <clause>', 'Search text; repeat with --include tools to require distinct matching calls', collectQueryClause, [])
|
|
5161
5172
|
.option('--resolve <selector>', 'Resolve one full ID, unique prefix, or keyword query to safe session metadata (requires --json; searches the fleet unless --local)')
|
|
5162
5173
|
.addOption(new Option('--resolve-safe-v1 <selector>').hideHelp())
|
|
5174
|
+
.addOption(new Option('--resolve-launch-id <id>').hideHelp())
|
|
5163
5175
|
.description('Find, browse, and read agent conversation transcripts. Live roster: `agents sessions --active`.')
|
|
5164
5176
|
.option('-a, --agent <agent>', 'Filter by agent type and version (e.g., claude, codex@0.116.0)')
|
|
5165
5177
|
.addOption(new Option('--session-version <version>', 'Internal spelling for the public sessions --version filter')
|
|
@@ -1,11 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Daemon lifecycle management for the routines scheduler.
|
|
3
|
-
*
|
|
4
|
-
* The daemon is a long-running process that holds a JobScheduler and
|
|
5
|
-
* triggers jobs on their cron schedules. It can be managed via launchd
|
|
6
|
-
* (macOS), systemd (Linux), or as a plain detached process. PID tracking,
|
|
7
|
-
* log output, reload (SIGHUP), and graceful shutdown are handled here.
|
|
8
|
-
*/
|
|
9
1
|
import { isolatedHomeSuffix } from '../service-manifest.js';
|
|
10
2
|
import { getAgentsBinPath } from '../cli-entry.js';
|
|
11
3
|
import type { ServiceHealth } from './service.js';
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { daemonProcessViewAllowed, recordDaemonProcessView } from '../session/process-view.js';
|
|
1
2
|
/**
|
|
2
3
|
* Daemon lifecycle management for the routines scheduler.
|
|
3
4
|
*
|
|
@@ -22,7 +23,6 @@ import { detectOverdueJobs, notifyOverdue } from '../overdue.js';
|
|
|
22
23
|
import { runCatchup } from '../catchup.js';
|
|
23
24
|
import { notifyRoutineStart, notifyRoutineFinish, notifyRoutineStartFailed } from '../routine-notify.js';
|
|
24
25
|
import { notifyOwnerRoutineFinish, notifyOwnerRoutineStartFailed } from '../routine-notify-owner.js';
|
|
25
|
-
import { BrowserService } from '../browser/service.js';
|
|
26
26
|
import { getSocketPath as getBrowserIpcSocketPath } from '../browser/ipc.js';
|
|
27
27
|
import { redactSecrets } from '../redact.js';
|
|
28
28
|
import { getAgentsBinPath, getCliLaunch, BUN_VIRTUAL_ROOT } from '../cli-entry.js';
|
|
@@ -30,27 +30,6 @@ import { localBinDir } from '../platform/posixpath.js';
|
|
|
30
30
|
import { isSchedulerEnabled, assertSchedulerEnabled, isDaemonEnabled } from '../device-config.js';
|
|
31
31
|
import { recordSubsystemOk, recordSubsystemError, recordSubsystemErrorReason, readSubsystemHealth, SUBSYSTEM_DAEMON_START } from '../daemon-health.js';
|
|
32
32
|
import { ServiceSupervisor } from './supervisor.js';
|
|
33
|
-
import { SessionIndexService } from './session-index-service.js';
|
|
34
|
-
import { SessionSummarizerService } from './session-summarizer-service.js';
|
|
35
|
-
import { SessionTitleService } from './session-title-service.js';
|
|
36
|
-
import { MonitorEngineService } from './monitor-engine-service.js';
|
|
37
|
-
import { AccountUsageService, AccountAuthService } from './account-state-daemon-service.js';
|
|
38
|
-
import { CatchupService } from './catchup-service.js';
|
|
39
|
-
import { BrowserIPCService } from './browser-ipc-service.js';
|
|
40
|
-
import { WatchdogService } from './watchdog-service.js';
|
|
41
|
-
import { DeviceProbeService } from './device-probe-service.js';
|
|
42
|
-
import { SelfHealService } from './self-heal-service.js';
|
|
43
|
-
import { SelfUpdateService } from './self-update-service.js';
|
|
44
|
-
import { HarnessUpdateService } from './harness-update-service.js';
|
|
45
|
-
import { AuthSyncService } from './auth-sync-service.js';
|
|
46
|
-
import { UsageSyncService } from './usage-sync-service.js';
|
|
47
|
-
import { StateDirCheckService } from './state-dir-check-service.js';
|
|
48
|
-
import { SessionStateService } from './session-state-service.js';
|
|
49
|
-
import { AttentionNotifyService } from './attention-notify-service.js';
|
|
50
|
-
import { WebhookReceiverService } from './webhook-receiver-service.js';
|
|
51
|
-
import { HeartbeatService } from './heartbeat-service.js';
|
|
52
|
-
import { TmuxReapService } from './tmux-reap-service.js';
|
|
53
|
-
import { BrowserTaskReapService } from './browser-task-reap-service.js';
|
|
54
33
|
import { emit, emitAsync, emitRoutineEnd } from '../feed/events.js';
|
|
55
34
|
import { readDaemonServicesConfig, drainDaemonServiceRestartQueue } from '../daemon-services.js';
|
|
56
35
|
import { sleepSync } from '../fs-atomic.js';
|
|
@@ -478,6 +457,12 @@ export function isDaemonRunning() {
|
|
|
478
457
|
* liveness check and the write.
|
|
479
458
|
*/
|
|
480
459
|
export function claimDaemonInstance() {
|
|
460
|
+
// A nested caller cannot interpret legacy numeric lock/PID files. Authenticate
|
|
461
|
+
// before acquiring the lock (whose stale-PID cleanup itself mutates state).
|
|
462
|
+
if (!daemonProcessViewAllowed()) {
|
|
463
|
+
console.error('Daemon startup requires the owning process namespace. Automatic reuse of a private-container HOME across namespaces is unsupported; run in its owning namespace or use a fresh HOME.');
|
|
464
|
+
return false;
|
|
465
|
+
}
|
|
481
466
|
// A stop owns this same lock through teardown. Waiting here is load-bearing:
|
|
482
467
|
// returning false while stopDaemon() holds it lets this replacement exit 0,
|
|
483
468
|
// then the stop completes with no singleton left alive. The bounded lifecycle
|
|
@@ -488,6 +473,10 @@ export function claimDaemonInstance() {
|
|
|
488
473
|
if (!release)
|
|
489
474
|
return false;
|
|
490
475
|
try {
|
|
476
|
+
// Recheck under lifecycle serialization; invocation is not provenance.
|
|
477
|
+
if (!daemonProcessViewAllowed())
|
|
478
|
+
return false;
|
|
479
|
+
recordDaemonProcessView();
|
|
491
480
|
// Do not overwrite a live-but-uninspectable owner. This is the non-
|
|
492
481
|
// destructive side of the same fail-closed rule stopDaemon applies.
|
|
493
482
|
if (unverifiedLiveDaemonPid() !== null)
|
|
@@ -887,6 +876,32 @@ export async function runDaemon() {
|
|
|
887
876
|
// HOME override must refuse to run against the operator's real state rather than
|
|
888
877
|
// schedule against the real host. No-op in production (the marker is never set).
|
|
889
878
|
assertTestDaemonHome();
|
|
879
|
+
// Lifecycle readers and launchers do not run services. Load their code only
|
|
880
|
+
// in the daemon process, before it claims or publishes lifecycle state.
|
|
881
|
+
const [{ BrowserService }, { SessionIndexService }, { SessionSummarizerService }, { SessionTitleService }, { MonitorEngineService }, { AccountUsageService, AccountAuthService }, { CatchupService }, { BrowserIPCService }, { WatchdogService }, { DeviceProbeService }, { SelfHealService }, { SelfUpdateService }, { HarnessUpdateService }, { AuthSyncService }, { UsageSyncService }, { StateDirCheckService }, { SessionStateService }, { AttentionNotifyService }, { WebhookReceiverService }, { HeartbeatService }, { TmuxReapService }, { BrowserTaskReapService },] = await Promise.all([
|
|
882
|
+
import('../browser/service.js'),
|
|
883
|
+
import('./session-index-service.js'),
|
|
884
|
+
import('./session-summarizer-service.js'),
|
|
885
|
+
import('./session-title-service.js'),
|
|
886
|
+
import('./monitor-engine-service.js'),
|
|
887
|
+
import('./account-state-daemon-service.js'),
|
|
888
|
+
import('./catchup-service.js'),
|
|
889
|
+
import('./browser-ipc-service.js'),
|
|
890
|
+
import('./watchdog-service.js'),
|
|
891
|
+
import('./device-probe-service.js'),
|
|
892
|
+
import('./self-heal-service.js'),
|
|
893
|
+
import('./self-update-service.js'),
|
|
894
|
+
import('./harness-update-service.js'),
|
|
895
|
+
import('./auth-sync-service.js'),
|
|
896
|
+
import('./usage-sync-service.js'),
|
|
897
|
+
import('./state-dir-check-service.js'),
|
|
898
|
+
import('./session-state-service.js'),
|
|
899
|
+
import('./attention-notify-service.js'),
|
|
900
|
+
import('./webhook-receiver-service.js'),
|
|
901
|
+
import('./heartbeat-service.js'),
|
|
902
|
+
import('./tmux-reap-service.js'),
|
|
903
|
+
import('./browser-task-reap-service.js'),
|
|
904
|
+
]);
|
|
890
905
|
// Install the shared-daemon reload signal boundary BEFORE publishing our PID
|
|
891
906
|
// in claimDaemonInstance(). Browser/routines clients use that PID to decide a
|
|
892
907
|
// daemon exists and may request a service reload immediately. POSIX otherwise
|
|
@@ -1739,6 +1754,10 @@ function assertDaemonLaunchHomeAllowed() {
|
|
|
1739
1754
|
}
|
|
1740
1755
|
/** Start the daemon via launchd, systemd, or as a detached process. */
|
|
1741
1756
|
export function startDaemon(agentsBin) {
|
|
1757
|
+
// The public launcher must obey the same namespace boundary as its child:
|
|
1758
|
+
// even probing/repairing legacy PID state or pruning a lock can mutate it.
|
|
1759
|
+
if (!daemonProcessViewAllowed())
|
|
1760
|
+
throw new Error('Daemon startup requires the owning process namespace. Automatic reuse of a private-container HOME across namespaces is unsupported; run in its owning namespace or use a fresh HOME.');
|
|
1742
1761
|
if (isDaemonRunning()) {
|
|
1743
1762
|
const pid = readDaemonPid();
|
|
1744
1763
|
return { pid, method: 'already-running' };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Runs the production daemon entrypoint against an isolated HOME. No installed
|
|
2
|
+
// CLI, service manager, scheduler, or production socket participates.
|
|
3
|
+
import { runDaemon, startDaemon } from '../daemon.js';
|
|
4
|
+
if (process.argv[2] === '__start-daemon') {
|
|
5
|
+
try {
|
|
6
|
+
startDaemon();
|
|
7
|
+
}
|
|
8
|
+
catch (error) {
|
|
9
|
+
console.log(error.message);
|
|
10
|
+
process.exitCode = 2;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
else
|
|
14
|
+
await runDaemon();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import net from 'node:net';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { serviceManagerRegistrationAllowed } from '../../service-manifest.js';
|
|
7
|
+
import { startDaemon, stopDaemon } from '../daemon.js';
|
|
8
|
+
import { getDaemonDir, getTerminalsDir, getCacheDir } from '../../state.js';
|
|
9
|
+
// Tripwire precedes ALL lifecycle calls. Some older Bun versions return HOME
|
|
10
|
+
// from os.userInfo(), which would incorrectly allow the real service manager.
|
|
11
|
+
assert.equal(serviceManagerRegistrationAllowed().allowed, false, 'isolated fixture must never access the production service manager');
|
|
12
|
+
const cold = process.argv[3] === 'cold';
|
|
13
|
+
const child = cold ? spawn('bun', [process.argv[4], '__daemon-run'], { stdio: 'ignore', env: process.env }) : undefined;
|
|
14
|
+
const started = child ? { pid: child.pid, method: 'detached' } : startDaemon(process.argv[2]);
|
|
15
|
+
assert.equal(started.method, 'detached');
|
|
16
|
+
assert.ok(started.pid);
|
|
17
|
+
fs.writeFileSync(path.join(process.env.HOME, 'test-child.pid'), String(started.pid));
|
|
18
|
+
try {
|
|
19
|
+
if (!cold)
|
|
20
|
+
assert.ok(fs.existsSync(path.join(getDaemonDir(), 'health.json')), 'ordinary launcher publishes health before daemon starts');
|
|
21
|
+
const socket = path.join(getCacheDir(), 'helpers', 'browser', 'browser.sock');
|
|
22
|
+
let ready = false;
|
|
23
|
+
for (let i = 0; i < 80 && !ready; i++) {
|
|
24
|
+
ready = await new Promise(resolve => {
|
|
25
|
+
const client = net.createConnection(socket);
|
|
26
|
+
const done = (value) => { client.destroy(); resolve(value); };
|
|
27
|
+
client.setTimeout(100, () => done(false));
|
|
28
|
+
client.once('connect', () => done(true));
|
|
29
|
+
client.once('error', () => done(false));
|
|
30
|
+
});
|
|
31
|
+
if (!ready)
|
|
32
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
33
|
+
}
|
|
34
|
+
assert.ok(ready, 'ordinary fresh-home daemon must serve its actual browser socket');
|
|
35
|
+
assert.equal(fs.readFileSync(path.join(getDaemonDir(), 'daemon.pid'), 'utf8').trim(), String(started.pid));
|
|
36
|
+
const owner = JSON.parse(fs.readFileSync(path.join(getTerminalsDir(), 'process-view.json'), 'utf8'));
|
|
37
|
+
assert.equal(owner.pidNamespace, fs.readlinkSync('/proc/self/ns/pid'));
|
|
38
|
+
assert.equal(owner.bootId, fs.readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim());
|
|
39
|
+
console.log(`${cold ? 'cold runDaemon' : 'ordinary startDaemon: health published'}; canonical socket ready; namespace owner verified`);
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
stopDaemon();
|
|
43
|
+
}
|
|
@@ -40,6 +40,10 @@ export function stampProvenance(env = process.env) {
|
|
|
40
40
|
provenance.launchId = env.AGENT_LAUNCH_ID;
|
|
41
41
|
if (env.AGENTS_PARENT_SESSION_ID)
|
|
42
42
|
provenance.parentSessionId = env.AGENTS_PARENT_SESSION_ID;
|
|
43
|
+
if (env.AGENTS_PARENT_LAUNCH_ID)
|
|
44
|
+
provenance.parentLaunchId = env.AGENTS_PARENT_LAUNCH_ID;
|
|
45
|
+
if (env.AGENTS_ORIGIN_TERMINAL_ID)
|
|
46
|
+
provenance.originTerminalId = env.AGENTS_ORIGIN_TERMINAL_ID;
|
|
43
47
|
return provenance;
|
|
44
48
|
}
|
|
45
49
|
export function resetEventProvenanceForTest() {
|
package/dist/lib/exec.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { captureLaunchBinding, recordCompletedLaunch } from './session/hook-sessions.js';
|
|
1
2
|
/**
|
|
2
3
|
* Agent execution -- command building, process spawning, and rate-limit fallback.
|
|
3
4
|
*
|
|
@@ -17,6 +18,7 @@ import { isTierToken, resolveTier } from './model-tiers.js';
|
|
|
17
18
|
import { emit, createTimer, redactPrompt, redactArgs } from './feed/events.js';
|
|
18
19
|
import { sanitizeProcessEnv } from './secrets-client.js';
|
|
19
20
|
import { resolveActor, actorEnv } from './actor.js';
|
|
21
|
+
import { launchIdentityEnv, LAUNCH_IDENTITY_KEYS } from './launch-identity.js';
|
|
20
22
|
import { expandLocalHome } from './project-root.js';
|
|
21
23
|
import { getShimsDir, getHistoryDir, getUserAgentsDir, getRuntimeStateDir } from './state.js';
|
|
22
24
|
import { readCodexConfiguredModel } from './installations/shims.js';
|
|
@@ -378,6 +380,14 @@ export function buildExecEnv(options) {
|
|
|
378
380
|
else {
|
|
379
381
|
stripForeignConfigDir(result);
|
|
380
382
|
}
|
|
383
|
+
// A new harness must not inherit its spawner's own session/mailbox or editor
|
|
384
|
+
// ownership. Keep those relationships as lineage, including across SSH.
|
|
385
|
+
delete result.AGENT_SESSION_ID;
|
|
386
|
+
delete result.AGENTS_SESSION_ID;
|
|
387
|
+
delete result.AGENTS_MAILBOX_DIR;
|
|
388
|
+
delete result.AGENT_TERMINAL_ID;
|
|
389
|
+
const launchIdentity = launchIdentityEnv();
|
|
390
|
+
Object.assign(result, launchIdentity);
|
|
381
391
|
// Point the agent at its own mailbox so the PreToolUse `mailbox-inject` hook
|
|
382
392
|
// knows which box to drain and inject mid-run. Keyed by the session id — the
|
|
383
393
|
// same id the writer resolves via mailboxIdForActiveSession(). A loop run
|
|
@@ -394,9 +404,9 @@ export function buildExecEnv(options) {
|
|
|
394
404
|
// (events.ts::resolveProvenance) reads AGENTS_PARENT_SESSION_ID and stamps it on
|
|
395
405
|
// every event the child emits. `options.sessionId` is the CHILD's id, so read the
|
|
396
406
|
// spawner from the live env; guard a same-session resume from naming itself parent.
|
|
397
|
-
//
|
|
407
|
+
// An SSH continuation has no runtime marker; keep its forwarded parent edge.
|
|
398
408
|
delete result.AGENTS_PARENT_SESSION_ID;
|
|
399
|
-
const spawnerSessionId =
|
|
409
|
+
const spawnerSessionId = launchIdentity.AGENTS_PARENT_SESSION_ID;
|
|
400
410
|
if (spawnerSessionId && spawnerSessionId !== options.sessionId) {
|
|
401
411
|
result.AGENTS_PARENT_SESSION_ID = spawnerSessionId;
|
|
402
412
|
}
|
|
@@ -1210,7 +1220,7 @@ async function execShimPassthroughLeased(agent, rawArgs, cwd, pinnedVersion) {
|
|
|
1210
1220
|
actor: resolveActor().id,
|
|
1211
1221
|
initiatedBy: resolveActor().kind,
|
|
1212
1222
|
launchId,
|
|
1213
|
-
terminalId:
|
|
1223
|
+
terminalId: launchIdentityEnv().AGENT_TERMINAL_ID,
|
|
1214
1224
|
startedAtMs: Date.now(),
|
|
1215
1225
|
});
|
|
1216
1226
|
// Durable sessionId -> actor record (RUSH-2019) so the scanner can attribute
|
|
@@ -1347,6 +1357,7 @@ export function isHarnessKnownSessionId(agent, sessionId, resume) {
|
|
|
1347
1357
|
*/
|
|
1348
1358
|
export function buildTmuxAgentCommand(executable, args, env, opts = {}) {
|
|
1349
1359
|
const agentCmd = [executable, ...args].map(shellQuote).join(' ');
|
|
1360
|
+
const absentIdentity = LAUNCH_IDENTITY_KEYS.filter(key => env[key] === undefined);
|
|
1350
1361
|
// envFile: source the values instead of inlining them, so no VALUE ever lands
|
|
1351
1362
|
// in the pane's argv. `exec env K=V …` put every resolved secret into the
|
|
1352
1363
|
// process table, readable by any process of this user — on one fleet box six
|
|
@@ -1361,13 +1372,13 @@ export function buildTmuxAgentCommand(executable, args, env, opts = {}) {
|
|
|
1361
1372
|
// strands the plaintext env (incl. the secrets-store master passphrase) on
|
|
1362
1373
|
// disk on any source failure, worse than the argv leak this replaces
|
|
1363
1374
|
// (RUSH-2100). Capture the source rc, unlink, then honor it.
|
|
1364
|
-
return `set -a; . ${f}; __agents_rc=$?; set +a; rm -f ${f}; [ "$__agents_rc" -eq 0 ] || exit 1; exec ${agentCmd}`;
|
|
1375
|
+
return `${absentIdentity.length ? `unset ${absentIdentity.join(' ')}; ` : ''}set -a; . ${f}; __agents_rc=$?; set +a; rm -f ${f}; [ "$__agents_rc" -eq 0 ] || exit 1; exec ${agentCmd}`;
|
|
1365
1376
|
}
|
|
1366
1377
|
const envPrefix = Object.entries(env)
|
|
1367
1378
|
.filter(([k, v]) => v !== undefined && EXEC_ENV_KEY_PATTERN.test(k))
|
|
1368
1379
|
.map(([k, v]) => `${k}=${opts.redactEnvValues ? '<redacted>' : shellQuote(String(v))}`)
|
|
1369
1380
|
.join(' ');
|
|
1370
|
-
return `exec env ${envPrefix} ${agentCmd}`;
|
|
1381
|
+
return `exec env ${absentIdentity.map(key => `-u ${key}`).join(' ')} ${envPrefix} ${agentCmd}`;
|
|
1371
1382
|
}
|
|
1372
1383
|
/**
|
|
1373
1384
|
* Serialize the pane env to a shell-sourceable file, created 0600 and exclusively
|
|
@@ -1493,9 +1504,14 @@ async function runInTmux(options, executable, args) {
|
|
|
1493
1504
|
* "the session went away underneath us", so an attach that skips this cannot
|
|
1494
1505
|
* do better than assume success — which is exactly the defect EXEC-23b fixes.
|
|
1495
1506
|
*/
|
|
1507
|
+
let paneLaunchBinding;
|
|
1508
|
+
const bindCompletedPane = () => {
|
|
1509
|
+
recordCompletedLaunch(paneLaunchBinding);
|
|
1510
|
+
};
|
|
1496
1511
|
const resolveAfterAttach = async (pane) => {
|
|
1497
1512
|
const after = pane ? await paneExitStatus(pane, socket) : { found: false, dead: false, status: undefined };
|
|
1498
1513
|
if (after.dead) {
|
|
1514
|
+
bindCompletedPane();
|
|
1499
1515
|
// Positive proof of a genuinely completed pane (paneExitStatus confirmed
|
|
1500
1516
|
// dead) — capture its scrollback as refusal evidence for the caller
|
|
1501
1517
|
// (classifyClaudeRunRefusal etc.) BEFORE tearing the session down.
|
|
@@ -1633,10 +1649,11 @@ async function runInTmux(options, executable, args) {
|
|
|
1633
1649
|
// pid) reconciles by launchId. This pane's pid usually IS the agent pid,
|
|
1634
1650
|
// but the launchId join is robust even when it isn't.
|
|
1635
1651
|
launchId: options.env?.AGENT_LAUNCH_ID,
|
|
1636
|
-
terminalId:
|
|
1652
|
+
terminalId: launchIdentityEnv().AGENT_TERMINAL_ID,
|
|
1637
1653
|
tmuxPane: pane,
|
|
1638
1654
|
startedAtMs: Date.now(),
|
|
1639
1655
|
});
|
|
1656
|
+
paneLaunchBinding = captureLaunchBinding(panePid, options.env?.AGENT_LAUNCH_ID ?? '');
|
|
1640
1657
|
if (options.sessionId) {
|
|
1641
1658
|
writeSessionActorRecord({
|
|
1642
1659
|
sessionId: options.sessionId,
|
|
@@ -1653,6 +1670,7 @@ async function runInTmux(options, executable, args) {
|
|
|
1653
1670
|
// already-dead pane — surface its output + status directly and tear down.
|
|
1654
1671
|
const before = pane ? await paneExitStatus(pane, socket) : { found: false, dead: false, status: undefined };
|
|
1655
1672
|
if (before.dead) {
|
|
1673
|
+
bindCompletedPane();
|
|
1656
1674
|
// Positive proof of a genuinely completed pane — capture scrollback as
|
|
1657
1675
|
// refusal evidence before tearing the session down (see the matching
|
|
1658
1676
|
// comment in resolveAfterAttach).
|
|
@@ -2071,10 +2089,11 @@ async function spawnAgentLeased(options) {
|
|
|
2071
2089
|
actor: resolveActor().id,
|
|
2072
2090
|
initiatedBy: resolveActor().kind,
|
|
2073
2091
|
launchId,
|
|
2074
|
-
terminalId:
|
|
2092
|
+
terminalId: launchIdentityEnv().AGENT_TERMINAL_ID,
|
|
2075
2093
|
tmuxPane: process.env.TMUX_PANE,
|
|
2076
2094
|
startedAtMs: Date.now(),
|
|
2077
2095
|
});
|
|
2096
|
+
const launchBinding = captureLaunchBinding(child.pid, launchId);
|
|
2078
2097
|
if (options.sessionId) {
|
|
2079
2098
|
writeSessionActorRecord({
|
|
2080
2099
|
sessionId: options.sessionId,
|
|
@@ -2146,6 +2165,7 @@ async function spawnAgentLeased(options) {
|
|
|
2146
2165
|
reject(err);
|
|
2147
2166
|
});
|
|
2148
2167
|
child.on('close', (code) => {
|
|
2168
|
+
recordCompletedLaunch(launchBinding);
|
|
2149
2169
|
if (timeoutTimer)
|
|
2150
2170
|
clearTimeout(timeoutTimer);
|
|
2151
2171
|
// Clear the budget-kill SIGKILL escalation timer (mirror the --timeout
|
|
@@ -56,6 +56,15 @@ export class ActivityStream {
|
|
|
56
56
|
this.maxBytesPerRead = options.maxBytesPerRead ?? ACTIVITY_TAIL_BYTES;
|
|
57
57
|
this.sweepMs = options.sweepMs ?? ACTIVITY_SWEEP_MS;
|
|
58
58
|
this.watchRequested = options.watch ?? true;
|
|
59
|
+
// A log directory that does not exist yet (nothing has logged activity
|
|
60
|
+
// since boot) means fs.watch below throws ENOENT and never retries except
|
|
61
|
+
// on the bounded sweep cadence — the very first session's activity can
|
|
62
|
+
// then sit unwatched for up to `sweepMs` before it is even noticed.
|
|
63
|
+
// Creating it up front lets the watcher arm immediately.
|
|
64
|
+
try {
|
|
65
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
66
|
+
}
|
|
67
|
+
catch { /* best-effort: sweep() covers a dir that still isn't there */ }
|
|
59
68
|
this.sweep(Date.now());
|
|
60
69
|
this.armWatcher();
|
|
61
70
|
}
|
|
@@ -136,6 +136,14 @@ export declare function appendActivityEvent(event: Omit<ActivityEvent, 'v' | 'ti
|
|
|
136
136
|
export declare function parseActivityLine(line: string): ActivityEvent | undefined;
|
|
137
137
|
/** Per-session byte budget for a bounded tail read of an activity log. */
|
|
138
138
|
export declare const ACTIVITY_TAIL_BYTES: number;
|
|
139
|
+
/** Read-only process-local diagnostics; no event content or file paths. */
|
|
140
|
+
export declare function getActivityCacheStats(): {
|
|
141
|
+
entries: number;
|
|
142
|
+
parsedTails: number;
|
|
143
|
+
bytes: number;
|
|
144
|
+
maxBytes: number;
|
|
145
|
+
tailReads: number;
|
|
146
|
+
};
|
|
139
147
|
/** Read all events for one session (bounded tail). */
|
|
140
148
|
export declare function readSessionActivity(sessionId: string, root?: string, maxBytes?: number): ActivityEvent[];
|
|
141
149
|
/** List session ids that have an activity log. */
|
|
@@ -198,14 +198,67 @@ function readTail(file, maxBytes) {
|
|
|
198
198
|
// sessions or directories a long-lived consumer visits. Never expose cached
|
|
199
199
|
// objects: callers enrich and mutate returned events (including nested values).
|
|
200
200
|
const ACTIVITY_CACHE_BYTES = 32 * 1024 * 1024;
|
|
201
|
-
const ACTIVITY_CACHE_FILES = 1024;
|
|
202
201
|
const activityTails = new Map();
|
|
202
|
+
// A separate LRU of payload references lets pressure release parsed events
|
|
203
|
+
// without repeatedly walking thousands of summaries that cannot shrink.
|
|
204
|
+
const activityTailPayloads = new Map();
|
|
203
205
|
let activityTailBytes = 0;
|
|
206
|
+
let activityTailReads = 0;
|
|
207
|
+
/** Read-only process-local diagnostics; no event content or file paths. */
|
|
208
|
+
export function getActivityCacheStats() {
|
|
209
|
+
return {
|
|
210
|
+
entries: activityTails.size,
|
|
211
|
+
parsedTails: activityTailPayloads.size,
|
|
212
|
+
bytes: activityTailBytes,
|
|
213
|
+
maxBytes: ACTIVITY_CACHE_BYTES,
|
|
214
|
+
tailReads: activityTailReads,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
204
217
|
function forgetActivityTail(key) {
|
|
205
218
|
const old = activityTails.get(key);
|
|
206
219
|
if (old)
|
|
207
220
|
activityTailBytes -= old.weight;
|
|
208
221
|
activityTails.delete(key);
|
|
222
|
+
activityTailPayloads.delete(key);
|
|
223
|
+
}
|
|
224
|
+
function activitySummaryWeight(key, stamp) {
|
|
225
|
+
return (key.length + stamp.length) * 2 + 256;
|
|
226
|
+
}
|
|
227
|
+
function retainActivityTail(key, result, sinceMs) {
|
|
228
|
+
const summaryWeight = activitySummaryWeight(key, result.stamp);
|
|
229
|
+
// Even metadata is byte-bounded. A tail whose summary cannot fit still
|
|
230
|
+
// returns normally, but must not evict the cache or be retained over budget.
|
|
231
|
+
if (summaryWeight > ACTIVITY_CACHE_BYTES)
|
|
232
|
+
return;
|
|
233
|
+
const retained = { ...result };
|
|
234
|
+
if (result.newestMs < sinceMs || result.weight > ACTIVITY_CACHE_BYTES / 2) {
|
|
235
|
+
retained.events = undefined;
|
|
236
|
+
retained.weight = summaryWeight;
|
|
237
|
+
}
|
|
238
|
+
// Prefer compact history over payloads, retaining all summaries that fit
|
|
239
|
+
// instead of imposing a file-count cap that thrashes on each directory scan.
|
|
240
|
+
for (const [oldKey, old] of activityTailPayloads) {
|
|
241
|
+
if (activityTailBytes + retained.weight <= ACTIVITY_CACHE_BYTES)
|
|
242
|
+
break;
|
|
243
|
+
const weight = activitySummaryWeight(oldKey, old.stamp);
|
|
244
|
+
activityTailBytes -= old.weight - weight;
|
|
245
|
+
old.events = undefined;
|
|
246
|
+
old.weight = weight;
|
|
247
|
+
activityTailPayloads.delete(oldKey);
|
|
248
|
+
}
|
|
249
|
+
// If all existing entries are summaries, sacrifice the incoming payload
|
|
250
|
+
// before evicting history. The current caller still receives result.events.
|
|
251
|
+
if (activityTailBytes + retained.weight > ACTIVITY_CACHE_BYTES) {
|
|
252
|
+
retained.events = undefined;
|
|
253
|
+
retained.weight = summaryWeight;
|
|
254
|
+
}
|
|
255
|
+
while (activityTailBytes + retained.weight > ACTIVITY_CACHE_BYTES) {
|
|
256
|
+
forgetActivityTail(activityTails.keys().next().value);
|
|
257
|
+
}
|
|
258
|
+
activityTails.set(key, retained);
|
|
259
|
+
if (retained.events)
|
|
260
|
+
activityTailPayloads.set(key, retained);
|
|
261
|
+
activityTailBytes += retained.weight;
|
|
209
262
|
}
|
|
210
263
|
function activityStamp(file) {
|
|
211
264
|
// ctime catches same-size rewrites even when a producer restores mtime;
|
|
@@ -228,9 +281,14 @@ function readActivityTail(file, maxBytes, sinceMs = -Infinity) {
|
|
|
228
281
|
if (cached?.stamp === stamp && (cached.events || cached.newestMs < sinceMs)) {
|
|
229
282
|
activityTails.delete(key);
|
|
230
283
|
activityTails.set(key, cached);
|
|
284
|
+
if (cached.events) {
|
|
285
|
+
activityTailPayloads.delete(key);
|
|
286
|
+
activityTailPayloads.set(key, cached);
|
|
287
|
+
}
|
|
231
288
|
return cached;
|
|
232
289
|
}
|
|
233
290
|
forgetActivityTail(key);
|
|
291
|
+
activityTailReads++;
|
|
234
292
|
const text = readTail(file, maxBytes);
|
|
235
293
|
// Transient I/O failure is not an empty file snapshot; retry next read.
|
|
236
294
|
if (text === undefined)
|
|
@@ -256,27 +314,12 @@ function readActivityTail(file, maxBytes, sinceMs = -Infinity) {
|
|
|
256
314
|
}
|
|
257
315
|
// Bound retained source-derived strings, objects, and entry overhead. Large
|
|
258
316
|
// one-off tails still read normally, but cannot displace the entire cache.
|
|
259
|
-
const weight = text.length * 2 + events.length * 1024 + key
|
|
317
|
+
const weight = text.length * 2 + events.length * 1024 + activitySummaryWeight(key, stamp);
|
|
260
318
|
const result = { stamp, events, newestMs, weight };
|
|
261
319
|
try {
|
|
262
320
|
// A writer racing the read must not pin a mixed snapshot as unchanged.
|
|
263
321
|
if (activityStamp(file) === stamp) {
|
|
264
|
-
|
|
265
|
-
forgetActivityTail(activityTails.keys().next().value);
|
|
266
|
-
const summaryWeight = key.length * 2 + 256;
|
|
267
|
-
const retained = weight <= ACTIVITY_CACHE_BYTES / 2 ? { ...result } : { ...result, events: undefined, weight: summaryWeight };
|
|
268
|
-
// Retain timestamp summaries even when parsed tails exceed the budget:
|
|
269
|
-
// otherwise a large historical corpus thrashes on every cursor poll.
|
|
270
|
-
for (const [oldKey, old] of activityTails) {
|
|
271
|
-
if (activityTailBytes + retained.weight <= ACTIVITY_CACHE_BYTES)
|
|
272
|
-
break;
|
|
273
|
-
const summary = oldKey.length * 2 + 256;
|
|
274
|
-
activityTailBytes -= old.weight - summary;
|
|
275
|
-
old.events = undefined;
|
|
276
|
-
old.weight = summary;
|
|
277
|
-
}
|
|
278
|
-
activityTails.set(key, retained);
|
|
279
|
-
activityTailBytes += retained.weight;
|
|
322
|
+
retainActivityTail(key, result, sinceMs);
|
|
280
323
|
}
|
|
281
324
|
}
|
|
282
325
|
catch { /* Deleted during the read: return this snapshot without caching. */ }
|
package/dist/lib/feed/watch.d.ts
CHANGED
|
@@ -48,6 +48,15 @@ export declare class FeedWatchState {
|
|
|
48
48
|
constructor(streamId?: `${string}-${string}-${string}-${string}-${string}`);
|
|
49
49
|
emit(event: FeedWatchPayload): FeedWatchEnvelope;
|
|
50
50
|
}
|
|
51
|
+
/** Reuse session ownership reconciliation for the combined operator stream. */
|
|
52
|
+
export declare class FeedSessionProjection {
|
|
53
|
+
private readonly state;
|
|
54
|
+
private readonly sessions;
|
|
55
|
+
private readonly observations;
|
|
56
|
+
private projectedAttention;
|
|
57
|
+
constructor(state?: FeedWatchState);
|
|
58
|
+
apply(event: FeedWatchEnvelope): FeedWatchEnvelope[];
|
|
59
|
+
}
|
|
51
60
|
export declare function projectSessionEnvelope(event: SessionWatchEnvelope, state: FeedWatchState, gh?: GhExec): Promise<FeedWatchEnvelope[]>;
|
|
52
61
|
export interface WatchLocalFeedOptions {
|
|
53
62
|
scope: string;
|