@zgeoff/atc 2.14.0 → 2.15.1

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 (71) hide show
  1. package/package.json +2 -1
  2. package/src/agents/agent-adapter.ts +3 -38
  3. package/src/agents/build-hook-settings.ts +1 -1
  4. package/src/agents/claude-adapter.ts +3 -3
  5. package/src/agents/codex-adapter.ts +3 -3
  6. package/src/agents/gateway-adapter.ts +3 -3
  7. package/src/agents/grok-adapter.ts +3 -3
  8. package/src/agents/start-claude-headless-run.ts +1 -1
  9. package/src/client/boot-daemon.ts +2 -2
  10. package/src/client/collect-agent-picks.ts +1 -1
  11. package/src/client/daemon-client.ts +7 -23
  12. package/src/client/format-overlay-agent-mark.ts +1 -1
  13. package/src/client/index.ts +4 -2
  14. package/src/client/pick-tab-target.ts +2 -2
  15. package/src/client/spawn-picker.ts +1 -1
  16. package/src/client/to-mirror-session.ts +3 -3
  17. package/src/client/ui.ts +3 -3
  18. package/src/daemon/build-imp-provider.ts +70 -23
  19. package/src/daemon/build-message-trail-entry.ts +1 -1
  20. package/src/daemon/build-report-trail-entry.ts +1 -1
  21. package/src/daemon/build-scoped-context.ts +1 -1
  22. package/src/daemon/build-session-message-event.ts +1 -1
  23. package/src/daemon/daemon-connection.ts +9 -253
  24. package/src/daemon/daemon-context.ts +240 -0
  25. package/src/daemon/daemon.ts +9 -5
  26. package/src/daemon/execution-provider.ts +12 -0
  27. package/src/daemon/hooks.ts +6 -24
  28. package/src/daemon/idempotency-ledger.ts +78 -18
  29. package/src/daemon/imp-client-port.ts +83 -27
  30. package/src/daemon/imp-harness.ts +41 -0
  31. package/src/daemon/imp-provider.ts +5 -11
  32. package/src/daemon/local-pty-provider.ts +45 -0
  33. package/src/daemon/parse-hook-line.ts +1 -1
  34. package/src/daemon/read-imp-token.ts +30 -0
  35. package/src/daemon/sessions.ts +134 -107
  36. package/src/daemon/start-headless-turn.ts +3 -3
  37. package/src/daemon/start-session-bridge.ts +2 -2
  38. package/src/mcp/collect-clients.ts +1 -1
  39. package/src/mcp/collect-grants.ts +1 -1
  40. package/src/mcp/find-client-name.ts +1 -1
  41. package/src/mcp/mcp-auth-schema.ts +37 -0
  42. package/src/mcp/open-mcp-auth.ts +2 -2
  43. package/src/mcp/reconnecting-caller.ts +15 -6
  44. package/src/mcp/remove-client.ts +1 -1
  45. package/src/mcp/revoke-grant.ts +1 -1
  46. package/src/mcp/types.ts +0 -38
  47. package/src/mcp-http-server.ts +2 -1
  48. package/src/mcp-server.ts +3 -14
  49. package/src/protocol/adapter-event.ts +25 -0
  50. package/src/protocol/count-session-states.ts +16 -0
  51. package/src/protocol/daemon-channel.ts +17 -0
  52. package/src/protocol/hook-event.ts +11 -0
  53. package/src/protocol/line-decoder.ts +34 -0
  54. package/src/{shared → protocol}/open-bridge-socket.ts +4 -7
  55. package/src/protocol/pinned-group-key.ts +2 -0
  56. package/src/protocol/session-state.ts +5 -0
  57. package/src/protocol/sort-grouped-session-views.ts +29 -0
  58. package/src/protocol/sort-session-views.ts +41 -0
  59. package/src/protocol/sortable-session-view.ts +13 -0
  60. package/src/report.ts +1 -1
  61. package/src/run-bridge-tap.ts +2 -2
  62. package/src/shared/agent-id.ts +6 -0
  63. package/src/shared/collect-gateways.ts +1 -1
  64. package/src/shared/to-agent-id.ts +11 -0
  65. package/src/statusline.ts +1 -1
  66. package/src/store/fleet-entry.ts +6 -3
  67. package/src/store/state-store.ts +80 -19
  68. /package/src/{shared → protocol}/send-bridge-request.ts +0 -0
  69. /package/src/{store → shared}/bun-sqlite-driver.ts +0 -0
  70. /package/src/{agents → shared}/truncate-detail.ts +0 -0
  71. /package/src/{daemon → shared}/truncate-summary.ts +0 -0
@@ -23,7 +23,9 @@ interface IdempotentCall<T> {
23
23
  // effect may still stand, so the claim is kept as outcome_unknown.
24
24
  readonly start: () => T | Promise<T>;
25
25
 
26
- // Resolves once the effect is durable; the claim completes only after.
26
+ // Resolves once the effect is durable; the claim completes only after. A
27
+ // settle that throws leaves the effect standing, so the answer is
28
+ // outcome_unknown and the claim stays held.
27
29
  readonly settle: () => Promise<void>;
28
30
 
29
31
  // The answer to a retry of a completed key.
@@ -43,19 +45,27 @@ interface IdempotentCall<T> {
43
45
  * answer; one with a different payload is `idempotency_conflict`; one whose
44
46
  * effect a stopped daemon may or may not have run is `outcome_unknown` with
45
47
  * the effect id in `data.effectRef`, and never starts the effect again.
48
+ * An effect that may still stand once its request fails is answered with
49
+ * `outcome_unknown` and its effect id even when recording that outcome
50
+ * fails: the claim stays held either way, and the failed write goes to the
51
+ * log.
46
52
  */
47
53
  export class IdempotencyLedger {
48
54
  private readonly store: StateStore;
49
55
 
50
56
  private readonly principal: string;
51
57
 
58
+ // Where a failed write to the ledger is reported, one line at a time.
59
+ private readonly log: (line: string) => void;
60
+
52
61
  // One promise chain per key, so two requests under the same key never
53
62
  // interleave their claim and completion on one daemon.
54
63
  private readonly locks = new Map<string, Promise<void>>();
55
64
 
56
- constructor(store: StateStore, principal: string) {
65
+ constructor(store: StateStore, principal: string, log: (line: string) => void) {
57
66
  this.store = store;
58
67
  this.principal = principal;
68
+ this.log = log;
59
69
  }
60
70
 
61
71
  async run<T extends Readonly<Record<string, unknown>>>(call: IdempotentCall<T>): Promise<T> {
@@ -114,13 +124,11 @@ export class IdempotencyLedger {
114
124
  result = await call.start();
115
125
  } catch (error) {
116
126
  if (error instanceof EffectRemainsError) {
117
- await this.store.updateIdempotencyOutcomeUnknown(id, Date.now());
127
+ this.logFailure(call, 'start', error);
128
+
129
+ await this.tryRecordOutcomeUnknown(call, id);
118
130
 
119
- throw new DaemonError(
120
- 'outcome_unknown',
121
- `the ${call.operation} under idempotency key '${call.keyed.key}' failed and its effect may still stand; check ${call.effectRef} before retrying under a new key`,
122
- { effectRef: call.effectRef },
123
- );
131
+ throw buildOutcomeUnknownError(call, 'failed');
124
132
  }
125
133
 
126
134
  await this.store.removeIdempotencyKey(id);
@@ -128,20 +136,61 @@ export class IdempotencyLedger {
128
136
  throw error;
129
137
  }
130
138
 
131
- // A settle that fails leaves the claim in progress: the effect started,
132
- // so the next daemon start marks its outcome unknown rather than letting
133
- // a retry run it again.
134
- await call.settle();
139
+ // From here the effect stands. A settle or completion that fails keeps
140
+ // the claim in progress, so a retry answers outcome_unknown rather than
141
+ // running the effect again.
142
+ try {
143
+ await call.settle();
144
+ } catch (error) {
145
+ this.logFailure(call, 'settle', error);
146
+
147
+ await this.tryRecordOutcomeUnknown(call, id);
135
148
 
136
- await this.store.updateIdempotencyCompleted(
137
- id,
138
- JSON.stringify(result),
139
- Date.now(),
140
- call.findEffectTarget?.(result) ?? null,
141
- );
149
+ throw buildOutcomeUnknownError(call, 'could not be made durable');
150
+ }
151
+
152
+ try {
153
+ await this.store.updateIdempotencyCompleted(
154
+ id,
155
+ JSON.stringify(result),
156
+ Date.now(),
157
+ call.findEffectTarget?.(result) ?? null,
158
+ );
159
+ } catch (error) {
160
+ this.logFailure(call, 'completion write', error);
161
+ throw buildOutcomeUnknownError(call, 'could not be recorded as completed');
162
+ }
142
163
 
143
164
  return result;
144
165
  }
166
+
167
+ // A claim this write fails to update stays in progress, which a retry
168
+ // answers the same way, so the failure is logged and not thrown.
169
+ private async tryRecordOutcomeUnknown(
170
+ call: Pick<IdempotentCall<unknown>, 'operation' | 'keyed' | 'effectRef'>,
171
+ id: Pick<IdempotencyRecord, 'principal' | 'operation' | 'key'>,
172
+ ): Promise<void> {
173
+ try {
174
+ await this.store.updateIdempotencyOutcomeUnknown(id, Date.now());
175
+ } catch (error) {
176
+ this.logFailure(call, 'outcome_unknown write', error);
177
+ }
178
+ }
179
+
180
+ // The line holds the operation, key, effect id, the step that failed, and
181
+ // the error, never the request's payload or the effect's result.
182
+ private logFailure(
183
+ call: Pick<IdempotentCall<unknown>, 'operation' | 'keyed' | 'effectRef'>,
184
+ step: string,
185
+ error: unknown,
186
+ ): void {
187
+ const cause =
188
+ error instanceof Error && error.cause instanceof Error ? ` (${String(error.cause)})` : '';
189
+
190
+ this.log(
191
+ `atc ${call.operation} under idempotency key '${call.keyed.key}' (effect ${call.effectRef}): ${step} failed: ${String(error)}${cause}`,
192
+ );
193
+ }
145
194
  }
146
195
 
147
196
  function answerHeldKey<T>(call: IdempotentCall<T>, held: IdempotencyRecord): T | Promise<T> {
@@ -162,3 +211,14 @@ function answerHeldKey<T>(call: IdempotentCall<T>, held: IdempotencyRecord): T |
162
211
  { effectRef: held.effectRef },
163
212
  );
164
213
  }
214
+
215
+ function buildOutcomeUnknownError(
216
+ call: Pick<IdempotentCall<unknown>, 'operation' | 'keyed' | 'effectRef'>,
217
+ what: string,
218
+ ): DaemonError {
219
+ return new DaemonError(
220
+ 'outcome_unknown',
221
+ `the ${call.operation} under idempotency key '${call.keyed.key}' ${what} and its effect may still stand; check ${call.effectRef} before retrying under a new key`,
222
+ { effectRef: call.effectRef },
223
+ );
224
+ }
@@ -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.
@@ -99,6 +99,15 @@ export class ImpHarness implements HarnessHandle {
99
99
 
100
100
  private killPending = false;
101
101
 
102
+ // Whether the process exited, once the harness stops being followed, and
103
+ // the waits on that answer until then.
104
+ private exitConfirmed: boolean | null = null;
105
+
106
+ private readonly exitWaiters = new Set<{
107
+ readonly waited: PromiseWithResolvers<boolean>;
108
+ readonly timer: ReturnType<typeof setTimeout>;
109
+ }>();
110
+
102
111
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
103
112
 
104
113
  // The terminal size the open connection's request asked for.
@@ -175,6 +184,28 @@ export class ImpHarness implements HarnessHandle {
175
184
  }
176
185
  };
177
186
 
187
+ // Only an exit impd reports counts: a host that lost the process or went
188
+ // to sleep with it inside confirms nothing.
189
+ readonly waitForExit = (timeoutMs: number): Promise<boolean> => {
190
+ if (this.exitConfirmed !== null) {
191
+ return Promise.resolve(this.exitConfirmed);
192
+ }
193
+
194
+ const waited = Promise.withResolvers<boolean>();
195
+
196
+ const waiter = {
197
+ waited,
198
+ timer: setTimeout(() => {
199
+ this.exitWaiters.delete(waiter);
200
+ waited.resolve(false);
201
+ }, timeoutMs),
202
+ };
203
+
204
+ this.exitWaiters.add(waiter);
205
+
206
+ return waited.promise;
207
+ };
208
+
178
209
  readonly detach = (): void => {
179
210
  if (this.done) {
180
211
  return;
@@ -521,6 +552,8 @@ export class ImpHarness implements HarnessHandle {
521
552
  private emitExit(exit: HarnessExit): void {
522
553
  const listeners = [...this.exitListeners];
523
554
 
555
+ this.exitConfirmed = exit.reason === undefined || exit.reason === 'exited';
556
+
524
557
  this.stopFollowing();
525
558
 
526
559
  for (const listener of listeners) {
@@ -537,7 +570,15 @@ export class ImpHarness implements HarnessHandle {
537
570
 
538
571
  this.done = true;
539
572
  this.connection = null;
573
+ this.exitConfirmed ??= false;
574
+
575
+ for (const waiter of this.exitWaiters) {
576
+ clearTimeout(waiter.timer);
577
+
578
+ waiter.waited.resolve(this.exitConfirmed);
579
+ }
540
580
 
581
+ this.exitWaiters.clear();
541
582
  this.dataListeners.clear();
542
583
  this.exitListeners.clear();
543
584
  this.attachmentListeners.clear();
@@ -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
  });
@@ -74,6 +74,35 @@ export class LocalPTYProvider implements ExecutionProvider {
74
74
  kill: () => {
75
75
  pty.kill();
76
76
  },
77
+
78
+ // A process already gone has nothing left to end.
79
+ killForced: () => {
80
+ try {
81
+ process.kill(pty.pid, 'SIGKILL');
82
+ } catch (error) {
83
+ if (!isMissingProcessError(error)) {
84
+ throw error;
85
+ }
86
+ }
87
+ },
88
+
89
+ // bun-pty's kill sends one SIGHUP and reports an exit at once, whether
90
+ // the process ended or not, so the exit is read from the process id
91
+ // instead: the library reaps its child, so the id stops answering a
92
+ // signal once the process is gone.
93
+ waitForExit: async (timeoutMs) => {
94
+ const deadline = Date.now() + timeoutMs;
95
+
96
+ while (isProcessRunning(pty.pid)) {
97
+ if (Date.now() >= deadline) {
98
+ return false;
99
+ }
100
+
101
+ await Bun.sleep(20);
102
+ }
103
+
104
+ return true;
105
+ },
77
106
  detach: () => {
78
107
  for (const subscription of subscriptions) {
79
108
  subscription.dispose();
@@ -140,3 +169,19 @@ export class LocalPTYProvider implements ExecutionProvider {
140
169
  return { exitCode, stdout, stderr };
141
170
  }
142
171
  }
172
+
173
+ // A process another user owns still runs, so only a missing process counts
174
+ // as gone.
175
+ function isProcessRunning(pid: number): boolean {
176
+ try {
177
+ process.kill(pid, 0);
178
+
179
+ return true;
180
+ } catch (error) {
181
+ return !isMissingProcessError(error);
182
+ }
183
+ }
184
+
185
+ function isMissingProcessError(error: unknown): boolean {
186
+ return error instanceof Error && 'code' in error && error.code === 'ESRCH';
187
+ }
@@ -1,6 +1,6 @@
1
+ import type { HookEvent } from '../protocol/hook-event';
1
2
  import { isRecord } from '../shared/report';
2
3
  import { toSessionID } from '../shared/to-session-id';
3
- import type { HookEvent } from './hooks';
4
4
 
5
5
  /**
6
6
  * One reporter line as a hook event, or null for a line that is not one.
@@ -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
+ }