@ours.network/fleet 1.0.5 → 1.1.0-nightly.2
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 +90 -112
- 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 +14 -9
- package/dist/application/role-removal-service.js +6 -3
- 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 +1 -8
- package/dist/briefing.js +3 -6
- package/dist/build-info.json +5 -5
- package/dist/cli.js +17 -28
- package/dist/config.d.ts +29 -6
- package/dist/config.js +320 -40
- package/dist/creation.d.ts +1 -1
- package/dist/creation.js +2 -2
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +51 -95
- package/dist/doctor.js +1 -8
- package/dist/fleet-proxy.d.ts +1 -1
- package/dist/generated-agent-source.d.ts +9 -0
- package/dist/generated-agent-source.js +53 -0
- 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 +46 -0
- package/dist/harness/agent-session.js +1 -0
- package/dist/harness/claude-code-session.d.ts +22 -0
- package/dist/harness/claude-code-session.js +32 -0
- package/dist/harness/claude-code.d.ts +2 -1
- package/dist/harness/claude-code.js +39 -81
- package/dist/harness/codex-session.d.ts +22 -0
- package/dist/harness/codex-session.js +32 -0
- package/dist/harness/codex.d.ts +2 -1
- package/dist/harness/codex.js +49 -45
- package/dist/harness/registry.js +4 -0
- package/dist/harness/types.d.ts +6 -38
- package/dist/index.d.ts +3 -5
- package/dist/index.js +1 -3
- package/dist/init-guidance.d.ts +1 -0
- package/dist/init-guidance.js +5 -0
- package/dist/isolation/resources.d.ts +3 -7
- package/dist/isolation/resources.js +3 -7
- package/dist/model-env.d.ts +2 -4
- package/dist/model-env.js +4 -7
- package/dist/monitor.d.ts +1 -24
- package/dist/monitor.js +5 -140
- package/dist/ops.js +4 -2
- package/dist/owner-channel/channel.d.ts +2 -2
- package/dist/resolved-plan.d.ts +3 -2
- package/dist/resolved-plan.js +21 -5
- package/dist/runner.d.ts +5 -15
- package/dist/runner.js +26 -102
- package/dist/sensitive-config.d.ts +2 -0
- package/dist/sensitive-config.js +4 -0
- 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.d.ts +4 -4
- package/dist/spawn.js +60 -32
- 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/fleet-config-service.d.ts +11 -8
- package/dist/web/fleet-config-service.js +383 -183
- 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 +18 -18
- package/dist/web/topology.js +2 -2
- package/dist/web/yaml-document-edit.js +2 -0
- package/dist/web-app/assets/index-59GF-gsZ.css +1 -0
- package/dist/web-app/assets/index-BbE9EX6n.js +10 -0
- package/dist/web-app/index.html +2 -2
- package/examples/fleet/agents/Alice.yaml +11 -0
- package/examples/fleet/agents/FleetCoordinator.yaml +7 -0
- package/examples/fleet/brains/claude-default.yaml +5 -0
- package/examples/fleet/roles/coordinator.yaml +5 -0
- package/examples/fleet/roles/developer.yaml +3 -0
- package/examples/fleet.yaml +74 -0
- package/package.json +4 -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-BCBK78hw.js +0 -10
- package/dist/web-app/assets/index-DuC-xnX4.css +0 -1
package/dist/web-app/index.html
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
<link rel="manifest" href="/manifest.webmanifest" />
|
|
9
9
|
<link rel="icon" href="/icons/ours-fleet.svg" type="image/svg+xml" />
|
|
10
10
|
<title>ours fleet console</title>
|
|
11
|
-
<script type="module" crossorigin src="/assets/index-
|
|
12
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
11
|
+
<script type="module" crossorigin src="/assets/index-BbE9EX6n.js"></script>
|
|
12
|
+
<link rel="stylesheet" crossorigin href="/assets/index-59GF-gsZ.css">
|
|
13
13
|
</head>
|
|
14
14
|
<body>
|
|
15
15
|
<div id="root"></div>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
role: { ref: developer }
|
|
2
|
+
brain: { ref: claude-default }
|
|
3
|
+
identity: Alice
|
|
4
|
+
coordinator: FleetCoordinator
|
|
5
|
+
cwd: ${work_root}/alice
|
|
6
|
+
permissions: { approval: ask, filesystem: workspace, unattended: deny }
|
|
7
|
+
env:
|
|
8
|
+
EXAMPLE_FLAG: "1"
|
|
9
|
+
monitor:
|
|
10
|
+
wake_sources: [message_received, file_received]
|
|
11
|
+
batch_ms: 2000
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
mission: "Coordinate the fleet: decompose goals, delegate work, review results, and report."
|
|
2
|
+
persona: |
|
|
3
|
+
You are the always-on hub. Keep ownership and status explicit and do not silently
|
|
4
|
+
take over work that should remain delegated.
|
|
5
|
+
bio: Fleet coordinator and owner-facing point of contact.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# fleet.yaml — fleet-wide manifest; Agent/Role/Brain files live under ./fleet/.
|
|
2
|
+
#
|
|
3
|
+
# ours-fleet up create/start every role (idempotent)
|
|
4
|
+
# ours-fleet up Alice only the named role(s)
|
|
5
|
+
# ours-fleet restart re-sync + bounce, resuming context
|
|
6
|
+
# ours-fleet down stop everything
|
|
7
|
+
# ours-fleet config validate + print the merged plan
|
|
8
|
+
#
|
|
9
|
+
# Agent files are bare mappings in ./fleet/agents; reusable presets are in
|
|
10
|
+
# ./fleet/roles and ./fleet/brains. Kind comes from the directory and ID from filename.
|
|
11
|
+
#
|
|
12
|
+
# The v2 manifest contains fleet-wide operational policy and automation only.
|
|
13
|
+
# Brain and Role settings belong in bare documents under ./fleet/brains and
|
|
14
|
+
# ./fleet/roles; Agents compose them under ./fleet/agents. Legacy `roles:` and
|
|
15
|
+
# `fleet.d` are rejected. The web editor writes the manifest and direct Agent
|
|
16
|
+
# documents transactionally; Role/Brain presets remain read-only. Run
|
|
17
|
+
# `ours-fleet config` to validate the complete source set.
|
|
18
|
+
|
|
19
|
+
api_version: ours.network/fleet/v2
|
|
20
|
+
|
|
21
|
+
vars:
|
|
22
|
+
work_root: /home/me/work
|
|
23
|
+
|
|
24
|
+
# Space out agent LAUNCHES host-wide (ms) so a burst of boots doesn't hit the
|
|
25
|
+
# harness/API rate limit at once. Default 0 (no stagger). Enforced at the launch
|
|
26
|
+
# point, so it covers systemd host-boot too — not just `ours-fleet up`/restart.
|
|
27
|
+
# A lone start/restart waits zero; only concurrent boots are spread out.
|
|
28
|
+
# (Replaces the retired FLEET_START_STAGGER env var — see README "Start staggering".)
|
|
29
|
+
# start_stagger_ms: 4000
|
|
30
|
+
|
|
31
|
+
defaults:
|
|
32
|
+
permissions: # common intent translated to each harness/backend
|
|
33
|
+
approval: ask # ask | allow | deny
|
|
34
|
+
filesystem: workspace # read-only | workspace | unrestricted
|
|
35
|
+
unattended: deny # deny | wait when no ACP console is attached
|
|
36
|
+
monitor:
|
|
37
|
+
mode: fleet # fleet (default): ours-fleet supervisor; native: harness-owned monitor
|
|
38
|
+
interrupt: false # false: queue; true: cancel; after_tool: steer at an ACP tool boundary
|
|
39
|
+
|
|
40
|
+
# Harness, model, effort, session, token limits, session_options, and
|
|
41
|
+
# harness_options are Brain-owned. Define them in ./fleet/brains and select the
|
|
42
|
+
# Brain from each Agent document; see ./fleet/brains/claude-default.yaml.
|
|
43
|
+
|
|
44
|
+
# Rooms always use ours-cowork; configure its connection directly. The owner
|
|
45
|
+
# provider is a separate setting and defaults to messenger-server.
|
|
46
|
+
# rooms:
|
|
47
|
+
# cowork:
|
|
48
|
+
# config: /home/me/.ours-cowork/config.json
|
|
49
|
+
# owner:
|
|
50
|
+
# expected_cid: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
|
51
|
+
# public_invite_file: /home/me/.ours-fleet/owner-room-invite.txt
|
|
52
|
+
# defaults:
|
|
53
|
+
# template: team
|
|
54
|
+
# attach_owner: true
|
|
55
|
+
# close_when_task_done: true
|
|
56
|
+
#
|
|
57
|
+
# tasks:
|
|
58
|
+
# default_room_template: team
|
|
59
|
+
# create_mode: start
|
|
60
|
+
# close_room_on_done: true
|
|
61
|
+
|
|
62
|
+
# Scheduled internal turns are ACP-only, idle-only, and never build a backlog.
|
|
63
|
+
# The base file must be owned by you and not group/world writable when an enabled
|
|
64
|
+
# loop is present. A wildcard targets permanent roles only; temp roles do not inherit.
|
|
65
|
+
# loops:
|
|
66
|
+
# coordinator_pass:
|
|
67
|
+
# roles: [FleetCoordinator]
|
|
68
|
+
# interval: 10m
|
|
69
|
+
# initial_delay: 10m # omitted = one full interval; use 0s explicitly for immediate
|
|
70
|
+
# jitter: 30s
|
|
71
|
+
# enabled: true
|
|
72
|
+
# prompt: |
|
|
73
|
+
# Review current fleet state once and unstick only actionable work.
|
|
74
|
+
# If nothing material changed, complete silently without an owner report.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/fleet",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml,
|
|
3
|
+
"version": "1.1.0-nightly.2",
|
|
4
|
+
"description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, ACP sessions, supervision, and ours.network messaging.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "FSL-1.1-Apache-2.0",
|
|
7
7
|
"repository": {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"files": [
|
|
16
16
|
"dist",
|
|
17
17
|
"contrib",
|
|
18
|
+
"examples",
|
|
18
19
|
"README.md",
|
|
19
20
|
"LICENSE"
|
|
20
21
|
],
|
|
@@ -40,10 +41,6 @@
|
|
|
40
41
|
"@fastify/websocket": "^11.2.0",
|
|
41
42
|
"@ours.network/cli": "1.0.1",
|
|
42
43
|
"@ours.network/sdk": "3.0.1",
|
|
43
|
-
"@xterm/addon-fit": "0.10.0",
|
|
44
|
-
"@xterm/addon-serialize": "0.13.0",
|
|
45
|
-
"@xterm/headless": "5.5.0",
|
|
46
|
-
"@xterm/xterm": "5.5.0",
|
|
47
44
|
"commander": "^12.1.0",
|
|
48
45
|
"fastify": "^5.4.0",
|
|
49
46
|
"react": "^19.1.1",
|
|
@@ -54,8 +51,7 @@
|
|
|
54
51
|
},
|
|
55
52
|
"optionalDependencies": {
|
|
56
53
|
"@agentclientprotocol/claude-agent-acp": "^0.63.0",
|
|
57
|
-
"@agentclientprotocol/codex-acp": "1.1.7"
|
|
58
|
-
"node-pty": "^1.1.0"
|
|
54
|
+
"@agentclientprotocol/codex-acp": "1.1.7"
|
|
59
55
|
},
|
|
60
56
|
"devDependencies": {
|
|
61
57
|
"@playwright/test": "^1.54.1",
|
package/dist/session/tmux.d.ts
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import type { Tmux } from '../tmux.js';
|
|
2
|
-
import type { ExitRecord, InterruptOutcome, QueuedPrompt, SessionEvent, SessionHandle, SessionSnapshot, SubmitPromptOptions, TurnResult } from './types.js';
|
|
3
|
-
/** SessionHandle adapter for the existing tmux transport. */
|
|
4
|
-
export declare class TmuxSession implements SessionHandle {
|
|
5
|
-
private readonly name;
|
|
6
|
-
readonly pid: number;
|
|
7
|
-
private readonly tmux;
|
|
8
|
-
private readonly processAlive;
|
|
9
|
-
readonly backend: "tmux";
|
|
10
|
-
constructor(name: string, pid: number, tmux: Tmux, processAlive: (pid: number) => boolean);
|
|
11
|
-
isAlive(): boolean;
|
|
12
|
-
snapshot(): SessionSnapshot;
|
|
13
|
-
queuePrompt(text: string, options?: SubmitPromptOptions): Promise<QueuedPrompt>;
|
|
14
|
-
submitPrompt(text: string, options?: SubmitPromptOptions): Promise<TurnResult>;
|
|
15
|
-
/** A keystroke is delivered or it throws; tmux offers no forced-recovery path. */
|
|
16
|
-
interrupt(): Promise<InterruptOutcome>;
|
|
17
|
-
respondPermission(): boolean;
|
|
18
|
-
eventsSince(): SessionEvent[];
|
|
19
|
-
subscribe(): () => void;
|
|
20
|
-
setControllerAttached(): void;
|
|
21
|
-
/**
|
|
22
|
-
* A tmux pane's exit is only visible through the record its shell wrapper
|
|
23
|
-
* writes; the runner owns that file and classifies it. Nothing observable
|
|
24
|
-
* from here, so say `null` rather than guess.
|
|
25
|
-
*/
|
|
26
|
-
exitResult(): ExitRecord | null;
|
|
27
|
-
close(): Promise<void>;
|
|
28
|
-
}
|
package/dist/session/tmux.js
DELETED
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { SessionControlError, turnResult } from './types.js';
|
|
3
|
-
/** SessionHandle adapter for the existing tmux transport. */
|
|
4
|
-
export class TmuxSession {
|
|
5
|
-
name;
|
|
6
|
-
pid;
|
|
7
|
-
tmux;
|
|
8
|
-
processAlive;
|
|
9
|
-
backend = 'tmux';
|
|
10
|
-
constructor(name, pid, tmux, processAlive) {
|
|
11
|
-
this.name = name;
|
|
12
|
-
this.pid = pid;
|
|
13
|
-
this.tmux = tmux;
|
|
14
|
-
this.processAlive = processAlive;
|
|
15
|
-
}
|
|
16
|
-
isAlive() {
|
|
17
|
-
return this.processAlive(this.pid);
|
|
18
|
-
}
|
|
19
|
-
snapshot() {
|
|
20
|
-
return {
|
|
21
|
-
backend: 'tmux',
|
|
22
|
-
alive: this.isAlive(),
|
|
23
|
-
readiness: this.isAlive() ? 'idle' : 'failed',
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
|
-
async queuePrompt(text, options = {}) {
|
|
27
|
-
if (!this.isAlive())
|
|
28
|
-
throw new SessionControlError('offline', `tmux pane for '${this.name}' is offline`);
|
|
29
|
-
try {
|
|
30
|
-
if (options.interrupt)
|
|
31
|
-
await this.interrupt();
|
|
32
|
-
await this.tmux.sendText(this.name, text);
|
|
33
|
-
}
|
|
34
|
-
catch (error) {
|
|
35
|
-
throw new SessionControlError('backend', error?.message ?? String(error));
|
|
36
|
-
}
|
|
37
|
-
// Keystrokes carry no terminal result: tmux cannot tell us how the turn ended.
|
|
38
|
-
return {
|
|
39
|
-
promptId: randomUUID(),
|
|
40
|
-
queuedBehind: 0,
|
|
41
|
-
completion: Promise.resolve(turnResult(true, 'inconclusive')),
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
async submitPrompt(text, options = {}) {
|
|
45
|
-
try {
|
|
46
|
-
return await (await this.queuePrompt(text, options)).completion;
|
|
47
|
-
}
|
|
48
|
-
catch (error) {
|
|
49
|
-
if (error instanceof SessionControlError)
|
|
50
|
-
return turnResult(false, 'failed', error.message);
|
|
51
|
-
throw error;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
/** A keystroke is delivered or it throws; tmux offers no forced-recovery path. */
|
|
55
|
-
async interrupt() {
|
|
56
|
-
await this.tmux.sendKey(this.name, 'C-c');
|
|
57
|
-
return { state: 'settled' };
|
|
58
|
-
}
|
|
59
|
-
respondPermission() {
|
|
60
|
-
return false;
|
|
61
|
-
}
|
|
62
|
-
eventsSince() {
|
|
63
|
-
return [];
|
|
64
|
-
}
|
|
65
|
-
subscribe() {
|
|
66
|
-
return () => { };
|
|
67
|
-
}
|
|
68
|
-
setControllerAttached() { }
|
|
69
|
-
/**
|
|
70
|
-
* A tmux pane's exit is only visible through the record its shell wrapper
|
|
71
|
-
* writes; the runner owns that file and classifies it. Nothing observable
|
|
72
|
-
* from here, so say `null` rather than guess.
|
|
73
|
-
*/
|
|
74
|
-
exitResult() {
|
|
75
|
-
return null;
|
|
76
|
-
}
|
|
77
|
-
async close() {
|
|
78
|
-
await this.tmux.kill(this.name);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import type { WebSocket } from 'ws';
|
|
2
|
-
import { Tmux } from '../../tmux.js';
|
|
3
|
-
import type { RoleRepository } from '../../application/role-repository.js';
|
|
4
|
-
import type { AuditSink } from '../audit.js';
|
|
5
|
-
type Constructor<T> = new (...args: any[]) => T;
|
|
6
|
-
/** Node exposes these CommonJS xterm packages under `default`; Vite may expose named exports. */
|
|
7
|
-
export declare function resolveModuleConstructor<T>(module: unknown, name: string): Constructor<T>;
|
|
8
|
-
export interface TerminalBridgeManagerOptions {
|
|
9
|
-
repository: RoleRepository;
|
|
10
|
-
audit: AuditSink;
|
|
11
|
-
tmux?: Tmux;
|
|
12
|
-
maxBridges?: number;
|
|
13
|
-
graceMs?: number;
|
|
14
|
-
loadPty?: () => Promise<typeof import('node-pty')>;
|
|
15
|
-
}
|
|
16
|
-
export declare class TerminalBridgeManager {
|
|
17
|
-
private readonly options;
|
|
18
|
-
private readonly bridges;
|
|
19
|
-
private ptyModule?;
|
|
20
|
-
private ptyError?;
|
|
21
|
-
constructor(options: TerminalBridgeManagerOptions);
|
|
22
|
-
available(): Promise<boolean>;
|
|
23
|
-
diagnostic(): string | undefined;
|
|
24
|
-
connect(socket: WebSocket, roleId: string, hello: Record<string, unknown>): Promise<void>;
|
|
25
|
-
close(): Promise<void>;
|
|
26
|
-
}
|
|
27
|
-
export {};
|
|
@@ -1,317 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { Tmux, tmuxArgs } from '../../tmux.js';
|
|
3
|
-
import { FleetError } from '../../application/errors.js';
|
|
4
|
-
const OUTPUT = 0x01;
|
|
5
|
-
const INPUT = 0x02;
|
|
6
|
-
const MAX_INPUT = 8 * 1024;
|
|
7
|
-
const RING_BYTES = 2 * 1024 * 1024;
|
|
8
|
-
const RING_FRAMES = 10_000;
|
|
9
|
-
const LEASE_MS = 30_000;
|
|
10
|
-
/** Node exposes these CommonJS xterm packages under `default`; Vite may expose named exports. */
|
|
11
|
-
export function resolveModuleConstructor(module, name) {
|
|
12
|
-
const record = module && typeof module === 'object' ? module : {};
|
|
13
|
-
const fallback = record.default && typeof record.default === 'object'
|
|
14
|
-
? record.default : {};
|
|
15
|
-
const value = record[name] ?? fallback[name];
|
|
16
|
-
if (typeof value !== 'function')
|
|
17
|
-
throw new FleetError('capability_unavailable', `${name} constructor is unavailable`);
|
|
18
|
-
return value;
|
|
19
|
-
}
|
|
20
|
-
export class TerminalBridgeManager {
|
|
21
|
-
options;
|
|
22
|
-
bridges = new Map();
|
|
23
|
-
ptyModule;
|
|
24
|
-
ptyError;
|
|
25
|
-
constructor(options) {
|
|
26
|
-
this.options = options;
|
|
27
|
-
}
|
|
28
|
-
async available() {
|
|
29
|
-
if (this.ptyModule)
|
|
30
|
-
return true;
|
|
31
|
-
if (this.ptyError)
|
|
32
|
-
return false;
|
|
33
|
-
try {
|
|
34
|
-
this.ptyModule = await (this.options.loadPty?.() ?? import('node-pty'));
|
|
35
|
-
return true;
|
|
36
|
-
}
|
|
37
|
-
catch (error) {
|
|
38
|
-
this.ptyError = error.message;
|
|
39
|
-
return false;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
diagnostic() { return this.ptyError; }
|
|
43
|
-
async connect(socket, roleId, hello) {
|
|
44
|
-
const role = await this.options.repository.get(roleId);
|
|
45
|
-
if (!role)
|
|
46
|
-
throw new FleetError('role_not_found', `no such role '${roleId}'`);
|
|
47
|
-
if (role.configuredBackend !== 'tmux' && role.detectedBackend !== 'tmux')
|
|
48
|
-
throw new FleetError('capability_unavailable', 'terminal is only available for tmux roles');
|
|
49
|
-
if (!await this.available())
|
|
50
|
-
throw new FleetError('capability_unavailable', `node-pty unavailable: ${this.ptyError ?? 'not installed'}`);
|
|
51
|
-
let bridge = this.bridges.get(roleId);
|
|
52
|
-
if (!bridge) {
|
|
53
|
-
if (this.bridges.size >= (this.options.maxBridges ?? 12))
|
|
54
|
-
throw new FleetError('rate_limited', 'terminal bridge limit reached');
|
|
55
|
-
const tmux = this.options.tmux ?? new Tmux();
|
|
56
|
-
if (!await tmux.has(roleId))
|
|
57
|
-
throw new FleetError('offline', `tmux session '${roleId}' is offline`, { provesOffline: true });
|
|
58
|
-
bridge = await TerminalBridge.create(roleId, tmux, this.ptyModule, this.options.audit, () => this.bridges.delete(roleId), this.options.graceMs ?? 30_000);
|
|
59
|
-
this.bridges.set(roleId, bridge);
|
|
60
|
-
}
|
|
61
|
-
bridge.add(socket, hello);
|
|
62
|
-
}
|
|
63
|
-
async close() {
|
|
64
|
-
for (const bridge of this.bridges.values())
|
|
65
|
-
bridge.dispose('server_shutdown');
|
|
66
|
-
this.bridges.clear();
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
class TerminalBridge {
|
|
70
|
-
roleId;
|
|
71
|
-
pty;
|
|
72
|
-
projection;
|
|
73
|
-
audit;
|
|
74
|
-
onDisposed;
|
|
75
|
-
graceMs;
|
|
76
|
-
id = randomUUID();
|
|
77
|
-
viewers = new Map();
|
|
78
|
-
ring = [];
|
|
79
|
-
ringBytes = 0;
|
|
80
|
-
seq = 0n;
|
|
81
|
-
cols = 120;
|
|
82
|
-
rows = 36;
|
|
83
|
-
lease;
|
|
84
|
-
cleanupTimer;
|
|
85
|
-
disposed = false;
|
|
86
|
-
constructor(roleId, pty, projection, audit, onDisposed, graceMs) {
|
|
87
|
-
this.roleId = roleId;
|
|
88
|
-
this.pty = pty;
|
|
89
|
-
this.projection = projection;
|
|
90
|
-
this.audit = audit;
|
|
91
|
-
this.onDisposed = onDisposed;
|
|
92
|
-
this.graceMs = graceMs;
|
|
93
|
-
}
|
|
94
|
-
static async create(roleId, tmux, ptyModule, audit, onDisposed, graceMs) {
|
|
95
|
-
const [headless, serialization] = await Promise.all([
|
|
96
|
-
import('@xterm/headless'), import('@xterm/addon-serialize'),
|
|
97
|
-
]);
|
|
98
|
-
const Terminal = resolveModuleConstructor(headless, 'Terminal');
|
|
99
|
-
const SerializeAddon = resolveModuleConstructor(serialization, 'SerializeAddon');
|
|
100
|
-
const terminal = new Terminal({ cols: 120, rows: 36, scrollback: 5_000, allowProposedApi: true });
|
|
101
|
-
const serialize = new SerializeAddon();
|
|
102
|
-
terminal.loadAddon(serialize);
|
|
103
|
-
const history = await tmux.captureHistory(roleId, 5_000).catch(() => '');
|
|
104
|
-
if (history)
|
|
105
|
-
await new Promise(resolve => terminal.write(history, resolve));
|
|
106
|
-
const env = Object.fromEntries(['PATH', 'HOME', 'LANG', 'LC_ALL'].flatMap(key => process.env[key] ? [[key, process.env[key]]] : []));
|
|
107
|
-
const pty = ptyModule.spawn('tmux', tmuxArgs(roleId, ['attach-session', '-t', roleId]), {
|
|
108
|
-
name: 'xterm-256color', cols: 120, rows: 36,
|
|
109
|
-
cwd: process.env.HOME ?? process.cwd(), env: { ...env, TERM: 'xterm-256color' },
|
|
110
|
-
});
|
|
111
|
-
const projection = {
|
|
112
|
-
write: (data, callback) => terminal.write(data, callback),
|
|
113
|
-
resize: (cols, rows) => terminal.resize(cols, rows),
|
|
114
|
-
dispose: () => { serialize.dispose(); terminal.dispose(); },
|
|
115
|
-
serialize: () => serialize.serialize(),
|
|
116
|
-
};
|
|
117
|
-
const bridge = new TerminalBridge(roleId, pty, projection, audit, onDisposed, graceMs);
|
|
118
|
-
pty.onData(data => bridge.output(new TextEncoder().encode(data)));
|
|
119
|
-
pty.onExit(() => bridge.dispose('tmux_client_exit'));
|
|
120
|
-
await audit.record({ roleId, action: 'terminal.bridge_open', result: 'succeeded' });
|
|
121
|
-
return bridge;
|
|
122
|
-
}
|
|
123
|
-
add(socket, hello) {
|
|
124
|
-
if (this.disposed)
|
|
125
|
-
throw new FleetError('stale_state', 'terminal bridge exited');
|
|
126
|
-
if (this.cleanupTimer)
|
|
127
|
-
clearTimeout(this.cleanupTimer);
|
|
128
|
-
const viewer = {
|
|
129
|
-
socket, sessionLabel: randomUUID().slice(0, 8), resyncPending: false,
|
|
130
|
-
tokens: 32 * 1024, tokenAt: Date.now(),
|
|
131
|
-
};
|
|
132
|
-
this.viewers.set(socket, viewer);
|
|
133
|
-
const lastSeq = parseSeq(hello.lastSeq);
|
|
134
|
-
const bridgeMatches = hello.bridgeId === this.id;
|
|
135
|
-
const first = this.ring[0]?.seq ?? this.seq;
|
|
136
|
-
if (bridgeMatches && lastSeq !== undefined && lastSeq >= first - 1n && lastSeq <= this.seq) {
|
|
137
|
-
for (const frame of this.ring)
|
|
138
|
-
if (frame.seq > lastSeq)
|
|
139
|
-
socket.send(binaryFrame(frame));
|
|
140
|
-
}
|
|
141
|
-
else {
|
|
142
|
-
socket.send(JSON.stringify({
|
|
143
|
-
type: 'snapshot', atSeq: this.seq.toString(), encoding: 'utf8',
|
|
144
|
-
data: this.projection.serialize(),
|
|
145
|
-
}));
|
|
146
|
-
}
|
|
147
|
-
socket.send(JSON.stringify({
|
|
148
|
-
type: 'ready', bridgeId: this.id, firstSeq: first.toString(), lastSeq: this.seq.toString(),
|
|
149
|
-
mode: this.lease?.socket === socket ? 'controller' : 'viewer',
|
|
150
|
-
leaseExpiresAt: this.lease ? new Date(this.lease.expiresAt).toISOString() : null,
|
|
151
|
-
}));
|
|
152
|
-
socket.on('message', (data, binary) => this.message(viewer, data, binary));
|
|
153
|
-
socket.on('close', () => this.remove(socket));
|
|
154
|
-
void this.audit.record({ roleId: this.roleId, action: 'terminal.viewer_open', result: 'succeeded' });
|
|
155
|
-
}
|
|
156
|
-
output(bytes) {
|
|
157
|
-
if (this.disposed || bytes.byteLength === 0)
|
|
158
|
-
return;
|
|
159
|
-
this.seq++;
|
|
160
|
-
const frame = { seq: this.seq, bytes };
|
|
161
|
-
this.ring.push(frame);
|
|
162
|
-
this.ringBytes += bytes.byteLength;
|
|
163
|
-
while (this.ring.length > RING_FRAMES || this.ringBytes > RING_BYTES) {
|
|
164
|
-
this.ringBytes -= this.ring.shift().bytes.byteLength;
|
|
165
|
-
}
|
|
166
|
-
this.projection.write(bytes);
|
|
167
|
-
const payload = binaryFrame(frame);
|
|
168
|
-
for (const viewer of this.viewers.values()) {
|
|
169
|
-
if (viewer.socket.bufferedAmount > 4 * 1024 * 1024) {
|
|
170
|
-
viewer.socket.close(4408, 'slow consumer');
|
|
171
|
-
continue;
|
|
172
|
-
}
|
|
173
|
-
if (viewer.socket.bufferedAmount > 1024 * 1024) {
|
|
174
|
-
viewer.resyncPending = true;
|
|
175
|
-
continue;
|
|
176
|
-
}
|
|
177
|
-
if (viewer.resyncPending) {
|
|
178
|
-
viewer.socket.send(JSON.stringify({ type: 'resync.required', reason: 'slow_consumer' }));
|
|
179
|
-
viewer.resyncPending = false;
|
|
180
|
-
continue;
|
|
181
|
-
}
|
|
182
|
-
viewer.socket.send(payload);
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
message(viewer, data, binary) {
|
|
186
|
-
try {
|
|
187
|
-
if (binary) {
|
|
188
|
-
const bytes = new Uint8Array(data);
|
|
189
|
-
if (bytes.byteLength < 9 || bytes[0] !== INPUT || bytes.byteLength - 9 > MAX_INPUT)
|
|
190
|
-
throw new FleetError('invalid_request', 'invalid terminal input frame');
|
|
191
|
-
if (!this.validLease(viewer.socket))
|
|
192
|
-
throw new FleetError('forbidden', 'writer lease required');
|
|
193
|
-
const input = bytes.slice(9);
|
|
194
|
-
if (!this.consume(viewer, input.byteLength))
|
|
195
|
-
throw new FleetError('rate_limited', 'terminal input rate exceeded');
|
|
196
|
-
this.pty.write(new TextDecoder().decode(input));
|
|
197
|
-
this.renewLease();
|
|
198
|
-
return;
|
|
199
|
-
}
|
|
200
|
-
const message = JSON.parse(data.toString());
|
|
201
|
-
if (message.type === 'lease.request')
|
|
202
|
-
this.requestLease(viewer);
|
|
203
|
-
else if (message.type === 'lease.release')
|
|
204
|
-
this.releaseLease(viewer.socket);
|
|
205
|
-
else if (message.type === 'resize')
|
|
206
|
-
this.resize(viewer, message);
|
|
207
|
-
else
|
|
208
|
-
throw new FleetError('invalid_request', 'unknown terminal control message');
|
|
209
|
-
}
|
|
210
|
-
catch (error) {
|
|
211
|
-
const fleet = error instanceof FleetError ? error : new FleetError('invalid_request', error.message);
|
|
212
|
-
viewer.socket.send(JSON.stringify({ type: 'error', code: fleet.code, message: fleet.message }));
|
|
213
|
-
if (fleet.code === 'rate_limited')
|
|
214
|
-
viewer.socket.close(4408, fleet.message);
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
requestLease(viewer) {
|
|
218
|
-
const now = Date.now();
|
|
219
|
-
if (this.lease && this.lease.expiresAt > now && this.lease.socket !== viewer.socket) {
|
|
220
|
-
viewer.socket.send(JSON.stringify({
|
|
221
|
-
type: 'error', code: 'lease_held',
|
|
222
|
-
message: `controlled by viewer ${this.viewers.get(this.lease.socket)?.sessionLabel ?? 'unknown'}`,
|
|
223
|
-
}));
|
|
224
|
-
return;
|
|
225
|
-
}
|
|
226
|
-
this.lease = { socket: viewer.socket, id: randomUUID(), expiresAt: now + LEASE_MS };
|
|
227
|
-
viewer.socket.send(JSON.stringify({
|
|
228
|
-
type: 'lease.granted', leaseId: this.lease.id,
|
|
229
|
-
leaseExpiresAt: new Date(this.lease.expiresAt).toISOString(),
|
|
230
|
-
}));
|
|
231
|
-
void this.audit.record({ roleId: this.roleId, action: 'terminal.lease_acquire', result: 'succeeded' });
|
|
232
|
-
}
|
|
233
|
-
releaseLease(socket) {
|
|
234
|
-
if (this.lease?.socket !== socket)
|
|
235
|
-
return;
|
|
236
|
-
this.lease = undefined;
|
|
237
|
-
socket.send(JSON.stringify({ type: 'lease.released' }));
|
|
238
|
-
void this.audit.record({ roleId: this.roleId, action: 'terminal.lease_release', result: 'succeeded' });
|
|
239
|
-
}
|
|
240
|
-
resize(viewer, message) {
|
|
241
|
-
if (!this.validLease(viewer.socket) || message.leaseId !== this.lease?.id)
|
|
242
|
-
throw new FleetError('forbidden', 'writer lease required');
|
|
243
|
-
const cols = clamp(Number(message.cols), 80, 240);
|
|
244
|
-
const rows = clamp(Number(message.rows), 24, 80);
|
|
245
|
-
this.cols = cols;
|
|
246
|
-
this.rows = rows;
|
|
247
|
-
this.pty.resize(cols, rows);
|
|
248
|
-
this.projection.resize(cols, rows);
|
|
249
|
-
this.renewLease();
|
|
250
|
-
}
|
|
251
|
-
validLease(socket) {
|
|
252
|
-
if (this.lease && this.lease.expiresAt <= Date.now())
|
|
253
|
-
this.lease = undefined;
|
|
254
|
-
return this.lease?.socket === socket;
|
|
255
|
-
}
|
|
256
|
-
renewLease() {
|
|
257
|
-
if (this.lease)
|
|
258
|
-
this.lease.expiresAt = Date.now() + LEASE_MS;
|
|
259
|
-
}
|
|
260
|
-
consume(viewer, amount) {
|
|
261
|
-
const now = Date.now();
|
|
262
|
-
viewer.tokens = Math.min(32 * 1024, viewer.tokens + (now - viewer.tokenAt) * 16);
|
|
263
|
-
viewer.tokenAt = now;
|
|
264
|
-
if (amount > viewer.tokens)
|
|
265
|
-
return false;
|
|
266
|
-
viewer.tokens -= amount;
|
|
267
|
-
return true;
|
|
268
|
-
}
|
|
269
|
-
remove(socket) {
|
|
270
|
-
this.releaseLease(socket);
|
|
271
|
-
this.viewers.delete(socket);
|
|
272
|
-
void this.audit.record({ roleId: this.roleId, action: 'terminal.viewer_close', result: 'succeeded' });
|
|
273
|
-
if (!this.viewers.size)
|
|
274
|
-
this.cleanupTimer = setTimeout(() => this.dispose('idle_grace_expired'), this.graceMs);
|
|
275
|
-
}
|
|
276
|
-
dispose(reason) {
|
|
277
|
-
if (this.disposed)
|
|
278
|
-
return;
|
|
279
|
-
this.disposed = true;
|
|
280
|
-
if (this.cleanupTimer)
|
|
281
|
-
clearTimeout(this.cleanupTimer);
|
|
282
|
-
for (const viewer of this.viewers.values()) {
|
|
283
|
-
if (viewer.socket.readyState === viewer.socket.OPEN) {
|
|
284
|
-
viewer.socket.send(JSON.stringify({ type: 'terminal.exit', reason }));
|
|
285
|
-
viewer.socket.close(1001, reason);
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
this.viewers.clear();
|
|
289
|
-
try {
|
|
290
|
-
this.pty.kill();
|
|
291
|
-
}
|
|
292
|
-
catch { /* already exited */ }
|
|
293
|
-
this.projection.dispose();
|
|
294
|
-
this.onDisposed();
|
|
295
|
-
void this.audit.record({ roleId: this.roleId, action: 'terminal.bridge_close', result: reason });
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
function binaryFrame(frame) {
|
|
299
|
-
const payload = new Uint8Array(9 + frame.bytes.byteLength);
|
|
300
|
-
payload[0] = OUTPUT;
|
|
301
|
-
new DataView(payload.buffer).setBigUint64(1, frame.seq);
|
|
302
|
-
payload.set(frame.bytes, 9);
|
|
303
|
-
return payload;
|
|
304
|
-
}
|
|
305
|
-
function parseSeq(value) {
|
|
306
|
-
try {
|
|
307
|
-
return value === undefined ? undefined : BigInt(String(value));
|
|
308
|
-
}
|
|
309
|
-
catch {
|
|
310
|
-
return undefined;
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
function clamp(value, min, max) {
|
|
314
|
-
if (!Number.isFinite(value))
|
|
315
|
-
throw new FleetError('invalid_request', 'terminal size must be numeric');
|
|
316
|
-
return Math.min(Math.max(Math.trunc(value), min), max);
|
|
317
|
-
}
|