@zgeoff/atc 2.11.0 → 2.13.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 +10 -10
- package/package.json +2 -1
- package/src/agents/agent-adapter.ts +98 -10
- package/src/agents/build-args-without-flags.ts +25 -0
- package/src/agents/build-atc-bridge-files.ts +13 -0
- package/src/agents/build-claude-override-args.ts +26 -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 +112 -18
- package/src/agents/claude-effort-levels.ts +5 -0
- package/src/agents/codex-adapter.ts +48 -2
- package/src/agents/find-claude-permission-mode.ts +24 -0
- package/src/agents/find-flag-value.ts +27 -0
- package/src/agents/gateway-adapter.ts +83 -13
- package/src/agents/grok-adapter.ts +24 -1
- 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 -42
- package/src/agents/write-atc-bridge.ts +2 -6
- package/src/cli.ts +23 -9
- package/src/client/daemon-client.ts +14 -2
- package/src/client/index.ts +50 -1
- package/src/client/spawn-picker.ts +27 -4
- package/src/client/ui.ts +8 -0
- package/src/daemon/build-agent-list.ts +37 -1
- package/src/daemon/build-config-revision.ts +27 -0
- package/src/daemon/build-execution-targets.ts +73 -0
- package/src/daemon/build-fleet-events.ts +2 -1
- package/src/daemon/build-imp-provider.ts +68 -0
- package/src/daemon/build-payload-hash.ts +31 -0
- package/src/daemon/build-report-trail-entry.ts +4 -2
- package/src/daemon/build-scoped-context.ts +235 -0
- package/src/daemon/build-session-lifecycle.ts +52 -0
- package/src/daemon/build-tar-archive.ts +85 -0
- package/src/daemon/build-target-access.ts +33 -0
- package/src/daemon/build-target-forbidden-error.ts +13 -0
- package/src/daemon/build-target-identity.ts +22 -0
- package/src/daemon/build-target-list.ts +50 -0
- package/src/daemon/daemon-connection.ts +425 -96
- package/src/daemon/daemon.ts +730 -96
- package/src/daemon/effect-remains-error.ts +13 -0
- package/src/daemon/execution-provider.ts +208 -0
- package/src/daemon/find-execution-refusal.ts +104 -0
- package/src/daemon/hooks.ts +5 -17
- package/src/daemon/idempotency-ledger.ts +164 -0
- 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 +142 -0
- package/src/daemon/materialize-workspace.ts +571 -0
- package/src/daemon/mint-session-id.ts +5 -5
- package/src/daemon/parse-hook-line.ts +31 -0
- package/src/daemon/parse-spawn-overrides.ts +94 -0
- package/src/daemon/permission-registry.ts +14 -4
- package/src/daemon/pick-session-state.ts +15 -0
- package/src/daemon/restore-fleet.ts +103 -38
- package/src/daemon/screen-model.ts +7 -0
- package/src/daemon/session-runtime.ts +10 -0
- package/src/daemon/sessions.ts +835 -105
- package/src/daemon/start-headless-turn.ts +12 -3
- package/src/daemon/start-session-bridge.ts +294 -0
- package/src/daemon/target-access.ts +36 -0
- package/src/mcp/answer-mcp-request.ts +12 -4
- package/src/mcp/answer-rpc-request.ts +28 -1
- package/src/mcp/build-principal-caller.ts +15 -0
- package/src/mcp/build-spawn-descriptions.ts +45 -0
- package/src/mcp/build-tool-list.ts +32 -4
- package/src/mcp/mcp-tools.ts +107 -10
- package/src/mcp/parse-idempotency-key.ts +29 -0
- package/src/mcp/reconnecting-caller.ts +39 -7
- package/src/mcp/require-daemon-features.ts +10 -0
- package/src/mcp/run-tool.ts +87 -16
- package/src/mcp/types.ts +2 -0
- package/src/protocol/daemon-error.ts +5 -1
- package/src/protocol/daemon-features.ts +35 -0
- package/src/protocol/protocol.ts +67 -11
- package/src/protocol/request-param-schemas.ts +126 -10
- package/src/report.ts +37 -2
- package/src/run-bridge-tap.ts +241 -0
- package/src/shared/agent-session-id.ts +1 -1
- package/src/shared/collect-clean-env.ts +9 -1
- package/src/shared/collect-principals.ts +51 -0
- package/src/shared/collect-targets.ts +144 -0
- package/src/shared/config.ts +139 -14
- package/src/shared/daemon-id.ts +8 -0
- package/src/shared/format-json-kind.ts +21 -0
- package/src/shared/open-bridge-socket.ts +86 -0
- package/src/shared/send-bridge-request.ts +47 -0
- package/src/shared/sort-json-keys.ts +19 -0
- package/src/shared/to-daemon-id.ts +11 -0
- package/src/statusline.ts +34 -3
- package/src/store/fleet-entry.ts +61 -9
- package/src/store/idempotency-record.ts +48 -0
- package/src/store/message-owner.ts +1 -1
- package/src/store/run-migrations.ts +330 -5
- package/src/store/state-store.ts +607 -44
- package/src/store/trail-entry.ts +4 -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/check-workspace-completeness.ts +90 -0
- package/src/workspace/create-workspace-clone.ts +251 -0
- package/src/workspace/normalize-git-url.ts +59 -0
- package/src/workspace/read-workspace-tar.ts +38 -0
- package/src/workspace/repository-env-vars.ts +22 -0
- package/src/workspace/resolve-path-source.ts +170 -0
- package/src/workspace/run-git.ts +87 -0
- package/src/workspace/sanitize-workspace-clone.ts +146 -0
- package/src/workspace/workspace-provenance.ts +11 -0
- package/src/workspace/workspace-source.ts +8 -0
- /package/src/{daemon → agents}/resolve-headless-executable.ts +0 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { basename } from 'node:path';
|
|
2
|
-
import type { AgentAdapter, AgentID, SpawnOptions } from '../agents/agent-adapter';
|
|
1
|
+
import { basename, isAbsolute } from 'node:path';
|
|
2
|
+
import type { AgentAdapter, AgentID, SpawnOptions, SpawnOverrides } from '../agents/agent-adapter';
|
|
3
|
+
import { DaemonError } from '../protocol/daemon-error';
|
|
3
4
|
import { DAEMON_FEATURES } from '../protocol/daemon-features';
|
|
4
5
|
import { decodeCursor } from '../protocol/decode-cursor';
|
|
5
6
|
import { encodeCursor } from '../protocol/encode-cursor';
|
|
@@ -14,19 +15,30 @@ import {
|
|
|
14
15
|
encodeMessage,
|
|
15
16
|
} from '../protocol/protocol';
|
|
16
17
|
import type { ErrorCode, EventMsg, RequestMsg } from '../protocol/protocol';
|
|
18
|
+
import type { SpawnWorkspaceSource } from '../protocol/request-param-schemas';
|
|
19
|
+
import type { TargetConfigError } from '../shared/collect-targets';
|
|
20
|
+
import type { DaemonID } from '../shared/daemon-id';
|
|
17
21
|
import type { MessageID } from '../shared/message-id';
|
|
22
|
+
import { isRecord } from '../shared/report';
|
|
18
23
|
import type { SessionID } from '../shared/session-id';
|
|
24
|
+
import { toSessionID } from '../shared/to-session-id';
|
|
19
25
|
import type { FleetEntry } from '../store/fleet-entry';
|
|
20
26
|
import type { MessageRecord } from '../store/message-record';
|
|
21
27
|
import type { Dims } from './attach-registry';
|
|
22
28
|
import type { AgentEntry } from './build-agent-list';
|
|
23
29
|
import type { FleetEvent } from './build-fleet-events';
|
|
30
|
+
import { buildPayloadHash } from './build-payload-hash';
|
|
31
|
+
import { buildScopedContext } from './build-scoped-context';
|
|
32
|
+
import type { TargetEntry } from './build-target-list';
|
|
33
|
+
import type { KeyedRequest } from './idempotency-ledger';
|
|
24
34
|
import type { TranscriptPage, TranscriptPosition } from './load-transcript-page';
|
|
35
|
+
import { parseSpawnOverrides } from './parse-spawn-overrides';
|
|
25
36
|
import type { AnswerResult } from './permission-registry';
|
|
26
37
|
import type { ScreenText } from './screen-model';
|
|
27
38
|
import type { SessionDescriptor } from './sessions';
|
|
39
|
+
import type { TargetAccess, TargetGrant } from './target-access';
|
|
28
40
|
|
|
29
|
-
interface SpawnParams {
|
|
41
|
+
export interface SpawnParams {
|
|
30
42
|
readonly cwd: string;
|
|
31
43
|
readonly name: string;
|
|
32
44
|
readonly prompt: string;
|
|
@@ -36,6 +48,12 @@ interface SpawnParams {
|
|
|
36
48
|
readonly namedBy: 'user' | 'auto';
|
|
37
49
|
readonly agent: AgentID;
|
|
38
50
|
readonly parent: SessionID | null;
|
|
51
|
+
readonly overrides: SpawnOverrides;
|
|
52
|
+
readonly target: string;
|
|
53
|
+
|
|
54
|
+
// Where the session's working directory comes from; null runs the
|
|
55
|
+
// session in cwd as it stands.
|
|
56
|
+
readonly workspace: SpawnWorkspaceSource | null;
|
|
39
57
|
}
|
|
40
58
|
|
|
41
59
|
interface SessionRecord {
|
|
@@ -48,6 +66,15 @@ interface SessionRecord {
|
|
|
48
66
|
|
|
49
67
|
// A transcript page with the file it came from, so a cursor into a replaced
|
|
50
68
|
// transcript is distinguishable from one into a grown transcript.
|
|
69
|
+
/**
|
|
70
|
+
* What `session.forget` answers: the token a forget that destroys a host
|
|
71
|
+
* must carry, with the time it stops being taken; or the forgotten session,
|
|
72
|
+
* with whether its host was destroyed.
|
|
73
|
+
*/
|
|
74
|
+
type ForgetResult =
|
|
75
|
+
| { readonly confirmToken: string; readonly expiresAt: number }
|
|
76
|
+
| { readonly forgotten: true; readonly destroyed: boolean };
|
|
77
|
+
|
|
51
78
|
interface SessionTranscriptRead {
|
|
52
79
|
readonly path: string;
|
|
53
80
|
readonly page: TranscriptPage;
|
|
@@ -55,14 +82,58 @@ interface SessionTranscriptRead {
|
|
|
55
82
|
|
|
56
83
|
export interface DaemonContext {
|
|
57
84
|
readonly build: string;
|
|
85
|
+
readonly daemonID: DaemonID;
|
|
58
86
|
readonly collectSessions: () => SessionDescriptor[];
|
|
59
87
|
readonly collectSpawnDirs: () => Promise<string[]>;
|
|
60
88
|
readonly collectAgents: () => AgentList;
|
|
61
89
|
readonly collectFleet: () => Promise<FleetEntry[]>;
|
|
62
90
|
readonly loadLastUsedAgent: () => Promise<AgentID>;
|
|
63
91
|
readonly findAdapter: (id: AgentID) => AgentAdapter | null;
|
|
64
|
-
|
|
92
|
+
|
|
93
|
+
// The targets a principal may use.
|
|
94
|
+
readonly buildTargetAccess: (principal: string) => TargetAccess;
|
|
95
|
+
|
|
96
|
+
// The target and identity a session is bound to, or null when no
|
|
97
|
+
// session holds the id.
|
|
98
|
+
readonly findSessionGrant: (id: SessionID) => TargetGrant | null;
|
|
99
|
+
|
|
100
|
+
// The identity a target holds now, or null when the config holds no such
|
|
101
|
+
// target.
|
|
102
|
+
readonly findTargetIdentity: (target: string) => string | null;
|
|
103
|
+
|
|
104
|
+
// The ids of a session's sub-sessions.
|
|
105
|
+
readonly collectChildIDs: (id: SessionID) => SessionID[];
|
|
106
|
+
|
|
107
|
+
// The session a permission request belongs to, answered or not, or null
|
|
108
|
+
// for an unknown request.
|
|
109
|
+
readonly findPermissionSession: (request: string) => SessionID | null;
|
|
110
|
+
|
|
111
|
+
// The target a spawn runs on: the one it names, else the default. Throws
|
|
112
|
+
// the refusal for a target the spawn cannot run on, and for a spawn
|
|
113
|
+
// without a target when the config gives no default.
|
|
114
|
+
readonly resolveSpawnTarget: (requested: string | undefined) => string;
|
|
115
|
+
|
|
116
|
+
// Throws the refusal for a target that cannot materialize a workspace:
|
|
117
|
+
// one whose provider cannot both transfer an archive and run a command.
|
|
118
|
+
readonly requireWorkspaceTarget: (target: string) => void;
|
|
119
|
+
|
|
120
|
+
// Runs the plan, which throws the refusal for a spawn it refuses, then
|
|
121
|
+
// spawns. Answers with the `session.spawn` ok payload, which a keyed
|
|
122
|
+
// retry replays as the first spawn answered it while the access, when
|
|
123
|
+
// there is one, still reaches the target the spawn's session was bound to.
|
|
124
|
+
readonly spawnSession: (
|
|
125
|
+
plan: () => SpawnParams,
|
|
126
|
+
keyed: KeyedRequest | null,
|
|
127
|
+
access: TargetAccess | null,
|
|
128
|
+
) => Promise<Readonly<Record<string, unknown>>>;
|
|
65
129
|
readonly killSession: (id: SessionID) => Promise<boolean>;
|
|
130
|
+
|
|
131
|
+
// Forgets a session, or answers with the token a forget that destroys a
|
|
132
|
+
// host must carry. Throws the refusal for a token it does not take.
|
|
133
|
+
readonly forgetSession: (
|
|
134
|
+
id: SessionID,
|
|
135
|
+
confirmToken: string | undefined,
|
|
136
|
+
) => Promise<ForgetResult | 'missing'>;
|
|
66
137
|
readonly updateSession: (id: SessionID, name?: string, pinned?: boolean) => boolean | 'child_pin';
|
|
67
138
|
readonly quitDaemon: () => void;
|
|
68
139
|
readonly ackSession: (id: SessionID) => boolean;
|
|
@@ -81,6 +152,13 @@ export interface DaemonContext {
|
|
|
81
152
|
sessionID: SessionID,
|
|
82
153
|
data: string,
|
|
83
154
|
) => 'busy' | 'ok' | 'missing' | 'dead';
|
|
155
|
+
|
|
156
|
+
// Types a line into the session and submits it the way the session's
|
|
157
|
+
// agent accepts a line, as opposed to the raw bytes the input write takes.
|
|
158
|
+
readonly writeSessionLine: (
|
|
159
|
+
sessionID: SessionID,
|
|
160
|
+
text: string,
|
|
161
|
+
) => 'busy' | 'ok' | 'missing' | 'dead';
|
|
84
162
|
readonly ejectSession: (
|
|
85
163
|
id: SessionID,
|
|
86
164
|
prompt: string,
|
|
@@ -89,7 +167,7 @@ export interface DaemonContext {
|
|
|
89
167
|
id: SessionID,
|
|
90
168
|
cols: number,
|
|
91
169
|
rows: number,
|
|
92
|
-
) => 'ok' | 'missing' | 'no_transcript'
|
|
170
|
+
) => Promise<'ok' | 'missing' | 'no_transcript'>;
|
|
93
171
|
readonly resizeSession: (client: OutputClient, sessionID: SessionID, dims: Dims) => boolean;
|
|
94
172
|
readonly resyncClient: (sessionID: SessionID, client: OutputClient) => Promise<void>;
|
|
95
173
|
readonly queueBytes?: number;
|
|
@@ -100,17 +178,25 @@ export interface DaemonContext {
|
|
|
100
178
|
from: TranscriptPosition | null,
|
|
101
179
|
limit: number,
|
|
102
180
|
) => Promise<SessionTranscriptRead | 'missing' | 'unsupported'>;
|
|
181
|
+
|
|
182
|
+
// Reads the trail, leaving out each event of a session outside the access
|
|
183
|
+
// when there is one.
|
|
103
184
|
readonly readEvents: (
|
|
104
185
|
afterID: number | null,
|
|
105
186
|
limit: number,
|
|
106
187
|
waitMs: number,
|
|
107
188
|
sessionID: SessionID | null,
|
|
189
|
+
access: TargetAccess | null,
|
|
108
190
|
) => Promise<EventsPage>;
|
|
191
|
+
|
|
192
|
+
// Answers with the `session.message` ok payload, which a keyed retry
|
|
193
|
+
// replays with the message's current status, or with the refusal.
|
|
109
194
|
readonly writeSessionMessage: (
|
|
110
195
|
sessionID: SessionID,
|
|
111
196
|
from: string,
|
|
112
197
|
text: string,
|
|
113
|
-
|
|
198
|
+
keyed: KeyedRequest | null,
|
|
199
|
+
) => Promise<Readonly<Record<string, unknown>> | MessageRefusal>;
|
|
114
200
|
readonly readMessage: (messageID: MessageID, waitMs: number) => Promise<MessageView | null>;
|
|
115
201
|
readonly attachTap: (client: TapClient, sessionID: SessionID) => 'ok' | 'missing' | 'unsupported';
|
|
116
202
|
readonly ackMessage: (
|
|
@@ -126,7 +212,10 @@ export interface OutputClient {
|
|
|
126
212
|
readonly sendOutput: (sessionID: SessionID, event: EventMsg, byteLength: number) => void;
|
|
127
213
|
}
|
|
128
214
|
|
|
129
|
-
// The `agents.list` answer: the host the daemon runs on,
|
|
215
|
+
// The `agents.list` answer: the host the daemon runs on, each agent, each
|
|
216
|
+
// execution target, what a spawn without either runs with, a digest of
|
|
217
|
+
// the target config, and the target config problems the daemon started
|
|
218
|
+
// with.
|
|
130
219
|
interface AgentList {
|
|
131
220
|
readonly daemon: {
|
|
132
221
|
readonly hostname: string;
|
|
@@ -135,6 +224,10 @@ interface AgentList {
|
|
|
135
224
|
readonly build: string;
|
|
136
225
|
};
|
|
137
226
|
readonly agents: readonly AgentEntry[];
|
|
227
|
+
readonly targets: readonly TargetEntry[];
|
|
228
|
+
readonly spawnDefaults: { readonly agent: AgentID; readonly target: string | null };
|
|
229
|
+
readonly configRevision: string;
|
|
230
|
+
readonly targetErrors: readonly TargetConfigError[];
|
|
138
231
|
}
|
|
139
232
|
|
|
140
233
|
// One `events.read` answer: the events, and whether more follow them.
|
|
@@ -151,11 +244,23 @@ interface MessageView {
|
|
|
151
244
|
readonly answeredWith: readonly MessageID[];
|
|
152
245
|
}
|
|
153
246
|
|
|
247
|
+
// Why a session refuses a message before the daemon accepts it.
|
|
248
|
+
export type MessageRefusal = 'missing' | 'dead' | 'unsupported' | 'no_tap';
|
|
249
|
+
|
|
154
250
|
// The slice of a connection a tap subscription needs.
|
|
155
251
|
export interface TapClient {
|
|
156
252
|
readonly sendEvent: (event: EventMsg) => void;
|
|
157
253
|
}
|
|
158
254
|
|
|
255
|
+
// The targets a request may use and the namespace of its idempotency keys.
|
|
256
|
+
interface RequestScope {
|
|
257
|
+
readonly access: TargetAccess;
|
|
258
|
+
readonly keyNamespace: string;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// The requests that act on the whole daemon, which only its owner may make.
|
|
262
|
+
const OWNER_METHODS: ReadonlySet<string> = new Set(['daemon.quit', 'fleet.restore']);
|
|
263
|
+
|
|
159
264
|
interface PeerSocket extends SocketWriter {
|
|
160
265
|
readonly end: () => void;
|
|
161
266
|
}
|
|
@@ -180,6 +285,17 @@ export class DaemonConnection {
|
|
|
180
285
|
|
|
181
286
|
private readonly desynced = new Map<SessionID, number>();
|
|
182
287
|
|
|
288
|
+
// The principal the whole connection acts as, from its handshake, and the
|
|
289
|
+
// targets that principal may use; null for the daemon's owner, whose
|
|
290
|
+
// reach has no limit.
|
|
291
|
+
private principal: string | null = null;
|
|
292
|
+
|
|
293
|
+
private access: TargetAccess | null = null;
|
|
294
|
+
|
|
295
|
+
// The sessions and permission requests this limited connection was shown,
|
|
296
|
+
// so the events that end them reach it after the session is gone.
|
|
297
|
+
private readonly shown = new Set<string>();
|
|
298
|
+
|
|
183
299
|
constructor(peer: PeerSocket, ctx: DaemonContext) {
|
|
184
300
|
this.peer = peer;
|
|
185
301
|
this.ctx = ctx;
|
|
@@ -195,7 +311,11 @@ export class DaemonConnection {
|
|
|
195
311
|
}
|
|
196
312
|
|
|
197
313
|
sendEvent(event: EventMsg): void {
|
|
198
|
-
if (this.helloed
|
|
314
|
+
if (!this.helloed || !this.canSeeEvent(event)) {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (!this.queue.send(encodeMessage(event))) {
|
|
199
319
|
this.peer.end();
|
|
200
320
|
}
|
|
201
321
|
}
|
|
@@ -298,18 +418,88 @@ export class DaemonConnection {
|
|
|
298
418
|
return false;
|
|
299
419
|
}
|
|
300
420
|
|
|
301
|
-
|
|
421
|
+
const scope = this.findRequestScope(req);
|
|
422
|
+
|
|
423
|
+
if (scope !== null && OWNER_METHODS.has(req.m)) {
|
|
424
|
+
this.sendErr(req.id, 'unauthorized', `${req.m} is open to the daemon's owner only`);
|
|
425
|
+
|
|
426
|
+
return true;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const ctx =
|
|
430
|
+
scope === null ? this.ctx : buildScopedContext(this.ctx, scope.access, scope.keyNamespace);
|
|
431
|
+
|
|
432
|
+
this.answerAsync(req.id, () => this.applyRequest(req, ctx));
|
|
433
|
+
|
|
434
|
+
return true;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// The targets a request may use and the namespace its idempotency keys
|
|
438
|
+
// live in: the request's own principal, limited to what the connection's
|
|
439
|
+
// may use, else the connection's. Null is the daemon's owner. The owner
|
|
440
|
+
// may act as any principal, that principal's keys included; a connection
|
|
441
|
+
// that acts as a principal always keeps its own keys, whatever principal
|
|
442
|
+
// a request acts as.
|
|
443
|
+
private findRequestScope(req: RequestMsg): RequestScope | null {
|
|
444
|
+
if (req.as === undefined) {
|
|
445
|
+
return this.principal === null || this.access === null
|
|
446
|
+
? null
|
|
447
|
+
: { access: this.access, keyNamespace: `client:${this.principal}` };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const access = this.ctx.buildTargetAccess(req.as);
|
|
451
|
+
|
|
452
|
+
if (this.principal === null || this.access === null) {
|
|
453
|
+
return { access, keyNamespace: `client:${req.as}` };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return { access: this.access.merge(access), keyNamespace: `client:${this.principal}` };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Whether this connection may see an event: always for the daemon's
|
|
460
|
+
// owner, and otherwise only for an event of a session the connection may
|
|
461
|
+
// see or was already shown.
|
|
462
|
+
private canSeeEvent(event: EventMsg): boolean {
|
|
463
|
+
if (this.access === null) {
|
|
464
|
+
return true;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const sessionID = findEventSession(event);
|
|
468
|
+
const request = typeof event['request'] === 'string' ? event['request'] : null;
|
|
469
|
+
|
|
470
|
+
if (sessionID === null) {
|
|
471
|
+
return request !== null && this.shown.has(`request:${request}`);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const grant = this.ctx.findSessionGrant(sessionID);
|
|
475
|
+
|
|
476
|
+
if (grant === null || !this.access.canUse(grant)) {
|
|
477
|
+
return this.shown.has(`session:${sessionID}`);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
this.shown.add(`session:${sessionID}`);
|
|
481
|
+
|
|
482
|
+
if (request !== null) {
|
|
483
|
+
this.shown.add(`request:${request}`);
|
|
484
|
+
}
|
|
302
485
|
|
|
303
486
|
return true;
|
|
304
487
|
}
|
|
305
488
|
|
|
306
489
|
// A store query that rejects must end one request, never the daemon: a
|
|
307
|
-
// floating promise here would take the whole process down with it.
|
|
490
|
+
// floating promise here would take the whole process down with it. A
|
|
491
|
+
// rejection that carries a protocol error code answers with that code.
|
|
308
492
|
private answerAsync(id: number, answered: () => Promise<void>): void {
|
|
309
493
|
void (async () => {
|
|
310
494
|
try {
|
|
311
495
|
await answered();
|
|
312
496
|
} catch (error) {
|
|
497
|
+
if (error instanceof DaemonError) {
|
|
498
|
+
this.sendErr(id, error.code, error.message, error.data);
|
|
499
|
+
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
|
|
313
503
|
const reason = error instanceof Error ? error.message : String(error);
|
|
314
504
|
|
|
315
505
|
this.sendErr(id, 'internal', reason);
|
|
@@ -317,7 +507,7 @@ export class DaemonConnection {
|
|
|
317
507
|
})();
|
|
318
508
|
}
|
|
319
509
|
|
|
320
|
-
private async applyRequest(req: RequestMsg): Promise<void> {
|
|
510
|
+
private async applyRequest(req: RequestMsg, ctx: DaemonContext): Promise<void> {
|
|
321
511
|
await this.helloAnswered;
|
|
322
512
|
|
|
323
513
|
switch (req.m) {
|
|
@@ -327,33 +517,33 @@ export class DaemonConnection {
|
|
|
327
517
|
return;
|
|
328
518
|
}
|
|
329
519
|
case 'session.list': {
|
|
330
|
-
this.sendOk(req.id, { sessions:
|
|
520
|
+
this.sendOk(req.id, { sessions: ctx.collectSessions() });
|
|
331
521
|
|
|
332
522
|
return;
|
|
333
523
|
}
|
|
334
524
|
case 'agents.list': {
|
|
335
|
-
this.sendOk(req.id, { ...
|
|
525
|
+
this.sendOk(req.id, { ...ctx.collectAgents() });
|
|
336
526
|
|
|
337
527
|
return;
|
|
338
528
|
}
|
|
339
529
|
case 'dirs.list': {
|
|
340
|
-
this.sendOk(req.id, { dirs: await
|
|
530
|
+
this.sendOk(req.id, { dirs: await ctx.collectSpawnDirs() });
|
|
341
531
|
|
|
342
532
|
return;
|
|
343
533
|
}
|
|
344
534
|
case 'fleet.list': {
|
|
345
|
-
this.sendOk(req.id, { fleet: await
|
|
535
|
+
this.sendOk(req.id, { fleet: await ctx.collectFleet() });
|
|
346
536
|
|
|
347
537
|
return;
|
|
348
538
|
}
|
|
349
539
|
case 'session.spawn': {
|
|
350
|
-
this.applySpawn(req);
|
|
540
|
+
await this.applySpawn(req, ctx);
|
|
351
541
|
|
|
352
542
|
return;
|
|
353
543
|
}
|
|
354
544
|
case 'daemon.quit': {
|
|
355
545
|
this.sendOk(req.id, {});
|
|
356
|
-
|
|
546
|
+
ctx.quitDaemon();
|
|
357
547
|
|
|
358
548
|
return;
|
|
359
549
|
}
|
|
@@ -367,7 +557,7 @@ export class DaemonConnection {
|
|
|
367
557
|
}
|
|
368
558
|
|
|
369
559
|
const sessionID = parsed.data.session;
|
|
370
|
-
const updated =
|
|
560
|
+
const updated = ctx.updateSession(sessionID, parsed.data.name, parsed.data.pinned);
|
|
371
561
|
|
|
372
562
|
if (updated === 'child_pin') {
|
|
373
563
|
this.sendErr(
|
|
@@ -384,12 +574,33 @@ export class DaemonConnection {
|
|
|
384
574
|
return;
|
|
385
575
|
}
|
|
386
576
|
case 'session.kill': {
|
|
387
|
-
await this.applySessionVerb(req, 'session.kill',
|
|
577
|
+
await this.applySessionVerb(req, 'session.kill', ctx.killSession);
|
|
388
578
|
|
|
389
579
|
return;
|
|
390
580
|
}
|
|
391
581
|
case 'session.ack': {
|
|
392
|
-
await this.applySessionVerb(req, 'session.ack',
|
|
582
|
+
await this.applySessionVerb(req, 'session.ack', ctx.ackSession);
|
|
583
|
+
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
case 'session.forget': {
|
|
587
|
+
const parsed = parseRequestParams('session.forget', req.p);
|
|
588
|
+
|
|
589
|
+
if (!parsed.ok) {
|
|
590
|
+
this.sendErr(req.id, 'bad_args', parsed.message);
|
|
591
|
+
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const id = parsed.data.session;
|
|
596
|
+
|
|
597
|
+
const forgotten = await this.ctx.forgetSession(id, parsed.data.confirmToken);
|
|
598
|
+
|
|
599
|
+
if (forgotten === 'missing') {
|
|
600
|
+
this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
|
|
601
|
+
} else {
|
|
602
|
+
this.sendOk(req.id, { ...forgotten });
|
|
603
|
+
}
|
|
393
604
|
|
|
394
605
|
return;
|
|
395
606
|
}
|
|
@@ -403,7 +614,7 @@ export class DaemonConnection {
|
|
|
403
614
|
}
|
|
404
615
|
|
|
405
616
|
const id = parsed.data.session;
|
|
406
|
-
const command =
|
|
617
|
+
const command = ctx.buildResumeCommand(id);
|
|
407
618
|
|
|
408
619
|
if (command === null) {
|
|
409
620
|
this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
|
|
@@ -424,7 +635,7 @@ export class DaemonConnection {
|
|
|
424
635
|
|
|
425
636
|
const id = parsed.data.session;
|
|
426
637
|
|
|
427
|
-
const screen = await
|
|
638
|
+
const screen = await ctx.readSessionScreen(id);
|
|
428
639
|
|
|
429
640
|
if (screen === 'missing') {
|
|
430
641
|
this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
|
|
@@ -446,7 +657,7 @@ export class DaemonConnection {
|
|
|
446
657
|
}
|
|
447
658
|
|
|
448
659
|
const sessionID = parsed.data.session;
|
|
449
|
-
const result =
|
|
660
|
+
const result = ctx.ejectSession(sessionID, parsed.data.prompt);
|
|
450
661
|
|
|
451
662
|
if (result === 'ok') {
|
|
452
663
|
this.sendOk(req.id, {});
|
|
@@ -478,7 +689,8 @@ export class DaemonConnection {
|
|
|
478
689
|
}
|
|
479
690
|
|
|
480
691
|
const sessionID = parsed.data.session;
|
|
481
|
-
|
|
692
|
+
|
|
693
|
+
const adoptResult = await ctx.adoptSession(sessionID, parsed.data.cols, parsed.data.rows);
|
|
482
694
|
|
|
483
695
|
if (adoptResult === 'ok') {
|
|
484
696
|
this.sendOk(req.id, {});
|
|
@@ -499,7 +711,7 @@ export class DaemonConnection {
|
|
|
499
711
|
return;
|
|
500
712
|
}
|
|
501
713
|
case 'session.attach': {
|
|
502
|
-
this.applyAttach(req);
|
|
714
|
+
this.applyAttach(req, ctx);
|
|
503
715
|
|
|
504
716
|
return;
|
|
505
717
|
}
|
|
@@ -512,43 +724,48 @@ export class DaemonConnection {
|
|
|
512
724
|
return;
|
|
513
725
|
}
|
|
514
726
|
|
|
515
|
-
|
|
727
|
+
ctx.detachSession(this, parsed.data.session);
|
|
516
728
|
this.sendOk(req.id, {});
|
|
517
729
|
|
|
518
730
|
return;
|
|
519
731
|
}
|
|
520
732
|
case 'session.input': {
|
|
521
|
-
this.applyInput(req);
|
|
733
|
+
this.applyInput(req, ctx);
|
|
734
|
+
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
case 'session.submit': {
|
|
738
|
+
this.applySubmit(req, ctx);
|
|
522
739
|
|
|
523
740
|
return;
|
|
524
741
|
}
|
|
525
742
|
case 'session.resize': {
|
|
526
|
-
this.applyResize(req);
|
|
743
|
+
this.applyResize(req, ctx);
|
|
527
744
|
|
|
528
745
|
return;
|
|
529
746
|
}
|
|
530
747
|
case 'permission.respond': {
|
|
531
|
-
this.applyPermissionRespond(req);
|
|
748
|
+
this.applyPermissionRespond(req, ctx);
|
|
532
749
|
|
|
533
750
|
return;
|
|
534
751
|
}
|
|
535
752
|
case 'session.message': {
|
|
536
|
-
await this.applySessionMessage(req);
|
|
753
|
+
await this.applySessionMessage(req, ctx);
|
|
537
754
|
|
|
538
755
|
return;
|
|
539
756
|
}
|
|
540
757
|
case 'session.tap': {
|
|
541
|
-
this.applyTap(req);
|
|
758
|
+
this.applyTap(req, ctx);
|
|
542
759
|
|
|
543
760
|
return;
|
|
544
761
|
}
|
|
545
762
|
case 'message.get': {
|
|
546
|
-
await this.applyMessageGet(req);
|
|
763
|
+
await this.applyMessageGet(req, ctx);
|
|
547
764
|
|
|
548
765
|
return;
|
|
549
766
|
}
|
|
550
767
|
case 'message.ack': {
|
|
551
|
-
await this.applyMessageAck(req);
|
|
768
|
+
await this.applyMessageAck(req, ctx);
|
|
552
769
|
|
|
553
770
|
return;
|
|
554
771
|
}
|
|
@@ -562,23 +779,23 @@ export class DaemonConnection {
|
|
|
562
779
|
}
|
|
563
780
|
|
|
564
781
|
this.sendOk(req.id, {
|
|
565
|
-
restored: await
|
|
782
|
+
restored: await ctx.restoreFleet(parsed.data.cols, parsed.data.rows),
|
|
566
783
|
});
|
|
567
784
|
|
|
568
785
|
return;
|
|
569
786
|
}
|
|
570
787
|
case 'session.get': {
|
|
571
|
-
await this.applySessionGet(req);
|
|
788
|
+
await this.applySessionGet(req, ctx);
|
|
572
789
|
|
|
573
790
|
return;
|
|
574
791
|
}
|
|
575
792
|
case 'session.read': {
|
|
576
|
-
await this.applySessionRead(req);
|
|
793
|
+
await this.applySessionRead(req, ctx);
|
|
577
794
|
|
|
578
795
|
return;
|
|
579
796
|
}
|
|
580
797
|
case 'events.read': {
|
|
581
|
-
await this.applyEventsRead(req);
|
|
798
|
+
await this.applyEventsRead(req, ctx);
|
|
582
799
|
|
|
583
800
|
return;
|
|
584
801
|
}
|
|
@@ -588,7 +805,7 @@ export class DaemonConnection {
|
|
|
588
805
|
}
|
|
589
806
|
}
|
|
590
807
|
|
|
591
|
-
private applySpawn(req: RequestMsg): void {
|
|
808
|
+
private async applySpawn(req: RequestMsg, ctx: DaemonContext): Promise<void> {
|
|
592
809
|
const parsed = parseRequestParams('session.spawn', req.p);
|
|
593
810
|
|
|
594
811
|
if (!parsed.ok) {
|
|
@@ -597,48 +814,91 @@ export class DaemonConnection {
|
|
|
597
814
|
return;
|
|
598
815
|
}
|
|
599
816
|
|
|
600
|
-
const
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
817
|
+
const data = parsed.data;
|
|
818
|
+
|
|
819
|
+
// Every refusal is thrown from the plan, which runs only once a key is
|
|
820
|
+
// claimed: a retry of a held key answers from the key without checking
|
|
821
|
+
// anything again, and a refused spawn drops its claim.
|
|
822
|
+
const plan = (): SpawnParams => {
|
|
823
|
+
// The target goes first: a config or target problem is the cause a
|
|
824
|
+
// spawn reports, ahead of any agent check that problem can skew.
|
|
825
|
+
const target = ctx.resolveSpawnTarget(data.target);
|
|
826
|
+
const agent: AgentID = data.agent ?? 'claude';
|
|
827
|
+
const adapter = ctx.findAdapter(agent);
|
|
828
|
+
const entry = ctx.collectAgents().agents.find((candidate) => candidate.id === agent);
|
|
829
|
+
|
|
830
|
+
if (adapter === null || entry === undefined) {
|
|
831
|
+
throw new DaemonError('unsupported', `no adapter for agent '${agent}'`);
|
|
832
|
+
}
|
|
606
833
|
|
|
607
|
-
|
|
608
|
-
|
|
834
|
+
// A stand-in adapter declares no binary to check, so only a profiled
|
|
835
|
+
// agent's missing binary refuses the spawn.
|
|
836
|
+
if (adapter.profile !== undefined && !entry.installed) {
|
|
837
|
+
throw new DaemonError(
|
|
838
|
+
'unsupported',
|
|
839
|
+
`agent '${agent}' is registered but not installed on this host`,
|
|
840
|
+
);
|
|
841
|
+
}
|
|
609
842
|
|
|
610
|
-
|
|
843
|
+
const overrides = parseSpawnOverrides(entry, { model: data.model, effort: data.effort });
|
|
611
844
|
|
|
612
|
-
|
|
613
|
-
|
|
845
|
+
if (!overrides.ok) {
|
|
846
|
+
throw new DaemonError(overrides.code, overrides.message);
|
|
847
|
+
}
|
|
614
848
|
|
|
615
|
-
|
|
616
|
-
|
|
849
|
+
// Checked before the spawn starts, so a target that cannot take a
|
|
850
|
+
// workspace refuses it before any git command runs.
|
|
851
|
+
if (data.workspace !== undefined) {
|
|
852
|
+
this.ctx.requireWorkspaceTarget(target);
|
|
853
|
+
}
|
|
617
854
|
|
|
618
|
-
|
|
855
|
+
// A workspace is materialized at cwd on the target, which only an
|
|
856
|
+
// absolute path names.
|
|
857
|
+
if (data.workspace !== undefined && !isAbsolute(data.cwd)) {
|
|
858
|
+
throw new DaemonError('bad_args', 'a spawn with a workspace requires an absolute cwd');
|
|
619
859
|
}
|
|
620
860
|
|
|
621
|
-
|
|
622
|
-
// so a set stays one level deep.
|
|
623
|
-
parent = owner.parent ?? owner.id;
|
|
624
|
-
}
|
|
861
|
+
let parent: SessionID | null = null;
|
|
625
862
|
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
resume: parsed.data.resume,
|
|
633
|
-
namedBy: name === '' ? 'auto' : 'user',
|
|
634
|
-
agent,
|
|
635
|
-
parent,
|
|
636
|
-
});
|
|
863
|
+
if (data.parent !== undefined) {
|
|
864
|
+
const owner = ctx.collectSessions().find((s) => s.id === data.parent);
|
|
865
|
+
|
|
866
|
+
if (owner === undefined) {
|
|
867
|
+
throw new DaemonError('no_such_session', `no session '${data.parent}'`);
|
|
868
|
+
}
|
|
637
869
|
|
|
638
|
-
|
|
870
|
+
// A sub-session spawning a sub-session of its own lands beside it,
|
|
871
|
+
// so a set stays one level deep.
|
|
872
|
+
parent = owner.parent ?? owner.id;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
return {
|
|
876
|
+
cwd: data.cwd,
|
|
877
|
+
name: data.name === '' ? basename(data.cwd) : data.name,
|
|
878
|
+
prompt: data.prompt,
|
|
879
|
+
cols: data.cols,
|
|
880
|
+
rows: data.rows,
|
|
881
|
+
resume: data.resume,
|
|
882
|
+
namedBy: data.name === '' ? 'auto' : 'user',
|
|
883
|
+
agent,
|
|
884
|
+
parent,
|
|
885
|
+
overrides: overrides.overrides,
|
|
886
|
+
target,
|
|
887
|
+
workspace: data.workspace ?? null,
|
|
888
|
+
};
|
|
889
|
+
};
|
|
890
|
+
|
|
891
|
+
const keyed =
|
|
892
|
+
data.idempotencyKey === undefined
|
|
893
|
+
? null
|
|
894
|
+
: { key: data.idempotencyKey, payloadHash: buildPayloadHash(data) };
|
|
895
|
+
|
|
896
|
+
const spawned = await ctx.spawnSession(plan, keyed, null);
|
|
897
|
+
|
|
898
|
+
this.sendOk(req.id, spawned);
|
|
639
899
|
}
|
|
640
900
|
|
|
641
|
-
private applyAttach(req: RequestMsg): void {
|
|
901
|
+
private applyAttach(req: RequestMsg, ctx: DaemonContext): void {
|
|
642
902
|
const parsed = parseRequestParams('session.attach', req.p);
|
|
643
903
|
|
|
644
904
|
if (!parsed.ok) {
|
|
@@ -649,7 +909,7 @@ export class DaemonConnection {
|
|
|
649
909
|
|
|
650
910
|
const sessionID = parsed.data.session;
|
|
651
911
|
|
|
652
|
-
const result =
|
|
912
|
+
const result = ctx.attachSession(this, sessionID, {
|
|
653
913
|
cols: parsed.data.cols,
|
|
654
914
|
rows: parsed.data.rows,
|
|
655
915
|
});
|
|
@@ -666,12 +926,12 @@ export class DaemonConnection {
|
|
|
666
926
|
return;
|
|
667
927
|
}
|
|
668
928
|
|
|
669
|
-
const dims =
|
|
929
|
+
const dims = ctx.getEffectiveDims(sessionID);
|
|
670
930
|
|
|
671
931
|
this.sendOk(req.id, { cols: dims.cols, rows: dims.rows });
|
|
672
932
|
}
|
|
673
933
|
|
|
674
|
-
private applyInput(req: RequestMsg): void {
|
|
934
|
+
private applyInput(req: RequestMsg, ctx: DaemonContext): void {
|
|
675
935
|
const parsed = parseRequestParams('session.input', req.p);
|
|
676
936
|
|
|
677
937
|
if (!parsed.ok) {
|
|
@@ -681,8 +941,29 @@ export class DaemonConnection {
|
|
|
681
941
|
}
|
|
682
942
|
|
|
683
943
|
const sessionID = parsed.data.session;
|
|
684
|
-
const result = this.ctx.writeSessionInput(sessionID, parsed.data.d);
|
|
685
944
|
|
|
945
|
+
this.sendInputResult(req, sessionID, ctx.writeSessionInput(sessionID, parsed.data.d));
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
private applySubmit(req: RequestMsg, ctx: DaemonContext): void {
|
|
949
|
+
const parsed = parseRequestParams('session.submit', req.p);
|
|
950
|
+
|
|
951
|
+
if (!parsed.ok) {
|
|
952
|
+
this.sendErr(req.id, 'bad_args', parsed.message);
|
|
953
|
+
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const sessionID = parsed.data.session;
|
|
958
|
+
|
|
959
|
+
this.sendInputResult(req, sessionID, ctx.writeSessionLine(sessionID, parsed.data.text));
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
private sendInputResult(
|
|
963
|
+
req: RequestMsg,
|
|
964
|
+
sessionID: SessionID,
|
|
965
|
+
result: 'busy' | 'ok' | 'missing' | 'dead',
|
|
966
|
+
): void {
|
|
686
967
|
if (result === 'missing') {
|
|
687
968
|
this.sendErr(req.id, 'no_such_session', `no session '${sessionID}'`);
|
|
688
969
|
|
|
@@ -708,7 +989,7 @@ export class DaemonConnection {
|
|
|
708
989
|
this.sendOk(req.id, {});
|
|
709
990
|
}
|
|
710
991
|
|
|
711
|
-
private applyResize(req: RequestMsg): void {
|
|
992
|
+
private applyResize(req: RequestMsg, ctx: DaemonContext): void {
|
|
712
993
|
const parsed = parseRequestParams('session.resize', req.p);
|
|
713
994
|
|
|
714
995
|
if (!parsed.ok) {
|
|
@@ -719,9 +1000,7 @@ export class DaemonConnection {
|
|
|
719
1000
|
|
|
720
1001
|
const sessionID = parsed.data.session;
|
|
721
1002
|
|
|
722
|
-
if (
|
|
723
|
-
!this.ctx.resizeSession(this, sessionID, { cols: parsed.data.cols, rows: parsed.data.rows })
|
|
724
|
-
) {
|
|
1003
|
+
if (!ctx.resizeSession(this, sessionID, { cols: parsed.data.cols, rows: parsed.data.rows })) {
|
|
725
1004
|
this.sendErr(req.id, 'bad_args', `not attached to session '${sessionID}'`);
|
|
726
1005
|
|
|
727
1006
|
return;
|
|
@@ -730,7 +1009,7 @@ export class DaemonConnection {
|
|
|
730
1009
|
this.sendOk(req.id, {});
|
|
731
1010
|
}
|
|
732
1011
|
|
|
733
|
-
private applyPermissionRespond(req: RequestMsg): void {
|
|
1012
|
+
private applyPermissionRespond(req: RequestMsg, ctx: DaemonContext): void {
|
|
734
1013
|
const parsed = parseRequestParams('permission.respond', req.p);
|
|
735
1014
|
|
|
736
1015
|
if (!parsed.ok) {
|
|
@@ -741,7 +1020,7 @@ export class DaemonConnection {
|
|
|
741
1020
|
|
|
742
1021
|
const request = parsed.data.request;
|
|
743
1022
|
const decision = parsed.data.decision;
|
|
744
|
-
const result =
|
|
1023
|
+
const result = ctx.answerPermission(request, decision);
|
|
745
1024
|
|
|
746
1025
|
switch (result) {
|
|
747
1026
|
case 'ok': {
|
|
@@ -765,7 +1044,7 @@ export class DaemonConnection {
|
|
|
765
1044
|
}
|
|
766
1045
|
}
|
|
767
1046
|
|
|
768
|
-
private async applySessionGet(req: RequestMsg): Promise<void> {
|
|
1047
|
+
private async applySessionGet(req: RequestMsg, ctx: DaemonContext): Promise<void> {
|
|
769
1048
|
const parsed = parseRequestParams('session.get', req.p);
|
|
770
1049
|
|
|
771
1050
|
if (!parsed.ok) {
|
|
@@ -776,7 +1055,7 @@ export class DaemonConnection {
|
|
|
776
1055
|
|
|
777
1056
|
const id = parsed.data.session;
|
|
778
1057
|
|
|
779
|
-
const record = await
|
|
1058
|
+
const record = await ctx.readSessionRecord(id);
|
|
780
1059
|
|
|
781
1060
|
if (record === 'missing') {
|
|
782
1061
|
this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
|
|
@@ -787,7 +1066,7 @@ export class DaemonConnection {
|
|
|
787
1066
|
this.sendOk(req.id, { ...record });
|
|
788
1067
|
}
|
|
789
1068
|
|
|
790
|
-
private async applySessionRead(req: RequestMsg): Promise<void> {
|
|
1069
|
+
private async applySessionRead(req: RequestMsg, ctx: DaemonContext): Promise<void> {
|
|
791
1070
|
const parsed = parseRequestParams('session.read', req.p);
|
|
792
1071
|
|
|
793
1072
|
if (!parsed.ok) {
|
|
@@ -811,7 +1090,7 @@ export class DaemonConnection {
|
|
|
811
1090
|
from = { path: decoded.path, offset: decoded.offset };
|
|
812
1091
|
}
|
|
813
1092
|
|
|
814
|
-
const read = await
|
|
1093
|
+
const read = await ctx.loadSessionTranscript(id, from, parsed.data.limit);
|
|
815
1094
|
|
|
816
1095
|
if (read === 'missing') {
|
|
817
1096
|
this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
|
|
@@ -830,7 +1109,7 @@ export class DaemonConnection {
|
|
|
830
1109
|
}
|
|
831
1110
|
}
|
|
832
1111
|
|
|
833
|
-
private async applyEventsRead(req: RequestMsg): Promise<void> {
|
|
1112
|
+
private async applyEventsRead(req: RequestMsg, ctx: DaemonContext): Promise<void> {
|
|
834
1113
|
const parsed = parseRequestParams('events.read', req.p);
|
|
835
1114
|
|
|
836
1115
|
if (!parsed.ok) {
|
|
@@ -853,11 +1132,12 @@ export class DaemonConnection {
|
|
|
853
1132
|
afterID = decoded.id;
|
|
854
1133
|
}
|
|
855
1134
|
|
|
856
|
-
const page = await
|
|
1135
|
+
const page = await ctx.readEvents(
|
|
857
1136
|
afterID,
|
|
858
1137
|
parsed.data.limit,
|
|
859
1138
|
parsed.data.waitMs,
|
|
860
1139
|
parsed.data.session ?? null,
|
|
1140
|
+
null,
|
|
861
1141
|
);
|
|
862
1142
|
|
|
863
1143
|
const last = page.events.at(-1);
|
|
@@ -870,7 +1150,7 @@ export class DaemonConnection {
|
|
|
870
1150
|
});
|
|
871
1151
|
}
|
|
872
1152
|
|
|
873
|
-
private async applySessionMessage(req: RequestMsg): Promise<void> {
|
|
1153
|
+
private async applySessionMessage(req: RequestMsg, ctx: DaemonContext): Promise<void> {
|
|
874
1154
|
const parsed = parseRequestParams('session.message', req.p);
|
|
875
1155
|
|
|
876
1156
|
if (!parsed.ok) {
|
|
@@ -881,10 +1161,16 @@ export class DaemonConnection {
|
|
|
881
1161
|
|
|
882
1162
|
const sessionID = parsed.data.session;
|
|
883
1163
|
|
|
884
|
-
const
|
|
1164
|
+
const keyed =
|
|
1165
|
+
parsed.data.idempotencyKey === undefined
|
|
1166
|
+
? null
|
|
1167
|
+
: { key: parsed.data.idempotencyKey, payloadHash: buildPayloadHash(parsed.data) };
|
|
1168
|
+
|
|
1169
|
+
const result = await ctx.writeSessionMessage(
|
|
885
1170
|
sessionID,
|
|
886
1171
|
parsed.data.from,
|
|
887
1172
|
parsed.data.text,
|
|
1173
|
+
keyed,
|
|
888
1174
|
);
|
|
889
1175
|
|
|
890
1176
|
if (result === 'missing') {
|
|
@@ -915,10 +1201,10 @@ export class DaemonConnection {
|
|
|
915
1201
|
return;
|
|
916
1202
|
}
|
|
917
1203
|
|
|
918
|
-
this.sendOk(req.id,
|
|
1204
|
+
this.sendOk(req.id, result);
|
|
919
1205
|
}
|
|
920
1206
|
|
|
921
|
-
private applyTap(req: RequestMsg): void {
|
|
1207
|
+
private applyTap(req: RequestMsg, ctx: DaemonContext): void {
|
|
922
1208
|
const parsed = parseRequestParams('session.tap', req.p);
|
|
923
1209
|
|
|
924
1210
|
if (!parsed.ok) {
|
|
@@ -928,7 +1214,7 @@ export class DaemonConnection {
|
|
|
928
1214
|
}
|
|
929
1215
|
|
|
930
1216
|
const sessionID = parsed.data.session;
|
|
931
|
-
const result =
|
|
1217
|
+
const result = ctx.attachTap(this, sessionID);
|
|
932
1218
|
|
|
933
1219
|
if (result === 'missing') {
|
|
934
1220
|
this.sendErr(req.id, 'no_such_session', `no session '${sessionID}'`);
|
|
@@ -945,7 +1231,7 @@ export class DaemonConnection {
|
|
|
945
1231
|
this.sendOk(req.id, {});
|
|
946
1232
|
}
|
|
947
1233
|
|
|
948
|
-
private async applyMessageGet(req: RequestMsg): Promise<void> {
|
|
1234
|
+
private async applyMessageGet(req: RequestMsg, ctx: DaemonContext): Promise<void> {
|
|
949
1235
|
const parsed = parseRequestParams('message.get', req.p);
|
|
950
1236
|
|
|
951
1237
|
if (!parsed.ok) {
|
|
@@ -956,7 +1242,7 @@ export class DaemonConnection {
|
|
|
956
1242
|
|
|
957
1243
|
const messageID = parsed.data.message;
|
|
958
1244
|
|
|
959
|
-
const view = await
|
|
1245
|
+
const view = await ctx.readMessage(messageID, parsed.data.waitMs);
|
|
960
1246
|
|
|
961
1247
|
if (view === null) {
|
|
962
1248
|
this.sendErr(req.id, 'bad_args', `no message '${messageID}'`);
|
|
@@ -981,7 +1267,7 @@ export class DaemonConnection {
|
|
|
981
1267
|
});
|
|
982
1268
|
}
|
|
983
1269
|
|
|
984
|
-
private async applyMessageAck(req: RequestMsg): Promise<void> {
|
|
1270
|
+
private async applyMessageAck(req: RequestMsg, ctx: DaemonContext): Promise<void> {
|
|
985
1271
|
const parsed = parseRequestParams('message.ack', req.p);
|
|
986
1272
|
|
|
987
1273
|
if (!parsed.ok) {
|
|
@@ -993,7 +1279,7 @@ export class DaemonConnection {
|
|
|
993
1279
|
const sessionID = parsed.data.session;
|
|
994
1280
|
const messageID = parsed.data.message;
|
|
995
1281
|
|
|
996
|
-
const result = await
|
|
1282
|
+
const result = await ctx.ackMessage(this, sessionID, messageID);
|
|
997
1283
|
|
|
998
1284
|
if (result === 'not_tapping') {
|
|
999
1285
|
this.sendErr(req.id, 'bad_args', `this connection is not tapping session '${sessionID}'`);
|
|
@@ -1038,6 +1324,21 @@ export class DaemonConnection {
|
|
|
1038
1324
|
const parsedHello = parseRequestParams('daemon.hello', req.p);
|
|
1039
1325
|
const client = parsedHello.ok ? parsedHello.data.client : 'unknown client';
|
|
1040
1326
|
|
|
1327
|
+
// A principal the handshake cannot read would otherwise leave the
|
|
1328
|
+
// connection with the owner's reach.
|
|
1329
|
+
if (!parsedHello.ok && req.p?.['principal'] !== undefined) {
|
|
1330
|
+
this.sendErr(req.id, 'bad_args', parsedHello.message);
|
|
1331
|
+
|
|
1332
|
+
return false;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
const principal = parsedHello.ok ? (parsedHello.data.principal ?? null) : null;
|
|
1336
|
+
|
|
1337
|
+
if (principal !== null) {
|
|
1338
|
+
this.principal = principal;
|
|
1339
|
+
this.access = this.ctx.buildTargetAccess(principal);
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1041
1342
|
if (req.v !== PROTOCOL_V) {
|
|
1042
1343
|
this.sendErr(
|
|
1043
1344
|
req.id,
|
|
@@ -1059,6 +1360,7 @@ export class DaemonConnection {
|
|
|
1059
1360
|
private async sendHelloOk(id: number): Promise<void> {
|
|
1060
1361
|
this.sendOk(id, {
|
|
1061
1362
|
daemon: this.ctx.build,
|
|
1363
|
+
daemonID: this.ctx.daemonID,
|
|
1062
1364
|
limits: { maxLine: MAX_LINE, maxChunk: MAX_CHUNK },
|
|
1063
1365
|
features: DAEMON_FEATURES,
|
|
1064
1366
|
lastUsedAgent: await this.ctx.loadLastUsedAgent(),
|
|
@@ -1069,7 +1371,34 @@ export class DaemonConnection {
|
|
|
1069
1371
|
this.queue.send(encodeMessage({ v: PROTOCOL_V, id, ok }));
|
|
1070
1372
|
}
|
|
1071
1373
|
|
|
1072
|
-
private sendErr(
|
|
1073
|
-
|
|
1374
|
+
private sendErr(
|
|
1375
|
+
id: number,
|
|
1376
|
+
code: ErrorCode,
|
|
1377
|
+
msg: string,
|
|
1378
|
+
data?: Readonly<Record<string, unknown>>,
|
|
1379
|
+
): void {
|
|
1380
|
+
this.queue.send(
|
|
1381
|
+
encodeMessage({
|
|
1382
|
+
v: PROTOCOL_V,
|
|
1383
|
+
id,
|
|
1384
|
+
err: { code, msg, ...(data === undefined ? {} : { data }) },
|
|
1385
|
+
}),
|
|
1386
|
+
);
|
|
1074
1387
|
}
|
|
1075
1388
|
}
|
|
1389
|
+
|
|
1390
|
+
// The session an event belongs to, from its session id or the session it
|
|
1391
|
+
// adds, or null for an event of no session.
|
|
1392
|
+
function findEventSession(event: EventMsg): SessionID | null {
|
|
1393
|
+
if (typeof event['s'] === 'string') {
|
|
1394
|
+
return toSessionID(event['s']);
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
const session = event['session'];
|
|
1398
|
+
|
|
1399
|
+
if (isRecord(session) && typeof session['id'] === 'string') {
|
|
1400
|
+
return toSessionID(session['id']);
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
return null;
|
|
1404
|
+
}
|