@workerdeck/core 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/index.d.mts CHANGED
@@ -242,6 +242,26 @@ interface Runner {
242
242
  /** Resolve a pending permission request. Returns false if the id is unknown (e.g. timed out). */
243
243
  resolvePermission(requestId: string, decision: PermissionDecision): boolean;
244
244
  interrupt(): Promise<void>;
245
+ /**
246
+ * Reset the conversation in place: the session keeps its id, its watermarks
247
+ * and its place in the list, and the engine starts over with an empty
248
+ * context. Announced with a `conversation_reset` event, whose replay rules
249
+ * (see `transcriptContent` in `@workerdeck/protocol`) are what stop an
250
+ * attaching client from resurrecting the cleared rows.
251
+ *
252
+ * Optional, like every member added after `Runner` became public API: an
253
+ * out-of-tree runner that declines it declines the `clear_context` command
254
+ * with it, which is exactly what `EngineCapabilities.clearContext: false`
255
+ * tells clients to expect.
256
+ *
257
+ * **Queues behind in-flight work rather than racing it** — resolving when the
258
+ * clear has actually happened, not when it was accepted. A clear that landed
259
+ * in the middle of the turn it was clearing would be neither, and the engines
260
+ * differ in how they wait (claude hands `/clear` to a CLI that queues its own
261
+ * streamed input; codex and the provider put it on their turn chain), so the
262
+ * one thing callers may rely on is the resolution, not the mechanism.
263
+ */
264
+ clearContext?(): Promise<void>;
245
265
  setPermissionMode(mode: PermissionMode): Promise<void>;
246
266
  /** Switch the model for subsequent responses; undefined = back to the default. */
247
267
  setModel(model?: string): Promise<void>;
@@ -357,6 +377,25 @@ declare class SessionRunner implements Runner {
357
377
  /** Resolve a pending permission request. Returns false if the id is unknown (e.g. timed out). */
358
378
  resolvePermission(requestId: string, decision: PermissionDecision): boolean;
359
379
  interrupt(): Promise<void>;
380
+ /**
381
+ * Reset the conversation by sending the `/clear` the CLI already honors.
382
+ *
383
+ * Deliberately not a second mechanism: this engine's reset arrives *from the
384
+ * SDK*, and `normalizeSdkMessage` turns the CLI's report of it into the
385
+ * `conversation_reset` event (adopting the new conversation id and re-polling
386
+ * context usage on the way through). Reimplementing the clear here would give
387
+ * one engine two ways to reach the same state, and only one of them would get
388
+ * the id adoption right. So the command and the composer's `/clear` are one
389
+ * behaviour, and this method is the thin end of it.
390
+ *
391
+ * The one place it differs from the other two engines: this resolves when the
392
+ * `/clear` has been **handed to the CLI**, not when the reset has happened —
393
+ * the CLI queues its own streamed input, so waiting is its job, and there is
394
+ * no chain here to ride. The observable contract is the same (a clear sent
395
+ * mid-turn queues rather than cutting the turn short); only the moment the
396
+ * promise settles is weaker, and no caller depends on it.
397
+ */
398
+ clearContext(): Promise<void>;
360
399
  setPermissionMode(mode: PermissionMode): Promise<void>;
361
400
  /** Switch the model for subsequent responses; undefined = back to the default. */
362
401
  setModel(model?: string): Promise<void>;
@@ -399,8 +438,12 @@ declare class SessionRunner implements Runner {
399
438
  * 1. `afterSeq` — the caller already holds everything at or below it.
400
439
  * 2. `resetSeq` — transcript *content* strictly below the latest
401
440
  * `conversation_reset` is skipped, so a re-attach cannot resurrect a cleared
402
- * conversation while state events still replay. Claude's alone; the other
403
- * engines pass 0.
441
+ * conversation while state events still replay. **Every engine that can emit
442
+ * a reset must track it and pass it** — this was Claude's alone only for as
443
+ * long as Claude's was the only engine that could produce the event, and the
444
+ * failure when a runner forgets is quiet: the end state is right for a
445
+ * current reducer, so nothing looks broken while every attach re-sends the
446
+ * whole cleared conversation for the process's lifetime.
404
447
  * 3. `coalesceReplay` — last-write-wins state readings superseded later in the
405
448
  * same replay (`staleReplaySeqs`), plus everything `replayRetains` says the
406
449
  * reducer reads and discards. Opt-in, and only sound for a consumer whose
@@ -627,6 +670,20 @@ declare class AiSdkRunner implements Runner {
627
670
  * its tokens are never lost from the turn's accounting.
628
671
  */
629
672
  generateDigest(prompt: string): Promise<string>;
673
+ /**
674
+ * Reset the conversation: drop the message array the next turn would have
675
+ * been built from. There is no engine round trip — this runner *is* where the
676
+ * transcript lives, so clearing it is the whole operation.
677
+ *
678
+ * Two things ride along, both already written elsewhere and both load-bearing
679
+ * here. `#emit`'s `conversation_reset` arm retires `#contextUsage` (the
680
+ * reading described a conversation that no longer exists), and the same arm
681
+ * in `restore` keeps a parked session that comes back after a clear from
682
+ * resurrecting it. Pending tool calls are NOT swept: a parked call is work a
683
+ * backend still owes an answer for, and a clear is not an interrupt — the
684
+ * refusal below is what keeps the two apart.
685
+ */
686
+ clearContext(): Promise<void>;
630
687
  interrupt(): Promise<void>;
631
688
  setPermissionMode(mode: PermissionMode): Promise<void>;
632
689
  setModel(model?: string): Promise<void>;
@@ -1717,6 +1774,36 @@ declare class CodexRunner implements Runner {
1717
1774
  * timed out, or already settled by codex itself). */
1718
1775
  resolvePermission(requestId: string, decision: PermissionDecision): boolean;
1719
1776
  interrupt(): Promise<void>;
1777
+ /**
1778
+ * Reset the conversation: a **fresh thread on the same session**.
1779
+ *
1780
+ * Codex has no clear/reset RPC — `thread/compact/start` summarises and
1781
+ * continues, `thread/fork` makes a second thread, and neither is "same
1782
+ * session, empty context". So the analog is to stop resuming the old thread
1783
+ * and start a new one, which is the path a dead child already takes minus the
1784
+ * resume. The old thread is NOT deleted: it stays in CODEX_HOME and stays
1785
+ * resumable from `GET /sdk-sessions`.
1786
+ *
1787
+ * Two things it does on the way through, both mirroring the Claude engine's
1788
+ * SDK-driven reset (`engines/claude/runner.ts`):
1789
+ *
1790
+ * 1. **The new thread id is adopted before `conversation_reset` is emitted**,
1791
+ * whenever a child is already up — the eager `thread/start` costs no
1792
+ * tokens and no model call, and it is what keeps the dormant record from
1793
+ * ever naming the conversation that was just cleared. With no child there
1794
+ * is nothing to start against and the id is simply dropped; the parking
1795
+ * service treats a resumable session with no engine session id as one with
1796
+ * nothing to come back to, and forgets the stale record.
1797
+ * 2. **The context reading is retired**, in `#emit`'s `conversation_reset`
1798
+ * arm. Codex cannot re-poll it the way Claude does — the only source is
1799
+ * `thread/tokenUsage/updated`, which arrives *during* a turn — so there is
1800
+ * no reading at all until the next turn runs, and the protocol's rule
1801
+ * applies: render nothing rather than a stale ring or a 0%.
1802
+ *
1803
+ * The turn counter stays monotonic across this, on purpose (it is an unread
1804
+ * cursor, not an item count), and so does `activityCount` — `#emit` owns both.
1805
+ */
1806
+ clearContext(): Promise<void>;
1720
1807
  setPermissionMode(mode: PermissionMode): Promise<void>;
1721
1808
  setModel(model?: string): Promise<void>;
1722
1809
  fail(message: string): void;
package/build/index.mjs CHANGED
@@ -607,8 +607,12 @@ function staleReplaySeqs(events, afterSeq) {
607
607
  * 1. `afterSeq` — the caller already holds everything at or below it.
608
608
  * 2. `resetSeq` — transcript *content* strictly below the latest
609
609
  * `conversation_reset` is skipped, so a re-attach cannot resurrect a cleared
610
- * conversation while state events still replay. Claude's alone; the other
611
- * engines pass 0.
610
+ * conversation while state events still replay. **Every engine that can emit
611
+ * a reset must track it and pass it** — this was Claude's alone only for as
612
+ * long as Claude's was the only engine that could produce the event, and the
613
+ * failure when a runner forgets is quiet: the end state is right for a
614
+ * current reducer, so nothing looks broken while every attach re-sends the
615
+ * whole cleared conversation for the process's lifetime.
612
616
  * 3. `coalesceReplay` — last-write-wins state readings superseded later in the
613
617
  * same replay (`staleReplaySeqs`), plus everything `replayRetains` says the
614
618
  * reducer reads and discards. Opt-in, and only sound for a consumer whose
@@ -1309,6 +1313,28 @@ var SessionRunner = class {
1309
1313
  async interrupt() {
1310
1314
  await this.#query?.interrupt();
1311
1315
  }
1316
+ /**
1317
+ * Reset the conversation by sending the `/clear` the CLI already honors.
1318
+ *
1319
+ * Deliberately not a second mechanism: this engine's reset arrives *from the
1320
+ * SDK*, and `normalizeSdkMessage` turns the CLI's report of it into the
1321
+ * `conversation_reset` event (adopting the new conversation id and re-polling
1322
+ * context usage on the way through). Reimplementing the clear here would give
1323
+ * one engine two ways to reach the same state, and only one of them would get
1324
+ * the id adoption right. So the command and the composer's `/clear` are one
1325
+ * behaviour, and this method is the thin end of it.
1326
+ *
1327
+ * The one place it differs from the other two engines: this resolves when the
1328
+ * `/clear` has been **handed to the CLI**, not when the reset has happened —
1329
+ * the CLI queues its own streamed input, so waiting is its job, and there is
1330
+ * no chain here to ride. The observable contract is the same (a clear sent
1331
+ * mid-turn queues rather than cutting the turn short); only the moment the
1332
+ * promise settles is weaker, and no caller depends on it.
1333
+ */
1334
+ async clearContext() {
1335
+ if (this.#status === "closed" || this.#status === "failed") throw new Error("session is closed");
1336
+ this.sendMessage("/clear");
1337
+ }
1312
1338
  async setPermissionMode(mode) {
1313
1339
  await this.#query?.setPermissionMode(mode);
1314
1340
  this.#permissionMode = mode;
@@ -1836,6 +1862,15 @@ var AiSdkRunner = class {
1836
1862
  */
1837
1863
  #contextUsage;
1838
1864
  #activityCount = 0;
1865
+ /**
1866
+ * Seq of the latest `conversation_reset` event, 0 when none. The log itself is
1867
+ * never truncated — it still carries the state-bearing events (`capabilities`,
1868
+ * `system_init`, …) a fresh attacher depends on and which are not re-emitted —
1869
+ * but `subscribe()` skips transcript *content* strictly below this mark, so a
1870
+ * replay does not resurrect a cleared conversation. A later reset supersedes
1871
+ * an earlier one by overwriting it.
1872
+ */
1873
+ #resetSeq = 0;
1839
1874
  #status = "starting";
1840
1875
  #permissionMode;
1841
1876
  #messages = [];
@@ -1887,8 +1922,10 @@ var AiSdkRunner = class {
1887
1922
  this.#activityCount = 0;
1888
1923
  for (const event of this.#events) {
1889
1924
  this.#activityCount += transcriptActivity(event);
1890
- if (event.type === "conversation_reset") this.#contextUsage = void 0;
1891
- else this.#contextUsage = contextReading(event) ?? this.#contextUsage;
1925
+ if (event.type === "conversation_reset") {
1926
+ this.#resetSeq = event.seq;
1927
+ this.#contextUsage = void 0;
1928
+ } else this.#contextUsage = contextReading(event) ?? this.#contextUsage;
1892
1929
  }
1893
1930
  this.#messages = [...state.messages];
1894
1931
  for (const call of state.pendingToolCalls) this.#pendingToolCalls.set(call.toolCallId, call);
@@ -2158,6 +2195,30 @@ var AiSdkRunner = class {
2158
2195
  }
2159
2196
  return result.text;
2160
2197
  }
2198
+ /**
2199
+ * Reset the conversation: drop the message array the next turn would have
2200
+ * been built from. There is no engine round trip — this runner *is* where the
2201
+ * transcript lives, so clearing it is the whole operation.
2202
+ *
2203
+ * Two things ride along, both already written elsewhere and both load-bearing
2204
+ * here. `#emit`'s `conversation_reset` arm retires `#contextUsage` (the
2205
+ * reading described a conversation that no longer exists), and the same arm
2206
+ * in `restore` keeps a parked session that comes back after a clear from
2207
+ * resurrecting it. Pending tool calls are NOT swept: a parked call is work a
2208
+ * backend still owes an answer for, and a clear is not an interrupt — the
2209
+ * refusal below is what keeps the two apart.
2210
+ */
2211
+ async clearContext() {
2212
+ if (this.#status === "closed" || this.#status === "failed") throw new Error("session is closed");
2213
+ const run = this.#turnChain.then(() => {
2214
+ if (this.#closed) throw new Error("session is closed");
2215
+ if (this.#pendingToolCalls.size > 0) throw new Error("cannot clear context while tool calls are outstanding");
2216
+ this.#messages = [];
2217
+ this.#emit({ type: "conversation_reset" });
2218
+ });
2219
+ this.#turnChain = run.then(() => void 0, () => void 0);
2220
+ await run;
2221
+ }
2161
2222
  async interrupt() {
2162
2223
  if (this.#abort) this.#abort.abort();
2163
2224
  else if (this.#pendingToolCalls.size > 0) {
@@ -2238,7 +2299,7 @@ var AiSdkRunner = class {
2238
2299
  return this.#events.find((event) => event.seq === seq);
2239
2300
  }
2240
2301
  subscribe(listener, afterSeq = 0, options) {
2241
- return this.#subscribers.subscribe(this.#events, listener, afterSeq, options);
2302
+ return this.#subscribers.subscribe(this.#events, listener, afterSeq, options, this.#resetSeq);
2242
2303
  }
2243
2304
  #scheduleTurn() {
2244
2305
  this.#turnChain = this.#turnChain.then(() => this.#runTurn());
@@ -2628,7 +2689,10 @@ var AiSdkRunner = class {
2628
2689
  this.#lastActivityAt = event.ts;
2629
2690
  this.#activityCount += transcriptActivity(body);
2630
2691
  this.#contextUsage = contextReading(body) ?? this.#contextUsage;
2631
- if (body.type === "conversation_reset") this.#contextUsage = void 0;
2692
+ if (body.type === "conversation_reset") {
2693
+ this.#resetSeq = event.seq;
2694
+ this.#contextUsage = void 0;
2695
+ }
2632
2696
  this.#events.push(event);
2633
2697
  this.#subscribers.emit(event);
2634
2698
  }
@@ -4100,6 +4164,25 @@ var CodexAgentTracker = class {
4100
4164
  sweep() {
4101
4165
  for (const record of this.#byThread.values()) if (record.status === "running") this.#settle(record, "failed");
4102
4166
  }
4167
+ /**
4168
+ * The conversation these agents belonged to is gone (a `conversation_reset`).
4169
+ *
4170
+ * Deliberately NOT {@link CodexAgentTracker.sweep}: that settles the running
4171
+ * ones as failed and keeps the rows, which is right when the *process* dies —
4172
+ * the transcript still holds the anchor `tool_use` each row points at, and a
4173
+ * row that vanished would leave that card unexplained. A clear is the other
4174
+ * way round. The anchors go with the transcript, so a surviving row would
4175
+ * publish a `toolUseId` that resolves to nothing — and clients key a
4176
+ * pressable, enterable agent line off exactly that id.
4177
+ */
4178
+ forget() {
4179
+ this.#byThread.clear();
4180
+ }
4181
+ /** The thread ids currently tracked — what a clear remembers so a still-running
4182
+ * agent's later traffic can be dropped rather than re-anchored. */
4183
+ threadIds() {
4184
+ return Array.from(this.#byThread.keys());
4185
+ }
4103
4186
  /** The rollup as `SessionInfo.subagents` serves it — spawn order, fresh
4104
4187
  * objects, and `undefined` when there is nothing to say (absent and empty
4105
4188
  * mean the same thing to a client, and bytes on a polled list are paid for). */
@@ -4943,6 +5026,15 @@ var CodexRunner = class {
4943
5026
  */
4944
5027
  #contextUsage;
4945
5028
  #activityCount = 0;
5029
+ /**
5030
+ * Seq of the latest `conversation_reset` event, 0 when none. The log itself is
5031
+ * never truncated — it still carries the state-bearing events (`capabilities`,
5032
+ * `system_init`, …) a fresh attacher depends on and which are not re-emitted —
5033
+ * but `subscribe()` skips transcript *content* strictly below this mark, so a
5034
+ * replay does not resurrect a cleared conversation. A later reset supersedes
5035
+ * an earlier one by overwriting it.
5036
+ */
5037
+ #resetSeq = 0;
4946
5038
  #status = "starting";
4947
5039
  #sdkSessionId;
4948
5040
  #model;
@@ -5005,6 +5097,15 @@ var CodexRunner = class {
5005
5097
  * it, and only the child process dying (or the session closing) ends them
5006
5098
  * all — see the module doc in `subagents.ts`. */
5007
5099
  #agents = new CodexAgentTracker();
5100
+ /** Threads that belonged to a conversation this session has cleared — the
5101
+ * agents that were still running when it happened. Their notifications keep
5102
+ * arriving on the same connection (a clear does not interrupt them and does
5103
+ * not drop the child), and without this {@link CodexRunner.#agentFor} would
5104
+ * mint them a fresh anchor and stream the cleared conversation's agent work
5105
+ * into the new one. Never pruned: it is a handful of uuids for the session's
5106
+ * life, and a late report from a long-dead agent is exactly what it exists to
5107
+ * catch. */
5108
+ #clearedThreads = /* @__PURE__ */ new Set();
5008
5109
  constructor(config, id = randomUUID()) {
5009
5110
  const mode = config.permissionMode ?? "default";
5010
5111
  if (!ENGINE_CAPABILITIES.codex.permissionModes.includes(mode)) throw new Error(`permission mode '${mode}' is not supported by the codex engine`);
@@ -5183,6 +5284,15 @@ var CodexRunner = class {
5183
5284
  }
5184
5285
  sendMessage(text, attachments) {
5185
5286
  if (this.#closed) throw new Error("session is closed");
5287
+ if (text.trim() === "/clear" && !attachments?.length) {
5288
+ this.clearContext().catch((error) => {
5289
+ this.#emit({
5290
+ type: "session_error",
5291
+ message: `could not clear the conversation: ${error instanceof Error ? error.message : String(error)}`
5292
+ });
5293
+ });
5294
+ return;
5295
+ }
5186
5296
  const input = this.#buildInput(text, attachments ?? []);
5187
5297
  const echo = () => this.#emit({
5188
5298
  type: "user_message",
@@ -5254,6 +5364,66 @@ var CodexRunner = class {
5254
5364
  await this.#interruptTurn();
5255
5365
  await this.#turnChain;
5256
5366
  }
5367
+ /**
5368
+ * Reset the conversation: a **fresh thread on the same session**.
5369
+ *
5370
+ * Codex has no clear/reset RPC — `thread/compact/start` summarises and
5371
+ * continues, `thread/fork` makes a second thread, and neither is "same
5372
+ * session, empty context". So the analog is to stop resuming the old thread
5373
+ * and start a new one, which is the path a dead child already takes minus the
5374
+ * resume. The old thread is NOT deleted: it stays in CODEX_HOME and stays
5375
+ * resumable from `GET /sdk-sessions`.
5376
+ *
5377
+ * Two things it does on the way through, both mirroring the Claude engine's
5378
+ * SDK-driven reset (`engines/claude/runner.ts`):
5379
+ *
5380
+ * 1. **The new thread id is adopted before `conversation_reset` is emitted**,
5381
+ * whenever a child is already up — the eager `thread/start` costs no
5382
+ * tokens and no model call, and it is what keeps the dormant record from
5383
+ * ever naming the conversation that was just cleared. With no child there
5384
+ * is nothing to start against and the id is simply dropped; the parking
5385
+ * service treats a resumable session with no engine session id as one with
5386
+ * nothing to come back to, and forgets the stale record.
5387
+ * 2. **The context reading is retired**, in `#emit`'s `conversation_reset`
5388
+ * arm. Codex cannot re-poll it the way Claude does — the only source is
5389
+ * `thread/tokenUsage/updated`, which arrives *during* a turn — so there is
5390
+ * no reading at all until the next turn runs, and the protocol's rule
5391
+ * applies: render nothing rather than a stale ring or a 0%.
5392
+ *
5393
+ * The turn counter stays monotonic across this, on purpose (it is an unread
5394
+ * cursor, not an item count), and so does `activityCount` — `#emit` owns both.
5395
+ */
5396
+ async clearContext() {
5397
+ if (this.#closed) throw new Error("session is closed");
5398
+ const run = this.#turnChain.then(() => this.#clearNow());
5399
+ this.#turnChain = run.then(() => void 0, () => void 0);
5400
+ await run;
5401
+ }
5402
+ /** The clear itself, only ever called as a turn-chain link. */
5403
+ async #clearNow() {
5404
+ if (this.#closed) throw new Error("session is closed");
5405
+ const previousThread = this.#sdkSessionId;
5406
+ this.#sdkSessionId = void 0;
5407
+ this.#threadLoaded = false;
5408
+ if (this.#connection) try {
5409
+ await this.#ensureThread();
5410
+ } catch (error) {
5411
+ this.#sdkSessionId = previousThread;
5412
+ this.#threadLoaded = false;
5413
+ throw error;
5414
+ }
5415
+ for (const agent of this.#agents.threadIds()) this.#clearedThreads.add(agent);
5416
+ this.#agents.forget();
5417
+ for (const [id, pending] of this.#approvals) this.#settleApproval(id, pending, {
5418
+ behavior: "deny",
5419
+ message: "the conversation was cleared"
5420
+ }, "policy");
5421
+ this.#resumedHistory = void 0;
5422
+ this.#emit({
5423
+ type: "conversation_reset",
5424
+ sdkSessionId: this.#sdkSessionId
5425
+ });
5426
+ }
5257
5427
  /** Address the in-flight turn only (no approval sweep) — also the follow-up
5258
5428
  * for a deny+interrupt whose wire decision couldn't carry the interrupt. */
5259
5429
  async #interruptTurn() {
@@ -5331,7 +5501,7 @@ var CodexRunner = class {
5331
5501
  return this.#events.find((event) => event.seq === seq);
5332
5502
  }
5333
5503
  subscribe(listener, afterSeq = 0, options) {
5334
- return this.#subscribers.subscribe(this.#events, listener, afterSeq, options);
5504
+ return this.#subscribers.subscribe(this.#events, listener, afterSeq, options, this.#resetSeq);
5335
5505
  }
5336
5506
  #scheduleTurn() {
5337
5507
  this.#turnChain = this.#turnChain.then(() => this.#runTurn());
@@ -5747,6 +5917,7 @@ var CodexRunner = class {
5747
5917
  if (threadId === void 0 || threadId === this.#sdkSessionId) return void 0;
5748
5918
  const known = this.#agents.get(threadId);
5749
5919
  if (known) return known;
5920
+ if (this.#clearedThreads.has(threadId)) return void 0;
5750
5921
  const nonce = this.#activeTurn?.nonce ?? "codex";
5751
5922
  const record = this.#agents.open(threadId, `${nonce}:agent:${threadId}`, void 0, Date.now());
5752
5923
  record.anchored = true;
@@ -6385,7 +6556,10 @@ var CodexRunner = class {
6385
6556
  this.#lastActivityAt = event.ts;
6386
6557
  this.#activityCount += transcriptActivity(body);
6387
6558
  this.#contextUsage = contextReading(body) ?? this.#contextUsage;
6388
- if (body.type === "conversation_reset") this.#contextUsage = void 0;
6559
+ if (body.type === "conversation_reset") {
6560
+ this.#resetSeq = event.seq;
6561
+ this.#contextUsage = void 0;
6562
+ }
6389
6563
  this.#events.push(event);
6390
6564
  this.#subscribers.emit(event);
6391
6565
  }