@ours.network/fleet 1.0.5 → 1.1.0-nightly.1
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/README.md +28 -41
- package/dist/application/capabilities.d.ts +0 -1
- package/dist/application/capabilities.js +3 -13
- package/dist/application/fleet-query-service.d.ts +0 -3
- package/dist/application/fleet-query-service.js +1 -24
- package/dist/application/role-creation-service.d.ts +5 -5
- package/dist/application/role-creation-service.js +5 -7
- package/dist/application/role-repository.d.ts +1 -2
- package/dist/application/role-repository.js +7 -7
- package/dist/application/session-control.d.ts +0 -12
- package/dist/application/session-control.js +0 -42
- package/dist/application/session-mutations.d.ts +5 -5
- package/dist/application/types.d.ts +0 -7
- package/dist/briefing.js +1 -4
- package/dist/build-info.json +5 -5
- package/dist/cli.js +9 -23
- package/dist/config.d.ts +1 -4
- package/dist/config.js +13 -17
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +20 -26
- package/dist/doctor.js +0 -7
- package/dist/fleet-proxy.d.ts +1 -1
- package/dist/harness/acp-session-transport.d.ts +8 -0
- package/dist/harness/acp-session-transport.js +3 -0
- package/dist/harness/agent-session.d.ts +33 -0
- package/dist/harness/agent-session.js +1 -0
- package/dist/harness/claude-code-session.d.ts +18 -0
- package/dist/harness/claude-code-session.js +28 -0
- package/dist/harness/claude-code.d.ts +2 -1
- package/dist/harness/claude-code.js +29 -81
- package/dist/harness/codex-session.d.ts +18 -0
- package/dist/harness/codex-session.js +28 -0
- package/dist/harness/codex.d.ts +2 -1
- package/dist/harness/codex.js +35 -45
- package/dist/harness/types.d.ts +6 -38
- package/dist/index.d.ts +3 -5
- package/dist/index.js +1 -3
- package/dist/isolation/resources.d.ts +3 -7
- package/dist/isolation/resources.js +3 -7
- package/dist/model-env.d.ts +2 -3
- package/dist/model-env.js +2 -3
- package/dist/monitor.d.ts +1 -24
- package/dist/monitor.js +5 -140
- package/dist/owner-channel/channel.d.ts +2 -2
- package/dist/runner.d.ts +5 -15
- package/dist/runner.js +26 -102
- package/dist/session/acp.d.ts +2 -2
- package/dist/session/acp.js +2 -3
- package/dist/session/activity.d.ts +2 -3
- package/dist/session/activity.js +2 -3
- package/dist/session/arbiter.d.ts +4 -4
- package/dist/session/control.d.ts +3 -3
- package/dist/session/types.d.ts +11 -5
- package/dist/spawn.js +8 -9
- package/dist/supervisor/none.d.ts +1 -1
- package/dist/supervisor/none.js +2 -2
- package/dist/tmux.d.ts +1 -14
- package/dist/tmux.js +1 -51
- package/dist/watchdog/config.js +6 -4
- package/dist/watchdog/run.js +8 -23
- package/dist/web/auth.d.ts +1 -1
- package/dist/web/runtime.js +4 -15
- package/dist/web/server.d.ts +1 -3
- package/dist/web/server.js +3 -14
- package/dist/web/topology-promote.js +2 -5
- package/dist/web-app/assets/index-59GF-gsZ.css +1 -0
- package/dist/web-app/assets/{index-BCBK78hw.js → index-DhMDVRU1.js} +6 -6
- package/dist/web-app/index.html +2 -2
- package/package.json +3 -8
- package/dist/session/tmux.d.ts +0 -28
- package/dist/session/tmux.js +0 -80
- package/dist/web/terminal/bridge.d.ts +0 -27
- package/dist/web/terminal/bridge.js +0 -317
- package/dist/web-app/assets/TerminalView-C_G1ID2P.js +0 -9
- package/dist/web-app/assets/index-DuC-xnX4.css +0 -1
package/dist/monitor.d.ts
CHANGED
|
@@ -26,15 +26,8 @@ export type FetchLike = (url: string, init?: {
|
|
|
26
26
|
headers?: Record<string, string>;
|
|
27
27
|
signal?: AbortSignal;
|
|
28
28
|
}) => Promise<FetchResponse>;
|
|
29
|
-
export interface MonitorTmux {
|
|
30
|
-
has(name: string): Promise<boolean>;
|
|
31
|
-
capture(name: string, lines?: number): Promise<string>;
|
|
32
|
-
sendText(name: string, text: string): Promise<void>;
|
|
33
|
-
sendKey(name: string, key: string): Promise<void>;
|
|
34
|
-
}
|
|
35
29
|
export interface MonitorDeps {
|
|
36
30
|
fetch: FetchLike;
|
|
37
|
-
tmux: MonitorTmux;
|
|
38
31
|
isAlive(pid: number): boolean;
|
|
39
32
|
sleep(ms: number): Promise<void>;
|
|
40
33
|
now(): number;
|
|
@@ -45,7 +38,7 @@ export interface MonitorDeps {
|
|
|
45
38
|
clear(t: ReturnType<typeof setTimeout>): void;
|
|
46
39
|
};
|
|
47
40
|
/**
|
|
48
|
-
* Structured prompt delivery used by
|
|
41
|
+
* Structured prompt delivery used by agent sessions.
|
|
49
42
|
* `succeeded` is the turn's TERMINAL result, not merely that the session took
|
|
50
43
|
* the prompt: a refused or cancelled wake was seen and not acted on, and must
|
|
51
44
|
* not commit the cursor.
|
|
@@ -208,14 +201,6 @@ export declare class Monitor {
|
|
|
208
201
|
/** Gather stragglers arriving within batch_ms so a burst lands as one line. */
|
|
209
202
|
private coalesce;
|
|
210
203
|
private deliver;
|
|
211
|
-
/**
|
|
212
|
-
* Watch the pane until the just-triggered turn settles, then fold its outcome
|
|
213
|
-
* into the API-error streak and republish `.monitor-status`. A completed turn
|
|
214
|
-
* (or an inconclusive give-up) resets/keeps the streak; an `API Error:` tail
|
|
215
|
-
* grows it. Once the streak reaches the threshold the status degrades; a later
|
|
216
|
-
* completed turn flips it back to armed. Detection only — no remediation (#19).
|
|
217
|
-
*/
|
|
218
|
-
private observeTurnOutcome;
|
|
219
204
|
/** Update the consecutive-API-error streak and derive `.monitor-status` from it. */
|
|
220
205
|
private recordTurn;
|
|
221
206
|
/**
|
|
@@ -226,14 +211,6 @@ export declare class Monitor {
|
|
|
226
211
|
* the composer is cleared by hand. Best-effort: a dead pane just makes the keys
|
|
227
212
|
* no-ops (delivery is still verified downstream).
|
|
228
213
|
*/
|
|
229
|
-
private clearComposer;
|
|
230
|
-
/**
|
|
231
|
-
* Block until the console can accept input; classify offline/stopped/ready, or
|
|
232
|
-
* `modal` when the pane still looks modal after `MODAL_GIVE_UP_MS`. The bound is
|
|
233
|
-
* what keeps a modal from wedging delivery silently: we still never `Enter` into
|
|
234
|
-
* the dialog, but the give-up is reported instead of retried forever.
|
|
235
|
-
*/
|
|
236
|
-
private awaitInjectable;
|
|
237
214
|
private doFetch;
|
|
238
215
|
private advance;
|
|
239
216
|
private persistCursor;
|
package/dist/monitor.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import { classifyFailureText } from './model-recovery.js';
|
|
5
4
|
// Code constants rather than user configuration.
|
|
6
5
|
const DEFAULT_PORT = 3050;
|
|
7
6
|
// The daemon normally holds for 25s, but that value is operator-configurable
|
|
@@ -522,7 +521,11 @@ export class Monitor {
|
|
|
522
521
|
}
|
|
523
522
|
async deliver(pid, batch) {
|
|
524
523
|
const line = formatNotificationLine(batch);
|
|
525
|
-
if (this.deps.delivery) {
|
|
524
|
+
if (!this.deps.delivery) {
|
|
525
|
+
this.degrade('delivery', 'structured agent-session delivery is unavailable');
|
|
526
|
+
return false;
|
|
527
|
+
}
|
|
528
|
+
{
|
|
526
529
|
const result = await this.deps.delivery.submit(line, { interrupt: this.cfg.interrupt });
|
|
527
530
|
if (!result.succeeded) {
|
|
528
531
|
// Name the reason: "refused" and "cancelled" are the agent's answer,
|
|
@@ -540,101 +543,6 @@ export class Monitor {
|
|
|
540
543
|
this.recordTurn('completed');
|
|
541
544
|
return true;
|
|
542
545
|
}
|
|
543
|
-
// Tmux exposes no authenticated tool lifecycle. `after_tool` therefore
|
|
544
|
-
// degrades to the existing non-cancelling injection path; never guess a
|
|
545
|
-
// boundary from pane text and never send C-c for this mode.
|
|
546
|
-
if (this.cfg.interrupt === true)
|
|
547
|
-
await this.deps.tmux.sendKey(this.name, 'C-c');
|
|
548
|
-
if (this.cfg.interrupt === 'after_tool')
|
|
549
|
-
this.degrade('safe-boundary', 'after_tool unsupported by tmux; using non-cancelling delivery');
|
|
550
|
-
const state = await this.awaitInjectable(pid);
|
|
551
|
-
if (state !== 'ready') {
|
|
552
|
-
if (state === 'offline')
|
|
553
|
-
this.degrade('offline', 'offline during delivery');
|
|
554
|
-
else if (state === 'modal')
|
|
555
|
-
this.degrade('modal', `modal wedge — pane held a dialog for ` +
|
|
556
|
-
`${MODAL_GIVE_UP_MS / 1000}s, wake not injected`);
|
|
557
|
-
return false;
|
|
558
|
-
}
|
|
559
|
-
await this.clearComposer(); // start from an empty composer
|
|
560
|
-
await this.deps.tmux.sendText(this.name, line); // send-keys -l + Enter
|
|
561
|
-
let delivered = false;
|
|
562
|
-
// Verify submission for THIS line even if stop() arrives mid-flight: the text
|
|
563
|
-
// is already in the composer and we want it submitted (at-least-once). A truly
|
|
564
|
-
// dead pane makes safeCapture return '' ⇒ not-in-composer ⇒ breaks, no wasted Enter.
|
|
565
|
-
for (let i = 0; i < MAX_ENTER_RETRIES;) {
|
|
566
|
-
await this.deps.sleep(POST_VERIFY_MS);
|
|
567
|
-
const capture = await safeCapture(this.deps.tmux, this.name);
|
|
568
|
-
if (!capture.ok) {
|
|
569
|
-
this.degrade('delivery', 'capture failed during injection verification');
|
|
570
|
-
return false;
|
|
571
|
-
}
|
|
572
|
-
// A dialog can appear after the initial send. Never let a verification
|
|
573
|
-
// retry confirm it. Wait under the same bounded modal policy as initial
|
|
574
|
-
// injection, then re-capture immediately before considering Enter.
|
|
575
|
-
if (looksModal(capture.pane)) {
|
|
576
|
-
const state = await this.awaitInjectable(pid);
|
|
577
|
-
if (state === 'ready')
|
|
578
|
-
continue;
|
|
579
|
-
if (state === 'offline')
|
|
580
|
-
this.degrade('offline', 'offline during injection verification');
|
|
581
|
-
else if (state === 'modal')
|
|
582
|
-
this.degrade('modal', `modal wedge during injection verification — ` +
|
|
583
|
-
`no Enter sent for ${MODAL_GIVE_UP_MS / 1000}s`);
|
|
584
|
-
return false;
|
|
585
|
-
}
|
|
586
|
-
if (!stillInComposer(capture.pane, line)) {
|
|
587
|
-
delivered = true;
|
|
588
|
-
break;
|
|
589
|
-
}
|
|
590
|
-
await this.deps.tmux.sendKey(this.name, 'Enter');
|
|
591
|
-
i++;
|
|
592
|
-
}
|
|
593
|
-
if (!delivered) {
|
|
594
|
-
this.degrade('delivery', 'injection unverified');
|
|
595
|
-
return false;
|
|
596
|
-
}
|
|
597
|
-
this.recover('delivery', 'modal');
|
|
598
|
-
// The wake landed and a turn started; observe how that turn terminates so a
|
|
599
|
-
// refusal-wedge (every turn dies with `API Error:` while delivery stays green)
|
|
600
|
-
// becomes visible in `.monitor-status` instead of masquerading as armed (#19).
|
|
601
|
-
const recoveryTriggered = await this.observeTurnOutcome(pid);
|
|
602
|
-
return !recoveryTriggered;
|
|
603
|
-
}
|
|
604
|
-
/**
|
|
605
|
-
* Watch the pane until the just-triggered turn settles, then fold its outcome
|
|
606
|
-
* into the API-error streak and republish `.monitor-status`. A completed turn
|
|
607
|
-
* (or an inconclusive give-up) resets/keeps the streak; an `API Error:` tail
|
|
608
|
-
* grows it. Once the streak reaches the threshold the status degrades; a later
|
|
609
|
-
* completed turn flips it back to armed. Detection only — no remediation (#19).
|
|
610
|
-
*/
|
|
611
|
-
async observeTurnOutcome(pid) {
|
|
612
|
-
for (let i = 0; i < TURN_OBSERVE_POLLS; i++) {
|
|
613
|
-
if (this.stopped)
|
|
614
|
-
return false; // shutting down — leave status
|
|
615
|
-
if (!this.deps.isAlive(pid) || !(await this.deps.tmux.has(this.name)))
|
|
616
|
-
return false; // loop marks offline
|
|
617
|
-
const capture = await safeCapture(this.deps.tmux, this.name);
|
|
618
|
-
if (!capture.ok) {
|
|
619
|
-
this.degrade('delivery', 'capture failed during turn observation');
|
|
620
|
-
return false;
|
|
621
|
-
}
|
|
622
|
-
if (looksApiError(capture.pane)) {
|
|
623
|
-
const evidence = classifyFailureText(capture.pane);
|
|
624
|
-
const recoveryTriggered = evidence
|
|
625
|
-
? this.deps.onFailureEvidence?.(evidence) === true
|
|
626
|
-
: false;
|
|
627
|
-
this.recordTurn('api-error');
|
|
628
|
-
return recoveryTriggered;
|
|
629
|
-
}
|
|
630
|
-
if (!looksRunning(capture.pane)) {
|
|
631
|
-
this.recordTurn('completed');
|
|
632
|
-
return false;
|
|
633
|
-
}
|
|
634
|
-
await this.deps.sleep(TURN_OBSERVE_INTERVAL_MS);
|
|
635
|
-
}
|
|
636
|
-
this.recordTurn('inconclusive'); // still running at give-up: hold the streak, don't re-arm
|
|
637
|
-
return false;
|
|
638
546
|
}
|
|
639
547
|
/** Update the consecutive-API-error streak and derive `.monitor-status` from it. */
|
|
640
548
|
recordTurn(outcome) {
|
|
@@ -658,41 +566,6 @@ export class Monitor {
|
|
|
658
566
|
* the composer is cleared by hand. Best-effort: a dead pane just makes the keys
|
|
659
567
|
* no-ops (delivery is still verified downstream).
|
|
660
568
|
*/
|
|
661
|
-
async clearComposer() {
|
|
662
|
-
for (const key of COMPOSER_CLEAR_KEYS)
|
|
663
|
-
await this.deps.tmux.sendKey(this.name, key);
|
|
664
|
-
}
|
|
665
|
-
/**
|
|
666
|
-
* Block until the console can accept input; classify offline/stopped/ready, or
|
|
667
|
-
* `modal` when the pane still looks modal after `MODAL_GIVE_UP_MS`. The bound is
|
|
668
|
-
* what keeps a modal from wedging delivery silently: we still never `Enter` into
|
|
669
|
-
* the dialog, but the give-up is reported instead of retried forever.
|
|
670
|
-
*/
|
|
671
|
-
async awaitInjectable(pid) {
|
|
672
|
-
let modalWaits = 0;
|
|
673
|
-
for (;;) {
|
|
674
|
-
if (this.stopped)
|
|
675
|
-
return 'stopped';
|
|
676
|
-
if (!this.deps.isAlive(pid) || !(await this.deps.tmux.has(this.name)))
|
|
677
|
-
return 'offline';
|
|
678
|
-
const now = this.deps.now();
|
|
679
|
-
if (now < this.bootDeadline) {
|
|
680
|
-
await this.deps.sleep(this.bootDeadline - now);
|
|
681
|
-
continue;
|
|
682
|
-
}
|
|
683
|
-
const capture = await safeCapture(this.deps.tmux, this.name);
|
|
684
|
-
if (!capture.ok) {
|
|
685
|
-
this.degrade('delivery', 'capture failed while checking session readiness');
|
|
686
|
-
await this.deps.sleep(MODAL_RETRY_MS);
|
|
687
|
-
continue;
|
|
688
|
-
}
|
|
689
|
-
if (!looksModal(capture.pane))
|
|
690
|
-
return 'ready';
|
|
691
|
-
if (modalWaits++ >= MAX_MODAL_WAITS)
|
|
692
|
-
return 'modal';
|
|
693
|
-
await this.deps.sleep(MODAL_RETRY_MS);
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
569
|
async doFetch(since, timeoutMs, timeoutKind) {
|
|
697
570
|
const ctrl = new AbortController();
|
|
698
571
|
this.currentAbort = ctrl;
|
|
@@ -820,12 +693,4 @@ export class Monitor {
|
|
|
820
693
|
export function createMonitor(o) {
|
|
821
694
|
return new Monitor(o);
|
|
822
695
|
}
|
|
823
|
-
async function safeCapture(tmux, name) {
|
|
824
|
-
try {
|
|
825
|
-
return { ok: true, pane: await tmux.capture(name) };
|
|
826
|
-
}
|
|
827
|
-
catch {
|
|
828
|
-
return { ok: false, pane: '' };
|
|
829
|
-
}
|
|
830
|
-
}
|
|
831
696
|
const msg = (e) => e?.message ?? String(e);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type OwnerChannelConfig } from '../config.js';
|
|
2
|
-
import { type
|
|
2
|
+
import { type AgentSession } from '../session/types.js';
|
|
3
3
|
import { type OwnerFleetOps } from './commands.js';
|
|
4
4
|
import type { ManagedFleetSpawnResult } from '../fleet-proxy.js';
|
|
5
5
|
import { type OursOps } from './ours-client.js';
|
|
@@ -12,7 +12,7 @@ export interface OwnerChannelOptions {
|
|
|
12
12
|
/** Harness id of the role (e.g. 'claude-code', 'codex'); gates which slash commands may be forwarded. */
|
|
13
13
|
harness: string;
|
|
14
14
|
config: OwnerChannelConfig;
|
|
15
|
-
session:
|
|
15
|
+
session: AgentSession;
|
|
16
16
|
stateDir: string;
|
|
17
17
|
env?: Record<string, string>;
|
|
18
18
|
log(line: string): void;
|
package/dist/runner.d.ts
CHANGED
|
@@ -1,15 +1,12 @@
|
|
|
1
1
|
import { type ResolvedRole } from './config.js';
|
|
2
|
-
import type { Launch } from './harness/types.js';
|
|
3
|
-
import { Tmux } from './tmux.js';
|
|
4
2
|
import { type MonitorHandle, type MonitorOpts, type FetchLike } from './monitor.js';
|
|
5
3
|
import { type Exec } from './exec.js';
|
|
6
|
-
import { type AcpSessionOptions } from './session/acp.js';
|
|
7
4
|
import { RoleControlServer } from './session/control.js';
|
|
8
|
-
import type {
|
|
5
|
+
import type { AgentSession, ExitRecord, TurnResult } from './session/types.js';
|
|
6
|
+
import type { AgentSessionAdapter, AgentSessionStartOptions } from './harness/agent-session.js';
|
|
9
7
|
import { type OwnerChannelHandle, type OwnerChannelOptions } from './owner-channel/channel.js';
|
|
10
8
|
import { type OwnerBinderLease } from './owner-channel/binder.js';
|
|
11
9
|
export interface RunnerDeps {
|
|
12
|
-
tmux: Tmux;
|
|
13
10
|
exec: Exec;
|
|
14
11
|
cpuDelegated(): boolean;
|
|
15
12
|
isAlive(pid: number): boolean;
|
|
@@ -23,9 +20,10 @@ export interface RunnerDeps {
|
|
|
23
20
|
/** Construct trusted owner ingress (injectable for lifecycle tests). */
|
|
24
21
|
createOwnerChannel(opts: OwnerChannelOptions): OwnerChannelHandle;
|
|
25
22
|
/** Start the ACP transport (injectable for deterministic runner lifecycle tests). */
|
|
26
|
-
|
|
23
|
+
/** Neutral construction seam for deterministic runner lifecycle tests. */
|
|
24
|
+
startAgentSession(adapter: AgentSessionAdapter, options: AgentSessionStartOptions): Promise<AgentSession>;
|
|
27
25
|
/** Construct the authenticated role control route (injectable where sockets are unavailable). */
|
|
28
|
-
createControlServer(stateDir: string, session:
|
|
26
|
+
createControlServer(stateDir: string, session: AgentSession, log: (line: string) => void): Pick<RoleControlServer, 'start' | 'close' | 'setFleetSpawner' | 'setOwnerChannel' | 'setConfigReloader' | 'setLoopManager'>;
|
|
29
27
|
/** Acquire the cross-process owner-channel binder lease before replacing the control socket. */
|
|
30
28
|
acquireOwnerBinder(stateDir: string, role: string, identity: string): Promise<OwnerBinderLease>;
|
|
31
29
|
/** Ask the still-authenticated predecessor to emit the fixed recovery notice. */
|
|
@@ -51,14 +49,6 @@ export declare function harnessChildEnv(role: ResolvedRole, launchEnv: Record<st
|
|
|
51
49
|
* responsible for.
|
|
52
50
|
*/
|
|
53
51
|
export declare function recordMonitorOwner(dir: string, owner: 'fleet' | 'native'): boolean;
|
|
54
|
-
/**
|
|
55
|
-
* Compose the tmux pane shell command: env prefix + argv + exit-status capture.
|
|
56
|
-
* `paneArgv` defaults to `launch.argv`; when isolation is active the caller passes
|
|
57
|
-
* the sandbox-wrapped argv (e.g. `bwrap … -- claude …`). The `env` prefix and the
|
|
58
|
-
* `echo $? > exitfile` capture stay host-side, outside the sandbox, so the runner
|
|
59
|
-
* still sees the real exit code.
|
|
60
|
-
*/
|
|
61
|
-
export declare function buildPaneCommand(launch: Launch, roleEnv: Record<string, string> | undefined, exitStatusPath: string, paneArgv?: string[]): string;
|
|
62
52
|
/**
|
|
63
53
|
* Read the pane's `.exit-status`. Three shapes are accepted: the structured
|
|
64
54
|
* record written above, a bare number left by a pre-upgrade pane (so an
|
package/dist/runner.js
CHANGED
|
@@ -5,16 +5,13 @@ import { parse } from 'yaml';
|
|
|
5
5
|
import { agentDir, stateRoot } from './paths.js';
|
|
6
6
|
import { loadConfig, findRole, isolationContextFor, resolveMonitorConfig, resolvePermissions, } from './config.js';
|
|
7
7
|
import { getAdapter } from './harness/registry.js';
|
|
8
|
-
import { Tmux } from './tmux.js';
|
|
9
8
|
import { createMonitor, probeIdentityPresence, } from './monitor.js';
|
|
10
|
-
import { realExec
|
|
9
|
+
import { realExec } from './exec.js';
|
|
11
10
|
import { resolveIsolation } from './isolation/policy.js';
|
|
12
11
|
import { selectIsolationBackend } from './isolation/registry.js';
|
|
13
12
|
import { resourceArgs, cpuControllerDelegated } from './isolation/resources.js';
|
|
14
13
|
import { resolveLaunchRuntime } from './isolation/runtime.js';
|
|
15
|
-
import { AcpSession } from './session/acp.js';
|
|
16
14
|
import { controlRequest, RoleControlServer } from './session/control.js';
|
|
17
|
-
import { TmuxSession } from './session/tmux.js';
|
|
18
15
|
import { ACP_CANCEL_DEADLINE_EXCEEDED, classifyShellStatus } from './session/types.js';
|
|
19
16
|
import { effectiveModelForRole, modelRecoveryHeld, reconcileModelRecovery, recordModelFailure, classifyFailureText, } from './model-recovery.js';
|
|
20
17
|
import { rotateWorklog } from './worklog.js';
|
|
@@ -28,7 +25,6 @@ import { effectivePermissionMode } from './permissions.js';
|
|
|
28
25
|
import { assertModelPinReachesChild, effectiveRoleModel, repinModelEnv } from './model-env.js';
|
|
29
26
|
import { archiveTempState, markTempSupervisorActive, requestedTempStopReason, } from './temp-lifecycle.js';
|
|
30
27
|
const defaultDeps = () => ({
|
|
31
|
-
tmux: new Tmux(),
|
|
32
28
|
exec: realExec,
|
|
33
29
|
cpuDelegated: () => cpuControllerDelegated(),
|
|
34
30
|
isAlive: pid => { try {
|
|
@@ -44,7 +40,7 @@ const defaultDeps = () => ({
|
|
|
44
40
|
fetch: (url, init) => globalThis.fetch(url, init),
|
|
45
41
|
createMonitor: opts => createMonitor(opts),
|
|
46
42
|
createOwnerChannel: opts => new OwnerChannel(opts),
|
|
47
|
-
|
|
43
|
+
startAgentSession: (adapter, options) => adapter.start(options),
|
|
48
44
|
createControlServer: (stateDir, session, log) => new RoleControlServer(stateDir, session, log),
|
|
49
45
|
acquireOwnerBinder: (stateDir, role, identity) => acquireOwnerBinderLease(stateDir, role, identity),
|
|
50
46
|
reportOwnerStartupFailure: async (stateDir) => {
|
|
@@ -133,38 +129,10 @@ export function recordMonitorOwner(dir, owner) {
|
|
|
133
129
|
catch { /* ownership diagnostics must never take the role down */ }
|
|
134
130
|
return owner === 'fleet' && previous === 'native';
|
|
135
131
|
}
|
|
136
|
-
/**
|
|
137
|
-
* Compose the tmux pane shell command: env prefix + argv + exit-status capture.
|
|
138
|
-
* `paneArgv` defaults to `launch.argv`; when isolation is active the caller passes
|
|
139
|
-
* the sandbox-wrapped argv (e.g. `bwrap … -- claude …`). The `env` prefix and the
|
|
140
|
-
* `echo $? > exitfile` capture stay host-side, outside the sandbox, so the runner
|
|
141
|
-
* still sees the real exit code.
|
|
142
|
-
*/
|
|
143
|
-
export function buildPaneCommand(launch, roleEnv, exitStatusPath, paneArgv = launch.argv) {
|
|
144
|
-
const env = {
|
|
145
|
-
PATH: process.env.PATH ?? '', COLORTERM: 'truecolor', ...launch.env, ...(roleEnv ?? {}),
|
|
146
|
-
};
|
|
147
|
-
delete env[OBSOLETE_OURS_AUTOSTART_ENV];
|
|
148
|
-
// Interactive panes should advertise colour even when the supervisor itself
|
|
149
|
-
// was launched with NO_COLOR. A role may still deliberately opt back in to
|
|
150
|
-
// NO_COLOR (or replace COLORTERM) through its explicit env block.
|
|
151
|
-
const unsetNoColor = Object.prototype.hasOwnProperty.call(roleEnv ?? {}, 'NO_COLOR')
|
|
152
|
-
? '' : '-u NO_COLOR ';
|
|
153
|
-
const envPfx = 'env -u OURS_AUTOSTART ' + unsetNoColor
|
|
154
|
-
+ Object.entries(env).map(([k, v]) => `${k}=${shq(v)}`).join(' ');
|
|
155
|
-
const cmd = paneArgv.map(shq).join(' ');
|
|
156
|
-
// Write a structured record, not a bare number: the wait status alone cannot
|
|
157
|
-
// say whether the file is missing because the program never exited or because
|
|
158
|
-
// nothing ever wrote it. `printf` is POSIX; no shell branching is needed
|
|
159
|
-
// because classification happens in one place, in TypeScript.
|
|
160
|
-
const record = `'{"version":1,"backend":"tmux","status":'"$__ofs"'}'`;
|
|
161
|
-
return `${envPfx} ${cmd}; __ofs=$?; printf %s ${record} > ${shq(exitStatusPath)}`;
|
|
162
|
-
}
|
|
163
132
|
/** Adapt runner deps and the role's daemon-profile overrides for the monitor. */
|
|
164
133
|
function monitorDeps(deps, roleEnv) {
|
|
165
134
|
return {
|
|
166
135
|
fetch: deps.fetch,
|
|
167
|
-
tmux: deps.tmux,
|
|
168
136
|
isAlive: deps.isAlive,
|
|
169
137
|
sleep: deps.sleep,
|
|
170
138
|
now: deps.now,
|
|
@@ -484,16 +452,9 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
484
452
|
writeFileSync(bootedFile, `${new Date(deps.now()).toISOString()} ${mode}\n`);
|
|
485
453
|
const runCwd = role.cwd && existsSync(role.cwd) ? role.cwd : dir;
|
|
486
454
|
const prep = await adapter.prepareSession(role, { stateDir: dir, runCwd });
|
|
487
|
-
const sessionBackend = role.session ?? '
|
|
488
|
-
let launch =
|
|
489
|
-
|
|
490
|
-
if (!adapter.buildAcpLaunch)
|
|
491
|
-
throw new Error(`harness '${role.harness}' does not support the ACP session backend`);
|
|
492
|
-
return adapter.buildAcpLaunch(role, prep);
|
|
493
|
-
})()
|
|
494
|
-
: adapter.buildLaunch(role, mode, { sessionId }, prep);
|
|
495
|
-
// Isolation is additive: only roles that declare `isolation:` are wrapped. The
|
|
496
|
-
// env prefix + exit capture in buildPaneCommand stay host-side.
|
|
455
|
+
const sessionBackend = role.session ?? 'acp';
|
|
456
|
+
let launch = adapter.agentSession.prepareLaunch(role, prep);
|
|
457
|
+
// Isolation is additive: only roles that declare `isolation:` are wrapped.
|
|
497
458
|
let wrappedArgv = launch.argv;
|
|
498
459
|
if (role.isolation) {
|
|
499
460
|
// Start with the same durable context that config validation and doctor judged,
|
|
@@ -530,7 +491,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
530
491
|
// user unit concurrently on boot; `ours-fleet up`/restart-all bulk-start) does not
|
|
531
492
|
// hit the harness/API rate limit at once. Time-based via a shared launch gate, so
|
|
532
493
|
// a lone start or a solo crash-restart waits zero. Applied right before the harness
|
|
533
|
-
//
|
|
494
|
+
// agent-session start; the cheap monitor prime still runs immediately after.
|
|
534
495
|
if (staggerMs > 0) {
|
|
535
496
|
const slot = await reserveLaunchSlot(stateRoot(), staggerMs, deps);
|
|
536
497
|
const wait = slot - deps.now();
|
|
@@ -575,7 +536,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
575
536
|
await monitor.prime({ resetCursor: resetMonitorCursor });
|
|
576
537
|
rmSync(exitFile, { force: true });
|
|
577
538
|
let pid;
|
|
578
|
-
let
|
|
539
|
+
let agentSession;
|
|
579
540
|
let control;
|
|
580
541
|
let unsubscribeRecovery;
|
|
581
542
|
let monitorLoop;
|
|
@@ -590,7 +551,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
590
551
|
let loopGeneration = JSON.stringify((role.loops ?? []).map(loop => [
|
|
591
552
|
loop.name, loop.definitionHash, loop.promptHash,
|
|
592
553
|
]));
|
|
593
|
-
|
|
554
|
+
{
|
|
594
555
|
const perms = role.permissions ?? resolvePermissions(undefined, undefined);
|
|
595
556
|
// Say once, at startup, that this role will decide permission requests by
|
|
596
557
|
// itself. Without it the only trace of an auto-denied tool call is a turn
|
|
@@ -598,34 +559,16 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
598
559
|
if (perms.unattended === 'deny')
|
|
599
560
|
deps.log(`[${name}] permission policy: unattended=deny — with no console attached, ` +
|
|
600
561
|
`permission requests are automatically denied once each (reject_once) and the turn continues`);
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
argv: wrappedArgv,
|
|
604
|
-
cwd: runCwd,
|
|
605
|
-
|
|
606
|
-
stateDir: dir,
|
|
607
|
-
mode,
|
|
608
|
-
permissions: perms,
|
|
609
|
-
modeId: adapter.acpPermissionModeId?.(role),
|
|
610
|
-
// The role's declared MCP servers, and the bundled agent's `_meta`
|
|
611
|
-
// vocabulary for the options it takes no flag for. Both come from the
|
|
612
|
-
// ADAPTER and from `prep`: the ACP launch cannot carry `prep.argv`, so this
|
|
613
|
-
// is the route by which harness_options that used to be silently dropped
|
|
614
|
-
// for an ACP role actually reach the session.
|
|
615
|
-
mcpServers: adapter.acpMcpServers?.(role),
|
|
616
|
-
sessionMeta: adapter.acpSessionMeta?.(role, prep),
|
|
617
|
-
permissionMode: effectivePermissionMode(role),
|
|
618
|
-
// Provenance travels with the exact ACP launch. Keeping it out of a
|
|
619
|
-
// role-only adapter hook prevents a PATH fallback or resolver skew from
|
|
620
|
-
// claiming metadata trust for an argv it did not authenticate.
|
|
621
|
-
permissionMetadataSource: launch.permissionMetadataSource,
|
|
622
|
-
scrubObsoleteOursAutostart: true,
|
|
623
|
-
log: deps.log,
|
|
562
|
+
agentSession = await deps.startAgentSession(adapter.agentSession, {
|
|
563
|
+
role, prep,
|
|
564
|
+
launch: { ...launch, argv: wrappedArgv, env: harnessChildEnv(role, launch.env, dir) },
|
|
565
|
+
cwd: runCwd, stateDir: dir, mode, permissions: perms,
|
|
566
|
+
permissionMode: effectivePermissionMode(role), log: deps.log,
|
|
624
567
|
});
|
|
625
|
-
pid =
|
|
626
|
-
arbiter = new RoleTurnArbiter(
|
|
568
|
+
pid = agentSession.pid;
|
|
569
|
+
arbiter = new RoleTurnArbiter(agentSession);
|
|
627
570
|
sessionHandle = arbiter;
|
|
628
|
-
unsubscribeRecovery =
|
|
571
|
+
unsubscribeRecovery = agentSession.subscribe(event => {
|
|
629
572
|
if (event.kind !== 'error' || !event.text)
|
|
630
573
|
return;
|
|
631
574
|
const evidence = classifyFailureText(event.text, 'acp', new Date(deps.now()).toISOString());
|
|
@@ -647,7 +590,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
647
590
|
+ `${notifyError?.message ?? String(notifyError)}`);
|
|
648
591
|
}
|
|
649
592
|
}
|
|
650
|
-
await
|
|
593
|
+
await agentSession.close();
|
|
651
594
|
unsubscribeRecovery?.();
|
|
652
595
|
throw new Error(`[${name}] owner channel failed to start: `
|
|
653
596
|
+ `${error?.message ?? String(error)}`);
|
|
@@ -659,7 +602,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
659
602
|
}
|
|
660
603
|
catch (error) {
|
|
661
604
|
ownerBinder?.release();
|
|
662
|
-
await
|
|
605
|
+
await agentSession.close();
|
|
663
606
|
unsubscribeRecovery?.();
|
|
664
607
|
throw error;
|
|
665
608
|
}
|
|
@@ -740,7 +683,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
740
683
|
const started = await starting;
|
|
741
684
|
// A temporary role's first turn can be the active turn when an ours wake
|
|
742
685
|
// needs immediate attention. A typed console/monitor cancellation ends
|
|
743
|
-
// only that turn: the already-live
|
|
686
|
+
// only that turn: the already-live agent session and any queued wake remain
|
|
744
687
|
// valid. Keep every unproven cancellation, refusal, shutdown, and genuine
|
|
745
688
|
// failure terminal so a role that never accepted its briefing is not
|
|
746
689
|
// silently reported as healthy.
|
|
@@ -749,7 +692,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
749
692
|
monitor?.stop();
|
|
750
693
|
await control.close();
|
|
751
694
|
ownerBinder?.release();
|
|
752
|
-
await
|
|
695
|
+
await agentSession.close();
|
|
753
696
|
unsubscribeRecovery?.();
|
|
754
697
|
if (modelRecovery) {
|
|
755
698
|
if (monitorLoop)
|
|
@@ -795,7 +738,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
795
738
|
await ownerChannel.close().catch(() => undefined);
|
|
796
739
|
await control.close();
|
|
797
740
|
ownerBinder?.release();
|
|
798
|
-
await
|
|
741
|
+
await agentSession.close();
|
|
799
742
|
unsubscribeRecovery?.();
|
|
800
743
|
throw new Error(`[${name}] owner channel failed to start: `
|
|
801
744
|
+ `${error?.message ?? String(error)}`);
|
|
@@ -854,20 +797,6 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
854
797
|
}
|
|
855
798
|
}
|
|
856
799
|
}
|
|
857
|
-
else {
|
|
858
|
-
await deps.tmux.kill(name);
|
|
859
|
-
await deps.tmux.newSession(name, runCwd, buildPaneCommand(launch, role.env, exitFile, wrappedArgv));
|
|
860
|
-
let panePid = null;
|
|
861
|
-
for (let i = 0; i < 40 && panePid === null; i++) {
|
|
862
|
-
panePid = await deps.tmux.panePid(name);
|
|
863
|
-
if (panePid === null)
|
|
864
|
-
await deps.sleep(250);
|
|
865
|
-
}
|
|
866
|
-
if (panePid === null)
|
|
867
|
-
throw new Error(`[${name}] could not resolve tmux pane pid`);
|
|
868
|
-
pid = panePid;
|
|
869
|
-
sessionHandle = new TmuxSession(name, pid, deps.tmux, deps.isAlive);
|
|
870
|
-
}
|
|
871
800
|
deps.log(`[${name}] up; pid=${pid} cwd=${runCwd} harness=${role.harness} session=${sessionBackend} mode=${mode}`);
|
|
872
801
|
// The monitor loop lives exactly as long as the session: it starts once the
|
|
873
802
|
// pane pid is known and is stopped when that pid dies (task dies with runner).
|
|
@@ -899,7 +828,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
899
828
|
else if (presence.state === 'absent' && identityObserved) {
|
|
900
829
|
identityAbsentSince ??= now;
|
|
901
830
|
// Require a continuous, time-bounded run of authoritative absence.
|
|
902
|
-
// The first positive observation is the readiness gate: cold
|
|
831
|
+
// The first positive observation is the readiness gate: cold harness
|
|
903
832
|
// starts may spend minutes loading the harness and briefing before the
|
|
904
833
|
// agent creates/binds its identity, and absence before then is not a
|
|
905
834
|
// close event. After readiness, debounce a real disappearance.
|
|
@@ -948,19 +877,14 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
948
877
|
await monitorLoop;
|
|
949
878
|
}
|
|
950
879
|
unsubscribeRecovery?.();
|
|
951
|
-
if (
|
|
952
|
-
await
|
|
880
|
+
if (agentSession && !sessionClosed)
|
|
881
|
+
await agentSession.close();
|
|
953
882
|
const elapsed = (deps.now() - start) / 1000;
|
|
954
883
|
// Establish what actually happened before deciding anything. Absence of a
|
|
955
884
|
// record is `unknown` — except when the console itself is gone, which is a
|
|
956
885
|
// different event with a different consequence.
|
|
957
|
-
const exitRecord =
|
|
958
|
-
|
|
959
|
-
?? { version: 1, class: 'unknown', detail: 'the ACP agent stopped without reporting an exit' }
|
|
960
|
-
: readExitRecord(exitFile)
|
|
961
|
-
?? (await deps.tmux.has(name)
|
|
962
|
-
? { version: 1, class: 'unknown', detail: 'the pane process ended without writing an exit record' }
|
|
963
|
-
: { version: 1, class: 'session-destroyed', detail: `the tmux session '${name}' no longer exists` });
|
|
886
|
+
const exitRecord = agentSession.exitResult()
|
|
887
|
+
?? { version: 1, class: 'unknown', detail: 'the agent session stopped without reporting an exit' };
|
|
964
888
|
writeFileSync(exitFile, JSON.stringify({
|
|
965
889
|
...exitRecord, at: new Date(deps.now()).toISOString(), elapsedSecs: Number(elapsed.toFixed(1)),
|
|
966
890
|
}) + '\n');
|
package/dist/session/acp.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { CommonPermissions } from '../config.js';
|
|
|
3
3
|
import type { AcpMcpServer } from '../harness/types.js';
|
|
4
4
|
import { ConversationEventStore } from './conversation-store.js';
|
|
5
5
|
import type { ConversationSnapshot, PromptOrigin, PromptReceipt, SubmitPromptCommand } from './conversation-types.js';
|
|
6
|
-
import type { ConversationHandlePage, ExitRecord, InterruptOutcome, QueuedPrompt, SessionEvent,
|
|
6
|
+
import type { ConversationHandlePage, ExitRecord, InterruptOutcome, QueuedPrompt, SessionEvent, AgentSession, RuntimeSelectorMetadata, SessionSnapshot, SubmitPromptOptions, TurnCancellationSource, TurnOutcome, TurnResult } from './types.js';
|
|
7
7
|
/** Bound safe-boundary waiting without turning a hung tool into cancellation. */
|
|
8
8
|
export declare const AFTER_TOOL_BOUNDARY_TIMEOUT_MS = 120000;
|
|
9
9
|
/**
|
|
@@ -81,7 +81,7 @@ export declare function classifyStopReason(stopReason: string | undefined): Turn
|
|
|
81
81
|
* Persistent ACP v1 client. It is the sole owner of the agent's stdio; all
|
|
82
82
|
* human/automation attachment happens through the fleet role-control protocol.
|
|
83
83
|
*/
|
|
84
|
-
export declare class AcpSession implements
|
|
84
|
+
export declare class AcpSession implements AgentSession {
|
|
85
85
|
private readonly options;
|
|
86
86
|
readonly backend: "acp";
|
|
87
87
|
readonly pid: number;
|
package/dist/session/acp.js
CHANGED
|
@@ -281,8 +281,7 @@ export class AcpSession {
|
|
|
281
281
|
clearTimeout(this.cancelForceKill);
|
|
282
282
|
this.cancelForceKill = undefined;
|
|
283
283
|
this.releaseSteeringOccupancy('adapter exited');
|
|
284
|
-
// Record the child's real exit code/signal
|
|
285
|
-
// shell's `$?`; here the truth is available, so keep it.
|
|
284
|
+
// Record the child's real exit code/signal while the truth is available.
|
|
286
285
|
const classified = classifyChildExit(code, signal);
|
|
287
286
|
this.exit = this.cancelRecoveryReason
|
|
288
287
|
? { ...classified, detail: `${this.cancelRecoveryReason}; ${classified.detail}` }
|
|
@@ -1476,7 +1475,7 @@ export class AcpSession {
|
|
|
1476
1475
|
...(normalized.adapterMeta ? { adapterMeta: normalized.adapterMeta } : {}),
|
|
1477
1476
|
});
|
|
1478
1477
|
}
|
|
1479
|
-
// ── conversation ledger access (
|
|
1478
|
+
// ── conversation ledger access (AgentSession) ─────────────────────────────
|
|
1480
1479
|
conversationPage(request = {}) {
|
|
1481
1480
|
const floor = Number(this.conversationStartCursor ?? 0);
|
|
1482
1481
|
const requested = Number(request.after ?? 0);
|
|
@@ -18,9 +18,8 @@ export interface ObservedActivity {
|
|
|
18
18
|
lastUpdateAt?: string;
|
|
19
19
|
}
|
|
20
20
|
/**
|
|
21
|
-
* Classify agent-side activity. `unobservable` is NOT `quiet`:
|
|
22
|
-
*
|
|
23
|
-
* reported as "doing nothing".
|
|
21
|
+
* Classify agent-side activity. `unobservable` is NOT `quiet`: absent evidence
|
|
22
|
+
* must never be reported as "doing nothing".
|
|
24
23
|
*/
|
|
25
24
|
export declare function classifyActivity(activity: SessionActivity | undefined, now?: number): ObservedActivity;
|
|
26
25
|
/**
|
package/dist/session/activity.js
CHANGED
|
@@ -11,9 +11,8 @@
|
|
|
11
11
|
*/
|
|
12
12
|
export const ACTIVITY_WINDOW_MS = 60_000;
|
|
13
13
|
/**
|
|
14
|
-
* Classify agent-side activity. `unobservable` is NOT `quiet`:
|
|
15
|
-
*
|
|
16
|
-
* reported as "doing nothing".
|
|
14
|
+
* Classify agent-side activity. `unobservable` is NOT `quiet`: absent evidence
|
|
15
|
+
* must never be reported as "doing nothing".
|
|
17
16
|
*/
|
|
18
17
|
export function classifyActivity(activity, now = Date.now()) {
|
|
19
18
|
if (!activity)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ConversationHandlePage, ExitRecord, InterruptOutcome, PromptOrigin, QueuedPrompt, SessionEvent,
|
|
1
|
+
import type { ConversationHandlePage, ExitRecord, InterruptOutcome, PromptOrigin, QueuedPrompt, SessionEvent, AgentSession, SessionSnapshot, SubmitPromptOptions, TurnCancellationSource, TurnResult } from './types.js';
|
|
2
2
|
import type { ConversationEventV1, ConversationSnapshot, PromptReceipt, SubmitPromptCommand } from './conversation-types.js';
|
|
3
3
|
export type ScheduledAttempt = {
|
|
4
4
|
state: 'started';
|
|
@@ -15,9 +15,9 @@ export type ScheduledAttempt = {
|
|
|
15
15
|
* producers retain ACP queue semantics while making their unsettled claim
|
|
16
16
|
* visible before another producer can inspect idle state.
|
|
17
17
|
*/
|
|
18
|
-
export declare class RoleTurnArbiter implements
|
|
18
|
+
export declare class RoleTurnArbiter implements AgentSession {
|
|
19
19
|
private readonly session;
|
|
20
|
-
readonly backend:
|
|
20
|
+
readonly backend: "acp";
|
|
21
21
|
readonly pid: number;
|
|
22
22
|
private tail;
|
|
23
23
|
private unsettled;
|
|
@@ -26,7 +26,7 @@ export declare class RoleTurnArbiter implements SessionHandle {
|
|
|
26
26
|
private generation;
|
|
27
27
|
/** Resolves the instant the CURRENT generation is retired. */
|
|
28
28
|
private retirement;
|
|
29
|
-
constructor(session:
|
|
29
|
+
constructor(session: AgentSession);
|
|
30
30
|
/**
|
|
31
31
|
* Waiters race the tail against their own generation's retirement, so a
|
|
32
32
|
* single operation that never settles cannot own the boundary forever. A
|