@salesforce/sfdx-agent-harness-claude 0.45.0 → 0.47.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/CHANGELOG.md CHANGED
@@ -3,6 +3,20 @@
3
3
  All notable changes to `@salesforce/sfdx-agent-harness-claude` are documented in this file.
4
4
  Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
+ ## [0.47.0] - 2026-08-18
7
+
8
+ ### Features
9
+ - **harness-claude**: opt-in approvalHookTimeoutMs to outlast SDK per-hook timeout @W-23848717@ ([#755](https://github.com/forcedotcom/agentic-dx/pull/755))
10
+
11
+ ### Chores
12
+ - **deps-dev**: bump the eslint group across 1 directory with 2 updates ([#754](https://github.com/forcedotcom/agentic-dx/pull/754))
13
+ - **deps-dev**: bump the dev-dependencies group with 2 updates ([#753](https://github.com/forcedotcom/agentic-dx/pull/753))
14
+
15
+ ## [0.46.0] - 2026-08-17
16
+
17
+ ### Features
18
+ - **agent-sdk,harness-mastra,harness-claude,harness-openai**: session-context contract + conformance gate @W-23632685@ ([#747](https://github.com/forcedotcom/agentic-dx/pull/747))
19
+
6
20
  ## [0.45.0] - 2026-08-14
7
21
 
8
22
  _No changes — released alongside dependent packages._
package/README.md CHANGED
@@ -51,7 +51,7 @@ cross-harness comparison.
51
51
  | Export | Kind | Description |
52
52
  | ------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
53
53
  | `ClaudeHarnessFactory` | class | `HarnessFactory` that produces a Claude-backed `AgentHarness` bound to a storage root. |
54
- | `ClaudeHarnessFactoryConfig` | interface | Configuration for `ClaudeHarnessFactory` (`permissionMode`, optional `queryDefaults`, `toolApprovalTimeoutMs`, `subprocessEnv`). |
54
+ | `ClaudeHarnessFactoryConfig` | interface | Configuration for `ClaudeHarnessFactory` (`permissionMode`, optional `queryDefaults`, `toolApprovalTimeoutMs`, `approvalHookTimeoutMs`, `subprocessEnv`). |
55
55
  | `CLAUDE_BUILT_IN_TOOL_POLICIES` | const | Frozen `ReadonlyArray<ToolPolicyRule>` the harness feeds the SDK's `resolveToolApprovalPolicy` as the `tiers.harness` slice — `require-approval` rules for Claude's mutating built-ins `Bash` / `Edit` / `MultiEdit` / `Write` / `NotebookEdit`. |
56
56
  | `CLAUDE_BUILT_IN_TOOL_NAMES` | const | The five built-in tool names `CLAUDE_BUILT_IN_TOOL_POLICIES` covers. |
57
57
  | `ClaudeHarnessPermissionMode` | type | Narrowed permission mode: `'bypassPermissions' \| 'acceptEdits' \| 'plan'`. |
@@ -246,6 +246,13 @@ Each pending approval is auto-denied after `ClaudeHarnessFactoryConfig.toolAppro
246
246
  Calling `approveToolCall` or `declineToolCall` before the timeout cancels the timer — no spurious error event surfaces
247
247
  afterwards.
248
248
 
249
+ > **SDK hook-timeout interaction:** `toolApprovalTimeoutMs` is the harness's own auto-deny timer, but the parked
250
+ > approval is a Claude SDK `PreToolUse` hook, and the SDK aborts a hook after its own per-hook timeout (~60s) regardless
251
+ > of the harness timer. A `toolApprovalTimeoutMs` above that default therefore does nothing on its own — the SDK tears
252
+ > the hook down first. Set `ClaudeHarnessFactoryConfig.approvalHookTimeoutMs` (milliseconds; typically equal to
253
+ > `toolApprovalTimeoutMs`) to raise the SDK's per-hook timeout so the parked approval survives long enough for the
254
+ > harness timer to own the deadline. Left unset, the SDK default is preserved.
255
+
249
256
  > **Consumer pattern:** to observe the timeout (or `abort` / `dispose`) error on the `eventStream`, iterate the stream
250
257
  > to its natural end rather than `break`ing out after the `tool-approval-request` event. Calling `iterator.return()`
251
258
  > (which `break` does inside `for await`) finalizes the harness's source generator before the terminal event can reach
@@ -20,6 +20,17 @@ export interface ClaudeHarnessFactoryConfig {
20
20
  * Defaults to `600_000` (10 minutes), matching the Mastra MCP timeout.
21
21
  */
22
22
  toolApprovalTimeoutMs?: number;
23
+ /**
24
+ * Opt-in override (milliseconds) for the SDK's per-hook timeout on the
25
+ * approval-gating `PreToolUse` hook. The SDK aborts a parked hook after
26
+ * its own default (~60s) regardless of {@link toolApprovalTimeoutMs} — so
27
+ * a consumer whose human-in-the-loop approval window must outlast that
28
+ * default has to raise this too, or the parked approval is torn down
29
+ * before {@link toolApprovalTimeoutMs} can fire. Undefined leaves the
30
+ * SDK default in place (unchanged behavior for existing consumers);
31
+ * typically set equal to `toolApprovalTimeoutMs`.
32
+ */
33
+ approvalHookTimeoutMs?: number;
23
34
  /**
24
35
  * Static operational environment variables to set on every Claude
25
36
  * subprocess spawn. Layered above the inherited host env (so these win
@@ -32,7 +32,7 @@ export class ClaudeHarnessFactory {
32
32
  ? { allowDangerouslySkipPermissions: true }
33
33
  : {}),
34
34
  };
35
- const optionsBuilder = new ClaudeQueryOptionsBuilder({ ...permissionDefaults, ...this.config.queryDefaults });
35
+ const optionsBuilder = new ClaudeQueryOptionsBuilder({ ...permissionDefaults, ...this.config.queryDefaults }, this.config.approvalHookTimeoutMs);
36
36
  // Bind `innerFetch` into the mcp-client factory closure so every
37
37
  // call site inside `ClaudeHarness` (createAgent, reconnect, updateAgent)
38
38
  // routes MCP HTTP transports through the same proxy-aware fetch.
@@ -1 +1 @@
1
- {"version":3,"file":"claude-harness-factory.js","sourceRoot":"","sources":["../src/claude-harness-factory.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,qBAAqB,EAAE,sBAAsB,EAAE,MAAM,4BAA4B,CAAC;AAG3F,OAAO,EAAE,qBAAqB,EAAE,MAAM,+BAA+B,CAAC;AACtE,OAAO,EAAE,yBAAyB,EAAE,MAAM,mCAAmC,CAAC;AAC9E,OAAO,EAAE,aAAa,EAA2B,MAAM,qBAAqB,CAAC;AAC7E,OAAO,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAC;AAa1D,MAAM,gCAAgC,GAAG,OAAO,CAAC;AAkCjD,MAAM,OAAO,oBAAoB;IAYA,MAAM;IAX1B,SAAS,GAAG,QAAQ,CAAC;IACrB,eAAe,GAAG,CAAC,CAAC;IAC7B;;;;;;OAMG;IACM,sBAAsB,GAA4B,CAAC,WAAW,EAAE,mBAAmB,CAAC,CAAC;IAE9F,YAA6B,MAAkC;sBAAlC,MAAM;IAA+B,CAAC;IAEnE,KAAK,CAAC,MAAM,CAAC,iBAAyB;QAClC,MAAM,UAAU,GAAG,qBAAqB,CAAC,sBAAsB,EAAE,CAAC,CAAC;QACnE,MAAM,UAAU,GAAG,IAAI,qBAAqB,CAAC,iBAAiB,EAAE,SAAS,EAAE,IAAI,CAAC,MAAM,CAAC,aAAa,CAAC,CAAC;QACtG,MAAM,kBAAkB,GAAG;YACvB,cAAc,EAAE,IAAI,CAAC,MAAM,CAAC,cAAc;YAC1C,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,cAAc,KAAK,mBAAmB;gBAClD,CAAC,CAAC,EAAE,+BAA+B,EAAE,IAAa,EAAE;gBACpD,CAAC,CAAC,EAAE,CAAC;SACZ,CAAC;QACF,MAAM,cAAc,GAAG,IAAI,yBAAyB,CAAC,EAAE,GAAG,kBAAkB,EAAE,GAAG,IAAI,CAAC,MAAM,CAAC,aAAa,EAAE,CAAC,CAAC;QAC9G,iEAAiE;QACjE,yEAAyE;QACzE,iEAAiE;QACjE,MAAM,gBAAgB,GAA2B,CAAC,MAAM,EAAE,MAAM,EAAE,EAAE,CAChE,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,UAAU,CAAC,CAAC;QAChD,OAAO,IAAI,aAAa,CACpB,UAAU,EACV,cAAc,EACd,IAAI,CAAC,MAAM,CAAC,qBAAqB,IAAI,gCAAgC,EACrE,iBAAiB,EACjB,EAAE,gBAAgB,EAAE,CACvB,CAAC;IACN,CAAC;CACJ"}
1
+ {"version":3,"file":"claude-harness-factory.js","sourceRoot":"","sources":["../src/claude-harness-factory.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,qBAAqB,EAAE,sBAAsB,EAAE,MAAM,4BAA4B,CAAC;AAG3F,OAAO,EAAE,qBAAqB,EAAE,MAAM,+BAA+B,CAAC;AACtE,OAAO,EAAE,yBAAyB,EAAE,MAAM,mCAAmC,CAAC;AAC9E,OAAO,EAAE,aAAa,EAA2B,MAAM,qBAAqB,CAAC;AAC7E,OAAO,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAC;AAa1D,MAAM,gCAAgC,GAAG,OAAO,CAAC;AA6CjD,MAAM,OAAO,oBAAoB;IAYA,MAAM;IAX1B,SAAS,GAAG,QAAQ,CAAC;IACrB,eAAe,GAAG,CAAC,CAAC;IAC7B;;;;;;OAMG;IACM,sBAAsB,GAA4B,CAAC,WAAW,EAAE,mBAAmB,CAAC,CAAC;IAE9F,YAA6B,MAAkC;sBAAlC,MAAM;IAA+B,CAAC;IAEnE,KAAK,CAAC,MAAM,CAAC,iBAAyB;QAClC,MAAM,UAAU,GAAG,qBAAqB,CAAC,sBAAsB,EAAE,CAAC,CAAC;QACnE,MAAM,UAAU,GAAG,IAAI,qBAAqB,CAAC,iBAAiB,EAAE,SAAS,EAAE,IAAI,CAAC,MAAM,CAAC,aAAa,CAAC,CAAC;QACtG,MAAM,kBAAkB,GAAG;YACvB,cAAc,EAAE,IAAI,CAAC,MAAM,CAAC,cAAc;YAC1C,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,cAAc,KAAK,mBAAmB;gBAClD,CAAC,CAAC,EAAE,+BAA+B,EAAE,IAAa,EAAE;gBACpD,CAAC,CAAC,EAAE,CAAC;SACZ,CAAC;QACF,MAAM,cAAc,GAAG,IAAI,yBAAyB,CAChD,EAAE,GAAG,kBAAkB,EAAE,GAAG,IAAI,CAAC,MAAM,CAAC,aAAa,EAAE,EACvD,IAAI,CAAC,MAAM,CAAC,qBAAqB,CACpC,CAAC;QACF,iEAAiE;QACjE,yEAAyE;QACzE,iEAAiE;QACjE,MAAM,gBAAgB,GAA2B,CAAC,MAAM,EAAE,MAAM,EAAE,EAAE,CAChE,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,UAAU,CAAC,CAAC;QAChD,OAAO,IAAI,aAAa,CACpB,UAAU,EACV,cAAc,EACd,IAAI,CAAC,MAAM,CAAC,qBAAqB,IAAI,gCAAgC,EACrE,iBAAiB,EACjB,EAAE,gBAAgB,EAAE,CACvB,CAAC;IACN,CAAC;CACJ"}
@@ -1,5 +1,5 @@
1
1
  import { type Clock, type LogRecord, type UniqueIDGenerator, type Unsubscribe } from '@salesforce/agentic-common';
2
- import { type AgentHarness, type ChatStreamResult, type HarnessAgentConfig, type McpServerInfo, type Message, type MessagePart, type ModelConnectivityInfo, type StreamOptions, type TelemetryEventCallback, type ToolResultInfo, type WireCommunicationEventCallback } from '@salesforce/sfdx-agent-sdk';
2
+ import { type AgentHarness, type ChatStreamResult, type HarnessAgentConfig, type McpServerInfo, type Message, type MessagePart, type ModelConnectivityInfo, type SessionContext, type StreamOptions, type TelemetryEventCallback, type ToolResultInfo, type WireCommunicationEventCallback } from '@salesforce/sfdx-agent-sdk';
3
3
  import { type AgentHooks, type WithAgentConfig } from '@salesforce/sfdx-agent-sdk/harness';
4
4
  import type { ClaudeAgentConfig } from './claude-config.js';
5
5
  import type { ClaudeQueryEnvBuilder } from './claude-query-env-builder.js';
@@ -222,16 +222,36 @@ export declare class ClaudeHarness implements ClaudeAgentHarness {
222
222
  */
223
223
  cloneThread(agentId: string, sourceThreadId: string): Promise<string>;
224
224
  /**
225
- * Write the sidecar for a thread so its `contextHistory`,
226
- * `pendingContext`, AND `claudeSessionId` survive across restart.
225
+ * Allocate and register a fresh `ThreadState` for `threadId`, mirroring
226
+ * `createThread`'s initial shape (legacy 1:1 `claudeSessionId === threadId`,
227
+ * empty context arrays, `sessionContext` absent). Used by `setSessionContext`
228
+ * to seed a thread the consumer references before `createThread` / the first
229
+ * `stream()` — the session-context contract allows seeding context on an
230
+ * as-yet-unregistered thread.
231
+ */
232
+ private allocateThreadState;
233
+ /**
234
+ * Project a `ThreadState` into the on-disk `ThreadContextRecord`. Every
235
+ * sidecar write goes through this one builder so all persisted channels —
236
+ * addContext arrays, the `claudeSessionId` mapping, AND `sessionContext` —
237
+ * travel together and no write site can silently drop one. `sessionContext`
238
+ * is included only when set, so a thread that never set it writes no field
239
+ * (and reads back `{}`).
240
+ */
241
+ private threadContextRecordFrom;
242
+ /**
243
+ * Write the sidecar for a thread so its `contextHistory`, `pendingContext`,
244
+ * `claudeSessionId`, AND `sessionContext` survive across restart.
227
245
  *
228
- * The early-return optimization for "nothing to persist" only fires
229
- * when ALL three pieces of state match the legacy default
230
- * (no addContext history AND `claudeSessionId === threadId`). The
231
- * `claudeSessionId` check matters post-#511: a thread that has had
232
- * its sessionId reassigned by `clearMessages()` MUST write a sidecar
233
- * so the next `createAgent` rehydrates with the divergent mapping
234
- * even when the thread otherwise has no addContext data.
246
+ * The early-return optimization for "nothing to persist" only fires when
247
+ * EVERY channel matches the legacy default: no addContext history, an
248
+ * un-rotated `claudeSessionId` (`=== threadId`), and no session context set.
249
+ * The `claudeSessionId` check matters post-#511 (a thread whose sessionId
250
+ * was rotated by `clearMessages()` MUST write a sidecar even with no
251
+ * addContext data); the `sessionContext` check matters symmetrically — a
252
+ * thread carrying only session context MUST persist it. `setSessionContext`
253
+ * itself writes unconditionally rather than through this helper, so this
254
+ * guard is for the incidental persist sites (stream-consume, clone).
235
255
  */
236
256
  private persistThreadContextIfPresent;
237
257
  /**
@@ -384,7 +404,11 @@ export declare class ClaudeHarness implements ClaudeAgentHarness {
384
404
  getMessages(agentId: string, threadId: string): Promise<Message[]>;
385
405
  clearMessages(agentId: string, threadId: string): Promise<void>;
386
406
  private buildContextPrompt;
407
+ addMessages(agentId: string, threadId: string, messages: Message[]): Promise<void>;
408
+ /** @deprecated Renamed to {@link addMessages}; this delegates verbatim. */
387
409
  addContext(agentId: string, threadId: string, messages: Message[]): Promise<void>;
410
+ setSessionContext(agentId: string, threadId: string, content: SessionContext): Promise<void>;
411
+ getSessionContext(agentId: string, threadId: string): Promise<SessionContext>;
388
412
  /**
389
413
  * Synchronously flips `serverState.currentStatus` (no-op when the next
390
414
  * status equals the current) and returns a closure that, when invoked,
@@ -48,6 +48,24 @@ function extractSystemPromptBody(systemPrompt) {
48
48
  }
49
49
  return undefined;
50
50
  }
51
+ /**
52
+ * Deep-copy a `SessionContext` at the harness's public boundary so the consumer
53
+ * and the in-memory `ThreadState.sessionContext` never share a reference — a
54
+ * caller mutating either the object it passed to `setSessionContext` or the one
55
+ * `getSessionContext` returns must not reach into harness-internal state (which
56
+ * would silently diverge from what is persisted to disk until the next write).
57
+ * A JSON round-trip is the right copy here: `SessionContext` is a `JsonValue`
58
+ * object by contract (no functions / `Date` / class instances), so the round-trip
59
+ * is lossless AND it normalizes the object to the exact shape a post-restart read
60
+ * off disk would produce — so `getSessionContext` returns an identical value
61
+ * whether or not a restart intervened. Caveat: `JSON.stringify` drops keys whose
62
+ * value is `undefined`; type-safe here because `JsonValue` excludes `undefined`,
63
+ * but an `any`-typed caller that smuggles one in would see that key silently
64
+ * dropped rather than preserved as `undefined`.
65
+ */
66
+ function cloneSessionContext(content) {
67
+ return JSON.parse(JSON.stringify(content));
68
+ }
51
69
  export class ClaudeHarness {
52
70
  envBuilder;
53
71
  optionsBuilder;
@@ -319,6 +337,11 @@ export class ClaudeHarness {
319
337
  claudeSessionId,
320
338
  pendingContext: ctx.pendingContext,
321
339
  contextHistory: ctx.contextHistory,
340
+ // Rehydrate the session-context channel so a set survives
341
+ // restart. Absent on the record (never set, or a pre-session-
342
+ // context sidecar) leaves `ThreadState.sessionContext`
343
+ // undefined → `getSessionContext` reads back `{}`.
344
+ ...(ctx.sessionContext !== undefined ? { sessionContext: ctx.sessionContext } : {}),
322
345
  });
323
346
  consumedSessionIds.add(claudeSessionId);
324
347
  }
@@ -415,6 +438,13 @@ export class ClaudeHarness {
415
438
  await closeClientWithTimeout(s.client);
416
439
  await s.ready.catch(() => undefined);
417
440
  }));
441
+ // Session context now shares the `ThreadContextStore` sidecar with the
442
+ // addContext channel (the spike's "extend the existing sidecar" for
443
+ // Claude), so it inherits that channel's lifecycle: sidecars are cleaned
444
+ // per-thread by `destroyThread`, NOT wiped en masse here. `destroyAgent`
445
+ // already leaves addContext sidecars on disk so a recreate over the same
446
+ // `projectRoot` rehydrates the threads (#455); session context rides the
447
+ // same durability. There is deliberately no agent-level sidecar wipe.
418
448
  return this.agents.delete(agentId);
419
449
  }
420
450
  async getAgentIds() {
@@ -642,6 +672,17 @@ export class ClaudeHarness {
642
672
  throw new AgentSDKError(`No Agent found with id: "${agentId}"`, AgentSDKErrorType.AGENT_NOT_FOUND);
643
673
  }
644
674
  const id = threadId ?? this.idGenerator.getUniqueId();
675
+ // A thread id may already carry state if the consumer seeded session
676
+ // context before creating the thread — `setSessionContext` lazily
677
+ // allocates a `ThreadState` for an unregistered id (the contract allows
678
+ // seeding pre-`createThread`). Carry that `sessionContext` forward so a
679
+ // later `createThread` on the same id does not silently drop the last
680
+ // object the consumer set — `getSessionContext` MUST still return it
681
+ // (and its persisted sidecar copy stays consistent with memory). The
682
+ // message channels DO reset here: `createThread` semantically starts a
683
+ // fresh conversation, and session context is the one out-of-history
684
+ // piece that outlives that reset.
685
+ const existingSessionContext = agent.threads.get(id)?.sessionContext;
645
686
  // claudeSessionId starts equal to threadId — only diverges after a
646
687
  // `clearMessages()` cycle (see issue #511 / ThreadState docstring).
647
688
  agent.threads.set(id, {
@@ -649,6 +690,7 @@ export class ClaudeHarness {
649
690
  claudeSessionId: id,
650
691
  pendingContext: [],
651
692
  contextHistory: [],
693
+ ...(existingSessionContext !== undefined ? { sessionContext: existingSessionContext } : {}),
652
694
  });
653
695
  return id;
654
696
  }
@@ -669,10 +711,11 @@ export class ClaudeHarness {
669
711
  sessionStore: this.sessionStore,
670
712
  });
671
713
  }
672
- // Delete the addContext sidecar regardless of `hasStreamed` — a
673
- // thread can have addContext-injected turns without ever being
674
- // streamed, and leaving the sidecar would resurrect them on the
675
- // next createAgent rehydrate pass.
714
+ // Delete the sidecar regardless of `hasStreamed` — a thread can have
715
+ // addContext-injected turns OR setSessionContext state without ever
716
+ // being streamed, and leaving the sidecar would resurrect either on the
717
+ // next createAgent rehydrate pass (or if the thread id is recreated).
718
+ // Both channels share this one sidecar, so a single delete covers them.
676
719
  await this.threadContextStore.delete(agent.projectRoot, threadId);
677
720
  agent.threads.delete(threadId);
678
721
  }
@@ -707,6 +750,12 @@ export class ClaudeHarness {
707
750
  if (!sourceState) {
708
751
  throw new Error(`Thread "${sourceThreadId}" not found`);
709
752
  }
753
+ // Both branches below build the clone's `ThreadState` inline rather than
754
+ // through `threadContextRecordFrom`, and deliberately do NOT copy
755
+ // `sourceState.sessionContext`: session-context carry-forward is deferred
756
+ // to this harness's render PR (W-23632691). When it lands, copy
757
+ // `sessionContext` here too. Until then a clone starts with empty session
758
+ // context by design, not by omission.
710
759
  if (!sourceState.hasStreamed) {
711
760
  const newId = this.idGenerator.getUniqueId();
712
761
  agent.threads.set(newId, {
@@ -739,27 +788,60 @@ export class ClaudeHarness {
739
788
  return result.sessionId;
740
789
  }
741
790
  /**
742
- * Write the sidecar for a thread so its `contextHistory`,
743
- * `pendingContext`, AND `claudeSessionId` survive across restart.
791
+ * Allocate and register a fresh `ThreadState` for `threadId`, mirroring
792
+ * `createThread`'s initial shape (legacy 1:1 `claudeSessionId === threadId`,
793
+ * empty context arrays, `sessionContext` absent). Used by `setSessionContext`
794
+ * to seed a thread the consumer references before `createThread` / the first
795
+ * `stream()` — the session-context contract allows seeding context on an
796
+ * as-yet-unregistered thread.
797
+ */
798
+ allocateThreadState(agent, threadId) {
799
+ const state = {
800
+ hasStreamed: false,
801
+ claudeSessionId: threadId,
802
+ pendingContext: [],
803
+ contextHistory: [],
804
+ };
805
+ agent.threads.set(threadId, state);
806
+ return state;
807
+ }
808
+ /**
809
+ * Project a `ThreadState` into the on-disk `ThreadContextRecord`. Every
810
+ * sidecar write goes through this one builder so all persisted channels —
811
+ * addContext arrays, the `claudeSessionId` mapping, AND `sessionContext` —
812
+ * travel together and no write site can silently drop one. `sessionContext`
813
+ * is included only when set, so a thread that never set it writes no field
814
+ * (and reads back `{}`).
815
+ */
816
+ threadContextRecordFrom(source) {
817
+ return {
818
+ contextHistory: source.contextHistory,
819
+ pendingContext: source.pendingContext,
820
+ claudeSessionId: source.claudeSessionId,
821
+ ...(source.sessionContext !== undefined ? { sessionContext: source.sessionContext } : {}),
822
+ };
823
+ }
824
+ /**
825
+ * Write the sidecar for a thread so its `contextHistory`, `pendingContext`,
826
+ * `claudeSessionId`, AND `sessionContext` survive across restart.
744
827
  *
745
- * The early-return optimization for "nothing to persist" only fires
746
- * when ALL three pieces of state match the legacy default
747
- * (no addContext history AND `claudeSessionId === threadId`). The
748
- * `claudeSessionId` check matters post-#511: a thread that has had
749
- * its sessionId reassigned by `clearMessages()` MUST write a sidecar
750
- * so the next `createAgent` rehydrates with the divergent mapping
751
- * even when the thread otherwise has no addContext data.
828
+ * The early-return optimization for "nothing to persist" only fires when
829
+ * EVERY channel matches the legacy default: no addContext history, an
830
+ * un-rotated `claudeSessionId` (`=== threadId`), and no session context set.
831
+ * The `claudeSessionId` check matters post-#511 (a thread whose sessionId
832
+ * was rotated by `clearMessages()` MUST write a sidecar even with no
833
+ * addContext data); the `sessionContext` check matters symmetrically — a
834
+ * thread carrying only session context MUST persist it. `setSessionContext`
835
+ * itself writes unconditionally rather than through this helper, so this
836
+ * guard is for the incidental persist sites (stream-consume, clone).
752
837
  */
753
838
  async persistThreadContextIfPresent(projectRoot, threadId, source) {
754
839
  const hasContext = source.contextHistory.length > 0 || source.pendingContext.length > 0;
755
840
  const hasDivergedSessionId = source.claudeSessionId !== threadId;
756
- if (!hasContext && !hasDivergedSessionId)
841
+ const hasSessionContext = source.sessionContext !== undefined;
842
+ if (!hasContext && !hasDivergedSessionId && !hasSessionContext)
757
843
  return;
758
- await this.threadContextStore.write(projectRoot, threadId, {
759
- contextHistory: source.contextHistory,
760
- pendingContext: source.pendingContext,
761
- claudeSessionId: source.claudeSessionId,
762
- });
844
+ await this.threadContextStore.write(projectRoot, threadId, this.threadContextRecordFrom(source));
763
845
  }
764
846
  /**
765
847
  * Compacts a thread into a new thread seeded with an LLM-generated summary
@@ -815,7 +897,7 @@ export class ClaudeHarness {
815
897
  const newThreadId = await this.createThread(agentId);
816
898
  const userTs = this.clock.now();
817
899
  const assistantTs = this.clock.nextAfter(userTs);
818
- await this.addContext(agentId, newThreadId, [
900
+ await this.addMessages(agentId, newThreadId, [
819
901
  {
820
902
  id: this.idGenerator.getUniqueId(),
821
903
  role: 'user',
@@ -1261,12 +1343,11 @@ export class ClaudeHarness {
1261
1343
  // systemPrompt. contextHistory persists unchanged and verbatim —
1262
1344
  // structured MessagePart[] content (incl. image/file base64) is
1263
1345
  // preserved so the getMessages() round-trip stays lossless across a
1264
- // restart, matching Mastra's persist-verbatim behavior.
1265
- await this.threadContextStore.write(projectRoot, threadId, {
1266
- pendingContext: [],
1267
- contextHistory: threadState.contextHistory,
1268
- claudeSessionId: threadState.claudeSessionId,
1269
- });
1346
+ // restart, matching Mastra's persist-verbatim behavior. Routed
1347
+ // through `threadContextRecordFrom` (reading the just-cleared
1348
+ // in-memory `pendingContext`) so any `sessionContext` on the thread
1349
+ // survives the clear.
1350
+ await this.threadContextStore.write(projectRoot, threadId, this.threadContextRecordFrom(threadState));
1270
1351
  }
1271
1352
  // Wire-communication monitoring on Claude (subscriber-gated): the Claude Agent SDK runs
1272
1353
  // the model client in a subprocess with no programmatic HTTP-interception seam, so we
@@ -1445,7 +1526,14 @@ export class ClaudeHarness {
1445
1526
  // the contract's ascending-by-createdAt post-condition under stable
1446
1527
  // sort, so step the fallback per-position via `clock.nextAfter`.
1447
1528
  let lastFallback = this.clock.now();
1448
- const persisted = sessionMessages.map((msg) => {
1529
+ const persisted = sessionMessages.flatMap((msg) => {
1530
+ // The SDK's `MessageRole` is `'user' | 'assistant' | 'tool'` — `'system'`
1531
+ // is not a consumer-visible transcript role (system state rides
1532
+ // `instructions` / session context). The Anthropic transcript never
1533
+ // stores a bare `system` turn on this path, but drop it defensively
1534
+ // rather than surface an off-contract role across the SDK boundary.
1535
+ if (msg.type !== 'user' && msg.type !== 'assistant')
1536
+ return [];
1449
1537
  const recorded = timestamps.get(msg.uuid);
1450
1538
  let createdAt;
1451
1539
  if (recorded) {
@@ -1455,12 +1543,14 @@ export class ClaudeHarness {
1455
1543
  lastFallback = this.clock.nextAfter(lastFallback);
1456
1544
  createdAt = lastFallback;
1457
1545
  }
1458
- return {
1459
- id: msg.uuid,
1460
- role: msg.type,
1461
- content: mapSessionMessageContent(msg.message),
1462
- createdAt,
1463
- };
1546
+ return [
1547
+ {
1548
+ id: msg.uuid,
1549
+ role: msg.type,
1550
+ content: mapSessionMessageContent(msg.message),
1551
+ createdAt,
1552
+ },
1553
+ ];
1464
1554
  });
1465
1555
  // Merge the addContext-injected mirror with the persisted transcript
1466
1556
  // and sort chronologically (#464). The Claude SDK has no public API
@@ -1542,7 +1632,7 @@ export class ClaudeHarness {
1542
1632
  const prefix = baseInstructions ? `${baseInstructions}\n\n` : '';
1543
1633
  return `${prefix}<prior-context>\n${contextBlock}\n</prior-context>`;
1544
1634
  }
1545
- async addContext(agentId, threadId, messages) {
1635
+ async addMessages(agentId, threadId, messages) {
1546
1636
  const agent = this.agents.get(agentId);
1547
1637
  if (!agent) {
1548
1638
  throw new AgentSDKError(`No Agent found with id: "${agentId}"`, AgentSDKErrorType.AGENT_NOT_FOUND);
@@ -1578,12 +1668,59 @@ export class ClaudeHarness {
1578
1668
  // restart — matching Mastra's persist-verbatim (LibSQL) behavior. The
1579
1669
  // earlier placeholder-flattening (#491) was a premature optimization that
1580
1670
  // silently destroyed structured tool parts on rehydrate and diverged from
1581
- // Mastra; removed in favor of cross-harness correctness.
1582
- await this.threadContextStore.write(agent.projectRoot, threadId, {
1583
- pendingContext: threadState.pendingContext,
1584
- contextHistory: threadState.contextHistory,
1585
- claudeSessionId: threadState.claudeSessionId,
1586
- });
1671
+ // Mastra; removed in favor of cross-harness correctness. Routed through
1672
+ // `threadContextRecordFrom` so any `sessionContext` on the thread is
1673
+ // carried in the same write and never clobbered by this addContext path.
1674
+ await this.threadContextStore.write(agent.projectRoot, threadId, this.threadContextRecordFrom(threadState));
1675
+ }
1676
+ /** @deprecated Renamed to {@link addMessages}; this delegates verbatim. */
1677
+ async addContext(agentId, threadId, messages) {
1678
+ await this.addMessages(agentId, threadId, messages);
1679
+ }
1680
+ async setSessionContext(agentId, threadId, content) {
1681
+ // AGENT_NOT_FOUND guard runs before any state touch — matches the
1682
+ // cross-harness contract (#464) that every agent-scoped method throws
1683
+ // on an unknown id. threadId is NOT validated: session context is a
1684
+ // per-thread channel the consumer can seed before a thread's first
1685
+ // stream (or before `createThread`), so an arbitrary threadId is a
1686
+ // legal write target — a `ThreadState` is allocated lazily for it,
1687
+ // mirroring `createThread`'s initial shape (`claudeSessionId ===
1688
+ // threadId`, empty context arrays), and only its `sessionContext`
1689
+ // field is set. An existing thread keeps all its other state.
1690
+ const agent = this.agents.get(agentId);
1691
+ if (!agent) {
1692
+ throw new AgentSDKError(`No Agent found with id: "${agentId}"`, AgentSDKErrorType.AGENT_NOT_FOUND);
1693
+ }
1694
+ const threadState = agent.threads.get(threadId) ?? this.allocateThreadState(agent, threadId);
1695
+ // Update the in-memory source of truth first, then persist — mirrors
1696
+ // `addMessages`, which mutates the arrays before writing the sidecar.
1697
+ // Store a DEEP COPY, not the caller's reference: the consumer keeps
1698
+ // ownership of the object it passed, so a later mutation of it must not
1699
+ // reach into harness-internal state (and diverge from what was written
1700
+ // to disk). The deleted `SessionContextStore` got this for free by
1701
+ // JSON-serializing to disk on every write; `cloneSessionContext`
1702
+ // preserves that isolation now that the object is held in memory.
1703
+ threadState.sessionContext = cloneSessionContext(content);
1704
+ // Unconditional write (not `persistThreadContextIfPresent`): an explicit
1705
+ // set MUST be durable even when the thread has no addContext history and
1706
+ // an un-rotated `claudeSessionId`.
1707
+ await this.threadContextStore.write(agent.projectRoot, threadId, this.threadContextRecordFrom(threadState));
1708
+ }
1709
+ async getSessionContext(agentId, threadId) {
1710
+ const agent = this.agents.get(agentId);
1711
+ if (!agent) {
1712
+ throw new AgentSDKError(`No Agent found with id: "${agentId}"`, AgentSDKErrorType.AGENT_NOT_FOUND);
1713
+ }
1714
+ // In-memory `ThreadState` is the source of truth (rehydrated from the
1715
+ // sidecar at `createAgent`, same as `contextHistory` / `pendingContext`).
1716
+ // `{}`-on-miss — never `null`/`undefined`, never throws — for a thread
1717
+ // that was never registered or never had context set. Return a DEEP COPY
1718
+ // so a caller mutating the result can't reach into harness-internal state
1719
+ // (which would silently diverge from disk until the next persist); this
1720
+ // matches the fresh-object-per-read the deleted `SessionContextStore`
1721
+ // gave via its `JSON.parse` read path.
1722
+ const current = agent.threads.get(threadId)?.sessionContext;
1723
+ return current === undefined ? {} : cloneSessionContext(current);
1587
1724
  }
1588
1725
  /**
1589
1726
  * Synchronously flips `serverState.currentStatus` (no-op when the next