@zgeoff/atc 0.1.12 → 1.0.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 (74) hide show
  1. package/README.md +50 -12
  2. package/package.json +7 -2
  3. package/src/{agent-adapter.ts → agents/agent-adapter.ts} +31 -14
  4. package/src/{build-cli-command.ts → agents/build-cli-command.ts} +1 -1
  5. package/src/agents/build-hook-settings.ts +59 -0
  6. package/src/{claude-adapter.ts → agents/claude-adapter.ts} +41 -80
  7. package/src/{codex-adapter.ts → agents/codex-adapter.ts} +41 -30
  8. package/src/agents/gateway-adapter.ts +106 -0
  9. package/src/{grok-adapter.ts → agents/grok-adapter.ts} +83 -46
  10. package/src/agents/resolve-agent-home.ts +13 -0
  11. package/src/agents/truncate-detail.ts +7 -0
  12. package/src/agents/write-hook-settings.ts +37 -0
  13. package/src/cli.ts +25 -14
  14. package/src/{boot-daemon.ts → client/boot-daemon.ts} +8 -7
  15. package/src/client/build-client-machine.ts +119 -0
  16. package/src/client/collect-agent-picks.ts +31 -0
  17. package/src/{daemon-client.ts → client/daemon-client.ts} +4 -4
  18. package/src/{dirs.ts → client/dirs.ts} +8 -0
  19. package/src/client/format-overlay-agent-mark.ts +15 -0
  20. package/src/{index.ts → client/index.ts} +181 -550
  21. package/src/client/keys.ts +111 -0
  22. package/src/client/parse-daemon-event.ts +114 -0
  23. package/src/{pick-tab-target.ts → client/pick-tab-target.ts} +2 -2
  24. package/src/client/spawn-picker.ts +305 -0
  25. package/src/client/to-mirror-session.ts +65 -0
  26. package/src/{ui.ts → client/ui.ts} +13 -8
  27. package/src/{attach-registry.ts → daemon/attach-registry.ts} +12 -10
  28. package/src/daemon/build-session-event.ts +43 -0
  29. package/src/{daemon-connection.ts → daemon/daemon-connection.ts} +194 -108
  30. package/src/{daemon.ts → daemon/daemon.ts} +142 -312
  31. package/src/{hooks.ts → daemon/hooks.ts} +6 -4
  32. package/src/daemon/mint-session-id.ts +12 -0
  33. package/src/{permission-registry.ts → daemon/permission-registry.ts} +5 -3
  34. package/src/daemon/restore-fleet.ts +141 -0
  35. package/src/daemon/run-eject-handoff.ts +33 -0
  36. package/src/{screen-model.ts → daemon/screen-model.ts} +1 -1
  37. package/src/daemon/session-runtime.ts +64 -0
  38. package/src/{sessions.ts → daemon/sessions.ts} +77 -133
  39. package/src/{start-headless-run.ts → daemon/start-headless-run.ts} +9 -3
  40. package/src/daemon/start-headless-turn.ts +57 -0
  41. package/src/hook-report.ts +2 -2
  42. package/src/mcp-server.ts +33 -44
  43. package/src/protocol/parse-request-params.ts +31 -0
  44. package/src/{protocol.ts → protocol/protocol.ts} +1 -1
  45. package/src/protocol/request-param-schemas.ts +109 -0
  46. package/src/shared/agent-session-id.ts +8 -0
  47. package/src/shared/build-optional-boolean.ts +10 -0
  48. package/src/shared/build-optional-string-array.ts +14 -0
  49. package/src/shared/build-optional-string.ts +10 -0
  50. package/src/shared/collect-gateways.ts +113 -0
  51. package/src/{config.ts → shared/config.ts} +47 -31
  52. package/src/{get-build.ts → shared/get-build.ts} +21 -6
  53. package/src/shared/get-record.ts +20 -0
  54. package/src/shared/session-id.ts +8 -0
  55. package/src/shared/to-agent-session-id.ts +12 -0
  56. package/src/shared/to-session-id.ts +11 -0
  57. package/src/shared/to-shell-arg.ts +7 -0
  58. package/src/statusline.ts +2 -2
  59. package/src/store/bun-sqlite-driver.ts +96 -0
  60. package/src/store/fleet-entry.ts +72 -0
  61. package/src/store/run-migrations.ts +242 -0
  62. package/src/store/state-store.ts +226 -0
  63. package/src/format-overlay-agent-mark.ts +0 -17
  64. package/src/state-store.ts +0 -236
  65. /package/src/{normalize-hook-event.ts → agents/normalize-hook-event.ts} +0 -0
  66. /package/src/{print-codex-hook-file.ts → agents/print-codex-hook-file.ts} +0 -0
  67. /package/src/{print-grok-hook-file.ts → agents/print-grok-hook-file.ts} +0 -0
  68. /package/src/{build-leader-chords.ts → client/build-leader-chords.ts} +0 -0
  69. /package/src/{daemon-error.ts → protocol/daemon-error.ts} +0 -0
  70. /package/src/{outbound-queue.ts → protocol/outbound-queue.ts} +0 -0
  71. /package/src/{collect-clean-env.ts → shared/collect-clean-env.ts} +0 -0
  72. /package/src/{report.ts → shared/report.ts} +0 -0
  73. /package/src/{reset-input-modes.ts → shared/reset-input-modes.ts} +0 -0
  74. /package/src/{resolve-repo-root.ts → shared/resolve-repo-root.ts} +0 -0
@@ -1,3 +1,5 @@
1
+ import type { SessionID } from '../shared/session-id';
2
+
1
3
  export interface Dims {
2
4
  readonly cols: number;
3
5
  readonly rows: number;
@@ -10,16 +12,16 @@ export interface Dims {
10
12
  * the whole screen.
11
13
  */
12
14
  export class AttachRegistry<TClient> {
13
- private readonly bySession = new Map<string, Map<TClient, Dims>>();
15
+ private readonly bySession = new Map<SessionID, Map<TClient, Dims>>();
14
16
 
15
- attach(sessionID: string, client: TClient, dims: Dims): void {
17
+ attach(sessionID: SessionID, client: TClient, dims: Dims): void {
16
18
  const clients = this.bySession.get(sessionID) ?? new Map<TClient, Dims>();
17
19
 
18
20
  clients.set(client, dims);
19
21
  this.bySession.set(sessionID, clients);
20
22
  }
21
23
 
22
- detach(sessionID: string, client: TClient): void {
24
+ detach(sessionID: SessionID, client: TClient): void {
23
25
  const clients = this.bySession.get(sessionID);
24
26
 
25
27
  clients?.delete(client);
@@ -29,8 +31,8 @@ export class AttachRegistry<TClient> {
29
31
  }
30
32
  }
31
33
 
32
- detachAll(client: TClient): string[] {
33
- const affected: string[] = [];
34
+ detachAll(client: TClient): SessionID[] {
35
+ const affected: SessionID[] = [];
34
36
 
35
37
  for (const [sessionID, clients] of this.bySession) {
36
38
  if (clients.delete(client)) {
@@ -45,15 +47,15 @@ export class AttachRegistry<TClient> {
45
47
  return affected;
46
48
  }
47
49
 
48
- removeSession(sessionID: string): void {
50
+ removeSession(sessionID: SessionID): void {
49
51
  this.bySession.delete(sessionID);
50
52
  }
51
53
 
52
- hasClient(sessionID: string, client: TClient): boolean {
54
+ hasClient(sessionID: SessionID, client: TClient): boolean {
53
55
  return this.bySession.get(sessionID)?.has(client) ?? false;
54
56
  }
55
57
 
56
- updateDims(sessionID: string, client: TClient, dims: Dims): boolean {
58
+ updateDims(sessionID: SessionID, client: TClient, dims: Dims): boolean {
57
59
  const clients = this.bySession.get(sessionID);
58
60
 
59
61
  if (clients === undefined || !clients.has(client)) {
@@ -65,11 +67,11 @@ export class AttachRegistry<TClient> {
65
67
  return true;
66
68
  }
67
69
 
68
- collectClients(sessionID: string): TClient[] {
70
+ collectClients(sessionID: SessionID): TClient[] {
69
71
  return [...(this.bySession.get(sessionID)?.keys() ?? [])];
70
72
  }
71
73
 
72
- findEffectiveDims(sessionID: string): Dims | null {
74
+ findEffectiveDims(sessionID: SessionID): Dims | null {
73
75
  const clients = this.bySession.get(sessionID);
74
76
 
75
77
  if (clients === undefined || clients.size === 0) {
@@ -0,0 +1,43 @@
1
+ import { PROTOCOL_V } from '../protocol/protocol';
2
+ import type { EventMsg } from '../protocol/protocol';
3
+ import type { SessionID } from '../shared/session-id';
4
+ import type { Session, SessionDescriptor, SessionEventKind, SessionManager } from './sessions';
5
+
6
+ /**
7
+ * Builds the wire event for a session-manager notification, or null when the
8
+ * notification carries no descriptor to send: `added` and `state` fold in
9
+ * the current descriptor and skip emitting if the session is gone by the
10
+ * time the descriptor is collected.
11
+ */
12
+ export function buildSessionEvent(
13
+ mgr: SessionManager,
14
+ kind: SessionEventKind,
15
+ s: Session,
16
+ ): EventMsg | null {
17
+ const builders: Record<SessionEventKind, () => EventMsg | null> = {
18
+ added: () => {
19
+ const session = findDescriptor(mgr, s.id);
20
+
21
+ return session === null ? null : { v: PROTOCOL_V, ev: 'session.added', session };
22
+ },
23
+ state: () => {
24
+ const session = findDescriptor(mgr, s.id);
25
+
26
+ return session === null ? null : { v: PROTOCOL_V, ev: 'session.state', session };
27
+ },
28
+ renamed: () => ({
29
+ v: PROTOCOL_V,
30
+ ev: 'session.renamed',
31
+ s: s.id,
32
+ name: s.name,
33
+ namedBy: s.namedBy,
34
+ }),
35
+ removed: () => ({ v: PROTOCOL_V, ev: 'session.removed', s: s.id }),
36
+ };
37
+
38
+ return builders[kind]();
39
+ }
40
+
41
+ function findDescriptor(mgr: SessionManager, id: SessionID): SessionDescriptor | null {
42
+ return mgr.collectDescriptors().find((x) => x.id === id) ?? null;
43
+ }
@@ -1,12 +1,21 @@
1
1
  import { basename } from 'node:path';
2
- import type { AgentAdapter, AgentKind } from './agent-adapter';
2
+ import type { AgentAdapter, AgentID, SpawnOptions } from '../agents/agent-adapter';
3
+ import { OutboundQueue } from '../protocol/outbound-queue';
4
+ import type { SocketWriter } from '../protocol/outbound-queue';
5
+ import { parseRequestParams } from '../protocol/parse-request-params';
6
+ import {
7
+ MAX_CHUNK,
8
+ MAX_LINE,
9
+ PROTOCOL_V,
10
+ decodeMessage,
11
+ encodeMessage,
12
+ } from '../protocol/protocol';
13
+ import type { ErrorCode, EventMsg, RequestMsg } from '../protocol/protocol';
14
+ import type { SessionID } from '../shared/session-id';
15
+ import type { FleetEntry } from '../store/fleet-entry';
3
16
  import type { Dims } from './attach-registry';
4
- import { OutboundQueue } from './outbound-queue';
5
- import type { SocketWriter } from './outbound-queue';
6
17
  import type { AnswerResult } from './permission-registry';
7
- import { MAX_CHUNK, MAX_LINE, PROTOCOL_V, decodeMessage, encodeMessage } from './protocol';
8
- import type { ErrorCode, EventMsg, RequestMsg } from './protocol';
9
- import type { FleetEntry, SessionDescriptor } from './sessions';
18
+ import type { SessionDescriptor } from './sessions';
10
19
 
11
20
  interface SpawnParams {
12
21
  readonly cwd: string;
@@ -14,56 +23,56 @@ interface SpawnParams {
14
23
  readonly prompt: string;
15
24
  readonly cols: number;
16
25
  readonly rows: number;
17
- readonly resume: boolean | string;
26
+ readonly resume: SpawnOptions['resume'];
18
27
  readonly namedBy: 'user' | 'auto';
19
- readonly agent: AgentKind;
28
+ readonly agent: AgentID;
20
29
  }
21
30
 
22
31
  export interface DaemonContext {
23
32
  readonly build: string;
24
33
  readonly collectSessions: () => SessionDescriptor[];
25
- readonly collectSpawnDirs: () => string[];
26
- readonly collectFleet: () => FleetEntry[];
27
- readonly loadLastUsedAgent: () => AgentKind;
28
- readonly findAdapter: (kind: AgentKind) => AgentAdapter | null;
34
+ readonly collectSpawnDirs: () => Promise<string[]>;
35
+ readonly collectFleet: () => Promise<FleetEntry[]>;
36
+ readonly loadLastUsedAgent: () => Promise<AgentID>;
37
+ readonly findAdapter: (id: AgentID) => AgentAdapter | null;
29
38
  readonly spawnSession: (p: SpawnParams) => SessionDescriptor;
30
- readonly killSession: (id: string) => boolean;
31
- readonly updateSession: (id: string, name?: string, pinned?: boolean) => boolean;
39
+ readonly killSession: (id: SessionID) => Promise<boolean>;
40
+ readonly updateSession: (id: SessionID, name?: string, pinned?: boolean) => boolean;
32
41
  readonly quitDaemon: () => void;
33
- readonly ackSession: (id: string) => boolean;
34
- readonly buildResumeCommand: (id: string) => string | null;
42
+ readonly ackSession: (id: SessionID) => boolean;
43
+ readonly buildResumeCommand: (id: SessionID) => string | null;
35
44
  readonly answerPermission: (request: string, decision: string) => AnswerResult;
36
- readonly restoreFleet: (cols: number, rows: number) => number;
45
+ readonly restoreFleet: (cols: number, rows: number) => Promise<number>;
37
46
  readonly attachSession: (
38
47
  client: OutputClient,
39
- sessionID: string,
48
+ sessionID: SessionID,
40
49
  dims: Dims,
41
50
  ) => 'ok' | 'missing' | 'dead';
42
- readonly detachSession: (client: OutputClient, sessionID: string) => void;
51
+ readonly detachSession: (client: OutputClient, sessionID: SessionID) => void;
43
52
  readonly detachClient: (client: OutputClient) => void;
44
53
  readonly writeSessionInput: (
45
- sessionID: string,
54
+ sessionID: SessionID,
46
55
  data: string,
47
56
  ) => 'busy' | 'ok' | 'missing' | 'dead';
48
57
  readonly ejectSession: (
49
- id: string,
58
+ id: SessionID,
50
59
  prompt: string,
51
60
  ) => 'ok' | 'missing' | 'unsupported' | 'no_transcript';
52
61
  readonly adoptSession: (
53
- id: string,
62
+ id: SessionID,
54
63
  cols: number,
55
64
  rows: number,
56
65
  ) => 'ok' | 'missing' | 'no_transcript';
57
- readonly resizeSession: (client: OutputClient, sessionID: string, dims: Dims) => boolean;
58
- readonly resyncClient: (sessionID: string, client: OutputClient) => Promise<void>;
66
+ readonly resizeSession: (client: OutputClient, sessionID: SessionID, dims: Dims) => boolean;
67
+ readonly resyncClient: (sessionID: SessionID, client: OutputClient) => Promise<void>;
59
68
  readonly queueBytes?: number;
60
- readonly getEffectiveDims: (sessionID: string) => Dims;
69
+ readonly getEffectiveDims: (sessionID: SessionID) => Dims;
61
70
  }
62
71
 
63
72
  // The slice of a connection the attach bookkeeping needs: identity plus the
64
73
  // ability to receive output events.
65
74
  export interface OutputClient {
66
- readonly sendOutput: (sessionID: string, event: EventMsg, byteLength: number) => void;
75
+ readonly sendOutput: (sessionID: SessionID, event: EventMsg, byteLength: number) => void;
67
76
  }
68
77
 
69
78
  interface PeerSocket extends SocketWriter {
@@ -81,7 +90,12 @@ export class DaemonConnection {
81
90
 
82
91
  private helloed = false;
83
92
 
84
- private readonly desynced = new Map<string, number>();
93
+ // The handshake answer is written before any other response on this
94
+ // connection, even though building it reads the store: a request that
95
+ // arrives while that read is in flight waits behind it.
96
+ private helloAnswered: Promise<void> = Promise.resolve();
97
+
98
+ private readonly desynced = new Map<SessionID, number>();
85
99
 
86
100
  constructor(peer: PeerSocket, ctx: DaemonContext) {
87
101
  this.peer = peer;
@@ -100,7 +114,7 @@ export class DaemonConnection {
100
114
  // this client and resynchronizes with a repaint once the queue drains. An
101
115
  // intermediate chunk is never dropped without that resync, because a byte
102
116
  // stream cut mid-escape corrupts the client's terminal state.
103
- sendOutput(sessionID: string, event: EventMsg, byteLength: number): void {
117
+ sendOutput(sessionID: SessionID, event: EventMsg, byteLength: number): void {
104
118
  if (!this.helloed) {
105
119
  return;
106
120
  }
@@ -187,12 +201,28 @@ export class DaemonConnection {
187
201
  return false;
188
202
  }
189
203
 
190
- this.applyRequest(req);
204
+ this.answerAsync(req.id, () => this.applyRequest(req));
191
205
 
192
206
  return true;
193
207
  }
194
208
 
195
- private applyRequest(req: RequestMsg): void {
209
+ // A store query that rejects must end one request, never the daemon: a
210
+ // floating promise here would take the whole process down with it.
211
+ private answerAsync(id: number, answered: () => Promise<void>): void {
212
+ void (async () => {
213
+ try {
214
+ await answered();
215
+ } catch (error) {
216
+ const reason = error instanceof Error ? error.message : String(error);
217
+
218
+ this.sendErr(id, 'internal', reason);
219
+ }
220
+ })();
221
+ }
222
+
223
+ private async applyRequest(req: RequestMsg): Promise<void> {
224
+ await this.helloAnswered;
225
+
196
226
  switch (req.m) {
197
227
  case 'daemon.ping': {
198
228
  this.sendOk(req.id, {});
@@ -205,12 +235,12 @@ export class DaemonConnection {
205
235
  return;
206
236
  }
207
237
  case 'dirs.list': {
208
- this.sendOk(req.id, { dirs: this.ctx.collectSpawnDirs() });
238
+ this.sendOk(req.id, { dirs: await this.ctx.collectSpawnDirs() });
209
239
 
210
240
  return;
211
241
  }
212
242
  case 'fleet.list': {
213
- this.sendOk(req.id, { fleet: this.ctx.collectFleet() });
243
+ this.sendOk(req.id, { fleet: await this.ctx.collectFleet() });
214
244
 
215
245
  return;
216
246
  }
@@ -226,11 +256,17 @@ export class DaemonConnection {
226
256
  return;
227
257
  }
228
258
  case 'session.update': {
229
- const sessionID = typeof req.p?.['session'] === 'string' ? req.p['session'] : '';
230
- const name = typeof req.p?.['name'] === 'string' ? req.p['name'] : undefined;
231
- const pinned = typeof req.p?.['pinned'] === 'boolean' ? req.p['pinned'] : undefined;
259
+ const parsed = parseRequestParams('session.update', req.p);
260
+
261
+ if (!parsed.ok) {
262
+ this.sendErr(req.id, 'bad_args', parsed.message);
263
+
264
+ return;
265
+ }
266
+
267
+ const sessionID = parsed.data.session;
232
268
 
233
- if (this.ctx.updateSession(sessionID, name, pinned)) {
269
+ if (this.ctx.updateSession(sessionID, parsed.data.name, parsed.data.pinned)) {
234
270
  this.sendOk(req.id, {});
235
271
  } else {
236
272
  this.sendErr(req.id, 'no_such_session', `no session '${sessionID}'`);
@@ -239,17 +275,25 @@ export class DaemonConnection {
239
275
  return;
240
276
  }
241
277
  case 'session.kill': {
242
- this.applySessionVerb(req, this.ctx.killSession);
278
+ await this.applySessionVerb(req, 'session.kill', this.ctx.killSession);
243
279
 
244
280
  return;
245
281
  }
246
282
  case 'session.ack': {
247
- this.applySessionVerb(req, this.ctx.ackSession);
283
+ await this.applySessionVerb(req, 'session.ack', this.ctx.ackSession);
248
284
 
249
285
  return;
250
286
  }
251
287
  case 'session.resumeCommand': {
252
- const id = typeof req.p?.['session'] === 'string' ? req.p['session'] : '';
288
+ const parsed = parseRequestParams('session.resumeCommand', req.p);
289
+
290
+ if (!parsed.ok) {
291
+ this.sendErr(req.id, 'bad_args', parsed.message);
292
+
293
+ return;
294
+ }
295
+
296
+ const id = parsed.data.session;
253
297
  const command = this.ctx.buildResumeCommand(id);
254
298
 
255
299
  if (command === null) {
@@ -261,14 +305,16 @@ export class DaemonConnection {
261
305
  return;
262
306
  }
263
307
  case 'session.eject': {
264
- const sessionID = typeof req.p?.['session'] === 'string' ? req.p['session'] : '';
308
+ const parsed = parseRequestParams('session.eject', req.p);
265
309
 
266
- const prompt =
267
- typeof req.p?.['prompt'] === 'string' && req.p['prompt'] !== ''
268
- ? req.p['prompt']
269
- : 'Continue the task autonomously. Verify your work as you go and stop when it is complete.';
310
+ if (!parsed.ok) {
311
+ this.sendErr(req.id, 'bad_args', parsed.message);
312
+
313
+ return;
314
+ }
270
315
 
271
- const result = this.ctx.ejectSession(sessionID, prompt);
316
+ const sessionID = parsed.data.session;
317
+ const result = this.ctx.ejectSession(sessionID, parsed.data.prompt);
272
318
 
273
319
  if (result === 'ok') {
274
320
  this.sendOk(req.id, {});
@@ -291,10 +337,16 @@ export class DaemonConnection {
291
337
  return;
292
338
  }
293
339
  case 'session.adopt': {
294
- const sessionID = typeof req.p?.['session'] === 'string' ? req.p['session'] : '';
295
- const cols = typeof req.p?.['cols'] === 'number' ? req.p['cols'] : 80;
296
- const rows = typeof req.p?.['rows'] === 'number' ? req.p['rows'] : 24;
297
- const adoptResult = this.ctx.adoptSession(sessionID, cols, rows);
340
+ const parsed = parseRequestParams('session.adopt', req.p);
341
+
342
+ if (!parsed.ok) {
343
+ this.sendErr(req.id, 'bad_args', parsed.message);
344
+
345
+ return;
346
+ }
347
+
348
+ const sessionID = parsed.data.session;
349
+ const adoptResult = this.ctx.adoptSession(sessionID, parsed.data.cols, parsed.data.rows);
298
350
 
299
351
  if (adoptResult === 'ok') {
300
352
  this.sendOk(req.id, {});
@@ -320,9 +372,15 @@ export class DaemonConnection {
320
372
  return;
321
373
  }
322
374
  case 'session.detach': {
323
- const sessionID = typeof req.p?.['session'] === 'string' ? req.p['session'] : '';
375
+ const parsed = parseRequestParams('session.detach', req.p);
324
376
 
325
- this.ctx.detachSession(this, sessionID);
377
+ if (!parsed.ok) {
378
+ this.sendErr(req.id, 'bad_args', parsed.message);
379
+
380
+ return;
381
+ }
382
+
383
+ this.ctx.detachSession(this, parsed.data.session);
326
384
  this.sendOk(req.id, {});
327
385
 
328
386
  return;
@@ -343,10 +401,17 @@ export class DaemonConnection {
343
401
  return;
344
402
  }
345
403
  case 'fleet.restore': {
346
- const cols = typeof req.p?.['cols'] === 'number' ? req.p['cols'] : 80;
347
- const rows = typeof req.p?.['rows'] === 'number' ? req.p['rows'] : 24;
404
+ const parsed = parseRequestParams('fleet.restore', req.p);
405
+
406
+ if (!parsed.ok) {
407
+ this.sendErr(req.id, 'bad_args', parsed.message);
348
408
 
349
- this.sendOk(req.id, { restored: this.ctx.restoreFleet(cols, rows) });
409
+ return;
410
+ }
411
+
412
+ this.sendOk(req.id, {
413
+ restored: await this.ctx.restoreFleet(parsed.data.cols, parsed.data.rows),
414
+ });
350
415
 
351
416
  return;
352
417
  }
@@ -357,36 +422,17 @@ export class DaemonConnection {
357
422
  }
358
423
 
359
424
  private applySpawn(req: RequestMsg): void {
360
- const cwd = req.p?.['cwd'];
425
+ const parsed = parseRequestParams('session.spawn', req.p);
361
426
 
362
- if (typeof cwd !== 'string' || cwd === '') {
363
- this.sendErr(req.id, 'bad_args', 'session.spawn requires a cwd');
427
+ if (!parsed.ok) {
428
+ this.sendErr(req.id, 'bad_args', parsed.message);
364
429
 
365
430
  return;
366
431
  }
367
432
 
368
- const name = typeof req.p?.['name'] === 'string' ? req.p['name'] : '';
369
- const rawResume = req.p?.['resume'];
370
- let resume: boolean | string = false;
371
-
372
- if (typeof rawResume === 'boolean' || typeof rawResume === 'string') {
373
- resume = rawResume;
374
- }
375
-
376
- const rawAgent = req.p?.['agent'];
377
-
378
- if (
379
- rawAgent !== undefined &&
380
- rawAgent !== 'claude' &&
381
- rawAgent !== 'grok' &&
382
- rawAgent !== 'codex'
383
- ) {
384
- this.sendErr(req.id, 'bad_args', "session.spawn agent must be 'claude', 'grok', or 'codex'");
385
-
386
- return;
387
- }
388
-
389
- const agent: AgentKind = rawAgent === 'grok' || rawAgent === 'codex' ? rawAgent : 'claude';
433
+ const cwd = parsed.data.cwd;
434
+ const name = parsed.data.name;
435
+ const agent: AgentID = parsed.data.agent ?? 'claude';
390
436
 
391
437
  if (this.ctx.findAdapter(agent) === null) {
392
438
  this.sendErr(req.id, 'unsupported', `no adapter for agent '${agent}'`);
@@ -397,10 +443,10 @@ export class DaemonConnection {
397
443
  const session = this.ctx.spawnSession({
398
444
  cwd,
399
445
  name: name === '' ? basename(cwd) : name,
400
- prompt: typeof req.p?.['prompt'] === 'string' ? req.p['prompt'] : '',
401
- cols: typeof req.p?.['cols'] === 'number' ? req.p['cols'] : 80,
402
- rows: typeof req.p?.['rows'] === 'number' ? req.p['rows'] : 24,
403
- resume,
446
+ prompt: parsed.data.prompt,
447
+ cols: parsed.data.cols,
448
+ rows: parsed.data.rows,
449
+ resume: parsed.data.resume,
404
450
  namedBy: name === '' ? 'auto' : 'user',
405
451
  agent,
406
452
  });
@@ -409,10 +455,20 @@ export class DaemonConnection {
409
455
  }
410
456
 
411
457
  private applyAttach(req: RequestMsg): void {
412
- const sessionID = typeof req.p?.['session'] === 'string' ? req.p['session'] : '';
413
- const cols = typeof req.p?.['cols'] === 'number' ? req.p['cols'] : 80;
414
- const rows = typeof req.p?.['rows'] === 'number' ? req.p['rows'] : 24;
415
- const result = this.ctx.attachSession(this, sessionID, { cols, rows });
458
+ const parsed = parseRequestParams('session.attach', req.p);
459
+
460
+ if (!parsed.ok) {
461
+ this.sendErr(req.id, 'bad_args', parsed.message);
462
+
463
+ return;
464
+ }
465
+
466
+ const sessionID = parsed.data.session;
467
+
468
+ const result = this.ctx.attachSession(this, sessionID, {
469
+ cols: parsed.data.cols,
470
+ rows: parsed.data.rows,
471
+ });
416
472
 
417
473
  if (result === 'missing') {
418
474
  this.sendErr(req.id, 'no_such_session', `no session '${sessionID}'`);
@@ -432,9 +488,16 @@ export class DaemonConnection {
432
488
  }
433
489
 
434
490
  private applyInput(req: RequestMsg): void {
435
- const sessionID = typeof req.p?.['session'] === 'string' ? req.p['session'] : '';
436
- const data = typeof req.p?.['d'] === 'string' ? req.p['d'] : '';
437
- const result = this.ctx.writeSessionInput(sessionID, data);
491
+ const parsed = parseRequestParams('session.input', req.p);
492
+
493
+ if (!parsed.ok) {
494
+ this.sendErr(req.id, 'bad_args', parsed.message);
495
+
496
+ return;
497
+ }
498
+
499
+ const sessionID = parsed.data.session;
500
+ const result = this.ctx.writeSessionInput(sessionID, parsed.data.d);
438
501
 
439
502
  if (result === 'missing') {
440
503
  this.sendErr(req.id, 'no_such_session', `no session '${sessionID}'`);
@@ -462,17 +525,19 @@ export class DaemonConnection {
462
525
  }
463
526
 
464
527
  private applyResize(req: RequestMsg): void {
465
- const sessionID = typeof req.p?.['session'] === 'string' ? req.p['session'] : '';
466
- const cols = typeof req.p?.['cols'] === 'number' ? req.p['cols'] : 0;
467
- const rows = typeof req.p?.['rows'] === 'number' ? req.p['rows'] : 0;
528
+ const parsed = parseRequestParams('session.resize', req.p);
468
529
 
469
- if (cols < 1 || rows < 1) {
470
- this.sendErr(req.id, 'bad_args', 'session.resize requires positive cols and rows');
530
+ if (!parsed.ok) {
531
+ this.sendErr(req.id, 'bad_args', parsed.message);
471
532
 
472
533
  return;
473
534
  }
474
535
 
475
- if (!this.ctx.resizeSession(this, sessionID, { cols, rows })) {
536
+ const sessionID = parsed.data.session;
537
+
538
+ if (
539
+ !this.ctx.resizeSession(this, sessionID, { cols: parsed.data.cols, rows: parsed.data.rows })
540
+ ) {
476
541
  this.sendErr(req.id, 'bad_args', `not attached to session '${sessionID}'`);
477
542
 
478
543
  return;
@@ -482,15 +547,16 @@ export class DaemonConnection {
482
547
  }
483
548
 
484
549
  private applyPermissionRespond(req: RequestMsg): void {
485
- const request = typeof req.p?.['request'] === 'string' ? req.p['request'] : '';
486
- const decision = typeof req.p?.['decision'] === 'string' ? req.p['decision'] : '';
550
+ const parsed = parseRequestParams('permission.respond', req.p);
487
551
 
488
- if (request === '' || decision === '') {
489
- this.sendErr(req.id, 'bad_args', 'permission.respond requires a request and a decision');
552
+ if (!parsed.ok) {
553
+ this.sendErr(req.id, 'bad_args', parsed.message);
490
554
 
491
555
  return;
492
556
  }
493
557
 
558
+ const request = parsed.data.request;
559
+ const decision = parsed.data.decision;
494
560
  const result = this.ctx.answerPermission(request, decision);
495
561
 
496
562
  switch (result) {
@@ -515,10 +581,24 @@ export class DaemonConnection {
515
581
  }
516
582
  }
517
583
 
518
- private applySessionVerb(req: RequestMsg, verb: (id: string) => boolean): void {
519
- const id = typeof req.p?.['session'] === 'string' ? req.p['session'] : '';
584
+ private async applySessionVerb(
585
+ req: RequestMsg,
586
+ method: 'session.kill' | 'session.ack',
587
+ verb: (id: SessionID) => boolean | Promise<boolean>,
588
+ ): Promise<void> {
589
+ const parsed = parseRequestParams(method, req.p);
590
+
591
+ if (!parsed.ok) {
592
+ this.sendErr(req.id, 'bad_args', parsed.message);
593
+
594
+ return;
595
+ }
596
+
597
+ const id = parsed.data.session;
520
598
 
521
- if (verb(id)) {
599
+ const ok = await verb(id);
600
+
601
+ if (ok) {
522
602
  this.sendOk(req.id, {});
523
603
  } else {
524
604
  this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
@@ -526,7 +606,8 @@ export class DaemonConnection {
526
606
  }
527
607
 
528
608
  private applyHello(req: RequestMsg): boolean {
529
- const client = typeof req.p?.['client'] === 'string' ? req.p['client'] : 'unknown client';
609
+ const parsedHello = parseRequestParams('daemon.hello', req.p);
610
+ const client = parsedHello.ok ? parsedHello.data.client : 'unknown client';
530
611
 
531
612
  if (req.v !== PROTOCOL_V) {
532
613
  this.sendErr(
@@ -539,14 +620,19 @@ export class DaemonConnection {
539
620
  }
540
621
 
541
622
  this.helloed = true;
623
+ this.helloAnswered = this.sendHelloOk(req.id);
624
+
625
+ this.answerAsync(req.id, () => this.helloAnswered);
542
626
 
543
- this.sendOk(req.id, {
627
+ return true;
628
+ }
629
+
630
+ private async sendHelloOk(id: number): Promise<void> {
631
+ this.sendOk(id, {
544
632
  daemon: this.ctx.build,
545
633
  limits: { maxLine: MAX_LINE, maxChunk: MAX_CHUNK },
546
- lastUsedAgent: this.ctx.loadLastUsedAgent(),
634
+ lastUsedAgent: await this.ctx.loadLastUsedAgent(),
547
635
  });
548
-
549
- return true;
550
636
  }
551
637
 
552
638
  private sendOk(id: number, ok: Readonly<Record<string, unknown>>): void {