@fitchmultz/pi-subagents 0.52.18 → 0.52.20

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 (57) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +4 -0
  3. package/dist/agent/host.js +12 -0
  4. package/dist/agent/index.js +70 -56
  5. package/dist/agent/launch.js +12 -3
  6. package/dist/agent/notifications.js +5 -5
  7. package/dist/agent/parking.js +26 -15
  8. package/dist/agent/runner.js +18 -16
  9. package/dist/agent/tool-schemas.js +2 -0
  10. package/dist/agent/usage.js +42 -3
  11. package/dist/agent/view.js +13 -13
  12. package/dist/agent/wake.js +17 -15
  13. package/dist/build-info.js +1 -1
  14. package/dist/pi-intercom/bridge-presence.js +30 -18
  15. package/dist/pi-intercom/bridge-wake.js +60 -37
  16. package/dist/pi-intercom/broker/broker.js +2 -2
  17. package/dist/pi-intercom/broker/client-last.js +6 -18
  18. package/dist/pi-intercom/broker/client.js +16 -50
  19. package/dist/pi-intercom/broker/pending-requests.d.ts +12 -0
  20. package/dist/pi-intercom/broker/pending-requests.js +26 -0
  21. package/dist/pi-intercom/broker/snapshot-reader.d.ts +3 -2
  22. package/dist/pi-intercom/broker/snapshot-reader.js +34 -15
  23. package/dist/pi-intercom/broker/snapshots.d.ts +25 -7
  24. package/dist/pi-intercom/broker/snapshots.js +56 -25
  25. package/dist/pi-intercom/broker/spawn-claim.js +89 -0
  26. package/dist/pi-intercom/broker/spawn.js +31 -78
  27. package/dist/pi-intercom/lifecycle.js +26 -26
  28. package/dist/shared/post-exit-stdio-guard.js +44 -37
  29. package/docs/development.md +25 -0
  30. package/docs/intercom.md +13 -0
  31. package/docs/reference.md +16 -5
  32. package/package.json +1 -1
  33. package/scripts/build.mjs +41 -14
  34. package/skills/pi-subagents/SKILL.md +8 -4
  35. package/src/agent/contracts.ts +17 -0
  36. package/src/agent/host.ts +19 -0
  37. package/src/agent/index.ts +91 -62
  38. package/src/agent/launch.ts +16 -3
  39. package/src/agent/notifications.ts +5 -5
  40. package/src/agent/parking.ts +39 -17
  41. package/src/agent/runner.ts +20 -21
  42. package/src/agent/tool-schemas.ts +2 -0
  43. package/src/agent/usage.ts +48 -3
  44. package/src/agent/view.ts +15 -13
  45. package/src/agent/wake.ts +19 -15
  46. package/src/pi-intercom/bridge-presence.ts +37 -18
  47. package/src/pi-intercom/bridge-wake.ts +70 -38
  48. package/src/pi-intercom/broker/broker.ts +2 -1
  49. package/src/pi-intercom/broker/client-last.ts +14 -25
  50. package/src/pi-intercom/broker/client.ts +36 -65
  51. package/src/pi-intercom/broker/pending-requests.ts +37 -0
  52. package/src/pi-intercom/broker/snapshot-reader.ts +44 -16
  53. package/src/pi-intercom/broker/snapshots.ts +82 -37
  54. package/src/pi-intercom/broker/spawn-claim.ts +98 -0
  55. package/src/pi-intercom/broker/spawn.ts +42 -82
  56. package/src/pi-intercom/lifecycle.ts +38 -27
  57. package/src/shared/post-exit-stdio-guard.ts +48 -40
@@ -8,6 +8,16 @@ import type { DelegateInput } from "./tool-schemas.ts";
8
8
  import { loadSDK, packageRoot, runtimeEntry } from "./host.ts";
9
9
  import { runDirectory, write } from "./storage.ts";
10
10
 
11
+ function selectedThinking(
12
+ requested: ThinkingLevel | undefined,
13
+ inherited: ThinkingLevel,
14
+ suffix?: ThinkingLevel,
15
+ ): ThinkingLevel {
16
+ if (requested !== undefined && suffix !== undefined && requested !== suffix) {
17
+ throw new Error("thinking conflicts with the model's thinking suffix.");
18
+ }
19
+ return suffix ?? requested ?? inherited;
20
+ }
11
21
  function selectedModel(
12
22
  input: DelegateInput,
13
23
  context: ExtensionContext,
@@ -18,7 +28,7 @@ function selectedModel(
18
28
  if (!context.model) {
19
29
  throw new Error("No selected model; no fallback was used.");
20
30
  }
21
- return { model: context.model, thinking };
31
+ return { model: context.model, thinking: selectedThinking(input.thinking, thinking) };
22
32
  }
23
33
  const slash = input.model.indexOf("/");
24
34
  if (slash < 1) {
@@ -29,7 +39,7 @@ function selectedModel(
29
39
  input.model.slice(slash + 1),
30
40
  );
31
41
  if (model) {
32
- return { model, thinking };
42
+ return { model, thinking: selectedThinking(input.thinking, thinking) };
33
43
  }
34
44
  if (typeof sdk.resolveModelReference !== "function") {
35
45
  throw new Error(
@@ -49,7 +59,10 @@ function selectedModel(
49
59
  if (resolved.status !== "resolved") {
50
60
  throw new Error(`Model ${input.model} is unavailable; no fallback was used.`);
51
61
  }
52
- return { model: resolved.model, thinking: resolved.thinkingLevel ?? thinking };
62
+ return {
63
+ model: resolved.model,
64
+ thinking: selectedThinking(input.thinking, thinking, resolved.thinkingLevel),
65
+ };
53
66
  }
54
67
  function targetTrust(
55
68
  sdk: Awaited<ReturnType<typeof loadSDK>>,
@@ -6,7 +6,7 @@ import { readOutputPage } from "../shared/output-page.ts";
6
6
  import { journalStamp } from "../shared/native-journal.ts";
7
7
  import type { Launch, Result } from "./contracts.ts";
8
8
  import { launchAt, list, runDirectory } from "./storage.ts";
9
- import { readCanonicalResult, readResult } from "./usage.ts";
9
+ import { accountingState, readCanonicalResult, readResult } from "./usage.ts";
10
10
 
11
11
  function isStringSet(value: unknown): value is Set<string> {
12
12
  if (!(value instanceof Set)) {
@@ -160,7 +160,7 @@ function unchangedPending(pending: Loaded | undefined): boolean {
160
160
  function load(base: string, id: string, owner: string): Loaded {
161
161
  const launch = launchAt(base, id, owner);
162
162
  const result = readCanonicalResult(base, launch);
163
- if (result?.accountingPending === undefined) {
163
+ if (accountingState(result).kind !== "pending") {
164
164
  return { launch, result };
165
165
  }
166
166
  // Only pending runs need this pre-read stamp to observe concurrent publication.
@@ -169,7 +169,7 @@ function load(base: string, id: string, owner: string): Loaded {
169
169
  return { launch, result: readResult(base, launch), journal };
170
170
  }
171
171
  function cacheableResult(loaded: Loaded): boolean {
172
- return loaded.result?.accountingPending === undefined || loaded.journal !== undefined;
172
+ return accountingState(loaded.result).kind !== "pending" || loaded.journal !== undefined;
173
173
  }
174
174
  interface Scan {
175
175
  readonly base: string;
@@ -198,7 +198,7 @@ function completedRun({ base, owner, known, settled }: Scan, id: string): Comple
198
198
  const order = { endedAt: loaded.result.endedAt, createdAt: loaded.launch.createdAt };
199
199
  // Pending accounting retries on child changes, even after the completion receipt.
200
200
  // Ordinary settled history retains its launch/result-only idle probes.
201
- const pending = loaded.result.accountingPending === undefined ? undefined : loaded;
201
+ const pending = accountingState(loaded.result).kind === "pending" ? loaded : undefined;
202
202
  if (launch !== undefined && result !== undefined && cacheableResult(loaded)) {
203
203
  settled.set(id, { launch, result, pending, ...order });
204
204
  }
@@ -216,7 +216,7 @@ function completed(base: string, owner: string, awaited: Set<string>): Completed
216
216
  const run = completedRun(scan, id);
217
217
  if (run === undefined) {
218
218
  awaited.add(path.join(runDirectory(base, id), "result.json"));
219
- } else if (run.loaded?.result?.accountingPending !== undefined) {
219
+ } else if (run.loaded && accountingState(run.loaded.result).kind === "pending") {
220
220
  awaited.add(run.loaded.launch.sessionFile);
221
221
  }
222
222
  return run ?? [];
@@ -29,7 +29,14 @@ import type { NativeHistory } from "../pi-intercom/last-message.ts";
29
29
  type SDK = Awaited<ReturnType<typeof loadSDK>>;
30
30
  type Restore = NonNullable<SDK["readWorkingSession"]>;
31
31
  export type Runtime = Awaited<ReturnType<SDK["createAgentSessionRuntime"]>>;
32
+ export type ParkOutcome = "resident" | "exit-parked" | "exit-failed";
32
33
  type SessionManager = Parameters<SDK["createAgentSessionRuntime"]>[1]["sessionManager"];
34
+ type OpenSource =
35
+ | { readonly kind: "journal" }
36
+ | { readonly kind: "parked"; readonly state: string; readonly saved: WorkingState };
37
+ type RestorePhase =
38
+ | { readonly kind: "unopened" }
39
+ | { readonly kind: "opening" | "adopted"; readonly source: OpenSource };
33
40
  interface WorkingHold {
34
41
  readonly state: WorkingState;
35
42
  readonly sleepReady: boolean;
@@ -156,10 +163,7 @@ export function parkedState(
156
163
  }
157
164
  /** One actor generation's idle parking and exact restore; commands stay with the runner. */
158
165
  export class Parking {
159
- saved?: WorkingState;
160
- /** Set before entering either native open, whose effects may outlive an exception. */
161
- opened = false;
162
- private restored?: string;
166
+ private restore: RestorePhase = { kind: "unopened" };
163
167
  private since = 0;
164
168
  private leaf?: string | null;
165
169
  private shell?: string | undefined;
@@ -179,36 +183,48 @@ export class Parking {
179
183
  private get file(): string {
180
184
  return path.join(this.dir, "parked.json");
181
185
  }
186
+ get saved(): WorkingState | undefined {
187
+ return this.restore.kind !== "unopened" && this.restore.source.kind === "parked"
188
+ ? this.restore.source.saved
189
+ : undefined;
190
+ }
191
+ /** Set before entering either native open, whose effects may outlive an exception. */
192
+ get opened(): boolean {
193
+ return this.restore.kind !== "unopened";
194
+ }
182
195
  /** Parked authority is exact state or nothing; never a journal-only reopen. */
183
196
  open(): SessionManager {
184
197
  const { sdk, launch } = this;
185
198
  const { openWorkingSession: restore } = sdk;
186
199
  const parked = parkedState(this.dir, launch, restore && sdk.readWorkingSession);
187
- this.opened = true;
200
+ this.restore = {
201
+ kind: "opening",
202
+ source: parked ? { kind: "parked", ...parked } : { kind: "journal" },
203
+ };
188
204
  if (!parked || !restore) {
189
205
  return sdk.SessionManager.open(launch.sessionFile, undefined, launch.cwd);
190
206
  }
191
- this.saved = parked.saved;
192
- this.restored = parked.state;
193
207
  return restore(parked.saved);
194
208
  }
195
209
  /** The ready restored generation owns this state now; a later park writes a new cut. */
196
210
  adopt(): void {
197
- if (this.restored !== undefined) {
211
+ if (this.restore.kind !== "opening") {
212
+ return;
213
+ }
214
+ const { source } = this.restore;
215
+ if (source.kind === "parked") {
198
216
  fs.rmSync(this.file);
199
- fs.rmSync(this.restored, { force: true });
217
+ fs.rmSync(source.state, { force: true });
200
218
  }
219
+ this.restore = { kind: "adopted", source };
201
220
  }
202
- /** True once this generation has exited its runtime with or without parked authority. */
203
- async park(runtime: Runtime, actor: Resident): Promise<boolean> {
221
+ /** Both exit outcomes have disposed the runtime; only one publishes parked authority. */
222
+ async park(runtime: Runtime, actor: Resident): Promise<ParkOutcome> {
204
223
  if (!actor.pi || !this.due(runtime.session, actor.busy) || this.hasShell()) {
205
- return false;
224
+ return "resident";
206
225
  }
207
226
  const hold = await this.prepare(runtime, actor.pi, actor);
208
- if (hold) {
209
- await this.commit(runtime, hold, actor.errors);
210
- }
211
- return hold !== undefined;
227
+ return hold ? this.commit(runtime, hold, actor.errors) : "resident";
212
228
  }
213
229
  private hasShell(): boolean {
214
230
  const witness = this.shell === undefined ? undefined : shell(this.shell, this.token);
@@ -259,7 +275,11 @@ export class Parking {
259
275
  return undefined;
260
276
  }
261
277
  /** Commit: native strict exit, then capture; failure leaves no parked authority. */
262
- private async commit(runtime: Runtime, hold: WorkingHold, errors: string[]): Promise<void> {
278
+ private async commit(
279
+ runtime: Runtime,
280
+ hold: WorkingHold,
281
+ errors: string[],
282
+ ): Promise<Exclude<ParkOutcome, "resident">> {
263
283
  const save = this.sdk.writeWorkingSession;
264
284
  if (save && finalizing(runtime)) {
265
285
  runtime.setFinalization((final) => this.capture(final, save), { strictShutdown: true });
@@ -275,7 +295,9 @@ export class Parking {
275
295
  if (read(this.file, parkedSchema)?.token !== this.token) {
276
296
  // Truthful boundary: retained input stays queued and nothing restarts implicitly.
277
297
  write(path.join(this.dir, "park-failed.json"), { token: this.token, errors });
298
+ return "exit-failed";
278
299
  }
300
+ return "exit-parked";
279
301
  }
280
302
  private async capture(
281
303
  session: AgentSession,
@@ -8,12 +8,13 @@ import {
8
8
  type Command,
9
9
  type Launch,
10
10
  type Result,
11
+ type AccountingState,
11
12
  } from "./contracts.ts";
12
13
  import { launchAt, missing, read, runDirectory, write } from "./storage.ts";
13
14
  import { ownIdentity, ParentCheck, shells, signalProcess } from "./process.ts";
14
15
  import { errorText, loadSDK } from "./host.ts";
15
- import { Parking, type Runtime } from "./parking.ts";
16
- import { UsageIndex } from "./usage.ts";
16
+ import { Parking, type ParkOutcome, type Runtime } from "./parking.ts";
17
+ import { accountingFields, UsageIndex } from "./usage.ts";
17
18
  import { initializeCwd } from "./cwd.ts";
18
19
  import { childRuntime } from "./child-runtime.ts";
19
20
  import { ActorObservation, type ObservationStats } from "./observation.ts";
@@ -56,7 +57,7 @@ class Actor {
56
57
  private stopping = false;
57
58
  private stopDeadline?: NodeJS.Timeout;
58
59
  private nextParentCheck = 0;
59
- private parking = false;
60
+ private parking: ParkOutcome = "resident";
60
61
  private readonly parent = new ParentCheck();
61
62
  // Raised when durable commands may need admission: change hints and stop.
62
63
  private readonly wake = new Wake();
@@ -110,10 +111,7 @@ class Actor {
110
111
  owner: launch.owner,
111
112
  status: completionStatus(this.stopping, message, error),
112
113
  endedAt: Date.now(),
113
- ...(accounting.accountingError === undefined && terminal?.id !== undefined
114
- ? { terminalEntryId: terminal.id }
115
- : {}),
116
- ...accounting,
114
+ ...accountingFields(accounting),
117
115
  ...(failure !== undefined ? { error: failure } : {}),
118
116
  };
119
117
  fs.writeFileSync(path.join(runDirectory(this.base, launch.id), "output.txt"), output, {
@@ -121,20 +119,21 @@ class Actor {
121
119
  });
122
120
  write(path.join(runDirectory(this.base, launch.id), "result.json"), result);
123
121
  }
124
- private usage(
125
- launch: Launch,
126
- baseline: ReadonlySet<string>,
127
- terminal?: string,
128
- ): Pick<Result, "contributions" | "accountingError" | "accountingPending"> {
122
+ private usage(launch: Launch, baseline: ReadonlySet<string>, terminal?: string): AccountingState {
129
123
  try {
130
- return { contributions: this.accounting.read(launch, baseline, terminal) };
131
- } catch (error) {
132
124
  return {
133
- accountingError: errorText(error),
134
- ...(terminal !== undefined
135
- ? { accountingPending: { terminalCandidate: terminal, baseline: [...baseline] } }
136
- : {}),
125
+ kind: "verified",
126
+ contributions: this.accounting.read(launch, baseline, terminal),
127
+ terminalEntryId: terminal,
137
128
  };
129
+ } catch (error) {
130
+ return terminal === undefined
131
+ ? { kind: "unavailable", error: errorText(error), terminalEntryId: undefined }
132
+ : {
133
+ kind: "pending",
134
+ error: errorText(error),
135
+ pending: { terminalCandidate: terminal, baseline: [...baseline] },
136
+ };
138
137
  }
139
138
  }
140
139
  private output(message: AssistantMessage | undefined): string {
@@ -384,7 +383,7 @@ class Actor {
384
383
  } finally {
385
384
  clearInterval(watch);
386
385
  }
387
- return this.parking;
386
+ return this.parking !== "resident";
388
387
  }
389
388
  private failure(error: unknown, unopened?: string): void {
390
389
  const launch = this.current ?? this.initial;
@@ -494,7 +493,7 @@ class Actor {
494
493
  process.off("SIGTERM", this.stop);
495
494
  process.off("SIGINT", this.stop);
496
495
  const observation: ObservationStats | undefined = this.observation?.close();
497
- if (!this.parking) {
496
+ if (this.parking === "resident") {
498
497
  await runtime?.dispose();
499
498
  }
500
499
  if (this.stopping) {
@@ -506,7 +505,7 @@ class Actor {
506
505
  errors: this.errors,
507
506
  ...(observation === undefined ? {} : { observation }),
508
507
  });
509
- if (this.parking) {
508
+ if (this.parking !== "resident") {
510
509
  // Parked authority requires actual exit; no leftover extension handle may keep it alive.
511
510
  process.exit();
512
511
  }
@@ -1,6 +1,7 @@
1
1
  import type { Static } from "typebox";
2
2
  import { StringEnum } from "@earendil-works/pi-ai";
3
3
  import { Type } from "./typebox.ts";
4
+ import { thinkingSchema } from "./contracts.ts";
4
5
 
5
6
  // Main-session tool parameters only; the detached runner must not load pi-ai values.
6
7
  export const delegateSchema = Type.Object(
@@ -15,6 +16,7 @@ export const delegateSchema = Type.Object(
15
16
  "Exact provider/model ID (:thinking and any case need host support); no fuzzy match or fallback.",
16
17
  }),
17
18
  ),
19
+ thinking: Type.Optional(thinkingSchema),
18
20
  context: Type.Optional(StringEnum(["fresh", "fork"] as const)),
19
21
  },
20
22
  { additionalProperties: false },
@@ -8,12 +8,49 @@ import { Assert } from "./typebox.ts";
8
8
  import {
9
9
  contributionsSchema,
10
10
  resultSchema,
11
+ type AccountingState,
11
12
  type Contribution,
12
13
  type Launch,
13
14
  type Result,
14
15
  } from "./contracts.ts";
15
16
  import { read, runDirectory, write } from "./storage.ts";
16
17
 
18
+ /** Pending repair remains authoritative until its native terminal is published and validated. */
19
+ export function accountingState(result: Result | undefined): AccountingState {
20
+ if (result?.accountingPending !== undefined) {
21
+ return { kind: "pending", pending: result.accountingPending, error: result.accountingError };
22
+ }
23
+ const terminalEntryId = result?.terminalEntryId;
24
+ if (result?.contributions !== undefined) {
25
+ return { kind: "verified", contributions: result.contributions, terminalEntryId };
26
+ }
27
+ return result?.accountingError === undefined
28
+ ? { kind: "unrecorded", terminalEntryId }
29
+ : { kind: "unavailable", error: result.accountingError, terminalEntryId };
30
+ }
31
+
32
+ export function accountingFields(
33
+ state: AccountingState,
34
+ ): Pick<Result, "contributions" | "accountingPending" | "accountingError" | "terminalEntryId"> {
35
+ const terminal =
36
+ state.kind !== "pending" && state.terminalEntryId !== undefined
37
+ ? { terminalEntryId: state.terminalEntryId }
38
+ : {};
39
+ switch (state.kind) {
40
+ case "unrecorded":
41
+ return terminal;
42
+ case "verified":
43
+ return { contributions: state.contributions, ...terminal };
44
+ case "pending":
45
+ return {
46
+ accountingPending: state.pending,
47
+ ...(state.error === undefined ? {} : { accountingError: state.error }),
48
+ };
49
+ case "unavailable":
50
+ return { accountingError: state.error, ...terminal };
51
+ }
52
+ }
53
+
17
54
  /** Billing evidence of one native entry. Missing required usage is an error only outside a baseline. */
18
55
  function billing(entry: UnknownRecord): UnknownRecord | "missing" | undefined {
19
56
  if (entry.checkpoint === true) {
@@ -171,10 +208,11 @@ export function readResult(base: string, launch: Launch): Result | undefined {
171
208
  if (!result) {
172
209
  return;
173
210
  }
174
- const pending = result.accountingPending;
175
- if (pending === undefined) {
211
+ const accounting = accountingState(result);
212
+ if (accounting.kind !== "pending") {
176
213
  return result;
177
214
  }
215
+ const { pending } = accounting;
178
216
  let contributions;
179
217
  try {
180
218
  contributions = childUsage(launch, new Set(pending.baseline), pending.terminalCandidate);
@@ -183,7 +221,14 @@ export function readResult(base: string, launch: Launch): Result | undefined {
183
221
  return result;
184
222
  }
185
223
  const { accountingPending: _pending, accountingError: _error, ...execution } = result;
186
- const repaired = { ...execution, terminalEntryId: pending.terminalCandidate, contributions };
224
+ const repaired = {
225
+ ...execution,
226
+ ...accountingFields({
227
+ kind: "verified",
228
+ terminalEntryId: pending.terminalCandidate,
229
+ contributions,
230
+ }),
231
+ };
187
232
  write(path.join(runDirectory(base, launch.id), "result.json"), repaired);
188
233
  return repaired;
189
234
  }
package/src/agent/view.ts CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  import type { RunsInput } from "./tool-schemas.ts";
16
16
  import { conversation, read, runDirectory } from "./storage.ts";
17
17
  import { published } from "./notifications.ts";
18
- import { readCanonicalResult, readResult } from "./usage.ts";
18
+ import { accountingFields, accountingState, readCanonicalResult, readResult } from "./usage.ts";
19
19
  import { Type } from "./typebox.ts";
20
20
 
21
21
  const commandMetadataSchema = Type.Pick(commandSchema, ["id", "runId", "action", "createdAt"]);
@@ -72,18 +72,20 @@ function toolResult(value: unknown, text: string): ToolReply {
72
72
  return { content: [{ type: "text" as const, text }], details: value };
73
73
  }
74
74
  function receiptResult(result: Result | undefined) {
75
- return result === undefined
76
- ? undefined
77
- : {
78
- id: result.id,
79
- owner: result.owner,
80
- status: result.status,
81
- endedAt: result.endedAt,
82
- terminalEntryId: result.terminalEntryId,
83
- error: result.error,
84
- accountingError: result.accountingError,
85
- accountingPending: result.accountingPending,
86
- };
75
+ if (result === undefined) {
76
+ return;
77
+ }
78
+ const { contributions: _contributions, ...accounting } = accountingFields(
79
+ accountingState(result),
80
+ );
81
+ return {
82
+ id: result.id,
83
+ owner: result.owner,
84
+ status: result.status,
85
+ endedAt: result.endedAt,
86
+ error: result.error,
87
+ ...accounting,
88
+ };
87
89
  }
88
90
  /** `observed` holds batched liveness by actor token; any other actor gets a fresh check. */
89
91
  function processView(base: string, launch: Launch, observed?: ReadonlyMap<string, boolean>) {
package/src/agent/wake.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import * as fs from "node:fs";
2
+ import assert from "node:assert/strict";
2
3
  import { journalStamp } from "../shared/native-journal.ts";
3
4
  import { missing } from "./storage.ts";
4
5
 
@@ -21,10 +22,12 @@ export type WatchState = "unwatched" | "armed" | "changed" | "watching";
21
22
 
22
23
  /** Native change hints for one directory identity; owners still reconcile canonical files. */
23
24
  export class DirectoryWatch {
24
- private watcher?: fs.FSWatcher | undefined;
25
- private armed = "";
25
+ private state:
26
+ | { readonly kind: "unarmed" | "polling" }
27
+ | { readonly kind: "armed"; readonly watcher: fs.FSWatcher; readonly identity: string } = {
28
+ kind: "unarmed",
29
+ };
26
30
  private checked = "";
27
- private failed = false;
28
31
  private readonly directory: string;
29
32
  private readonly relevant: (file: string | null) => boolean;
30
33
  private readonly wake: () => void;
@@ -38,40 +41,40 @@ export class DirectoryWatch {
38
41
  const identity = stat?.isDirectory() === true ? `${stat.dev}:${stat.ino}` : "";
39
42
  const checked = this.checked;
40
43
  this.checked = stat === undefined ? "" : `${identity}:${stat.mtimeNs}:${stat.ctimeNs}`;
41
- if (this.watcher !== undefined && identity === this.armed) {
44
+ if (this.state.kind === "armed" && identity === this.state.identity) {
42
45
  return checked === this.checked ? "watching" : "changed";
43
46
  }
44
47
  // A replaced directory leaves an inode watch silent on Linux.
45
48
  this.close();
46
- return identity === "" || this.failed ? "unwatched" : this.arm(identity);
49
+ return identity === "" || this.state.kind === "polling" ? "unwatched" : this.arm(identity);
47
50
  }
48
51
  private arm(identity: string): WatchState {
49
52
  try {
50
53
  const watcher = fs.watch(this.directory, { persistent: false }, (_event, file) => {
51
- if (watcher === this.watcher && this.relevant(file)) {
54
+ if (this.state.kind === "armed" && watcher === this.state.watcher && this.relevant(file)) {
52
55
  this.wake();
53
56
  }
54
57
  });
55
58
  watcher.on("error", () => {
56
- if (watcher === this.watcher) {
57
- this.failed = true;
58
- this.close();
59
+ if (this.state.kind === "armed" && watcher === this.state.watcher) {
60
+ watcher.close();
61
+ this.state = { kind: "polling" };
59
62
  this.wake();
60
63
  }
61
64
  });
62
- this.watcher = watcher;
63
- this.armed = identity;
65
+ this.state = { kind: "armed", watcher, identity };
64
66
  return "armed";
65
67
  } catch (error) {
66
68
  // Unsupported platforms and watch limits keep their owner's polling fallback.
67
- this.failed = !missing(error);
69
+ this.state = { kind: missing(error) ? "unarmed" : "polling" };
68
70
  return "unwatched";
69
71
  }
70
72
  }
71
73
  close(): void {
72
- this.watcher?.close();
73
- this.watcher = undefined;
74
- this.armed = "";
74
+ if (this.state.kind === "armed") {
75
+ this.state.watcher.close();
76
+ this.state = { kind: "unarmed" };
77
+ }
75
78
  }
76
79
  }
77
80
 
@@ -90,6 +93,7 @@ export class Wake {
90
93
  }
91
94
  /** Returns at once for a wake raised since the last take. */
92
95
  async sleep(ms: number): Promise<void> {
96
+ assert.equal(this.resolve, undefined, "A Wake belongs to one waiting owner loop.");
93
97
  if (this.raised) {
94
98
  return;
95
99
  }
@@ -18,11 +18,15 @@ interface PresenceOwner {
18
18
  readonly restored: (identity: Identity, expires: number) => void;
19
19
  }
20
20
 
21
+ type PresencePhase =
22
+ | { readonly kind: "idle" }
23
+ | { readonly kind: "scheduled"; readonly timer: NodeJS.Timeout }
24
+ | { readonly kind: "running"; readonly task: Promise<void> }
25
+ | { readonly kind: "stopped"; readonly task: Promise<void> };
26
+
21
27
  export class PersistentPresence {
22
28
  private readonly registrations = new Map<string, Registration>();
23
- private stopped = false;
24
- private timer?: NodeJS.Timeout;
25
- private running?: Promise<void> | undefined;
29
+ private phase: PresencePhase = { kind: "idle" };
26
30
  private readonly owner: PresenceOwner;
27
31
 
28
32
  constructor(owner: PresenceOwner) {
@@ -88,22 +92,30 @@ export class PersistentPresence {
88
92
  }
89
93
 
90
94
  start(): void {
91
- if (!this.stopped && !this.running) {
92
- clearTimeout(this.timer);
93
- this.timer = setTimeout(() => {
94
- this.running = this.maintain().finally(() => {
95
- this.running = undefined;
96
- if (!this.stopped) {
97
- this.timer = setTimeout(() => this.start(), 15000);
98
- }
99
- });
100
- }, 0);
95
+ if (this.phase.kind === "running" || this.phase.kind === "stopped") {
96
+ return;
97
+ }
98
+ this.schedule(0);
99
+ }
100
+
101
+ private schedule(delay: number): void {
102
+ if (this.phase.kind === "scheduled") {
103
+ clearTimeout(this.phase.timer);
101
104
  }
105
+ const timer = setTimeout(() => {
106
+ const task = this.maintain().finally(() => {
107
+ if (this.phase.kind !== "stopped") {
108
+ this.schedule(15000);
109
+ }
110
+ });
111
+ this.phase = { kind: "running", task };
112
+ }, delay);
113
+ this.phase = { kind: "scheduled", timer };
102
114
  }
103
115
 
104
116
  private async maintain(): Promise<void> {
105
117
  for (const registration of this.registrations.values()) {
106
- if (this.stopped) {
118
+ if (this.phase.kind === "stopped") {
107
119
  return;
108
120
  }
109
121
  const identity = this.owner.clients().get(registration.fingerprint);
@@ -125,9 +137,16 @@ export class PersistentPresence {
125
137
  }
126
138
  }
127
139
 
128
- async stop(): Promise<void> {
129
- this.stopped = true;
130
- clearTimeout(this.timer);
131
- await this.running;
140
+ stop(): Promise<void> {
141
+ const phase = this.phase;
142
+ if (phase.kind === "stopped") {
143
+ return phase.task;
144
+ }
145
+ if (phase.kind === "scheduled") {
146
+ clearTimeout(phase.timer);
147
+ }
148
+ const task = phase.kind === "running" ? phase.task : Promise.resolve();
149
+ this.phase = { kind: "stopped", task };
150
+ return task;
132
151
  }
133
152
  }