@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.
Files changed (75) hide show
  1. package/README.md +12 -11
  2. package/package.json +2 -1
  3. package/src/agents/agent-adapter.ts +48 -11
  4. package/src/agents/build-atc-bridge-files.ts +13 -0
  5. package/src/agents/build-claude-query-options.ts +72 -0
  6. package/src/agents/build-cli-command.ts +7 -5
  7. package/src/{daemon → agents}/build-headless-env.ts +12 -6
  8. package/src/agents/build-hook-settings.ts +8 -2
  9. package/src/agents/build-restore-mode-args.ts +23 -0
  10. package/src/agents/claude-adapter.ts +64 -17
  11. package/src/agents/codex-adapter.ts +5 -0
  12. package/src/agents/find-claude-permission-mode.ts +24 -0
  13. package/src/agents/gateway-adapter.ts +38 -11
  14. package/src/agents/grok-adapter.ts +5 -0
  15. package/src/agents/make-claude-headless-runner.ts +46 -0
  16. package/src/agents/plan-pasted-line-input.ts +22 -0
  17. package/src/agents/plan-typed-line-input.ts +8 -0
  18. package/src/agents/resolve-claude-permission-mode.ts +13 -0
  19. package/src/{daemon/start-headless-run.ts → agents/start-claude-headless-run.ts} +6 -53
  20. package/src/agents/write-atc-bridge.ts +2 -6
  21. package/src/cli.ts +8 -12
  22. package/src/client/boot-daemon.ts +47 -21
  23. package/src/client/format-protocol-mismatch.ts +37 -0
  24. package/src/client/index.ts +104 -2
  25. package/src/client/ui.ts +8 -0
  26. package/src/daemon/build-execution-targets.ts +49 -10
  27. package/src/daemon/build-imp-provider.ts +68 -0
  28. package/src/daemon/build-report-trail-entry.ts +6 -2
  29. package/src/daemon/build-report-view.ts +41 -0
  30. package/src/daemon/build-scoped-context.ts +9 -1
  31. package/src/daemon/build-session-lifecycle.ts +52 -0
  32. package/src/daemon/build-tar-archive.ts +85 -0
  33. package/src/daemon/daemon-connection.ts +134 -4
  34. package/src/daemon/daemon.ts +306 -42
  35. package/src/daemon/execution-provider.ts +109 -4
  36. package/src/daemon/hooks.ts +5 -17
  37. package/src/daemon/imp-client-port.ts +343 -0
  38. package/src/daemon/imp-harness.ts +618 -0
  39. package/src/daemon/imp-port-error.ts +18 -0
  40. package/src/daemon/imp-port.ts +246 -0
  41. package/src/daemon/imp-provider.ts +601 -0
  42. package/src/daemon/is-binding-current.ts +43 -0
  43. package/src/daemon/local-pty-provider.ts +62 -3
  44. package/src/daemon/materialize-workspace.ts +574 -0
  45. package/src/daemon/parse-hook-line.ts +31 -0
  46. package/src/daemon/pick-session-state.ts +15 -0
  47. package/src/daemon/restore-fleet.ts +41 -23
  48. package/src/daemon/screen-model.ts +7 -0
  49. package/src/daemon/sessions.ts +574 -61
  50. package/src/daemon/start-headless-turn.ts +1 -1
  51. package/src/daemon/start-session-bridge.ts +294 -0
  52. package/src/mcp/mcp-tools.ts +41 -3
  53. package/src/mcp/reconnecting-caller.ts +1 -0
  54. package/src/mcp/require-daemon-features.ts +4 -0
  55. package/src/mcp/run-tool.ts +32 -8
  56. package/src/protocol/daemon-features.ts +14 -0
  57. package/src/protocol/protocol.ts +23 -0
  58. package/src/protocol/request-param-schemas.ts +72 -0
  59. package/src/report.ts +37 -2
  60. package/src/run-bridge-tap.ts +241 -0
  61. package/src/shared/collect-clean-env.ts +9 -1
  62. package/src/shared/open-bridge-socket.ts +86 -0
  63. package/src/shared/send-bridge-request.ts +47 -0
  64. package/src/statusline.ts +34 -3
  65. package/src/store/fleet-entry.ts +19 -0
  66. package/src/store/run-migrations.ts +85 -0
  67. package/src/store/state-store.ts +226 -21
  68. package/src/store/trail-entry.ts +7 -0
  69. package/src/store/workspace-materialization.ts +70 -0
  70. package/src/tap.ts +11 -0
  71. package/src/workspace/check-url-credentials.ts +59 -0
  72. package/src/workspace/create-workspace-clone.ts +13 -1
  73. package/src/workspace/repository-env-vars.ts +22 -0
  74. package/src/workspace/run-git.ts +2 -18
  75. /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(configs: readonly TargetConfig[]): ExecutionTarget[] {
27
- return configs.map((config) => ({
28
- id: config.id,
29
- kind: config.provider,
30
- options: config.options,
31
- identity: buildTargetIdentity(config.provider, config.options),
32
- provider: config.provider === 'local-pty' ? new LocalPTYProvider() : null,
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 and a
9
- * preview of its text.
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) => (canSee(id) ? ctx.adoptSession(id, cols, rows) : 'missing'),
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
- const adoptResult = ctx.adoptSession(sessionID, parsed.data.cols, parsed.data.rows);
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