@agentshouse/kit 0.1.0-alpha.27 → 0.1.0-alpha.29

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/src/house.js CHANGED
@@ -5,7 +5,6 @@ import { dirname, join } from "node:path";
5
5
  import { assertAcquiredCommandAdapter } from "./adapter.js";
6
6
  import { readFrames, writeFrame } from "./channel.js";
7
7
  import {
8
- assistantDraftsPath,
9
8
  assistantTextPath,
10
9
  ADAPTER_KINDS,
11
10
  INTERACTION_CORRELATION_MAX,
@@ -45,7 +44,6 @@ const RESIDENT = "Resident";
45
44
  const LEASE_ACTIVE = "environment_control_lease_active";
46
45
  const UPGRADE_REQUIRED = "kit_upgrade_required";
47
46
  const UPDATE_DRAINING = "kit_update_draining";
48
- const ASSISTANT_DRAFT_STATES = ["streamed", "unsupported"];
49
47
  const ITEM_OUTCOMES = ["accepted", "duplicate", "rejected"];
50
48
 
51
49
  async function readCredential(directory) {
@@ -174,12 +172,13 @@ async function residentRelay(session, request, signal, draining) {
174
172
  }
175
173
  }
176
174
 
177
- export async function serveResident(session, directory, signal, drain, push, capture) {
175
+ export async function serveResident(session, directory, signal, drain, push, capture, stream = null) {
178
176
  const socket = residentSocket(directory);
179
177
  await mkdir(dirname(socket), { recursive: true, mode: 0o700 });
180
178
  await rm(socket, { force: true });
181
179
  const connections = new Set();
182
180
  let holder = null;
181
+ let listening = null;
183
182
  let draining = false;
184
183
  function answerDrain(connection) {
185
184
  draining = true;
@@ -200,7 +199,10 @@ export async function serveResident(session, directory, signal, drain, push, cap
200
199
  }
201
200
  holder = connection;
202
201
  renewControl(session, signal).then(
203
- () => writeFrame(connection, { held: true }),
202
+ () => {
203
+ writeFrame(connection, { held: true });
204
+ if (holder === connection) listening = connection;
205
+ },
204
206
  (error) => {
205
207
  writeFrame(connection, { refused: error.code ?? "kit_lease_rejected" });
206
208
  connection.end();
@@ -216,6 +218,7 @@ export async function serveResident(session, directory, signal, drain, push, cap
216
218
  connection.on("close", () => {
217
219
  connections.delete(connection);
218
220
  if (holder === connection) holder = null;
221
+ if (listening === connection) listening = null;
219
222
  for (const cancelled of relayed.values()) cancelled.abort();
220
223
  });
221
224
  readFrames(connection, {
@@ -225,6 +228,10 @@ export async function serveResident(session, directory, signal, drain, push, cap
225
228
  return;
226
229
  }
227
230
  if (!held) return;
231
+ if (request.stream !== undefined) {
232
+ if (request.stream?.type === "gap") stream?.write(request.stream);
233
+ return;
234
+ }
228
235
  if (request.cancel === true) {
229
236
  relayed.get(request.id)?.abort();
230
237
  return;
@@ -256,6 +263,9 @@ export async function serveResident(session, directory, signal, drain, push, cap
256
263
  await chmod(socket, 0o600);
257
264
  return {
258
265
  serving: () => holder !== null,
266
+ forward(frame) {
267
+ if (listening !== null && !listening.destroyed) writeFrame(listening, { stream: frame });
268
+ },
259
269
  async close() {
260
270
  for (const connection of connections) connection.destroy();
261
271
  await new Promise((resolve) => server.close(resolve));
@@ -284,6 +294,7 @@ export function requestDrain(directory) {
284
294
  async function residentDelegation(directory, fetcher) {
285
295
  const connection = connect(residentSocket(directory));
286
296
  const pending = new Map();
297
+ const watchers = new Set();
287
298
  let greet;
288
299
  const greeting = new Promise((resolve) => {
289
300
  greet = resolve;
@@ -300,7 +311,11 @@ async function residentDelegation(directory, fetcher) {
300
311
  for (const id of [...pending.keys()]) settle(id, unavailable());
301
312
  });
302
313
  readFrames(connection, {
303
- onFrame: (frame) => (frame.id === undefined ? greet(frame) : settle(frame.id, frame)),
314
+ onFrame: (frame) => {
315
+ if (frame.id !== undefined) settle(frame.id, frame);
316
+ else if (frame.stream !== undefined) for (const heard of watchers) heard.viewed?.(frame.stream);
317
+ else greet(frame);
318
+ },
304
319
  onRefusal: () => connection.destroy(),
305
320
  });
306
321
  writeFrame(connection, { hold: true });
@@ -365,7 +380,25 @@ async function residentDelegation(directory, fetcher) {
365
380
  headers: answer.type === null ? {} : { "content-type": answer.type },
366
381
  });
367
382
  }
368
- return { connection, fetch: delegated, push, capture };
383
+ function relay(signal, heard) {
384
+ return new Promise((resolve) => {
385
+ const end = () => {
386
+ signal.removeEventListener("abort", end);
387
+ connection.removeListener("close", end);
388
+ if (!watchers.delete(heard)) return;
389
+ heard.closed?.();
390
+ resolve();
391
+ };
392
+ watchers.add(heard);
393
+ heard.opened?.((frame) => {
394
+ if (!connection.destroyed) writeFrame(connection, { stream: frame });
395
+ });
396
+ signal.addEventListener("abort", end, { once: true });
397
+ connection.once("close", end);
398
+ if (signal.aborted || connection.destroyed) end();
399
+ });
400
+ }
401
+ return { connection, fetch: delegated, push, capture, relay };
369
402
  }
370
403
 
371
404
  export async function openSession(options, dependencies = {}) {
@@ -386,6 +419,7 @@ export async function openSession(options, dependencies = {}) {
386
419
  session.resident = resident.connection;
387
420
  session.push = resident.push;
388
421
  session.capture = resident.capture;
422
+ session.relay = resident.relay;
389
423
  session.dependencies = { ...dependencies, fetch: resident.fetch };
390
424
  return session;
391
425
  }
@@ -942,14 +976,17 @@ export async function commitRun(session, run) {
942
976
  return answer;
943
977
  }
944
978
 
945
- export async function reportRunOutcome(session, run, reported) {
979
+ export async function reportRunOutcome(session, run, reported, plan = null) {
946
980
  const answer = await houseRequest(
947
981
  session.house,
948
982
  await heldControl(session),
949
983
  runOutcomePath(run),
950
- reported.cause === null
951
- ? { operation_id: reported.operation, outcome: reported.outcome }
952
- : { operation_id: reported.operation, outcome: reported.outcome, cause: reported.cause },
984
+ withPlan(
985
+ reported.cause === null
986
+ ? { operation_id: reported.operation, outcome: reported.outcome }
987
+ : { operation_id: reported.operation, outcome: reported.outcome, cause: reported.cause },
988
+ plan,
989
+ ),
953
990
  session.dependencies,
954
991
  session.signal,
955
992
  );
@@ -982,27 +1019,16 @@ export async function reportSessions(session, held) {
982
1019
  return sessions;
983
1020
  }
984
1021
 
985
- export async function streamAssistantDraft(session, workerSession, blocks) {
986
- const answer = await houseRequest(
987
- session.house,
988
- await heldControl(session),
989
- assistantDraftsPath(workerSession),
990
- { blocks },
991
- session.dependencies,
992
- session.signal,
993
- );
994
- if (!ASSISTANT_DRAFT_STATES.includes(answer?.state)) {
995
- throw new KitRefusal("kit_assistant_draft_rejected", workerSession);
996
- }
997
- return { state: answer.state };
1022
+ function withPlan(body, plan) {
1023
+ return plan === null || plan === undefined ? body : { ...body, plan };
998
1024
  }
999
1025
 
1000
- export async function acceptAssistantText(session, workerSession, sealed) {
1026
+ export async function acceptAssistantText(session, workerSession, sealed, plan = null) {
1001
1027
  const answer = await houseRequest(
1002
1028
  session.house,
1003
1029
  await heldControl(session),
1004
1030
  assistantTextPath(workerSession),
1005
- { operation_id: sealed.operation, blocks: sealed.blocks },
1031
+ withPlan({ operation_id: sealed.operation, blocks: sealed.blocks }, plan),
1006
1032
  session.dependencies,
1007
1033
  session.signal,
1008
1034
  );
@@ -1017,27 +1043,12 @@ export async function acceptAssistantText(session, workerSession, sealed) {
1017
1043
  return { turn: answer.turn, delivery: answer.delivery };
1018
1044
  }
1019
1045
 
1020
- export async function admitPlanSnapshot(session, workerSession, snapshot) {
1021
- const answer = await houseRequest(
1022
- session.house,
1023
- await heldControl(session),
1024
- `/kit/sessions/${encodeURIComponent(workerSession)}/plans`,
1025
- snapshot,
1026
- session.dependencies,
1027
- session.signal,
1028
- );
1029
- if (
1030
- answer?.session !== workerSession ||
1031
- typeof answer.plan !== "string" ||
1032
- !["live", "frozen"].includes(answer.state)
1033
- ) {
1034
- throw new KitRefusal("kit_plan_rejected", workerSession);
1035
- }
1036
- return { plan: answer.plan, state: answer.state };
1046
+ function named(value) {
1047
+ return typeof value === "string" && value.length > 0 ? value : null;
1037
1048
  }
1038
1049
 
1039
1050
  export async function observeSessionStart(session, workerSession) {
1040
- return houseRequest(
1051
+ const answer = await houseRequest(
1041
1052
  session.house,
1042
1053
  await heldControl(session),
1043
1054
  `/kit/sessions/${encodeURIComponent(workerSession)}/started`,
@@ -1045,14 +1056,19 @@ export async function observeSessionStart(session, workerSession) {
1045
1056
  session.dependencies,
1046
1057
  session.signal,
1047
1058
  );
1059
+ const conversation = named(answer?.conversation_id);
1060
+ const turn = named(answer?.turn_id);
1061
+ return conversation === null || turn === null
1062
+ ? null
1063
+ : { conversation_id: conversation, turn_id: turn, attempt_id: workerSession };
1048
1064
  }
1049
1065
 
1050
- export async function observeSessionStop(session, workerSession, turnCompleted = false) {
1066
+ export async function observeSessionStop(session, workerSession, turnCompleted = false, plan = null) {
1051
1067
  return houseRequest(
1052
1068
  session.house,
1053
1069
  await heldControl(session),
1054
1070
  `/kit/sessions/${encodeURIComponent(workerSession)}/stopped`,
1055
- turnCompleted ? { turn_completed: true } : {},
1071
+ withPlan(turnCompleted ? { turn_completed: true } : {}, plan),
1056
1072
  session.dependencies,
1057
1073
  session.signal,
1058
1074
  );
@@ -1117,17 +1133,6 @@ function acquiredInterrupt(value, declaration) {
1117
1133
  };
1118
1134
  }
1119
1135
 
1120
- export async function reportSessionHeartbeat(session, workerSession) {
1121
- await houseRequest(
1122
- session.house,
1123
- await heldControl(session),
1124
- `/kit/sessions/${encodeURIComponent(workerSession)}/heartbeat`,
1125
- {},
1126
- session.dependencies,
1127
- session.signal,
1128
- );
1129
- }
1130
-
1131
1136
  function answeredValue(value, declaration) {
1132
1137
  if (typeof value === "boolean") return value;
1133
1138
  if (typeof value === "number") {
@@ -1297,12 +1302,12 @@ export async function reportInteractionOutcome(session, interaction, outcome) {
1297
1302
  return { disposition: answer.disposition ?? null };
1298
1303
  }
1299
1304
 
1300
- export async function reportInterruptOutcome(session, interrupt, outcome) {
1305
+ export async function reportInterruptOutcome(session, interrupt, outcome, plan = null) {
1301
1306
  const answer = await houseRequest(
1302
1307
  session.house,
1303
1308
  await heldControl(session),
1304
1309
  `/kit/interrupts/${encodeURIComponent(interrupt)}/outcome`,
1305
- { outcome },
1310
+ withPlan({ outcome }, outcome === "interrupted" ? plan : null),
1306
1311
  session.dependencies,
1307
1312
  session.signal,
1308
1313
  );
@@ -1,11 +1,18 @@
1
1
  import { setTimeout as delay } from "node:timers/promises";
2
2
  import WebSocket from "ws";
3
- import { KIT_CONTRACT_VERSION } from "./contract.js";
3
+ import {
4
+ KIT_CONTRACT_VERSION,
5
+ KIT_STREAM_RECONNECT_MAX_MS,
6
+ KIT_STREAM_RECONNECT_MS,
7
+ KIT_STREAM_REJOIN_MS,
8
+ } from "./contract.js";
4
9
  import { heldControl } from "./house.js";
5
10
  import { KitRefusal } from "./refusal.js";
6
11
 
7
12
  const PROTOCOL = "house.kit.stream.1";
8
- const WATCHED_SUBJECTS = ["agents", "working_copy", "sources"];
13
+ const WORK_SUBJECTS = ["runs", "commands", "agents", "working_copy", "sources"];
14
+ const FINAL_REFUSALS = ["kit_upgrade_required", "kit_stream_replaced"];
15
+ const VIEW_FRAMES = ["draft", "plan"];
9
16
 
10
17
  function streamAddress(house) {
11
18
  const url = new URL("/kit/stream", house);
@@ -15,72 +22,95 @@ function streamAddress(house) {
15
22
  return url;
16
23
  }
17
24
 
18
- async function connected(session, signal, available, dependencies) {
25
+ function frameOf(data) {
26
+ try {
27
+ return JSON.parse(data.toString());
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ async function held(session, signal, heard, dependencies) {
19
34
  const authorization = await heldControl(session, signal);
20
35
  return new Promise((resolve, reject) => {
21
36
  const Socket = dependencies.workSocket ?? WebSocket;
22
37
  const socket = new Socket(streamAddress(session.house), PROTOCOL, {
23
38
  headers: { authorization, "x-house-kit-version": KIT_CONTRACT_VERSION },
24
39
  handshakeTimeout: 10_000,
25
- maxPayload: 4096,
26
40
  perMessageDeflate: false,
27
41
  });
28
42
  let finished = false;
29
- const finish = (error = null) => {
43
+ const finish = (reason = null, refusal = null) => {
30
44
  if (finished) return;
31
45
  finished = true;
32
46
  signal.removeEventListener("abort", stop);
33
- if (error === null) resolve();
34
- else reject(error);
47
+ heard.closed?.();
48
+ if (refusal === null) resolve(reason);
49
+ else reject(refusal);
35
50
  };
36
51
  const stop = () => {
37
52
  socket.terminate();
38
53
  finish();
39
54
  };
40
55
  signal.addEventListener("abort", stop, { once: true });
41
- socket.on("open", () => available(null));
56
+ socket.on("open", () => {
57
+ if (finished) return;
58
+ heard.opened?.((frame) => socket.send(JSON.stringify(frame), () => undefined));
59
+ heard.available(null);
60
+ });
42
61
  socket.on("message", (data) => {
43
- let frame;
44
- try {
45
- frame = JSON.parse(data.toString());
46
- } catch {
47
- return;
48
- }
49
- if (frame?.type === "work_available" && WATCHED_SUBJECTS.includes(frame.subject)) {
50
- available(frame.subject);
62
+ const frame = frameOf(data);
63
+ if (frame?.type === "work_available" && WORK_SUBJECTS.includes(frame.subject)) {
64
+ heard.available(frame.subject);
65
+ } else if (frame?.type === "entries") {
66
+ heard.changed();
67
+ } else if (VIEW_FRAMES.includes(frame?.type)) {
68
+ heard.viewed?.(frame);
51
69
  }
52
70
  });
53
71
  socket.on("unexpected-response", (_request, response) => {
54
72
  response.resume();
55
73
  if (response.statusCode === 409) session.dependencies.contractRefused?.();
56
- const refusal =
57
- response.statusCode === 409 ? new KitRefusal("kit_upgrade_required") : null;
74
+ finish(null, response.statusCode === 409 ? new KitRefusal("kit_upgrade_required") : null);
58
75
  socket.terminate();
59
- finish(refusal);
60
76
  });
61
77
  socket.on("error", () => {
62
78
  socket.terminate();
63
79
  finish();
64
80
  });
65
- socket.on("close", () => finish());
81
+ socket.on("close", (_code, reason) => {
82
+ const closed = reason?.toString() ?? "";
83
+ finish(closed, closed === "replaced" ? new KitRefusal("kit_stream_replaced") : null);
84
+ });
66
85
  if (signal.aborted) stop();
67
86
  });
68
87
  }
69
88
 
70
- export async function watchKitWork(session, signal, available, dependencies = {}) {
71
- let retryMs = 500;
89
+ export async function watchKitWork(session, signal, heard, dependencies = {}) {
90
+ const random = dependencies.random ?? Math.random;
91
+ const sleep = dependencies.streamSleep ?? ((milliseconds) => delay(milliseconds, undefined, { signal }));
92
+ let failures = 0;
72
93
  while (!signal.aborted) {
73
94
  const began = Date.now();
95
+ let reason = null;
74
96
  try {
75
- await connected(session, signal, available, dependencies);
97
+ reason = await held(session, signal, heard, dependencies);
76
98
  } catch (error) {
77
- if (error instanceof KitRefusal && error.code === "kit_upgrade_required") throw error;
78
- if (signal.aborted) return;
99
+ if (error instanceof KitRefusal && FINAL_REFUSALS.includes(error.code)) throw error;
79
100
  }
80
101
  if (signal.aborted) return;
81
- retryMs = Date.now() - began >= 30_000 ? 1000 : Math.min(retryMs * 2, 30_000);
102
+ let wait;
103
+ if (reason === "authority_changed") {
104
+ failures = 0;
105
+ wait = random() * KIT_STREAM_REJOIN_MS;
106
+ } else {
107
+ if (Date.now() - began >= KIT_STREAM_RECONNECT_MAX_MS) failures = 0;
108
+ const ceiling = Math.min(KIT_STREAM_RECONNECT_MAX_MS, KIT_STREAM_RECONNECT_MS * 2 ** failures);
109
+ failures += 1;
110
+ wait = ceiling / 2 + (random() * ceiling) / 2;
111
+ }
82
112
  try {
83
- await delay(dependencies.workReconnectMs ?? retryMs, undefined, { signal });
113
+ await sleep(wait);
84
114
  } catch (error) {
85
115
  if (!signal.aborted) throw error;
86
116
  }
package/src/launcher.js CHANGED
@@ -155,11 +155,23 @@ async function runTurn(channel, start, bridge, turn) {
155
155
  );
156
156
  const said = [];
157
157
  const message = { text: "" };
158
- const drafts = observedAssistantDrafts({ send: (text) => channel.send({ type: "draft", text }) });
158
+ const sequences = { draft: 0, plan: 0 };
159
+ const drafts = observedAssistantDrafts({
160
+ send: (text) => channel.send({ type: "draft", text, sequence: (sequences.draft += 1) }),
161
+ });
159
162
  const plans = observedPlans({
160
- send: (snapshot) =>
161
- channel.send({ type: "plan", provider_turn: turn.providerTurn, ...snapshot }),
163
+ send: (steps) =>
164
+ channel.send({
165
+ type: "plan",
166
+ provider_turn: turn.providerTurn,
167
+ sequence: (sequences.plan += 1),
168
+ steps,
169
+ }),
162
170
  });
171
+ turn.plan = () => {
172
+ const steps = plans.latest();
173
+ return steps === null ? null : { provider_turn: turn.providerTurn, steps };
174
+ };
163
175
  const wholeSoFar = () => [...said, message.text].filter((text) => text !== "").join("\n\n");
164
176
  let loading = false;
165
177
  const flush = () => {
@@ -167,7 +179,7 @@ async function runTurn(channel, start, bridge, turn) {
167
179
  channel.send({ type: "assistant", text: message.text });
168
180
  said.push(message.text);
169
181
  message.text = "";
170
- drafts.complete(wholeSoFar());
182
+ drafts.observe(wholeSoFar());
171
183
  };
172
184
  const handlers = {
173
185
  onPermission: (params) => resolvedPermission(params, start.execution_authority),
@@ -218,16 +230,16 @@ async function runTurn(channel, start, bridge, turn) {
218
230
  });
219
231
  await new Promise((resolve) => setImmediate(resolve));
220
232
  flush();
221
- plans.complete();
222
233
  channel.send({
223
234
  type: "turn_ended",
224
235
  stop_outcome: turnOutcome(contract, prompted?.stopReason),
225
236
  provider_continuation: contract.sessionLoad,
226
237
  provider_session_id: providerSessionId,
238
+ plan: turn.plan(),
227
239
  });
228
240
  } finally {
229
241
  turn.cancel = null;
230
- plans.complete();
242
+ plans.close();
231
243
  abandonQuestions(channel, turn);
232
244
  drafts.close();
233
245
  stopCommandAdapter(adapter);
@@ -240,6 +252,7 @@ export async function launchWorkerSession(bridge) {
240
252
  providerTurn: randomUUID(),
241
253
  questions: new Map(),
242
254
  cancel: null,
255
+ plan: () => null,
243
256
  };
244
257
  const peer = bridgePeer({
245
258
  socket: bridge.socket,
@@ -267,6 +280,7 @@ export async function launchWorkerSession(bridge) {
267
280
  type: "interrupt_applied",
268
281
  interrupt: frame.interrupt,
269
282
  outcome: "interrupted",
283
+ plan: turn.plan(),
270
284
  }),
271
285
  () =>
272
286
  peer.send({
@@ -289,6 +303,7 @@ export async function launchWorkerSession(bridge) {
289
303
  type: "failed",
290
304
  code: refusal instanceof KitRefusal ? refusal.code : "kit_adapter_failed",
291
305
  detail: refusal instanceof Error ? refusal.message : null,
306
+ plan: turn.plan(),
292
307
  });
293
308
  }
294
309
  peer.send({ type: "exiting" });
package/src/pacing.js ADDED
@@ -0,0 +1,41 @@
1
+ const PACING_INTERVAL_MS = 500;
2
+
3
+ export function pacedLatest(options) {
4
+ const intervalMs = options.intervalMs ?? PACING_INTERVAL_MS;
5
+ const now = options.now ?? (() => Date.now());
6
+ let timer = null;
7
+ let sentAt = null;
8
+ let sent = null;
9
+ let latest = null;
10
+ let closed = false;
11
+
12
+ function write() {
13
+ timer = null;
14
+ const serialized = JSON.stringify(latest);
15
+ if (latest === null || serialized === sent) return;
16
+ sent = serialized;
17
+ sentAt = now();
18
+ options.send(latest);
19
+ }
20
+
21
+ return {
22
+ observe(value) {
23
+ latest = value;
24
+ if (closed || timer !== null) return;
25
+ const wait = sentAt === null ? 0 : sentAt + intervalMs - now();
26
+ if (wait <= 0) {
27
+ write();
28
+ return;
29
+ }
30
+ timer = setTimeout(write, wait);
31
+ timer.unref?.();
32
+ },
33
+ latest: () => latest,
34
+ close() {
35
+ closed = true;
36
+ if (timer === null) return;
37
+ clearTimeout(timer);
38
+ timer = null;
39
+ },
40
+ };
41
+ }
package/src/plan.js CHANGED
@@ -1,57 +1,49 @@
1
- const PLAN_INTERVAL_MS = 500;
1
+ import { pacedLatest } from "./pacing.js";
2
2
 
3
- function steps(entries) {
4
- if (!Array.isArray(entries) || entries.length === 0) return null;
5
- return entries.map((entry) => ({ label: entry.content, status: entry.status }));
3
+ function plannedStep(step) {
4
+ if (typeof step !== "object" || step === null || Array.isArray(step)) return null;
5
+ if (typeof step.label !== "string" || typeof step.status !== "string") return null;
6
+ return {
7
+ label: step.label,
8
+ ...(typeof step.priority === "string" ? { priority: step.priority } : {}),
9
+ status: step.status,
10
+ };
6
11
  }
7
12
 
8
- export function observedPlans(options) {
9
- const intervalMs = options.intervalMs ?? PLAN_INTERVAL_MS;
10
- const now = options.now ?? (() => Date.now());
11
- let timer = null;
12
- let sentAt = null;
13
- let sent = null;
14
- let latest = null;
15
- let completed = false;
13
+ export function planSnapshot(value) {
14
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
15
+ if (typeof value.provider_turn !== "string" || value.provider_turn === "") return null;
16
+ if (!Array.isArray(value.steps) || value.steps.length === 0) return null;
17
+ const steps = value.steps.map(plannedStep);
18
+ if (steps.includes(null)) return null;
19
+ return { provider_turn: value.provider_turn, steps };
20
+ }
16
21
 
17
- function write(terminal) {
18
- if (latest === null) return;
19
- const serialized = JSON.stringify(latest);
20
- if (!terminal && serialized === sent) return;
21
- sent = serialized;
22
- sentAt = now();
23
- options.send({ steps: latest, terminal });
24
- }
22
+ export function finalPlan(snapshot) {
23
+ if (snapshot === null || snapshot === undefined) return null;
24
+ return {
25
+ provider_turn: snapshot.provider_turn,
26
+ steps: snapshot.steps.map((step) => ({ label: step.label, status: step.status })),
27
+ };
28
+ }
25
29
 
26
- function cancel() {
27
- if (timer === null) return;
28
- clearTimeout(timer);
29
- timer = null;
30
- }
30
+ function steps(entries) {
31
+ if (!Array.isArray(entries) || entries.length === 0) return null;
32
+ return entries.map((entry) => ({
33
+ label: entry.content,
34
+ priority: entry.priority,
35
+ status: entry.status,
36
+ }));
37
+ }
31
38
 
39
+ export function observedPlans(options) {
40
+ const paced = pacedLatest(options);
32
41
  return {
33
42
  observe(entries) {
34
- if (completed) return;
35
43
  const observed = steps(entries);
36
- if (observed === null) return;
37
- latest = observed;
38
- if (timer !== null) return;
39
- const wait = sentAt === null ? 0 : sentAt + intervalMs - now();
40
- if (wait <= 0) {
41
- write(false);
42
- return;
43
- }
44
- timer = setTimeout(() => {
45
- timer = null;
46
- write(false);
47
- }, wait);
48
- timer.unref?.();
49
- },
50
- complete() {
51
- if (completed) return;
52
- completed = true;
53
- cancel();
54
- write(true);
44
+ if (observed !== null) paced.observe(observed);
55
45
  },
46
+ latest: paced.latest,
47
+ close: paced.close,
56
48
  };
57
49
  }
package/src/tasks.js CHANGED
@@ -33,25 +33,28 @@ export function residentWake() {
33
33
  release?.();
34
34
  },
35
35
  async wait(milliseconds, signal) {
36
- if (pending) {
37
- pending = false;
38
- return;
39
- }
40
- const pause = new AbortController();
41
- try {
42
- await Promise.race([
43
- new Promise((resolve) => {
44
- release = resolve;
45
- }),
46
- delay(milliseconds, undefined, { signal: AbortSignal.any([signal, pause.signal]) }),
47
- ]);
48
- } catch (error) {
49
- if (!signal.aborted) throw error;
50
- } finally {
51
- release = null;
52
- pending = false;
53
- pause.abort();
36
+ if (!pending && !signal.aborted) {
37
+ const pause = new AbortController();
38
+ const paused = AbortSignal.any([signal, pause.signal]);
39
+ try {
40
+ await Promise.race([
41
+ new Promise((resolve) => {
42
+ release = resolve;
43
+ }),
44
+ Number.isFinite(milliseconds)
45
+ ? delay(milliseconds, undefined, { signal: paused })
46
+ : new Promise((resolve) => paused.addEventListener("abort", resolve, { once: true })),
47
+ ]);
48
+ } catch (error) {
49
+ if (!signal.aborted) throw error;
50
+ } finally {
51
+ release = null;
52
+ pause.abort();
53
+ }
54
54
  }
55
+ const woken = pending;
56
+ pending = false;
57
+ return woken;
55
58
  },
56
59
  };
57
60
  }