@zgeoff/atc 2.14.0 → 2.15.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "2.14.0",
3
+ "version": "2.15.0",
4
4
  "description": "Terminal control tower for coding-agent sessions",
5
5
  "homepage": "https://github.com/zgeoff/atc#readme",
6
6
  "bugs": "https://github.com/zgeoff/atc/issues",
@@ -1,4 +1,5 @@
1
1
  import { DaemonError } from '../protocol/daemon-error';
2
+ import { LineDecoder } from '../protocol/line-decoder';
2
3
  import { OutboundQueue } from '../protocol/outbound-queue';
3
4
  import { PROTOCOL_V, decodeMessage, encodeMessage } from '../protocol/protocol';
4
5
  import type { EventMsg, ResponseMsg } from '../protocol/protocol';
@@ -20,9 +21,7 @@ export class DaemonClient {
20
21
 
21
22
  private queue: OutboundQueue | null = null;
22
23
 
23
- private buffer = '';
24
-
25
- private readonly decoder = new TextDecoder();
24
+ private readonly lines = new LineDecoder();
26
25
 
27
26
  private nextID = 1;
28
27
 
@@ -41,7 +40,7 @@ export class DaemonClient {
41
40
  unix: socketPath,
42
41
  socket: {
43
42
  data(_s, buf) {
44
- client.applyChunk(client.decodeChunk(buf));
43
+ client.applyChunk(buf);
45
44
  },
46
45
  drain() {
47
46
  client.queue?.drain();
@@ -99,25 +98,9 @@ export class DaemonClient {
99
98
  this.drainPending('client closed');
100
99
  }
101
100
 
102
- // Decodes with state kept across reads, so a multi-byte character split
103
- // between two reads decodes whole.
104
101
  // oxlint-disable-next-line prefer-readonly-parameter-types -- a socket read buffer has no readonly form
105
- private decodeChunk(buf: Uint8Array): string {
106
- return this.decoder.decode(buf, { stream: true });
107
- }
108
-
109
- private applyChunk(chunk: string): void {
110
- this.buffer += chunk;
111
-
112
- const lines = this.buffer.split('\n');
113
-
114
- this.buffer = lines.pop() ?? '';
115
-
116
- for (const line of lines) {
117
- if (line.trim() === '') {
118
- continue;
119
- }
120
-
102
+ private applyChunk(buf: Uint8Array): void {
103
+ for (const line of this.lines.splitChunk(buf)) {
121
104
  this.applyLine(line);
122
105
  }
123
106
  }
@@ -1,9 +1,10 @@
1
1
  import { ImpClientPort } from './imp-client-port';
2
2
  import { ImpProvider } from './imp-provider';
3
+ import { readImpToken } from './read-imp-token';
3
4
 
4
5
  /**
5
6
  * 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
+ * A problem never includes a token or any other environment value.
7
8
  */
8
9
  export interface ImpProviderBuild {
9
10
  readonly provider: ImpProvider | null;
@@ -12,13 +13,16 @@ export interface ImpProviderBuild {
12
13
 
13
14
  /**
14
15
  * 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.
16
+ * listens, and at most one of `tokenEnv` and `tokenFile` gives the token,
17
+ * so no credential sits in the config. `tokenEnv` holds the name of the
18
+ * daemon's environment variable that carries the token, read once here.
19
+ * `tokenFile` holds the path of a file that carries it, read here and again
20
+ * before each impd call and connection. `image`, `memoryMib`, `guestDir`,
21
+ * and `guestATC` pass through. No provider when `url` is missing, so the
22
+ * target lists and refuses every spawn. Both token options, either one not
23
+ * a non-empty string, an unset or empty variable, or an empty or unreadable
24
+ * file is a problem and leaves no provider; a target without either calls
25
+ * impd with no token.
22
26
  */
23
27
  export function buildImpProvider(
24
28
  id: string,
@@ -26,29 +30,18 @@ export function buildImpProvider(
26
30
  env: Readonly<Record<string, string | undefined>> = process.env,
27
31
  ): ImpProviderBuild {
28
32
  const url = options['url'];
29
- const tokenEnv = options['tokenEnv'];
30
33
 
31
34
  if (typeof url !== 'string' || url === '') {
32
35
  return { provider: null, problem: null };
33
36
  }
34
37
 
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];
38
+ const source = loadTokenSource(id, options, env);
43
39
 
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
- };
40
+ if (typeof source === 'string') {
41
+ return { provider: null, problem: source };
49
42
  }
50
43
 
51
- const provider = new ImpProvider(new ImpClientPort({ url, token }), {
44
+ const provider = new ImpProvider(new ImpClientPort({ url, readToken: source.readToken }), {
52
45
  ...pickString(options, 'image'),
53
46
  ...pickString(options, 'guestDir'),
54
47
  ...pickString(options, 'guestATC'),
@@ -58,6 +51,60 @@ export function buildImpProvider(
58
51
  return { provider, problem: null };
59
52
  }
60
53
 
54
+ // How the port reads the token: a function, so a file token rereads.
55
+ interface TokenSource {
56
+ readonly readToken: () => string | null;
57
+ }
58
+
59
+ // The token source the options give, or the problem that keeps the target
60
+ // from one. A problem holds a variable's name or a file's path, never a
61
+ // token.
62
+ function loadTokenSource(
63
+ id: string,
64
+ options: Readonly<Record<string, unknown>>,
65
+ env: Readonly<Record<string, string | undefined>>,
66
+ ): TokenSource | string {
67
+ const tokenEnv = options['tokenEnv'];
68
+ const tokenFile = options['tokenFile'];
69
+ const target = JSON.stringify(id);
70
+
71
+ if (tokenEnv !== undefined && tokenFile !== undefined) {
72
+ return `target ${target} must give its impd token through tokenEnv or tokenFile, not both`;
73
+ }
74
+
75
+ if (tokenFile !== undefined) {
76
+ if (typeof tokenFile !== 'string' || tokenFile === '') {
77
+ return `target ${target} must give tokenFile as a non-empty string`;
78
+ }
79
+
80
+ try {
81
+ readImpToken(tokenFile);
82
+ } catch (error) {
83
+ const detail = error instanceof Error ? error.message : String(error);
84
+
85
+ return `target ${target} reads its impd token from a file it cannot use: ${detail}`;
86
+ }
87
+
88
+ return { readToken: () => readImpToken(tokenFile) };
89
+ }
90
+
91
+ if (tokenEnv === undefined) {
92
+ return { readToken: () => null };
93
+ }
94
+
95
+ if (typeof tokenEnv !== 'string' || tokenEnv === '') {
96
+ return `target ${target} must give tokenEnv as a non-empty string`;
97
+ }
98
+
99
+ const token = env[tokenEnv];
100
+
101
+ if (token === undefined || token === '') {
102
+ return `target ${target} reads its impd token from ${tokenEnv}, which is unset or empty in the daemon's environment`;
103
+ }
104
+
105
+ return { readToken: () => token };
106
+ }
107
+
61
108
  function pickString(
62
109
  options: Readonly<Record<string, unknown>>,
63
110
  key: 'image' | 'guestDir' | 'guestATC',
@@ -4,6 +4,7 @@ import { DaemonError } from '../protocol/daemon-error';
4
4
  import { DAEMON_FEATURES } from '../protocol/daemon-features';
5
5
  import { decodeCursor } from '../protocol/decode-cursor';
6
6
  import { encodeCursor } from '../protocol/encode-cursor';
7
+ import { LineDecoder } from '../protocol/line-decoder';
7
8
  import { OutboundQueue } from '../protocol/outbound-queue';
8
9
  import type { SocketWriter } from '../protocol/outbound-queue';
9
10
  import { parseRequestParams } from '../protocol/parse-request-params';
@@ -278,9 +279,7 @@ export class DaemonConnection {
278
279
 
279
280
  private readonly queue: OutboundQueue;
280
281
 
281
- private buffer = '';
282
-
283
- private readonly decoder = new TextDecoder();
282
+ private readonly lines = new LineDecoder();
284
283
 
285
284
  private helloed = false;
286
285
 
@@ -352,28 +351,18 @@ export class DaemonConnection {
352
351
  // between two reads decodes whole.
353
352
  // oxlint-disable-next-line prefer-readonly-parameter-types -- a socket read buffer has no readonly form
354
353
  decodeChunk(buf: Uint8Array): string {
355
- return this.decoder.decode(buf, { stream: true });
354
+ return this.lines.decodeText(buf);
356
355
  }
357
356
 
358
357
  applyChunk(chunk: string): void {
359
- const buffered = this.buffer + chunk;
360
-
361
- if (buffered.length > MAX_LINE) {
358
+ if (this.lines.pendingLength + chunk.length > MAX_LINE) {
362
359
  this.sendErr(0, 'bad_args', `line exceeds ${MAX_LINE} bytes`);
363
360
  this.peer.end();
364
361
 
365
362
  return;
366
363
  }
367
364
 
368
- const lines = buffered.split('\n');
369
-
370
- this.buffer = lines.pop() ?? '';
371
-
372
- for (const line of lines) {
373
- if (line.trim() === '') {
374
- continue;
375
- }
376
-
365
+ for (const line of this.lines.splitText(chunk)) {
377
366
  if (!this.applyLine(line)) {
378
367
  this.peer.end();
379
368
 
@@ -1,4 +1,5 @@
1
1
  import { unlinkSync } from 'node:fs';
2
+ import { LineDecoder } from '../protocol/line-decoder';
2
3
  import { socketPath } from '../shared/config';
3
4
  import type { SessionID } from '../shared/session-id';
4
5
  import { parseHookLine } from './parse-hook-line';
@@ -9,12 +10,9 @@ export interface HookEvent {
9
10
  payload: Record<string, unknown>;
10
11
  }
11
12
 
12
- // Per-connection read state: the unterminated tail of the line in progress,
13
- // and a streaming decoder so a multi-byte character split across two reads
14
- // decodes whole.
13
+ // Per-connection read state: the connection's own line framing.
15
14
  interface HookConnection {
16
- pending: string;
17
- readonly decoder: TextDecoder;
15
+ readonly lines: LineDecoder;
18
16
  }
19
17
 
20
18
  export function startHookServer(onEvent: (e: HookEvent) => void, path: string = socketPath) {
@@ -26,17 +24,7 @@ export function startHookServer(onEvent: (e: HookEvent) => void, path: string =
26
24
  unix: path,
27
25
  socket: {
28
26
  data(socket, buf) {
29
- const conn = socket.data;
30
- const buffered = conn.pending + conn.decoder.decode(buf, { stream: true });
31
- const lines = buffered.split('\n');
32
-
33
- conn.pending = lines.pop() ?? '';
34
-
35
- for (const line of lines) {
36
- if (line.trim() === '') {
37
- continue;
38
- }
39
-
27
+ for (const line of socket.data.lines.splitChunk(buf)) {
40
28
  const event = parseHookLine(line);
41
29
 
42
30
  if (event !== null) {
@@ -45,7 +33,7 @@ export function startHookServer(onEvent: (e: HookEvent) => void, path: string =
45
33
  }
46
34
  },
47
35
  open(socket) {
48
- socket.data = { pending: '', decoder: new TextDecoder() };
36
+ socket.data = { lines: new LineDecoder() };
49
37
  },
50
38
  error() {},
51
39
  },
@@ -18,12 +18,15 @@ import type {
18
18
  import { ImpPortError } from './imp-port-error';
19
19
 
20
20
  /**
21
- * Where impd listens and the token the daemon calls it with. The token
22
- * comes from the daemon's environment, never from a config file.
21
+ * Where impd listens, and how the daemon reads the token it calls impd
22
+ * with: null for no token. The port reads the token again before each
23
+ * call and connection, so a token that changes at its source takes effect
24
+ * on the next one. A read that throws fails that call without reaching
25
+ * impd.
23
26
  */
24
27
  export interface ImpClientPortOptions {
25
28
  readonly url: string;
26
- readonly token: string | null;
29
+ readonly readToken: () => string | null;
27
30
  }
28
31
 
29
32
  /**
@@ -35,30 +38,26 @@ export interface ImpClientPortOptions {
35
38
  export class ImpClientPort implements ImpPort {
36
39
  private readonly url: string;
37
40
 
38
- private readonly token: string | null;
41
+ private readonly readToken: () => string | null;
39
42
 
40
- private readonly client: ImpClient;
43
+ // The client for the token read last, rebuilt when the token changes.
44
+ private client: Readonly<{ token: string | null; client: ImpClient }> | null = null;
41
45
 
42
46
  constructor(options: ImpClientPortOptions) {
43
47
  this.url = options.url;
44
- this.token = options.token;
45
-
46
- this.client = createImpClient({
47
- url: options.url,
48
- ...(options.token === null ? {} : { token: options.token }),
49
- });
48
+ this.readToken = options.readToken;
50
49
  }
51
50
 
52
51
  // An impd from before the flags has neither.
53
52
  readonly readFeatures = async (): Promise<ImpFeatures> => {
54
- const info = await this.tryCall(() => this.client.system.info());
53
+ const info = await this.tryCall((client) => client.system.info());
55
54
 
56
55
  return info.features ?? { sessionOffsets: false, leases: false };
57
56
  };
58
57
 
59
58
  readonly readImp = async (name: string): Promise<ImpView | null> => {
60
59
  try {
61
- const imp = await this.tryCall(() => this.client.imps.get({ name }));
60
+ const imp = await this.tryCall((client) => client.imps.get({ name }));
62
61
 
63
62
  return {
64
63
  name: imp.name,
@@ -76,8 +75,8 @@ export class ImpClientPort implements ImpPort {
76
75
  };
77
76
 
78
77
  readonly createImp = async (spec: ImpCreateSpec): Promise<ImpView> => {
79
- const imp = await this.tryCall(() =>
80
- this.client.imps.create({
78
+ const imp = await this.tryCall((client) =>
79
+ client.imps.create({
81
80
  name: spec.name,
82
81
  ...(spec.image === undefined ? {} : { image: spec.image }),
83
82
  ...(spec.memoryMib === undefined ? {} : { memoryMib: spec.memoryMib }),
@@ -92,7 +91,9 @@ export class ImpClientPort implements ImpPort {
92
91
  label: string,
93
92
  ttlSeconds: number,
94
93
  ): Promise<ImpLease> => {
95
- const lease = await this.tryCall(() => this.client.leases.acquire({ name, label, ttlSeconds }));
94
+ const lease = await this.tryCall((client) =>
95
+ client.leases.acquire({ name, label, ttlSeconds }),
96
+ );
96
97
 
97
98
  return toLease(lease);
98
99
  };
@@ -102,23 +103,23 @@ export class ImpClientPort implements ImpPort {
102
103
  label: string,
103
104
  ttlSeconds: number,
104
105
  ): Promise<ImpLease> => {
105
- const lease = await this.tryCall(() => this.client.leases.renew({ name, label, ttlSeconds }));
106
+ const lease = await this.tryCall((client) => client.leases.renew({ name, label, ttlSeconds }));
106
107
 
107
108
  return toLease(lease);
108
109
  };
109
110
 
110
111
  readonly releaseLease = async (name: string, label: string): Promise<boolean> => {
111
- const result = await this.tryCall(() => this.client.leases.release({ name, label }));
112
+ const result = await this.tryCall((client) => client.leases.release({ name, label }));
112
113
 
113
114
  return result.released;
114
115
  };
115
116
 
116
117
  readonly suspendImp = async (name: string): Promise<void> => {
117
- await this.tryCall(() => this.client.imps.sleep({ name }));
118
+ await this.tryCall((client) => client.imps.sleep({ name }));
118
119
  };
119
120
 
120
121
  readonly destroyImp = async (name: string): Promise<void> => {
121
- await this.tryCall(() => this.client.imps.destroy({ name }));
122
+ await this.tryCall((client) => client.imps.destroy({ name }));
122
123
  };
123
124
 
124
125
  // The exec WebSocket answers only after it opens, so nothing reaches the
@@ -127,9 +128,23 @@ export class ImpClientPort implements ImpPort {
127
128
  request: ImpSessionRequest,
128
129
  handlers: ImpSessionHandlers,
129
130
  ): ImpSessionConnection => {
131
+ const token = this.tryReadToken();
132
+
133
+ // A token that cannot be read ends the connection the way impd's
134
+ // refusal of a token does, without dialing impd.
135
+ if (token instanceof ImpPortError) {
136
+ return {
137
+ outcome: Promise.resolve({ kind: 'unauthorized' }),
138
+ write: () => {},
139
+ resize: () => {},
140
+ sendSignal: () => {},
141
+ close: () => {},
142
+ };
143
+ }
144
+
130
145
  const session = openExecSession({
131
146
  baseUrl: this.url,
132
- token: this.token,
147
+ token,
133
148
  start:
134
149
  request.kind === 'start'
135
150
  ? {
@@ -179,8 +194,8 @@ export class ImpClientPort implements ImpPort {
179
194
 
180
195
  // oxlint-disable-next-line prefer-readonly-parameter-types -- the command's input bytes have no readonly form
181
196
  readonly runCommand = async (name: string, command: ImpCommand): Promise<ImpCommandResult> => {
182
- const result = await this.tryCall(() =>
183
- this.client.run(name, command.argv, {
197
+ const result = await this.tryCall((client) =>
198
+ client.run(name, command.argv, {
184
199
  ...(command.cwd === undefined ? {} : { cwd: command.cwd }),
185
200
  ...(command.stdin === undefined ? {} : { stdin: command.stdin }),
186
201
  }),
@@ -194,12 +209,30 @@ export class ImpClientPort implements ImpPort {
194
209
  guestPath: string,
195
210
  onConnection: (connection: ImpRelayConnection) => void,
196
211
  ): ImpReverseForward => {
212
+ const token = this.tryReadToken();
213
+
214
+ if (token instanceof ImpPortError) {
215
+ return { listening: Promise.reject(token), stop: () => {} };
216
+ }
217
+
197
218
  const forward = openReverseForward({
198
219
  baseUrl: this.url,
199
- token: this.token,
220
+ token,
200
221
  name,
201
222
  guest: { network: 'unix', path: guestPath },
202
- connect: (url, headers) => new WebSocket(url, { headers }),
223
+
224
+ // The client reuses the headers of the listening socket for every
225
+ // guest connection relay, so each socket reads the token afresh. A
226
+ // token that cannot be read dials without one, which impd refuses.
227
+ connect: (url, headers) => {
228
+ const { authorization: _, ...rest } = headers;
229
+ const current = this.tryReadToken();
230
+
231
+ return new WebSocket(url, {
232
+ headers:
233
+ typeof current === 'string' ? { ...rest, authorization: `Bearer ${current}` } : rest,
234
+ });
235
+ },
203
236
  onConnection: (accept) => {
204
237
  onConnection(openRelay(accept));
205
238
  },
@@ -215,13 +248,36 @@ export class ImpClientPort implements ImpPort {
215
248
 
216
249
  // Runs one client call, turning impd's refusals and transport failures
217
250
  // into port errors.
218
- private async tryCall<T>(call: () => Promise<T>): Promise<T> {
251
+ private async tryCall<T>(call: (client: ImpClient) => Promise<T>): Promise<T> {
219
252
  try {
220
- return await call();
253
+ return await call(this.loadClient());
221
254
  } catch (error) {
222
255
  throw toPortError(error);
223
256
  }
224
257
  }
258
+
259
+ // The token as it reads now, or the port error its read throws.
260
+ private tryReadToken(): string | null | ImpPortError {
261
+ try {
262
+ return this.readToken();
263
+ } catch (error) {
264
+ return toPortError(error);
265
+ }
266
+ }
267
+
268
+ // The client that calls impd with the token as it reads now.
269
+ private loadClient(): ImpClient {
270
+ const token = this.readToken();
271
+
272
+ if (this.client === null || this.client.token !== token) {
273
+ this.client = {
274
+ token,
275
+ client: createImpClient({ url: this.url, ...(token === null ? {} : { token }) }),
276
+ };
277
+ }
278
+
279
+ return this.client.client;
280
+ }
225
281
  }
226
282
 
227
283
  // A lease as the client returns it.
@@ -1,4 +1,5 @@
1
1
  import { DaemonError } from '../protocol/daemon-error';
2
+ import { LineDecoder } from '../protocol/line-decoder';
2
3
  import { isCompiledBinary } from '../shared/is-compiled-binary';
3
4
  import { buildTarArchive } from './build-tar-archive';
4
5
  import type {
@@ -523,22 +524,15 @@ function buildSocketName(sessionID: string): string {
523
524
 
524
525
  // Frames a guest connection's bytes into lines, both ways.
525
526
  function toHarnessRelay(connection: ImpRelayConnection): HarnessRelay {
526
- const decoder = new TextDecoder();
527
+ const decoder = new LineDecoder();
527
528
  const encoder = new TextEncoder();
528
529
 
529
530
  const lineListeners: ((line: string) => void)[] = [];
530
- let pending = '';
531
531
 
532
532
  connection.onData((data) => {
533
- const lines = `${pending}${decoder.decode(data, { stream: true })}`.split('\n');
534
-
535
- pending = lines.pop() ?? '';
536
-
537
- for (const line of lines) {
538
- if (line.trim() !== '') {
539
- for (const listener of lineListeners) {
540
- listener(line);
541
- }
533
+ for (const line of decoder.splitChunk(data)) {
534
+ for (const listener of lineListeners) {
535
+ listener(line);
542
536
  }
543
537
  }
544
538
  });
@@ -0,0 +1,30 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { ImpPortError } from './imp-port-error';
3
+
4
+ /**
5
+ * The impd token held in the file at `path`, without one trailing newline.
6
+ * An empty or unreadable file throws an `UNAUTHORIZED` port error whose
7
+ * message holds the path and the read's error code, never file content.
8
+ */
9
+ export function readImpToken(path: string): string {
10
+ let text: string;
11
+
12
+ try {
13
+ text = readFileSync(path, 'utf8');
14
+ } catch (error) {
15
+ const code =
16
+ error instanceof Error && 'code' in error && typeof error.code === 'string'
17
+ ? error.code
18
+ : 'unknown error';
19
+
20
+ throw new ImpPortError('UNAUTHORIZED', `cannot read the impd token file ${path}: ${code}`);
21
+ }
22
+
23
+ const token = text.endsWith('\n') ? text.slice(0, -1) : text;
24
+
25
+ if (token === '') {
26
+ throw new ImpPortError('UNAUTHORIZED', `the impd token file ${path} is empty`);
27
+ }
28
+
29
+ return token;
30
+ }
@@ -232,6 +232,10 @@ export class SessionManager {
232
232
  // of the same session does not start a second harness.
233
233
  private readonly adopting = new Set<SessionID>();
234
234
 
235
+ // Sessions dropped from the list on purpose whose rows the next fleet
236
+ // write deletes; each stays here until a write carrying it lands.
237
+ private readonly removedIDs = new Set<SessionID>();
238
+
235
239
  // The epoch the next harness start or attach takes, unique across every
236
240
  // session this manager holds.
237
241
  private nextBridgeEpoch = 1;
@@ -1350,6 +1354,8 @@ export class SessionManager {
1350
1354
  private remove(s: Session) {
1351
1355
  this.sessions = this.sessions.filter((x) => x.id !== s.id);
1352
1356
 
1357
+ this.removedIDs.add(s.id);
1358
+
1353
1359
  if (this.focusedId === s.id) {
1354
1360
  this.focusedId = null;
1355
1361
  }
@@ -1383,8 +1389,11 @@ export class SessionManager {
1383
1389
  }
1384
1390
 
1385
1391
  // Persisted continuously so a crash (or quit) leaves a restorable fleet.
1386
- // Deliberate kills rewrite the file; unexpected session/atc deaths do not,
1387
- // so the last known fleet survives for `R` restore. Sessions whose
1392
+ // A write covers the listed sessions and the ones dropped on purpose
1393
+ // since the last write; a stored row the daemon has not restored yet
1394
+ // stays as it is, so a write before `R` keeps it restorable. Deliberate
1395
+ // kills rewrite the fleet; unexpected session/atc deaths do not, so the
1396
+ // last known fleet survives for `R` restore. Sessions whose
1388
1397
  // terminal is gone persist as exited entries, so the killed archive
1389
1398
  // survives a daemon restart too. A session the agent has not yet given
1390
1399
  // its own session id persists as well, under its atc session id alone.
@@ -1416,7 +1425,13 @@ export class SessionManager {
1416
1425
  });
1417
1426
  }
1418
1427
 
1419
- await this.store.writeFleet(fleet);
1428
+ const removed = [...this.removedIDs];
1429
+
1430
+ await this.store.writeFleet(fleet, removed);
1431
+
1432
+ for (const id of removed) {
1433
+ this.removedIDs.delete(id);
1434
+ }
1420
1435
  }
1421
1436
 
1422
1437
  // A write nobody awaits that fails leaves the stored rows as they were,
package/src/mcp-server.ts CHANGED
@@ -2,6 +2,7 @@ import { bootDaemonClient } from './client/boot-daemon';
2
2
  import { answerRPCRequest } from './mcp/answer-rpc-request';
3
3
  import { requireDaemonFeatures } from './mcp/require-daemon-features';
4
4
  import type { FleetCaller, ToolContext } from './mcp/types';
5
+ import { LineDecoder } from './protocol/line-decoder';
5
6
 
6
7
  /**
7
8
  * An MCP server over stdio bridging to the atc daemon: any MCP client —
@@ -34,26 +35,14 @@ export async function runMCPServer(build: string): Promise<void> {
34
35
  sender: { kind: 'default', name: callerSessionID ?? 'mcp' },
35
36
  };
36
37
 
37
- const decoder = new TextDecoder('utf-8');
38
-
39
- let buffer = '';
38
+ const decoder = new LineDecoder();
40
39
 
41
40
  // Each request runs on its own, so a long poll never holds up the others;
42
41
  // responses carry their request's id.
43
42
  const inFlight = new Set<Promise<void>>();
44
43
 
45
44
  for await (const chunk of Bun.stdin.stream()) {
46
- buffer += decoder.decode(chunk, { stream: true });
47
-
48
- const lines = buffer.split('\n');
49
-
50
- buffer = lines.pop() ?? '';
51
-
52
- for (const line of lines) {
53
- if (line.trim() === '') {
54
- continue;
55
- }
56
-
45
+ for (const line of decoder.splitChunk(chunk)) {
57
46
  const finished = Promise.withResolvers<void>();
58
47
 
59
48
  inFlight.add(finished.promise);
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Splits one stream of newline-framed text into whole lines. Bytes decode
3
+ * as UTF-8 with state kept across reads, so a multi-byte character split
4
+ * between two reads decodes whole. The unterminated tail waits for the read
5
+ * that ends it, and a line holding only whitespace is dropped. A carriage
6
+ * return before the newline stays on the line.
7
+ */
8
+ export class LineDecoder {
9
+ private readonly decoder = new TextDecoder();
10
+
11
+ private pending = '';
12
+
13
+ get pendingLength(): number {
14
+ return this.pending.length;
15
+ }
16
+
17
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- a socket read buffer has no readonly form
18
+ splitChunk(bytes: Uint8Array): string[] {
19
+ return this.splitText(this.decodeText(bytes));
20
+ }
21
+
22
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- a socket read buffer has no readonly form
23
+ decodeText(bytes: Uint8Array): string {
24
+ return this.decoder.decode(bytes, { stream: true });
25
+ }
26
+
27
+ splitText(text: string): string[] {
28
+ const lines = `${this.pending}${text}`.split('\n');
29
+
30
+ this.pending = lines.pop() ?? '';
31
+
32
+ return lines.filter((line) => line.trim() !== '');
33
+ }
34
+ }
@@ -1,3 +1,5 @@
1
+ import { LineDecoder } from './line-decoder';
2
+
1
3
  /**
2
4
  * One line-framed connection to a session bridge, from inside a remote
3
5
  * session: each line the bridge writes arrives whole, and each line written
@@ -20,10 +22,9 @@ export async function openBridgeSocket(
20
22
  onLine: (line: Readonly<Record<string, unknown>>) => void,
21
23
  ): Promise<BridgeSocket> {
22
24
  const encoder = new TextEncoder();
23
- const decoder = new TextDecoder();
25
+ const decoder = new LineDecoder();
24
26
 
25
27
  const closed = Promise.withResolvers<void>();
26
- let pending = '';
27
28
 
28
29
  let unsent = new Uint8Array(0);
29
30
 
@@ -31,11 +32,7 @@ export async function openBridgeSocket(
31
32
  unix: path,
32
33
  socket: {
33
34
  data(_socket, buf) {
34
- const lines = `${pending}${decoder.decode(buf, { stream: true })}`.split('\n');
35
-
36
- pending = lines.pop() ?? '';
37
-
38
- for (const line of lines) {
35
+ for (const line of decoder.splitChunk(buf)) {
39
36
  const parsed = parseLine(line);
40
37
 
41
38
  if (parsed !== null) {
package/src/report.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
+ import { sendBridgeRequest } from './protocol/send-bridge-request';
4
5
  import { sendReport } from './shared/report';
5
6
  import { REPORT_KINDS } from './shared/report-kinds';
6
- import { sendBridgeRequest } from './shared/send-bridge-request';
7
7
 
8
8
  interface ReportOptions {
9
9
  readonly message: string;
@@ -1,8 +1,8 @@
1
1
  import { readFileSync, readdirSync, rmSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { z } from 'zod';
4
- import { openBridgeSocket } from './shared/open-bridge-socket';
5
- import type { BridgeSocket } from './shared/open-bridge-socket';
4
+ import { openBridgeSocket } from './protocol/open-bridge-socket';
5
+ import type { BridgeSocket } from './protocol/open-bridge-socket';
6
6
 
7
7
  const INBOX_MESSAGE_SCHEMA = z.looseObject({
8
8
  ev: z.literal('InboxMessage'),
package/src/statusline.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
+ import { sendBridgeRequest } from './protocol/send-bridge-request';
4
5
  import { statusFile } from './shared/config';
5
6
  import { isRecord, sendReport } from './shared/report';
6
- import { sendBridgeRequest } from './shared/send-bridge-request';
7
7
 
8
8
  /**
9
9
  * Runs as the statusLine command injected into wrangled sessions. Chains the
@@ -72,7 +72,10 @@ export interface FleetEntry {
72
72
  export interface FleetStore {
73
73
  readonly daemonID: DaemonID;
74
74
  readonly loadFleet: () => Promise<FleetEntry[]>;
75
- readonly writeFleet: (entries: readonly FleetEntry[]) => Promise<void>;
75
+ readonly writeFleet: (
76
+ entries: readonly FleetEntry[],
77
+ removed?: readonly SessionID[],
78
+ ) => Promise<void>;
76
79
  readonly updateFleetEntry: (sessionID: SessionID, fields: FleetEntryUpdate) => Promise<void>;
77
80
  }
78
81
 
@@ -241,12 +241,22 @@ export class StateStore {
241
241
  return recency;
242
242
  }
243
243
 
244
- // Replaces this daemon's rows wholesale and leaves every other daemon's
245
- // rows as they stand. A session another daemon owns, or one whose stored
246
- // ownership epoch has moved past this daemon's, rejects the whole write
247
- // with stale_epoch.
248
- async writeFleet(entries: readonly FleetEntry[]): Promise<void> {
244
+ // Rewrites this daemon's rows for the given entries and drops its rows
245
+ // for the removed sessions, plus any row of its own that an entry
246
+ // replaces by holding the same agent session id. Every other row stays as
247
+ // it stands, so a row the daemon never loaded survives a write. A session
248
+ // another daemon owns, or one whose stored ownership epoch has moved past
249
+ // this daemon's, rejects the whole write with stale_epoch.
250
+ async writeFleet(
251
+ entries: readonly FleetEntry[],
252
+ removed: readonly SessionID[] = [],
253
+ ): Promise<void> {
249
254
  const kept = buildFleetWithoutReplacedRows(entries);
255
+ const sessionIDs = [...entries.map((entry) => entry.sessionID), ...removed];
256
+
257
+ const agentSessionIDs = entries.flatMap((entry) =>
258
+ entry.agentSessionID === undefined ? [] : [entry.agentSessionID],
259
+ );
250
260
 
251
261
  await this.db.transaction().execute(async (trx) => {
252
262
  await requireCurrentOwnership(
@@ -255,17 +265,35 @@ export class StateStore {
255
265
  entries.map((entry) => entry.sessionID),
256
266
  );
257
267
 
258
- await trx
259
- .deleteFrom('fleet')
260
- .where('session_id', 'in', (eb) =>
261
- eb
262
- .selectFrom('session_owner')
263
- .select('session_id')
264
- .where('daemon_id', '=', this.daemonID),
265
- )
266
- .execute();
268
+ const replaced =
269
+ agentSessionIDs.length === 0
270
+ ? []
271
+ : await trx
272
+ .selectFrom('fleet')
273
+ .select(['session_id', 'agent_session_id'])
274
+ .where('agent_session_id', 'in', agentSessionIDs)
275
+ .execute();
267
276
 
268
- await trx.deleteFrom('session_owner').where('daemon_id', '=', this.daemonID).execute();
277
+ const dropped = [...new Set([...sessionIDs, ...replaced.map((row) => row.session_id)])];
278
+
279
+ if (dropped.length > 0) {
280
+ await trx
281
+ .deleteFrom('fleet')
282
+ .where('session_id', 'in', dropped)
283
+ .where('session_id', 'in', (eb) =>
284
+ eb
285
+ .selectFrom('session_owner')
286
+ .select('session_id')
287
+ .where('daemon_id', '=', this.daemonID),
288
+ )
289
+ .execute();
290
+
291
+ await trx
292
+ .deleteFrom('session_owner')
293
+ .where('daemon_id', '=', this.daemonID)
294
+ .where('session_id', 'in', dropped)
295
+ .execute();
296
+ }
269
297
 
270
298
  const updatedAt = Date.now();
271
299
 
@@ -304,6 +332,38 @@ export class StateStore {
304
332
  })
305
333
  .execute();
306
334
  }
335
+
336
+ // A stored row this write leaves in place follows its replaced parent
337
+ // to the session that replaced it, or to that session's own parent
338
+ // when the replacement is itself a sub-session.
339
+ const survivors = new Map(
340
+ kept.flatMap((entry) =>
341
+ entry.agentSessionID === undefined ? [] : [[entry.agentSessionID, entry] as const],
342
+ ),
343
+ );
344
+
345
+ for (const row of replaced) {
346
+ const survivor =
347
+ row.agent_session_id === null
348
+ ? undefined
349
+ : survivors.get(toAgentSessionID(row.agent_session_id));
350
+
351
+ if (survivor === undefined || survivor.sessionID === row.session_id) {
352
+ continue;
353
+ }
354
+
355
+ await trx
356
+ .updateTable('fleet')
357
+ .set({ parent_session_id: survivor.parent ?? survivor.sessionID })
358
+ .where('parent_session_id', '=', row.session_id)
359
+ .where('session_id', 'in', (eb) =>
360
+ eb
361
+ .selectFrom('session_owner')
362
+ .select('session_id')
363
+ .where('daemon_id', '=', this.daemonID),
364
+ )
365
+ .execute();
366
+ }
307
367
  });
308
368
  }
309
369
 
File without changes