@fitchmultz/pi-subagents 0.52.20 → 0.52.21

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 (44) hide show
  1. package/CHANGELOG.md +30 -2
  2. package/dist/agent/contracts.js +12 -3
  3. package/dist/agent/index.js +9 -9
  4. package/dist/agent/legacy.js +5 -5
  5. package/dist/agent/notifications.js +21 -14
  6. package/dist/agent/parent-check.js +38 -0
  7. package/dist/agent/process.js +45 -52
  8. package/dist/agent/runner.js +59 -64
  9. package/dist/agent/storage.js +16 -4
  10. package/dist/agent/view.js +1 -3
  11. package/dist/build-info.js +1 -1
  12. package/dist/pi-intercom/broker/broker.js +4 -26
  13. package/dist/pi-intercom/broker/client-park.d.ts +1 -1
  14. package/dist/pi-intercom/broker/client-park.js +22 -6
  15. package/dist/pi-intercom/broker/client.d.ts +3 -2
  16. package/dist/pi-intercom/broker/client.js +88 -62
  17. package/dist/pi-intercom/broker/connection.d.ts +3 -5
  18. package/dist/pi-intercom/broker/connection.js +48 -33
  19. package/dist/pi-intercom/broker/framing.d.ts +1 -1
  20. package/dist/pi-intercom/broker/framing.js +19 -12
  21. package/dist/pi-intercom/broker/replace-deliveries.js +38 -0
  22. package/dist/pi-intercom/connection.js +5 -8
  23. package/dist/pi-intercom/inbound-journal.js +5 -2
  24. package/dist/shared/journal-frames.js +19 -24
  25. package/docs/development.md +6 -0
  26. package/package.json +1 -1
  27. package/src/agent/contracts.ts +12 -3
  28. package/src/agent/index.ts +10 -10
  29. package/src/agent/legacy.ts +5 -5
  30. package/src/agent/notifications.ts +35 -15
  31. package/src/agent/parent-check.ts +40 -0
  32. package/src/agent/process.ts +53 -55
  33. package/src/agent/runner.ts +65 -65
  34. package/src/agent/storage.ts +18 -5
  35. package/src/agent/view.ts +1 -4
  36. package/src/pi-intercom/broker/broker.ts +5 -37
  37. package/src/pi-intercom/broker/client-park.ts +27 -6
  38. package/src/pi-intercom/broker/client.ts +91 -61
  39. package/src/pi-intercom/broker/connection.ts +58 -39
  40. package/src/pi-intercom/broker/framing.ts +24 -12
  41. package/src/pi-intercom/broker/replace-deliveries.ts +56 -0
  42. package/src/pi-intercom/connection.ts +9 -9
  43. package/src/pi-intercom/inbound-journal.ts +5 -2
  44. package/src/shared/journal-frames.ts +22 -23
@@ -12,7 +12,6 @@ export class IntercomConnection {
12
12
  reconnectTimer = null;
13
13
  startupTimer = null;
14
14
  connecting = null;
15
- connectingGeneration = null;
16
15
  attempt = 0;
17
16
  lifecycle;
18
17
  config;
@@ -294,8 +293,8 @@ export class IntercomConnection {
294
293
  if (!ctx || generation !== this.lifecycle.generation) {
295
294
  throw new Error("Intercom shutting down");
296
295
  }
297
- if (this.connecting && this.connectingGeneration === generation) {
298
- return this.connecting;
296
+ if (this.connecting?.generation === generation) {
297
+ return this.connecting.task;
299
298
  }
300
299
  if (this.client?.isConnected() === true) {
301
300
  return this.client;
@@ -307,8 +306,7 @@ export class IntercomConnection {
307
306
  this.clearReconnect();
308
307
  // Publish ownership before registration, which can synchronously invoke subscribers.
309
308
  const attempt = Promise.resolve().then(() => this.connect(ctx, generation, sessionId));
310
- this.connecting = attempt;
311
- this.connectingGeneration = generation;
309
+ this.connecting = { task: attempt, generation };
312
310
  return await this.finishAttempt(attempt, ctx, generation);
313
311
  }
314
312
  async finishAttempt(attempt, ctx, generation) {
@@ -321,9 +319,8 @@ export class IntercomConnection {
321
319
  throw error;
322
320
  }
323
321
  finally {
324
- if (this.connecting === attempt) {
322
+ if (this.connecting?.task === attempt) {
325
323
  this.connecting = null;
326
- this.connectingGeneration = null;
327
324
  }
328
325
  if (retry) {
329
326
  this.scheduleReconnect();
@@ -334,7 +331,7 @@ export class IntercomConnection {
334
331
  this.clearTimers();
335
332
  this.cancelWakes();
336
333
  // The bounded launcher attempt is joined before socket shutdown.
337
- await Promise.allSettled([this.connecting]);
334
+ await Promise.allSettled([this.connecting?.task]);
338
335
  if (this.client) {
339
336
  await this.client.disconnect();
340
337
  this.client = null;
@@ -150,13 +150,16 @@ export class InboundJournal {
150
150
  if (pending.stage === "queued" &&
151
151
  pending.from.id === entry.from.id &&
152
152
  pending.message.threadId === entry.message.threadId) {
153
- this.consume(pending.message.id);
154
- this.replies.markReplied(pending.message.id);
153
+ this.supersede(pending.message.id);
155
154
  }
156
155
  }
157
156
  }
158
157
  this.pending.set(entry.message.id, entry);
159
158
  }
159
+ supersede(id) {
160
+ this.consume(id);
161
+ this.replies.markReplied(id);
162
+ }
160
163
  setStage(entry, stage) {
161
164
  if (entry.stage === stage) {
162
165
  return;
@@ -5,9 +5,7 @@ import { errorDescription, isRecord } from "./unknown.js";
5
5
  export class JournalFrames {
6
6
  projection;
7
7
  decoder = new TextDecoder("utf8", { fatal: true });
8
- error;
9
- failed = false;
10
- nonblank = false;
8
+ state = { kind: "blank" };
11
9
  start;
12
10
  offset;
13
11
  policy;
@@ -29,21 +27,21 @@ export class JournalFrames {
29
27
  return new JsonProjection(this.select, this.options.stringChunk);
30
28
  }
31
29
  part(bytes) {
32
- if (!this.nonblank && bytes.some((byte) => ![9, 10, 13, 32].includes(byte))) {
33
- this.nonblank = true;
30
+ if (this.state.kind === "invalid") {
31
+ return;
34
32
  }
35
- if (!this.failed) {
36
- try {
37
- this.projection.write(this.decoder.decode(bytes, { stream: true }));
38
- }
39
- catch (error) {
40
- this.failed = true;
41
- this.error = error;
42
- }
33
+ if (this.state.kind === "blank" && bytes.some((byte) => ![9, 10, 13, 32].includes(byte))) {
34
+ this.state = { kind: "reading" };
35
+ }
36
+ try {
37
+ this.projection.write(this.decoder.decode(bytes, { stream: true }));
38
+ }
39
+ catch (error) {
40
+ this.state = { kind: "invalid", error };
43
41
  }
44
42
  }
45
43
  finishRecord(end) {
46
- if (!this.nonblank || this.failed) {
44
+ if (this.state.kind !== "reading") {
47
45
  return undefined;
48
46
  }
49
47
  try {
@@ -55,17 +53,16 @@ export class JournalFrames {
55
53
  return { value, start: this.start, end };
56
54
  }
57
55
  catch (error) {
58
- this.failed = true;
59
- this.error = error;
56
+ this.state = { kind: "invalid", error };
60
57
  return undefined;
61
58
  }
62
59
  }
63
60
  commit(end) {
64
61
  const start = this.start;
65
62
  const record = this.finishRecord(end);
66
- const failed = this.failed;
67
- const error = this.error;
68
- if (failed && this.policy !== "inspect") {
63
+ const state = this.state;
64
+ if (state.kind === "invalid" && this.policy !== "inspect") {
65
+ const { error } = state;
69
66
  throw new SyntaxError(`Invalid JSONL record at byte ${start}: ${errorDescription(error)}`, {
70
67
  cause: error,
71
68
  });
@@ -73,10 +70,8 @@ export class JournalFrames {
73
70
  this.start = end;
74
71
  this.projection = this.newProjection();
75
72
  this.decoder = new TextDecoder("utf8", { fatal: true });
76
- this.error = undefined;
77
- this.failed = false;
78
- this.nonblank = false;
79
- if (!failed && record) {
73
+ this.state = { kind: "blank" };
74
+ if (record) {
80
75
  this.record(record);
81
76
  }
82
77
  }
@@ -93,7 +88,7 @@ export class JournalFrames {
93
88
  }
94
89
  finish() {
95
90
  if (this.requireNewline) {
96
- if (this.policy === "strict" && this.nonblank) {
91
+ if (this.policy === "strict" && this.state.kind !== "blank") {
97
92
  throw new SyntaxError(`Unpublished JSONL record at byte ${this.start}: newline required`);
98
93
  }
99
94
  }
@@ -86,6 +86,12 @@ npm and attached to the matching GitHub release.
86
86
 
87
87
  ## Isolation and safety
88
88
 
89
+ Actor command admission keeps execution separate from native session activity.
90
+ Execution is either idle or running with one launch and task. A stopping actor
91
+ admits no new Run work. Run, Steer and Stop payloads are action-specific;
92
+ guidance always carries a prompt and new Stops always name the actor token.
93
+ Historical unbound Stops remain readable and receive stale admission receipts.
94
+
89
95
  Parent attachment keeps one binding through detached, waiting, attaching and
90
96
  attached phases. The existing shutdown signal closes the owner. Managed
91
97
  readiness can attach only its own still-current waiting binding; shutdown joins
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fitchmultz/pi-subagents",
3
- "version": "0.52.20",
3
+ "version": "0.52.21",
4
4
  "description": "Pi extension for subagent orchestration and local session coordination",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -110,14 +110,23 @@ export type AccountingState =
110
110
  readonly error: string;
111
111
  readonly terminalEntryId: string | undefined;
112
112
  };
113
- export const commandSchema = Type.Object({
113
+ export const commandMetadataSchema = Type.Object({
114
114
  id: Type.String(),
115
115
  runId: Type.String(),
116
116
  action: Type.Union([Type.Literal("run"), Type.Literal("steer"), Type.Literal("stop")]),
117
- prompt: Type.Optional(Type.String()),
118
- actorToken: Type.Optional(Type.String()),
119
117
  createdAt: Type.Number(),
120
118
  });
119
+ const commandFields = Type.Omit(commandMetadataSchema, ["action"]).properties;
120
+ export const commandSchema = Type.Union([
121
+ Type.Object({ ...commandFields, action: Type.Literal("run") }),
122
+ Type.Object({ ...commandFields, action: Type.Literal("steer"), prompt: Type.String() }),
123
+ Type.Object({
124
+ ...commandFields,
125
+ action: Type.Literal("stop"),
126
+ // Historical unbound Stops parse but cannot target any current actor.
127
+ actorToken: Type.Optional(Type.String()),
128
+ }),
129
+ ]);
121
130
  export type Command = Static<typeof commandSchema>;
122
131
  export const successorSchema = Type.Object({ id: Type.String() });
123
132
  /** Authority to restore exact working state, valid only after the named actor has exited. */
@@ -8,8 +8,8 @@ import type {
8
8
  ToolDefinition,
9
9
  } from "@earendil-works/pi-coding-agent";
10
10
  import {
11
- actor,
12
- alive,
11
+ inspectActorClaim,
12
+ possiblyLive,
13
13
  ensureActor,
14
14
  ownIdentity,
15
15
  parkFailure,
@@ -324,7 +324,7 @@ class AgentRuntime {
324
324
  executionCwd: executionCwd(this.pi, context),
325
325
  });
326
326
  this.checkMain();
327
- enqueue(this.base, launch, "run");
327
+ enqueue(this.base, launch, { action: "run" });
328
328
  await ensureActor(this.base, launch, this.shutdown.signal);
329
329
  return renderReceipt(this.base, launch, this.ctx, {
330
330
  receipt: "launch",
@@ -352,14 +352,14 @@ class AgentRuntime {
352
352
  return;
353
353
  }
354
354
  private async retryContinuation(launch: Launch, input: RunsInput) {
355
- const identity = actor(this.base, launch);
355
+ const claim = inspectActorClaim(this.base, launch);
356
356
  const command = read(
357
357
  path.join(conversation(this.base, launch), "commands", `${launch.id}.json`),
358
358
  commandSchema,
359
359
  );
360
360
  if (
361
361
  input.prompt !== launch.prompt ||
362
- (identity && alive(identity)) ||
362
+ possiblyLive(claim) ||
363
363
  command?.id !== launch.id ||
364
364
  command.runId !== launch.id ||
365
365
  command.action !== "run"
@@ -383,9 +383,9 @@ class AgentRuntime {
383
383
  if (completed === "pending") {
384
384
  return this.retryContinuation(launch, input);
385
385
  }
386
- const identity = actor(this.base, launch);
387
- if (!completed && identity && alive(identity)) {
388
- const command = enqueue(this.base, launch, "steer", { prompt: input.prompt });
386
+ const claim = completed ? undefined : inspectActorClaim(this.base, launch);
387
+ if (possiblyLive(claim)) {
388
+ const command = enqueue(this.base, launch, { action: "steer", prompt: input.prompt });
389
389
  return renderReceipt(this.base, launch, this.ctx, {
390
390
  receipt: "guidance",
391
391
  command,
@@ -400,7 +400,7 @@ class AgentRuntime {
400
400
  });
401
401
  }
402
402
  // Retain successor claims on ambiguous after-launch failures: no replay.
403
- enqueue(this.base, next, "run");
403
+ enqueue(this.base, next, { action: "run" });
404
404
  await ensureActor(this.base, next, this.shutdown.signal);
405
405
  return renderReceipt(this.base, next, this.ctx, { receipt: "continuation" });
406
406
  }
@@ -417,7 +417,7 @@ class AgentRuntime {
417
417
  });
418
418
  if (created) {
419
419
  // The durable adoption claim survives ambiguous after-launch failures.
420
- enqueue(this.base, launch, "run");
420
+ enqueue(this.base, launch, { action: "run" });
421
421
  await ensureActor(this.base, launch, this.shutdown.signal);
422
422
  }
423
423
  return renderReceipt(this.base, launch, this.ctx, {
@@ -7,7 +7,7 @@ import { journalStamp } from "../shared/native-journal.ts";
7
7
  import { receiptId } from "./receipts.ts";
8
8
  import { resolveTempRootDir } from "../shared/types.ts";
9
9
  import { isRecord } from "../shared/unknown.ts";
10
- import { release, isHeld, type Completion } from "./notifications.ts";
10
+ import { release, completionDelivery, type Completion } from "./notifications.ts";
11
11
  import { missing } from "./storage.ts";
12
12
 
13
13
  interface LegacyInput {
@@ -135,13 +135,13 @@ function published(ctx: ExtensionContext, input: LegacyInput): "file" | "memory"
135
135
  }
136
136
  function notification(ctx: ExtensionContext, input: LegacyInput): Completion | undefined {
137
137
  const identity = `legacy:${input.id}:${input.key}`;
138
- const where = published(ctx, input);
139
- if (where !== undefined) {
138
+ const state = completionDelivery(input.owner, identity, published(ctx, input));
139
+ if (state.kind === "published") {
140
140
  release(input.owner, identity);
141
141
  // Retire only an unchanged recovery input whose receipt is LF-published in the session file.
142
142
  // An in-memory receipt stops redelivery but never removes the saved input.
143
143
  if (
144
- where === "file" &&
144
+ state.where === "file" &&
145
145
  journalStamp(fs.lstatSync(input.file, { bigint: true })) === input.stamp &&
146
146
  fs.readFileSync(input.file, "utf8") === input.raw
147
147
  ) {
@@ -149,7 +149,7 @@ function notification(ctx: ExtensionContext, input: LegacyInput): Completion | u
149
149
  }
150
150
  return;
151
151
  }
152
- if (isHeld(input.owner, identity)) {
152
+ if (state.kind === "held") {
153
153
  return;
154
154
  }
155
155
  return {
@@ -28,19 +28,29 @@ function queuedAdmissions(): Set<string> {
28
28
  return queued;
29
29
  }
30
30
  const admissions = queuedAdmissions();
31
- function hold(owner: string, id: string): boolean {
32
- const key = `${owner}/${id}`;
33
- if (admissions.has(key)) {
34
- return false;
35
- }
36
- admissions.add(key);
37
- return true;
31
+ function hold(owner: string, id: string): void {
32
+ admissions.add(`${owner}/${id}`);
38
33
  }
39
34
  export function release(owner: string, id: string): void {
40
35
  admissions.delete(`${owner}/${id}`);
41
36
  }
42
- export function isHeld(owner: string, id: string): boolean {
43
- return admissions.has(`${owner}/${id}`);
37
+ type DeliveryDecision =
38
+ | { readonly kind: "published"; readonly where: "file" | "memory" }
39
+ | { readonly kind: "held" | "deliverable" };
40
+
41
+ /** Canonical result readers establish availability before deciding delivery. */
42
+ function deliveryDecision(receipt: "file" | "memory" | undefined, held: boolean): DeliveryDecision {
43
+ return receipt === undefined
44
+ ? { kind: held ? "held" : "deliverable" }
45
+ : { kind: "published", where: receipt };
46
+ }
47
+
48
+ export function completionDelivery(
49
+ owner: string,
50
+ id: string,
51
+ receipt: "file" | "memory" | undefined,
52
+ ): DeliveryDecision {
53
+ return deliveryDecision(receipt, admissions.has(`${owner}/${id}`));
44
54
  }
45
55
  /** Returns whether any admission was released for reconciliation. */
46
56
  export function settle(owner: string, pending: boolean): boolean {
@@ -243,14 +253,24 @@ export function notifications(ctx: ExtensionContext, base: string, owner: string
243
253
  const awaited = new Set<string>();
244
254
  const runs = completed(base, owner, awaited);
245
255
  const receipts = runs.length === 0 ? undefined : completionReceipts(ctx.sessionManager, owner);
256
+ const where = ctx.sessionManager.getSessionFile() === undefined ? "memory" : "file";
246
257
  for (const run of runs) {
247
- const key = `${owner}/${run.id}`;
248
- if (receipts?.get(run.id) !== undefined) {
249
- admissions.delete(key);
250
- continue;
258
+ const state = completionDelivery(
259
+ owner,
260
+ run.id,
261
+ receipts?.get(run.id) === undefined ? undefined : where,
262
+ );
263
+ switch (state.kind) {
264
+ case "published":
265
+ release(owner, run.id);
266
+ continue;
267
+ case "held":
268
+ continue;
269
+ case "deliverable":
270
+ break;
251
271
  }
252
- const loaded = admissions.has(key) ? undefined : (run.loaded ?? load(base, run.id, owner));
253
- if (loaded?.result !== undefined) {
272
+ const loaded = run.loaded ?? load(base, run.id, owner);
273
+ if (loaded.result !== undefined) {
254
274
  notices.push(notification(base, loaded.launch, loaded.result));
255
275
  }
256
276
  }
@@ -0,0 +1,40 @@
1
+ import { attachmentSchema } from "./contracts.ts";
2
+ import { observeAlive, published } from "./process.ts";
3
+ import { read } from "./storage.ts";
4
+
5
+ /** A parent liveness verdict acts only while its check and attachment generation are current. */
6
+ export class ParentCheck {
7
+ private current?: object | undefined;
8
+ check(file: string, detached: () => void): Promise<void> {
9
+ if (this.current !== undefined) {
10
+ return Promise.resolve();
11
+ }
12
+ const attachment = published(file, read(file, attachmentSchema));
13
+ if (attachment === undefined || !attachment.attached) {
14
+ detached();
15
+ return Promise.resolve();
16
+ }
17
+ const check = {};
18
+ this.current = check;
19
+ const settle = async () => {
20
+ try {
21
+ const [live] = await observeAlive([attachment]);
22
+ if (
23
+ this.current === check &&
24
+ live !== true &&
25
+ read(file, attachmentSchema)?.generation === attachment.generation
26
+ ) {
27
+ detached();
28
+ }
29
+ } finally {
30
+ if (this.current === check) {
31
+ this.current = undefined;
32
+ }
33
+ }
34
+ };
35
+ return settle();
36
+ }
37
+ cancel(): void {
38
+ this.current = undefined;
39
+ }
40
+ }
@@ -14,7 +14,6 @@ import {
14
14
  import { agentObservationSocketPath } from "../pi-intercom/broker/paths.ts";
15
15
  import { hasErrorCode } from "../shared/unknown.ts";
16
16
  import {
17
- attachmentSchema,
18
17
  parkedSchema,
19
18
  processSchema,
20
19
  wakeFailureSchema,
@@ -49,12 +48,19 @@ function probe(identity: ClaimedProcess): boolean | undefined {
49
48
  }
50
49
  return identity.identity === "unverified" ? true : undefined;
51
50
  }
51
+ function presence(identity: ClaimedProcess): "live" | "exited" | "uncertain" {
52
+ const probed = probe(identity);
53
+ if (probed !== undefined) {
54
+ return probed ? "uncertain" : "exited";
55
+ }
56
+ if (readChildProcessIdentity(identity.pid) === identity.identity) {
57
+ return "live";
58
+ }
59
+ return replaced(identity) ? "exited" : "uncertain";
60
+ }
52
61
  export function alive(identity: ClaimedProcess): boolean {
53
- // Only a proven different birth disproves a live PID; ambiguous identities stay possibly owned.
54
- return (
55
- probe(identity) ??
56
- (readChildProcessIdentity(identity.pid) === identity.identity || !replaced(identity))
57
- );
62
+ // Uncertain identity remains possibly owned; only death/replacement authorizes reuse.
63
+ return presence(identity) !== "exited";
58
64
  }
59
65
  /** Routine `alive` without waiting on `ps`; signals still use the synchronous proof. */
60
66
  export async function observeAlive(identities: readonly ClaimedProcess[]): Promise<boolean[]> {
@@ -68,51 +74,6 @@ export async function observeAlive(identities: readonly ClaimedProcess[]): Promi
68
74
  }
69
75
  return verdicts;
70
76
  }
71
- /**
72
- * A child's routine parent check, one observation at a time. A dead parent detaches the child
73
- * only while its check is current and the attachment generation is unchanged: a newer parent
74
- * may adopt the child meanwhile.
75
- */
76
- export class ParentCheck {
77
- private current?: object | undefined;
78
- /**
79
- * Reads the attachment synchronously, so an unreadable one still fails the caller at once.
80
- * Missing or released attachments detach immediately; a check in flight makes this a no-op.
81
- */
82
- check(file: string, detached: () => void): Promise<void> {
83
- if (this.current !== undefined) {
84
- return Promise.resolve();
85
- }
86
- const attachment = published(file, read(file, attachmentSchema));
87
- if (attachment === undefined || !attachment.attached) {
88
- detached();
89
- return Promise.resolve();
90
- }
91
- const check = {};
92
- this.current = check;
93
- const settle = async () => {
94
- try {
95
- const [live] = await observeAlive([attachment]);
96
- if (
97
- this.current === check &&
98
- live !== true &&
99
- read(file, attachmentSchema)?.generation === attachment.generation
100
- ) {
101
- detached();
102
- }
103
- } finally {
104
- if (this.current === check) {
105
- this.current = undefined;
106
- }
107
- }
108
- };
109
- return settle();
110
- }
111
- /** A check settling later no longer acts. */
112
- cancel(): void {
113
- this.current = undefined;
114
- }
115
- }
116
77
  function replaced(identity: ClaimedProcess): boolean {
117
78
  return (
118
79
  identity.identity !== "unverified" &&
@@ -132,6 +93,42 @@ export function actor(base: string, launch: Launch): OwnedProcess | undefined {
132
93
  const file = path.join(conversation(base, launch), "actor.lock", "process.json");
133
94
  return published(file, read(file, processSchema));
134
95
  }
96
+ type ActorClaim =
97
+ | { readonly kind: "absent" | "launcher-only"; readonly process: undefined }
98
+ | { readonly kind: "actor-live" | "actor-exited"; readonly process: OwnedProcess }
99
+ | { readonly kind: "uncertain"; readonly process: OwnedProcess | undefined };
100
+
101
+ export function possiblyLive(claim: ActorClaim | undefined): boolean {
102
+ return (
103
+ claim?.kind === "actor-live" || (claim?.kind === "uncertain" && claim.process !== undefined)
104
+ );
105
+ }
106
+
107
+ /** Launcher claims do not prove actor identity; shell ownership is checked separately. */
108
+ export function inspectActorClaim(base: string, launch: Launch): ActorClaim {
109
+ const process = actor(base, launch);
110
+ if (process !== undefined) {
111
+ switch (presence(process)) {
112
+ case "live":
113
+ return { kind: "actor-live", process };
114
+ case "exited":
115
+ return { kind: "actor-exited", process };
116
+ case "uncertain":
117
+ return { kind: "uncertain", process };
118
+ }
119
+ }
120
+ const lock = path.join(conversation(base, launch), "actor.lock");
121
+ if (!fs.existsSync(lock)) {
122
+ return { kind: "absent", process: undefined };
123
+ }
124
+ return {
125
+ kind:
126
+ read(path.join(lock, "claim.json"), processSchema) === undefined
127
+ ? "uncertain"
128
+ : "launcher-only",
129
+ process: undefined,
130
+ };
131
+ }
135
132
  function childEnvironment(launch: Launch): NodeJS.ProcessEnv {
136
133
  const env = { ...process.env };
137
134
  for (const key of Object.keys(env)) {
@@ -299,8 +296,9 @@ export async function ensureActor(
299
296
  await awaitStopping(dir, actor(base, launch), signal);
300
297
  signal?.throwIfAborted();
301
298
  // Another parallel tool may replace the claim while this invocation yields.
302
- const existing = actor(base, launch);
303
- if (existing && alive(existing)) {
299
+ const claim = inspectActorClaim(base, launch);
300
+ const existing = claim.process;
301
+ if (possiblyLive(claim)) {
304
302
  return;
305
303
  }
306
304
  parkFailure(base, launch, existing);
@@ -438,13 +436,13 @@ export function requestStop(
438
436
  return false;
439
437
  }
440
438
  };
441
- const identity = actor(base, launch);
439
+ const identity = inspectActorClaim(base, launch).process;
442
440
  const targets = identity ? [identity, ...shells(base, launch, identity.token)] : [];
443
441
  // Interactive Stop validates the whole set; shutdown reports and skips only ambiguous targets.
444
442
  const live = targets.filter((target) => attempt(() => verifyProcess(target)));
445
443
  const command =
446
444
  identity && live.includes(identity)
447
- ? enqueue(base, launch, "stop", { actorToken: identity.token })
445
+ ? enqueue(base, launch, { action: "stop", actorToken: identity.token })
448
446
  : undefined;
449
447
  if (identity && live.length > 0) {
450
448
  write(path.join(conversation(base, launch), `stopping-${identity.token}.json`), {