@zgeoff/atc 2.12.0 → 2.14.0
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 +12 -11
- package/package.json +2 -1
- package/src/agents/agent-adapter.ts +48 -11
- package/src/agents/build-atc-bridge-files.ts +13 -0
- package/src/agents/build-claude-query-options.ts +72 -0
- package/src/agents/build-cli-command.ts +7 -5
- package/src/{daemon → agents}/build-headless-env.ts +12 -6
- package/src/agents/build-hook-settings.ts +8 -2
- package/src/agents/build-restore-mode-args.ts +23 -0
- package/src/agents/claude-adapter.ts +64 -17
- package/src/agents/codex-adapter.ts +5 -0
- package/src/agents/find-claude-permission-mode.ts +24 -0
- package/src/agents/gateway-adapter.ts +38 -11
- package/src/agents/grok-adapter.ts +5 -0
- package/src/agents/make-claude-headless-runner.ts +46 -0
- package/src/agents/plan-pasted-line-input.ts +22 -0
- package/src/agents/plan-typed-line-input.ts +8 -0
- package/src/agents/resolve-claude-permission-mode.ts +13 -0
- package/src/{daemon/start-headless-run.ts → agents/start-claude-headless-run.ts} +6 -53
- package/src/agents/write-atc-bridge.ts +2 -6
- package/src/cli.ts +8 -12
- package/src/client/boot-daemon.ts +47 -21
- package/src/client/format-protocol-mismatch.ts +37 -0
- package/src/client/index.ts +104 -2
- package/src/client/ui.ts +8 -0
- package/src/daemon/build-execution-targets.ts +49 -10
- package/src/daemon/build-imp-provider.ts +68 -0
- package/src/daemon/build-report-trail-entry.ts +6 -2
- package/src/daemon/build-report-view.ts +41 -0
- package/src/daemon/build-scoped-context.ts +9 -1
- package/src/daemon/build-session-lifecycle.ts +52 -0
- package/src/daemon/build-tar-archive.ts +85 -0
- package/src/daemon/daemon-connection.ts +134 -4
- package/src/daemon/daemon.ts +306 -42
- package/src/daemon/execution-provider.ts +109 -4
- package/src/daemon/hooks.ts +5 -17
- package/src/daemon/imp-client-port.ts +343 -0
- package/src/daemon/imp-harness.ts +618 -0
- package/src/daemon/imp-port-error.ts +18 -0
- package/src/daemon/imp-port.ts +246 -0
- package/src/daemon/imp-provider.ts +601 -0
- package/src/daemon/is-binding-current.ts +43 -0
- package/src/daemon/local-pty-provider.ts +62 -3
- package/src/daemon/materialize-workspace.ts +574 -0
- package/src/daemon/parse-hook-line.ts +31 -0
- package/src/daemon/pick-session-state.ts +15 -0
- package/src/daemon/restore-fleet.ts +41 -23
- package/src/daemon/screen-model.ts +7 -0
- package/src/daemon/sessions.ts +574 -61
- package/src/daemon/start-headless-turn.ts +1 -1
- package/src/daemon/start-session-bridge.ts +294 -0
- package/src/mcp/mcp-tools.ts +41 -3
- package/src/mcp/reconnecting-caller.ts +1 -0
- package/src/mcp/require-daemon-features.ts +4 -0
- package/src/mcp/run-tool.ts +32 -8
- package/src/protocol/daemon-features.ts +14 -0
- package/src/protocol/protocol.ts +23 -0
- package/src/protocol/request-param-schemas.ts +72 -0
- package/src/report.ts +37 -2
- package/src/run-bridge-tap.ts +241 -0
- package/src/shared/collect-clean-env.ts +9 -1
- package/src/shared/open-bridge-socket.ts +86 -0
- package/src/shared/send-bridge-request.ts +47 -0
- package/src/statusline.ts +34 -3
- package/src/store/fleet-entry.ts +19 -0
- package/src/store/run-migrations.ts +85 -0
- package/src/store/state-store.ts +226 -21
- package/src/store/trail-entry.ts +7 -0
- package/src/store/workspace-materialization.ts +70 -0
- package/src/tap.ts +11 -0
- package/src/workspace/check-url-credentials.ts +59 -0
- package/src/workspace/create-workspace-clone.ts +13 -1
- package/src/workspace/repository-env-vars.ts +22 -0
- package/src/workspace/run-git.ts +2 -18
- /package/src/{daemon → agents}/resolve-headless-executable.ts +0 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { TargetConfig } from '../shared/collect-targets';
|
|
1
|
+
import type { TargetConfig, TargetConfigError } from '../shared/collect-targets';
|
|
2
|
+
import { buildImpProvider } from './build-imp-provider';
|
|
2
3
|
import { buildTargetIdentity } from './build-target-identity';
|
|
3
4
|
import type { ExecutionProvider } from './execution-provider';
|
|
4
5
|
import { LocalPTYProvider } from './local-pty-provider';
|
|
@@ -19,16 +20,54 @@ export interface ExecutionTarget {
|
|
|
19
20
|
readonly provider: ExecutionProvider | null;
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
/**
|
|
24
|
+
* The targets the daemon serves, and the problems with their entries that
|
|
25
|
+
* only the daemon's environment shows.
|
|
26
|
+
*/
|
|
27
|
+
export interface ExecutionTargetsBuild {
|
|
28
|
+
readonly targets: ExecutionTarget[];
|
|
29
|
+
readonly errors: TargetConfigError[];
|
|
30
|
+
}
|
|
31
|
+
|
|
22
32
|
/**
|
|
23
33
|
* Builds one target per configured entry, in config order, each with a
|
|
24
|
-
* provider of its kind when this daemon has one.
|
|
34
|
+
* provider of its kind when this daemon has one. An entry whose provider
|
|
35
|
+
* cannot start from `env` keeps no provider and adds a target error.
|
|
25
36
|
*/
|
|
26
|
-
export function buildExecutionTargets(
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
37
|
+
export function buildExecutionTargets(
|
|
38
|
+
configs: readonly TargetConfig[],
|
|
39
|
+
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
40
|
+
): ExecutionTargetsBuild {
|
|
41
|
+
const errors: TargetConfigError[] = [];
|
|
42
|
+
|
|
43
|
+
const targets = configs.map((config) => {
|
|
44
|
+
const built = buildProvider(config, env);
|
|
45
|
+
|
|
46
|
+
if (built.problem !== null) {
|
|
47
|
+
errors.push({ scope: 'target', target: config.id, problem: built.problem });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
id: config.id,
|
|
52
|
+
kind: config.provider,
|
|
53
|
+
options: config.options,
|
|
54
|
+
identity: buildTargetIdentity(config.provider, config.options),
|
|
55
|
+
provider: built.provider,
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return { targets, errors };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function buildProvider(
|
|
63
|
+
config: TargetConfig,
|
|
64
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
65
|
+
): Readonly<{ provider: ExecutionProvider | null; problem: string | null }> {
|
|
66
|
+
if (config.provider === 'local-pty') {
|
|
67
|
+
return { provider: new LocalPTYProvider(), problem: null };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return config.provider === 'imp'
|
|
71
|
+
? buildImpProvider(config.id, config.options, env)
|
|
72
|
+
: { provider: null, problem: null };
|
|
34
73
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { ImpClientPort } from './imp-client-port';
|
|
2
|
+
import { ImpProvider } from './imp-provider';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The provider of an `imp` target, or the problem that keeps it from one.
|
|
6
|
+
* A problem never includes the token or any other environment value.
|
|
7
|
+
*/
|
|
8
|
+
export interface ImpProviderBuild {
|
|
9
|
+
readonly provider: ImpProvider | null;
|
|
10
|
+
readonly problem: string | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The provider of target `id` from its `imp` options: `url` is where impd
|
|
15
|
+
* listens, and `tokenEnv` holds the name of the daemon's environment
|
|
16
|
+
* variable that carries the token, so no credential sits in the config.
|
|
17
|
+
* `image`, `memoryMib`, `guestDir`, and `guestATC` pass through. No
|
|
18
|
+
* provider when `url` is missing, so the target lists and refuses every
|
|
19
|
+
* spawn. A `tokenEnv` that is not a non-empty string, or whose variable is
|
|
20
|
+
* unset or empty in `env`, is a problem and leaves no provider; a target
|
|
21
|
+
* without `tokenEnv` calls impd with no token.
|
|
22
|
+
*/
|
|
23
|
+
export function buildImpProvider(
|
|
24
|
+
id: string,
|
|
25
|
+
options: Readonly<Record<string, unknown>>,
|
|
26
|
+
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
27
|
+
): ImpProviderBuild {
|
|
28
|
+
const url = options['url'];
|
|
29
|
+
const tokenEnv = options['tokenEnv'];
|
|
30
|
+
|
|
31
|
+
if (typeof url !== 'string' || url === '') {
|
|
32
|
+
return { provider: null, problem: null };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (tokenEnv !== undefined && (typeof tokenEnv !== 'string' || tokenEnv === '')) {
|
|
36
|
+
return {
|
|
37
|
+
provider: null,
|
|
38
|
+
problem: `target ${JSON.stringify(id)} must give tokenEnv as a non-empty string`,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const token = tokenEnv === undefined ? null : env[tokenEnv];
|
|
43
|
+
|
|
44
|
+
if (token === undefined || token === '') {
|
|
45
|
+
return {
|
|
46
|
+
provider: null,
|
|
47
|
+
problem: `target ${JSON.stringify(id)} reads its impd token from ${String(tokenEnv)}, which is unset or empty in the daemon's environment`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const provider = new ImpProvider(new ImpClientPort({ url, token }), {
|
|
52
|
+
...pickString(options, 'image'),
|
|
53
|
+
...pickString(options, 'guestDir'),
|
|
54
|
+
...pickString(options, 'guestATC'),
|
|
55
|
+
...(typeof options['memoryMib'] === 'number' ? { memoryMib: options['memoryMib'] } : {}),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return { provider, problem: null };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function pickString(
|
|
62
|
+
options: Readonly<Record<string, unknown>>,
|
|
63
|
+
key: 'image' | 'guestDir' | 'guestATC',
|
|
64
|
+
): Partial<Record<typeof key, string>> {
|
|
65
|
+
const value = options[key];
|
|
66
|
+
|
|
67
|
+
return typeof value === 'string' && value !== '' ? { [key]: value } : {};
|
|
68
|
+
}
|
|
@@ -5,14 +5,16 @@ import type { TrailEntry } from '../store/trail-entry';
|
|
|
5
5
|
import type { NoteReport } from './parse-report';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* The trail entry for one note a session reported, carrying its label
|
|
9
|
-
* preview of its
|
|
8
|
+
* The trail entry for one note a session reported, carrying its label, its
|
|
9
|
+
* text and a preview of it, and the id its reporter gave it, when it gave
|
|
10
|
+
* one.
|
|
10
11
|
*/
|
|
11
12
|
export function buildReportTrailEntry(
|
|
12
13
|
sessionID: SessionID,
|
|
13
14
|
agentSessionID: AgentSessionID | undefined,
|
|
14
15
|
report: Readonly<NoteReport>,
|
|
15
16
|
reportedAt: number,
|
|
17
|
+
reportID?: string,
|
|
16
18
|
): TrailEntry {
|
|
17
19
|
return {
|
|
18
20
|
at: reportedAt,
|
|
@@ -21,5 +23,7 @@ export function buildReportTrailEntry(
|
|
|
21
23
|
kind: 'report',
|
|
22
24
|
label: report.label,
|
|
23
25
|
detail: truncateDetail(report.text),
|
|
26
|
+
text: report.text,
|
|
27
|
+
...(reportID === undefined ? {} : { reportID }),
|
|
24
28
|
};
|
|
25
29
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { encodeCursor } from '../protocol/encode-cursor';
|
|
2
|
+
import type { StoredReport } from '../store/state-store';
|
|
3
|
+
import type { SessionDescriptor } from './sessions';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One report as `report.get` returns it: the cursor of its event, when it
|
|
7
|
+
* arrived, the session that sent it and that session's name, its label, its
|
|
8
|
+
* text, and whether that text is whole.
|
|
9
|
+
*/
|
|
10
|
+
export interface ReportView {
|
|
11
|
+
readonly report: string;
|
|
12
|
+
readonly at: number;
|
|
13
|
+
readonly session: string;
|
|
14
|
+
readonly name: string | null;
|
|
15
|
+
readonly label: string;
|
|
16
|
+
readonly text: string;
|
|
17
|
+
readonly complete: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function buildReportView(
|
|
21
|
+
stored: StoredReport,
|
|
22
|
+
sessions: readonly SessionDescriptor[],
|
|
23
|
+
): ReportView {
|
|
24
|
+
// A row written before atc session ids stayed stable across restores
|
|
25
|
+
// carries an earlier atc id, so the agent session id links it.
|
|
26
|
+
const live =
|
|
27
|
+
(stored.agentSessionID === null
|
|
28
|
+
? undefined
|
|
29
|
+
: sessions.find((s) => s.agentSessionID === stored.agentSessionID)) ??
|
|
30
|
+
sessions.find((s) => s.id === stored.atcID);
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
report: encodeCursor({ kind: 'events', id: stored.id }),
|
|
34
|
+
at: stored.at,
|
|
35
|
+
session: live?.id ?? stored.atcID,
|
|
36
|
+
name: live?.name ?? null,
|
|
37
|
+
label: stored.label,
|
|
38
|
+
text: stored.text,
|
|
39
|
+
complete: stored.complete,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -187,8 +187,11 @@ export function buildScopedContext(
|
|
|
187
187
|
},
|
|
188
188
|
writeSessionInput: (sessionID, data) =>
|
|
189
189
|
canSee(sessionID) ? ctx.writeSessionInput(sessionID, data) : 'missing',
|
|
190
|
+
writeSessionLine: (sessionID, text) =>
|
|
191
|
+
canSee(sessionID) ? ctx.writeSessionLine(sessionID, text) : 'missing',
|
|
190
192
|
ejectSession: (id, prompt) => (canSee(id) ? ctx.ejectSession(id, prompt) : 'missing'),
|
|
191
|
-
adoptSession: (id, cols, rows) =>
|
|
193
|
+
adoptSession: (id, cols, rows) =>
|
|
194
|
+
canSee(id) ? ctx.adoptSession(id, cols, rows) : Promise.resolve('missing' as const),
|
|
192
195
|
resizeSession: (client, sessionID, dims) =>
|
|
193
196
|
canSee(sessionID) && ctx.resizeSession(client, sessionID, dims),
|
|
194
197
|
readSessionRecord: (id) =>
|
|
@@ -200,6 +203,11 @@ export function buildScopedContext(
|
|
|
200
203
|
|
|
201
204
|
return ctx.readEvents(afterID, limit, waitMs, sessionID, merged);
|
|
202
205
|
},
|
|
206
|
+
readReport: (id, outer) => {
|
|
207
|
+
const merged = outer === null ? access : outer.merge(access);
|
|
208
|
+
|
|
209
|
+
return ctx.readReport(id, merged);
|
|
210
|
+
},
|
|
203
211
|
writeSessionMessage: (sessionID, from, text, keyed) =>
|
|
204
212
|
canSee(sessionID)
|
|
205
213
|
? ctx.writeSessionMessage(sessionID, from, text, buildPrincipalKey(keyed))
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A session's lifecycle as four layers, each owned by one actor:
|
|
3
|
+
*
|
|
4
|
+
* - `desired`: what the operator asked for. `run` keeps the harness
|
|
5
|
+
* running, `sleep` keeps its host asleep with the harness inside it, and
|
|
6
|
+
* `stop` leaves the harness ended.
|
|
7
|
+
* - `vm`: the host the harness runs on. `none` is the daemon's own machine,
|
|
8
|
+
* which has no lifecycle of its own; `awake`, `asleep`, and `unknown` are
|
|
9
|
+
* the last state the daemon saw of a remote host.
|
|
10
|
+
* - `harness`: the agent process. `suspended` is a process kept inside a
|
|
11
|
+
* sleeping host, which a revive brings back as it was.
|
|
12
|
+
* - `attachment`: the daemon's own connection to the harness's output.
|
|
13
|
+
* `local` is a harness in the daemon's process tree; `attached`,
|
|
14
|
+
* `reattaching`, and `detached` follow the connection to a remote one.
|
|
15
|
+
*/
|
|
16
|
+
export interface SessionLifecycle {
|
|
17
|
+
readonly desired: 'run' | 'sleep' | 'stop';
|
|
18
|
+
readonly vm: 'none' | 'awake' | 'asleep' | 'unknown';
|
|
19
|
+
readonly harness: 'running' | 'suspended' | 'exited';
|
|
20
|
+
readonly attachment: 'local' | 'attached' | 'reattaching' | 'detached';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The session facts the layers derive from.
|
|
24
|
+
interface LifecycleFacts {
|
|
25
|
+
readonly desired: SessionLifecycle['desired'];
|
|
26
|
+
readonly vm: SessionLifecycle['vm'];
|
|
27
|
+
readonly attachment: SessionLifecycle['attachment'];
|
|
28
|
+
|
|
29
|
+
// Whether the harness is kept inside a sleeping host.
|
|
30
|
+
readonly suspended: boolean;
|
|
31
|
+
readonly hasHarness: boolean;
|
|
32
|
+
readonly kind: 'pty' | 'headless';
|
|
33
|
+
readonly state: 'running' | 'needs_you' | 'done' | 'exited';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Builds the four layers from a session's facts. The harness runs while the
|
|
38
|
+
* session holds a terminal or a live headless run. A harness without one is
|
|
39
|
+
* suspended while it is kept inside a sleeping host, and exited otherwise.
|
|
40
|
+
*/
|
|
41
|
+
export function buildSessionLifecycle(facts: Readonly<LifecycleFacts>): SessionLifecycle {
|
|
42
|
+
const running = facts.hasHarness || (facts.kind === 'headless' && facts.state !== 'exited');
|
|
43
|
+
let harness: SessionLifecycle['harness'] = 'exited';
|
|
44
|
+
|
|
45
|
+
if (running) {
|
|
46
|
+
harness = 'running';
|
|
47
|
+
} else if (facts.suspended && facts.vm === 'asleep') {
|
|
48
|
+
harness = 'suspended';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return { desired: facts.desired, vm: facts.vm, harness, attachment: facts.attachment };
|
|
52
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One file in an archive: its path relative to the directory it unpacks
|
|
3
|
+
* into, its bytes, and its permission bits.
|
|
4
|
+
*/
|
|
5
|
+
export interface ArchiveFile {
|
|
6
|
+
readonly path: string;
|
|
7
|
+
readonly content: string | Uint8Array;
|
|
8
|
+
readonly mode?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const BLOCK = 512;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Packs files into a POSIX ustar archive that `tar -x` unpacks, each with
|
|
15
|
+
* its parent directories created on the way. A path longer than ustar
|
|
16
|
+
* holds is refused.
|
|
17
|
+
*/
|
|
18
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- file bytes have no readonly form
|
|
19
|
+
export function buildTarArchive(files: readonly ArchiveFile[]): Uint8Array {
|
|
20
|
+
const encoder = new TextEncoder();
|
|
21
|
+
|
|
22
|
+
const blocks: Uint8Array[] = [];
|
|
23
|
+
|
|
24
|
+
for (const file of files) {
|
|
25
|
+
const content = typeof file.content === 'string' ? encoder.encode(file.content) : file.content;
|
|
26
|
+
|
|
27
|
+
blocks.push(
|
|
28
|
+
buildHeader(file.path, content.length, file.mode ?? 0o644),
|
|
29
|
+
content,
|
|
30
|
+
new Uint8Array((BLOCK - (content.length % BLOCK)) % BLOCK),
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Two empty blocks end the archive.
|
|
35
|
+
blocks.push(new Uint8Array(BLOCK * 2));
|
|
36
|
+
|
|
37
|
+
const archive = new Uint8Array(blocks.reduce((total, block) => total + block.length, 0));
|
|
38
|
+
|
|
39
|
+
let at = 0;
|
|
40
|
+
|
|
41
|
+
for (const block of blocks) {
|
|
42
|
+
archive.set(block, at);
|
|
43
|
+
|
|
44
|
+
at += block.length;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return archive;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function buildHeader(path: string, size: number, mode: number): Uint8Array {
|
|
51
|
+
const encoder = new TextEncoder();
|
|
52
|
+
|
|
53
|
+
const name = encoder.encode(path);
|
|
54
|
+
|
|
55
|
+
if (name.length > 100) {
|
|
56
|
+
throw new Error(`archive path is longer than 100 bytes: ${path}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const header = new Uint8Array(BLOCK);
|
|
60
|
+
|
|
61
|
+
header.set(name, 0);
|
|
62
|
+
header.set(encoder.encode(formatOctal(mode, 8)), 100);
|
|
63
|
+
header.set(encoder.encode(formatOctal(0, 8)), 108);
|
|
64
|
+
header.set(encoder.encode(formatOctal(0, 8)), 116);
|
|
65
|
+
header.set(encoder.encode(formatOctal(size, 12)), 124);
|
|
66
|
+
header.set(encoder.encode(formatOctal(0, 12)), 136);
|
|
67
|
+
|
|
68
|
+
// The checksum counts its own field as spaces.
|
|
69
|
+
header.set(encoder.encode(' '), 148);
|
|
70
|
+
|
|
71
|
+
header[156] = '0'.codePointAt(0) ?? 0;
|
|
72
|
+
|
|
73
|
+
header.set(encoder.encode('ustar\u000000'), 257);
|
|
74
|
+
|
|
75
|
+
const checksum = header.reduce((total, byte) => total + byte, 0);
|
|
76
|
+
|
|
77
|
+
header.set(encoder.encode(`${checksum.toString(8).padStart(6, '0')}\u0000 `), 148);
|
|
78
|
+
|
|
79
|
+
return header;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// An octal number padded to fill its field, ending in a NUL.
|
|
83
|
+
function formatOctal(value: number, width: number): string {
|
|
84
|
+
return `${value.toString(8).padStart(width - 1, '0')}\u0000`;
|
|
85
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { basename } from 'node:path';
|
|
1
|
+
import { basename, isAbsolute } from 'node:path';
|
|
2
2
|
import type { AgentAdapter, AgentID, SpawnOptions, SpawnOverrides } from '../agents/agent-adapter';
|
|
3
3
|
import { DaemonError } from '../protocol/daemon-error';
|
|
4
4
|
import { DAEMON_FEATURES } from '../protocol/daemon-features';
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
encodeMessage,
|
|
16
16
|
} from '../protocol/protocol';
|
|
17
17
|
import type { ErrorCode, EventMsg, RequestMsg } from '../protocol/protocol';
|
|
18
|
+
import type { SpawnWorkspaceSource } from '../protocol/request-param-schemas';
|
|
18
19
|
import type { TargetConfigError } from '../shared/collect-targets';
|
|
19
20
|
import type { DaemonID } from '../shared/daemon-id';
|
|
20
21
|
import type { MessageID } from '../shared/message-id';
|
|
@@ -27,6 +28,7 @@ import type { Dims } from './attach-registry';
|
|
|
27
28
|
import type { AgentEntry } from './build-agent-list';
|
|
28
29
|
import type { FleetEvent } from './build-fleet-events';
|
|
29
30
|
import { buildPayloadHash } from './build-payload-hash';
|
|
31
|
+
import type { ReportView } from './build-report-view';
|
|
30
32
|
import { buildScopedContext } from './build-scoped-context';
|
|
31
33
|
import type { TargetEntry } from './build-target-list';
|
|
32
34
|
import type { KeyedRequest } from './idempotency-ledger';
|
|
@@ -49,6 +51,10 @@ export interface SpawnParams {
|
|
|
49
51
|
readonly parent: SessionID | null;
|
|
50
52
|
readonly overrides: SpawnOverrides;
|
|
51
53
|
readonly target: string;
|
|
54
|
+
|
|
55
|
+
// Where the session's working directory comes from; null runs the
|
|
56
|
+
// session in cwd as it stands.
|
|
57
|
+
readonly workspace: SpawnWorkspaceSource | null;
|
|
52
58
|
}
|
|
53
59
|
|
|
54
60
|
interface SessionRecord {
|
|
@@ -61,6 +67,15 @@ interface SessionRecord {
|
|
|
61
67
|
|
|
62
68
|
// A transcript page with the file it came from, so a cursor into a replaced
|
|
63
69
|
// transcript is distinguishable from one into a grown transcript.
|
|
70
|
+
/**
|
|
71
|
+
* What `session.forget` answers: the token a forget that destroys a host
|
|
72
|
+
* must carry, with the time it stops being taken; or the forgotten session,
|
|
73
|
+
* with whether its host was destroyed.
|
|
74
|
+
*/
|
|
75
|
+
type ForgetResult =
|
|
76
|
+
| { readonly confirmToken: string; readonly expiresAt: number }
|
|
77
|
+
| { readonly forgotten: true; readonly destroyed: boolean };
|
|
78
|
+
|
|
64
79
|
interface SessionTranscriptRead {
|
|
65
80
|
readonly path: string;
|
|
66
81
|
readonly page: TranscriptPage;
|
|
@@ -99,6 +114,10 @@ export interface DaemonContext {
|
|
|
99
114
|
// without a target when the config gives no default.
|
|
100
115
|
readonly resolveSpawnTarget: (requested: string | undefined) => string;
|
|
101
116
|
|
|
117
|
+
// Throws the refusal for a target that cannot materialize a workspace:
|
|
118
|
+
// one whose provider cannot both transfer an archive and run a command.
|
|
119
|
+
readonly requireWorkspaceTarget: (target: string) => void;
|
|
120
|
+
|
|
102
121
|
// Runs the plan, which throws the refusal for a spawn it refuses, then
|
|
103
122
|
// spawns. Answers with the `session.spawn` ok payload, which a keyed
|
|
104
123
|
// retry replays as the first spawn answered it while the access, when
|
|
@@ -109,6 +128,13 @@ export interface DaemonContext {
|
|
|
109
128
|
access: TargetAccess | null,
|
|
110
129
|
) => Promise<Readonly<Record<string, unknown>>>;
|
|
111
130
|
readonly killSession: (id: SessionID) => Promise<boolean>;
|
|
131
|
+
|
|
132
|
+
// Forgets a session, or answers with the token a forget that destroys a
|
|
133
|
+
// host must carry. Throws the refusal for a token it does not take.
|
|
134
|
+
readonly forgetSession: (
|
|
135
|
+
id: SessionID,
|
|
136
|
+
confirmToken: string | undefined,
|
|
137
|
+
) => Promise<ForgetResult | 'missing'>;
|
|
112
138
|
readonly updateSession: (id: SessionID, name?: string, pinned?: boolean) => boolean | 'child_pin';
|
|
113
139
|
readonly quitDaemon: () => void;
|
|
114
140
|
readonly ackSession: (id: SessionID) => boolean;
|
|
@@ -127,6 +153,13 @@ export interface DaemonContext {
|
|
|
127
153
|
sessionID: SessionID,
|
|
128
154
|
data: string,
|
|
129
155
|
) => 'busy' | 'ok' | 'missing' | 'dead';
|
|
156
|
+
|
|
157
|
+
// Types a line into the session and submits it the way the session's
|
|
158
|
+
// agent accepts a line, as opposed to the raw bytes the input write takes.
|
|
159
|
+
readonly writeSessionLine: (
|
|
160
|
+
sessionID: SessionID,
|
|
161
|
+
text: string,
|
|
162
|
+
) => 'busy' | 'ok' | 'missing' | 'dead';
|
|
130
163
|
readonly ejectSession: (
|
|
131
164
|
id: SessionID,
|
|
132
165
|
prompt: string,
|
|
@@ -135,7 +168,7 @@ export interface DaemonContext {
|
|
|
135
168
|
id: SessionID,
|
|
136
169
|
cols: number,
|
|
137
170
|
rows: number,
|
|
138
|
-
) => 'ok' | 'missing' | 'no_transcript'
|
|
171
|
+
) => Promise<'ok' | 'missing' | 'no_transcript'>;
|
|
139
172
|
readonly resizeSession: (client: OutputClient, sessionID: SessionID, dims: Dims) => boolean;
|
|
140
173
|
readonly resyncClient: (sessionID: SessionID, client: OutputClient) => Promise<void>;
|
|
141
174
|
readonly queueBytes?: number;
|
|
@@ -157,6 +190,11 @@ export interface DaemonContext {
|
|
|
157
190
|
access: TargetAccess | null,
|
|
158
191
|
) => Promise<EventsPage>;
|
|
159
192
|
|
|
193
|
+
// One report by the trail id of its event, or null for a trail id that
|
|
194
|
+
// holds no report, or whose report's session is outside the access when
|
|
195
|
+
// there is one.
|
|
196
|
+
readonly readReport: (id: number, access: TargetAccess | null) => Promise<ReportView | null>;
|
|
197
|
+
|
|
160
198
|
// Answers with the `session.message` ok payload, which a keyed retry
|
|
161
199
|
// replays with the message's current status, or with the refusal.
|
|
162
200
|
readonly writeSessionMessage: (
|
|
@@ -551,6 +589,27 @@ export class DaemonConnection {
|
|
|
551
589
|
|
|
552
590
|
return;
|
|
553
591
|
}
|
|
592
|
+
case 'session.forget': {
|
|
593
|
+
const parsed = parseRequestParams('session.forget', req.p);
|
|
594
|
+
|
|
595
|
+
if (!parsed.ok) {
|
|
596
|
+
this.sendErr(req.id, 'bad_args', parsed.message);
|
|
597
|
+
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const id = parsed.data.session;
|
|
602
|
+
|
|
603
|
+
const forgotten = await this.ctx.forgetSession(id, parsed.data.confirmToken);
|
|
604
|
+
|
|
605
|
+
if (forgotten === 'missing') {
|
|
606
|
+
this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
|
|
607
|
+
} else {
|
|
608
|
+
this.sendOk(req.id, { ...forgotten });
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
554
613
|
case 'session.resumeCommand': {
|
|
555
614
|
const parsed = parseRequestParams('session.resumeCommand', req.p);
|
|
556
615
|
|
|
@@ -636,7 +695,8 @@ export class DaemonConnection {
|
|
|
636
695
|
}
|
|
637
696
|
|
|
638
697
|
const sessionID = parsed.data.session;
|
|
639
|
-
|
|
698
|
+
|
|
699
|
+
const adoptResult = await ctx.adoptSession(sessionID, parsed.data.cols, parsed.data.rows);
|
|
640
700
|
|
|
641
701
|
if (adoptResult === 'ok') {
|
|
642
702
|
this.sendOk(req.id, {});
|
|
@@ -680,6 +740,11 @@ export class DaemonConnection {
|
|
|
680
740
|
|
|
681
741
|
return;
|
|
682
742
|
}
|
|
743
|
+
case 'session.submit': {
|
|
744
|
+
this.applySubmit(req, ctx);
|
|
745
|
+
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
683
748
|
case 'session.resize': {
|
|
684
749
|
this.applyResize(req, ctx);
|
|
685
750
|
|
|
@@ -740,6 +805,11 @@ export class DaemonConnection {
|
|
|
740
805
|
|
|
741
806
|
return;
|
|
742
807
|
}
|
|
808
|
+
case 'report.get': {
|
|
809
|
+
await this.applyReportGet(req, ctx);
|
|
810
|
+
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
743
813
|
default: {
|
|
744
814
|
this.sendErr(req.id, 'unknown_method', `unknown method '${req.m}'`);
|
|
745
815
|
}
|
|
@@ -787,6 +857,18 @@ export class DaemonConnection {
|
|
|
787
857
|
throw new DaemonError(overrides.code, overrides.message);
|
|
788
858
|
}
|
|
789
859
|
|
|
860
|
+
// Checked before the spawn starts, so a target that cannot take a
|
|
861
|
+
// workspace refuses it before any git command runs.
|
|
862
|
+
if (data.workspace !== undefined) {
|
|
863
|
+
this.ctx.requireWorkspaceTarget(target);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// A workspace is materialized at cwd on the target, which only an
|
|
867
|
+
// absolute path names.
|
|
868
|
+
if (data.workspace !== undefined && !isAbsolute(data.cwd)) {
|
|
869
|
+
throw new DaemonError('bad_args', 'a spawn with a workspace requires an absolute cwd');
|
|
870
|
+
}
|
|
871
|
+
|
|
790
872
|
let parent: SessionID | null = null;
|
|
791
873
|
|
|
792
874
|
if (data.parent !== undefined) {
|
|
@@ -813,6 +895,7 @@ export class DaemonConnection {
|
|
|
813
895
|
parent,
|
|
814
896
|
overrides: overrides.overrides,
|
|
815
897
|
target,
|
|
898
|
+
workspace: data.workspace ?? null,
|
|
816
899
|
};
|
|
817
900
|
};
|
|
818
901
|
|
|
@@ -869,8 +952,29 @@ export class DaemonConnection {
|
|
|
869
952
|
}
|
|
870
953
|
|
|
871
954
|
const sessionID = parsed.data.session;
|
|
872
|
-
const result = ctx.writeSessionInput(sessionID, parsed.data.d);
|
|
873
955
|
|
|
956
|
+
this.sendInputResult(req, sessionID, ctx.writeSessionInput(sessionID, parsed.data.d));
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
private applySubmit(req: RequestMsg, ctx: DaemonContext): void {
|
|
960
|
+
const parsed = parseRequestParams('session.submit', req.p);
|
|
961
|
+
|
|
962
|
+
if (!parsed.ok) {
|
|
963
|
+
this.sendErr(req.id, 'bad_args', parsed.message);
|
|
964
|
+
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
const sessionID = parsed.data.session;
|
|
969
|
+
|
|
970
|
+
this.sendInputResult(req, sessionID, ctx.writeSessionLine(sessionID, parsed.data.text));
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
private sendInputResult(
|
|
974
|
+
req: RequestMsg,
|
|
975
|
+
sessionID: SessionID,
|
|
976
|
+
result: 'busy' | 'ok' | 'missing' | 'dead',
|
|
977
|
+
): void {
|
|
874
978
|
if (result === 'missing') {
|
|
875
979
|
this.sendErr(req.id, 'no_such_session', `no session '${sessionID}'`);
|
|
876
980
|
|
|
@@ -1057,6 +1161,32 @@ export class DaemonConnection {
|
|
|
1057
1161
|
});
|
|
1058
1162
|
}
|
|
1059
1163
|
|
|
1164
|
+
// A cursor that is not an events cursor, one at a row that holds no
|
|
1165
|
+
// report, and one at a report outside the access all get one refusal, so
|
|
1166
|
+
// the refusal is the same for a report out of reach and a missing one.
|
|
1167
|
+
private async applyReportGet(req: RequestMsg, ctx: DaemonContext): Promise<void> {
|
|
1168
|
+
const parsed = parseRequestParams('report.get', req.p);
|
|
1169
|
+
|
|
1170
|
+
if (!parsed.ok) {
|
|
1171
|
+
this.sendErr(req.id, 'bad_args', parsed.message);
|
|
1172
|
+
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
const decoded = decodeCursor(parsed.data.report);
|
|
1177
|
+
|
|
1178
|
+
const view =
|
|
1179
|
+
decoded === null || decoded.kind !== 'events' ? null : await ctx.readReport(decoded.id, null);
|
|
1180
|
+
|
|
1181
|
+
if (view === null) {
|
|
1182
|
+
this.sendErr(req.id, 'bad_args', `no report '${parsed.data.report}'`);
|
|
1183
|
+
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
this.sendOk(req.id, { ...view });
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1060
1190
|
private async applySessionMessage(req: RequestMsg, ctx: DaemonContext): Promise<void> {
|
|
1061
1191
|
const parsed = parseRequestParams('session.message', req.p);
|
|
1062
1192
|
|