@salesforce/sfdx-agent-sdk 0.3.0 → 0.5.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/README.md CHANGED
@@ -6,16 +6,32 @@ without coupling consumer code to a specific AI framework.
6
6
 
7
7
  ## Quick Start
8
8
 
9
- > **Closed source.** This package is published to npm under the [Salesforce Public Code License](../../LICENSE.txt) and is for use by Salesforce only.
9
+ > **Closed source.** This package is published to npm under the [Salesforce Public Code License](../../LICENSE.txt) and
10
+ > is for use by Salesforce only.
10
11
 
11
12
  ```typescript
12
13
  import { createAgentManager } from '@salesforce/sfdx-agent-sdk';
13
14
  import { MastraHarnessFactory } from '@salesforce/sfdx-agent-harness-mastra';
14
15
 
15
- // 1. Create the manager (validates the storage folder exists)
16
+ // 1. Create the manager. This validates the storage folder, gates the harness's
17
+ // protocol version, and replays any agents the SDK persisted on a prior run
18
+ // (one JSON file per agent under `${storageRootFolder}/agents/`).
16
19
  const manager = await createAgentManager('/path/to/storage', new MastraHarnessFactory());
17
20
 
18
- // 2. Create an agent
21
+ // Bridge SDK logs into your host logger so you observe restore failures + warnings.
22
+ manager.onLog((record) => {
23
+ console[record.level](record.message, record.context);
24
+ });
25
+
26
+ // Boot-time restore failures are queryable as instance state — use them to seed
27
+ // per-agent UI state, not for logging (the SDK already emitted each via onLog).
28
+ for (const failure of manager.getRestoreFailures()) {
29
+ // mark `failure.agentId` as `error` in your application state
30
+ }
31
+
32
+ // 2. Create an agent. The identity triple `{ agentId, projectRoot, config }` is
33
+ // persisted to disk; the next call to `createAgentManager` over the same
34
+ // storage folder will replay this agent automatically.
19
35
  const agent = await manager.createAgent('/path/to/project', {
20
36
  agentId: 'developer-assistant',
21
37
  modelId: 'llmgateway__OpenAIGPT5',
@@ -35,31 +51,57 @@ for await (const event of eventStream) {
35
51
  }
36
52
  }
37
53
 
38
- // 4. Shut down
54
+ // 4. Shut down. The harness is torn down; persisted identity files are NOT
55
+ // removed, so a subsequent `createAgentManager` call restores them.
39
56
  await manager.shutdown();
40
57
  ```
41
58
 
42
59
  ## API Reference
43
60
 
44
- ### `createAgentManager(storageRootFolder, harnessFactory): Promise<AgentManager>`
61
+ ### `createAgentManager(storageRootFolder, harnessFactory, connectivityResolver?): Promise<AgentManager>`
45
62
 
46
63
  Factory function that creates an `AgentManager` backed by the provided `HarnessFactory`. The `storageRootFolder` must be
47
- an existing directory and is used for persistent state (database, memory). The SDK verifies that the constructed harness
48
- uses a supported protocol version before returning the manager.
64
+ an existing directory and is used for persistent state (the harness's runtime data plus the SDK's per-agent identity
65
+ files at `${storageRootFolder}/agents/<id>.json`). The SDK verifies that the constructed harness uses a supported
66
+ protocol version, replays any persisted agents the harness can still serve, and returns the manager. The optional
67
+ `connectivityResolver` overrides the default sf-CLI-based org resolution — used by e2e tests and custom-auth
68
+ deployments; production callers leave it unset.
69
+
70
+ Restore failures (a persisted record the SDK could not bring back online — e.g. missing project directory, harness
71
+ rejection, thread rehydration failure) are queryable on the returned manager via `getRestoreFailures()`. Soft skips
72
+ inside the persistence directory (corrupt JSON, harness-id mismatch) are silently dropped from the restore pass and emit
73
+ a `warn` on the SDK's log bus.
49
74
 
50
75
  ### `AgentManager`
51
76
 
52
- Top-level orchestrator that owns the harness and manages agent lifecycle.
77
+ Top-level orchestrator that owns the harness and manages agent lifecycle. `AgentManager` is an interface; the concrete
78
+ implementation is internal — `createAgentManager` is the only public entry point.
79
+
80
+ | Method | Signature | Description |
81
+ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
82
+ | `createAgent` | `(projectRoot: string, config?: AgentConfig & { agentId?: string }, options?: { abortSignal?: AbortSignal }) => Promise<Agent>` | Create and register a new agent and persist its identity triple. `projectRoot` must be an existing directory. If `agentId` is omitted a UUID is generated. |
83
+ | `getAgent` | `(agentId: string) => Agent` | Retrieve a live agent by ID. Throws `AgentSDKError` (`AGENT_NOT_FOUND`) for unknown ids and for ids that are only present in `getRestoreFailures()`. |
84
+ | `getAgentIds` | `() => string[]` | List all live agent IDs (successful + successfully restored). Failed-restore agents are not included — query `getRestoreFailures()` separately. |
85
+ | `destroyAgent` | `(agentId: string) => Promise<void>` | Destroy an agent, remove its identity record from disk, and clear any matching `getRestoreFailures()` entry. Failed-restore-only ids are accepted (no harness call made). |
86
+ | `shutdown` | `() => Promise<void>` | Destroy all live agents and shut down the harness. Identity files survive (that's the whole point) — restart `createAgentManager` over the same root to bring them back. |
87
+ | `onTelemetry` | `(callback: TelemetryEventCallback) => Unsubscribe` | Subscribe to telemetry across all managed agents. |
88
+ | `onLog` | `(callback: (record: LogRecord) => void) => Unsubscribe` | Subscribe to structured logs across all managed agents. Bridge this into your host logger to observe restore-failure events + soft-skip warnings. |
89
+ | `getRestoreFailures` | `() => RestoreFailure[]` | Snapshot of agents the SDK could not restore on this boot. Each entry carries the persisted `{ agentId, projectRoot, config }` plus the underlying error. |
90
+
91
+ #### `RestoreFailure`
92
+
93
+ ```typescript
94
+ type RestoreFailure = {
95
+ agentId: string;
96
+ projectRoot: string;
97
+ config: AgentConfig;
98
+ error: unknown;
99
+ };
100
+ ```
53
101
 
54
- | Method | Signature | Description |
55
- | -------------- | ------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
56
- | `createAgent` | `(projectRoot: string, config?: AgentConfig & { agentId?: string }, options?: { abortSignal?: AbortSignal }) => Promise<Agent>` | Create and register a new agent. `projectRoot` must be an existing directory. If `agentId` is omitted a UUID is generated. |
57
- | `getAgent` | `(agentId: string) => Agent` | Retrieve an agent by ID. Throws `AgentSDKError` (`AGENT_NOT_FOUND`) if missing. |
58
- | `getAgentIds` | `() => string[]` | List all managed agent IDs. |
59
- | `destroyAgent` | `(agentId: string) => Promise<void>` | Destroy an agent and release its resources. Throws `AgentSDKError` (`AGENT_NOT_FOUND`) if missing. |
60
- | `shutdown` | `() => Promise<void>` | Destroy all agents and shut down the harness. |
61
- | `onTelemetry` | `(callback: TelemetryEventCallback) => Unsubscribe` | Subscribe to telemetry across all managed agents. |
62
- | `onLog` | `(callback: (record: LogRecord) => void) => Unsubscribe` | Subscribe to structured logs across all managed agents. |
102
+ Returned by `AgentManager.getRestoreFailures()`. Use it to seed `error`-state placeholders in your application; do
103
+ **not** iterate it for logging the SDK already emitted each failure via `onLog` at `error` level during the restore
104
+ pass, before this function returned.
63
105
 
64
106
  ### `Agent`
65
107
 
@@ -134,16 +176,16 @@ Discriminated union (`event.type`) of streaming events:
134
176
 
135
177
  #### `AgentConfig`
136
178
 
137
- | Field | Type | Description |
138
- | --------------- | ------------------ | -------------------------------------------------------------------- |
139
- | `orgAlias?` | `string` | Salesforce org alias or username. Falls back to project/default org. |
140
- | `modelId?` | `ModelName` | LLM model identifier (e.g. `'llmgateway__OpenAIGPT5'`). |
141
- | `name?` | `string` | Human-readable agent name. |
142
- | `description?` | `string` | Agent purpose description. |
143
- | `instructions?` | `string` | System instructions for the agent. |
144
- | `tools?` | `ToolDefinition[]` | Consumer-executed tool schemas. |
145
- | `mcpServers?` | `MCPConfiguration` | MCP server connections. |
146
- | `skills?` | `string[]` | Each entry is either an individual skill folder (containing `SKILL.md`) or a parent folder containing skill subfolders. Relative and absolute paths supported; forms can be mixed in the same array. |
179
+ | Field | Type | Description |
180
+ | --------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
181
+ | `orgAlias?` | `string` | Salesforce org alias or username. Falls back to project/default org. |
182
+ | `modelId?` | `ModelName` | LLM model identifier (e.g. `'llmgateway__OpenAIGPT5'`). |
183
+ | `name?` | `string` | Human-readable agent name. |
184
+ | `description?` | `string` | Agent purpose description. |
185
+ | `instructions?` | `string` | System instructions for the agent. |
186
+ | `tools?` | `ToolDefinition[]` | Consumer-executed tool schemas. |
187
+ | `mcpServers?` | `MCPConfiguration` | MCP server connections. |
188
+ | `skills?` | `string[]` | Each entry is either an individual skill folder (containing `SKILL.md`) or a parent folder containing skill subfolders. Relative and absolute paths supported; forms can be mixed in the same array. |
147
189
  | `rules?` | `string[]` | Each entry is either an individual `.md` rule file or a directory of `.md` rule files (scanned one level deep, alphabetical, non-`.md` skipped). Bodies are composed verbatim into the agent's effective system prompt; YAML frontmatter is optional and stripped if present. Matches Claude Code's `.claude/rules/*.md` convention. |
148
190
 
149
191
  #### `StreamOptions`
@@ -5,130 +5,168 @@ import { type AgentConfig } from './harness/harness-config.js';
5
5
  import { type Agent } from './agent.js';
6
6
  import type { TelemetryEventCallback } from './types/telemetry-events.js';
7
7
  import { type AgentConnectivityResolver } from './agent-connectivity-resolver.js';
8
+ /**
9
+ * Per-agent failure recorded during the boot-time restore pass.
10
+ *
11
+ * Returned in bulk by {@link AgentManager.getRestoreFailures}. Each entry
12
+ * captures the persisted identity triple plus the underlying error so the
13
+ * consumer can mark its placeholder as `error` and either DELETE or recreate
14
+ * the agent.
15
+ */
16
+ export type RestoreFailure = {
17
+ agentId: string;
18
+ projectRoot: string;
19
+ config: AgentConfig;
20
+ error: unknown;
21
+ };
8
22
  /**
9
23
  * Manages the lifecycle of {@link Agent} instances.
10
24
  *
11
- * The manager owns an {@link AgentHarness} and coordinates initialization,
12
- * agent creation, and graceful shutdown. The harness implementation is
13
- * abstracted away from downstream code (agents, sessions).
25
+ * The concrete implementation ({@link DefaultAgentManager}) is internal to
26
+ * the SDK and constructed only via {@link createAgentManager}. Consumers
27
+ * program against this interface; they never see the implementation type.
28
+ *
29
+ * ### Boot-time restore
30
+ *
31
+ * `createAgentManager` reads any agents the SDK persisted on a prior run
32
+ * (`${storageRootFolder}/agents/<id>.json`) and replays `installAgent` for
33
+ * each before returning. Failures land in {@link getRestoreFailures}, not
34
+ * thrown. Subscribe to {@link onLog} to bridge SDK log events into your
35
+ * host logger; restore failures are emitted at `error` level and soft skips
36
+ * (corrupt JSON, harness-id mismatch) at `warn`.
37
+ */
38
+ export interface AgentManager {
39
+ /**
40
+ * Creates a new {@link Agent} with the given configuration and persists
41
+ * its identity triple `{ agentId, projectRoot, config }` so it can be
42
+ * restored on the next boot.
43
+ *
44
+ * @throws If `projectRoot` does not exist or is not a directory.
45
+ * @throws If `config.agentId` is provided and an agent with that ID is
46
+ * already registered (live or in restore-failure state).
47
+ */
48
+ createAgent(projectRoot: string, config?: AgentConfig & {
49
+ agentId?: string;
50
+ }, options?: {
51
+ abortSignal?: AbortSignal;
52
+ }): Promise<Agent>;
53
+ /**
54
+ * Returns the live {@link Agent} for the given id.
55
+ *
56
+ * @throws `AGENT_NOT_FOUND` if the id is unknown or only present in
57
+ * {@link getRestoreFailures}.
58
+ */
59
+ getAgent(agentId: string): Agent;
60
+ /**
61
+ * Returns the ids of all live agents (successfully created or
62
+ * restored). Failed-restore agents are not included — query
63
+ * {@link getRestoreFailures} separately.
64
+ */
65
+ getAgentIds(): string[];
66
+ /**
67
+ * Destroys a managed agent, removes its identity record from disk,
68
+ * and clears any matching entry in {@link getRestoreFailures}.
69
+ *
70
+ * If the id is only in `getRestoreFailures()` (no live agent), the
71
+ * harness is not consulted; the identity file is removed and the
72
+ * failure entry cleared. After this returns, the same id may be
73
+ * passed to {@link createAgent} again.
74
+ *
75
+ * @throws `AGENT_NOT_FOUND` if the id is neither live nor a
76
+ * restore-failure entry.
77
+ */
78
+ destroyAgent(agentId: string): Promise<void>;
79
+ /** Shuts down the harness and destroys all live agents. */
80
+ shutdown(): Promise<void>;
81
+ /** Subscribe to telemetry events across every managed agent. */
82
+ onTelemetry(callback: TelemetryEventCallback): Unsubscribe;
83
+ /** Subscribe to structured log records across every managed agent. */
84
+ onLog(callback: (record: LogRecord) => void): Unsubscribe;
85
+ /**
86
+ * Returns a snapshot of the boot-time restore failures the SDK has not
87
+ * yet been told to forget. Each entry is cleared on a successful
88
+ * {@link destroyAgent} for the same id; recreating an agent with the
89
+ * same id via {@link createAgent} also clears the entry.
90
+ */
91
+ getRestoreFailures(): RestoreFailure[];
92
+ }
93
+ /**
94
+ * Concrete implementation of {@link AgentManager}. **Not exported** from
95
+ * `src/index.ts` — public construction goes through {@link createAgentManager}.
14
96
  *
15
- * ### Persistence Responsibility
16
- * Persistence is shared between the harness and the consuming application:
17
- * - **Engine**: Handles physical data persistence (database storage of threads, messages, and memory state).
18
- * - **Application**: Handles lifecycle persistence. It manages the discovery and restoration of saved agents,
19
- * and calls `createAgent(projectRoot, { agentId: savedId, ...savedConfig })` to rebuild active agent state in memory
20
- * (like MCP connections and custom tool closures) while reconnecting to the persistent memory data.
97
+ * Construction is asynchronous because {@link init} reads the persistence
98
+ * directory and replays prior agents before the manager becomes useful. The
99
+ * pattern mirrors {@link Workspace.create}: a private constructor for sync
100
+ * field assignment, then a static async builder that calls `init()`.
21
101
  */
22
- export declare class AgentManager {
102
+ export declare class DefaultAgentManager implements AgentManager {
23
103
  private readonly harness;
24
104
  private readonly agentIdGenerator;
25
105
  private readonly agentConnectivityResolver;
26
106
  private readonly clock;
107
+ private readonly identityStore;
27
108
  private readonly agents;
109
+ private restoreFailures;
28
110
  private readonly telemetryBus;
29
111
  private readonly logBus;
30
112
  private readonly router;
31
113
  private readonly unroutedUnsubs;
32
114
  private disposed;
115
+ private constructor();
33
116
  /**
34
- * Creates a new {@link AgentManager}.
117
+ * Module-internal builder used by {@link createAgentManager}. Mirrors
118
+ * {@link Workspace.create}: sync field assignment in the private
119
+ * constructor, then `await init()` to do async startup work
120
+ * (reading the persistence directory + replaying prior agents).
35
121
  *
36
- * This constructor is **exposed for testing only** so unit tests can inject a stubbed
37
- * {@link AgentHarness} and deterministic ID generator.
38
- *
39
- * Production callers should use {@link createAgentManager} instead.
40
- *
41
- * @example
42
- * ```ts
43
- * // Recommended for production usage (constructs the default connectivity resolver):
44
- * const manager = await createAgentManager(storageRootFolder, harnessFactory);
45
- * ```
46
- */
47
- constructor(harness: AgentHarness, agentConnectivityResolver: AgentConnectivityResolver, agentIdGenerator?: UniqueIDGenerator, clock?: Clock, logBus?: LogBus);
48
- /**
49
- * Shuts down the harness and destroys all managed agents.
50
- *
51
- * @requirements
52
- * - MUST iterate through all agents in the internal `agents` map and call `agent.destroy()` on each.
53
- * - MUST clear the internal `agents` map.
54
- * - MUST delegate to `this.harness.shutdown()` to release harness-level resources.
122
+ * The double-underscore signals "do not call directly" the constructor
123
+ * is private, so this is the only way to obtain an instance, but
124
+ * consumers should always go through {@link createAgentManager}.
55
125
  */
126
+ static __build(harness: AgentHarness, agentConnectivityResolver: AgentConnectivityResolver, storageRootFolder: string, agentIdGenerator: UniqueIDGenerator, clock: Clock, logBus: LogBus): Promise<DefaultAgentManager>;
127
+ private init;
56
128
  shutdown(): Promise<void>;
57
- /**
58
- * Creates a new `Agent` with the given configuration.
59
- *
60
- * @param projectRoot - Absolute path to the project folder the agent can manipulate files from.
61
- * Relative paths are resolved against `process.cwd()`. The path must exist and be a directory.
62
- * @param config - Agent configuration (instructions, tools, model, etc.).
63
- * @param options - Optional execution options, including abort signals.
64
- * @returns A new `Agent` ready for chat sessions.
65
- *
66
- * @throws If `projectRoot` does not exist or is not a directory.
67
- *
68
- * @requirements
69
- * - MUST resolve `projectRoot` to an absolute, normalized path before use.
70
- * - MUST throw an Error if the resolved `projectRoot` does not exist or is not a directory.
71
- * - MUST throw an Error if `config.agentId` is provided and an agent with that ID already exists in the internal `agents` map.
72
- * - MUST generate a unique ID via `this.agentIdGenerator` if `config.agentId` is omitted.
73
- * - MUST delegate to `this.harness.createAgent(agentId, projectRoot, llmGatewayClient, config, options)` to register the agent in the harness and allow for cancellation.
74
- * - MUST instantiate a `DefaultAgent` with the harness and the complete configuration.
75
- * - MUST store the newly created agent in the internal `agents` map, keyed by its `agentId`.
76
- * - MUST return the newly created agent.
77
- */
78
129
  createAgent(projectRoot: string, config?: AgentConfig & {
79
130
  agentId?: string;
80
131
  }, options?: {
81
132
  abortSignal?: AbortSignal;
82
133
  }): Promise<Agent>;
83
134
  /**
84
- * Returns the IDs of all currently managed agents.
135
+ * Shared install path for {@link createAgent} and the boot-time restore
136
+ * loop. Resolves connectivity, calls `harness.createAgent`, constructs
137
+ * the {@link DefaultAgent}, registers the telemetry slice, emits
138
+ * `agent-created`, and (when restoring) attaches a chat session per
139
+ * persisted thread id.
85
140
  *
86
- * @requirements
87
- * - MUST return an array of all string keys (agent IDs) currently tracked in the internal `agents` map.
141
+ * Returns {@link DefaultAgent} (concrete) rather than {@link Agent}
142
+ * because both call sites are internal widening to the interface only
143
+ * happens at the public-method return statement.
88
144
  */
145
+ private installAgent;
146
+ private rollbackInstall;
89
147
  getAgentIds(): string[];
90
- /**
91
- * Retrieves a managed agent by its ID.
92
- *
93
- * @param agentId - ID of the agent to retrieve.
94
- *
95
- * @requirements
96
- * - MUST throw an Error if the provided `agentId` is not found in the internal `agents` map.
97
- * - MUST return the `Agent` instance associated with the given `agentId`.
98
- */
99
148
  getAgent(agentId: string): Agent;
100
- /**
101
- * Destroys a managed agent by its ID, releasing all its resources.
102
- *
103
- * @param agentId - ID of the agent to destroy.
104
- *
105
- * @requirements
106
- * - MUST throw an Error if the provided `agentId` is not found in the internal `agents` map.
107
- * - MUST call `destroy()` on the target agent instance.
108
- * - MUST remove the agent from the internal `agents` map.
109
- */
110
149
  destroyAgent(agentId: string): Promise<void>;
111
- /** Subscribe to telemetry events across every managed agent. Returns an unsubscribe function. */
112
150
  onTelemetry(callback: TelemetryEventCallback): Unsubscribe;
113
- /** Subscribe to structured log records across every managed agent. Returns an unsubscribe function. */
114
151
  onLog(callback: (record: LogRecord) => void): Unsubscribe;
152
+ getRestoreFailures(): RestoreFailure[];
115
153
  private assertNotDisposed;
116
154
  }
117
155
  /**
118
- * Creates an {@link AgentManager} using the provided harness factory.
119
- *
120
- * Use this function in production code. It validates the `storageRootFolder`,
121
- * verifies the factory advertises a supported protocol version (failing fast
122
- * before any expensive harness construction), constructs the harness
123
- * asynchronously, sanity-checks that the constructed harness honors the
124
- * factory's advertised version, and returns a ready-to-use manager.
156
+ * Public entry point. Validates the storage root, gates the harness's
157
+ * advertised protocol version, constructs the harness, and returns a
158
+ * fully-restored {@link AgentManager}. Boot-time restore runs before this
159
+ * function returns; failures are queryable via
160
+ * {@link AgentManager.getRestoreFailures}.
125
161
  *
126
- * @param storageRootFolder - Existing directory used for agent persistence.
127
- * @param harnessFactory - Factory that constructs the harness implementation.
162
+ * The optional `connectivityResolver` overrides the default
163
+ * `DefaultAgentConnectivityResolver` used by e2e tests and custom-auth
164
+ * deployments where the SDK should not run sf-CLI-based org resolution.
165
+ * Production callers leave it unset.
128
166
  *
129
167
  * @throws {AgentSDKError} `INCOMPATIBLE_HARNESS` when either the factory or
130
168
  * the constructed harness reports a `protocolVersion` outside
131
169
  * {@link SUPPORTED_PROTOCOL_VERSIONS}, or when the harness's reported
132
170
  * version disagrees with the factory's.
133
171
  */
134
- export declare function createAgentManager(storageRootFolder: string, harnessFactory: HarnessFactory): Promise<AgentManager>;
172
+ export declare function createAgentManager(storageRootFolder: string, harnessFactory: HarnessFactory, connectivityResolver?: AgentConnectivityResolver): Promise<AgentManager>;
@@ -2,7 +2,7 @@
2
2
  * Copyright 2026, Salesforce, Inc. All rights reserved.
3
3
  * See LICENSE.txt for license terms.
4
4
  */
5
- import { EventBus, LogBus, RealClock, UUIDGenerator, } from '@salesforce/agentic-common';
5
+ import { EventBus, getErrorMessage, LogBus, RealClock, UUIDGenerator, } from '@salesforce/agentic-common';
6
6
  import { resolve } from 'node:path';
7
7
  import { stat } from 'node:fs/promises';
8
8
  import { SUPPORTED_PROTOCOL_VERSIONS } from './harness/agent-harness.js';
@@ -10,49 +10,34 @@ import { toHarnessConfig } from './harness/harness-config.js';
10
10
  import { DefaultAgent } from './agent.js';
11
11
  import { AgentSDKError, AgentSDKErrorType } from './errors.js';
12
12
  import { TelemetryRouter } from './internal/telemetry-router.js';
13
+ import { AgentIdentityStore } from './internal/agent-identity-store.js';
13
14
  import { DefaultAgentConnectivityResolver } from './agent-connectivity-resolver.js';
14
15
  /**
15
- * Manages the lifecycle of {@link Agent} instances.
16
+ * Concrete implementation of {@link AgentManager}. **Not exported** from
17
+ * `src/index.ts` — public construction goes through {@link createAgentManager}.
16
18
  *
17
- * The manager owns an {@link AgentHarness} and coordinates initialization,
18
- * agent creation, and graceful shutdown. The harness implementation is
19
- * abstracted away from downstream code (agents, sessions).
20
- *
21
- * ### Persistence Responsibility
22
- * Persistence is shared between the harness and the consuming application:
23
- * - **Engine**: Handles physical data persistence (database storage of threads, messages, and memory state).
24
- * - **Application**: Handles lifecycle persistence. It manages the discovery and restoration of saved agents,
25
- * and calls `createAgent(projectRoot, { agentId: savedId, ...savedConfig })` to rebuild active agent state in memory
26
- * (like MCP connections and custom tool closures) while reconnecting to the persistent memory data.
19
+ * Construction is asynchronous because {@link init} reads the persistence
20
+ * directory and replays prior agents before the manager becomes useful. The
21
+ * pattern mirrors {@link Workspace.create}: a private constructor for sync
22
+ * field assignment, then a static async builder that calls `init()`.
27
23
  */
28
- export class AgentManager {
24
+ export class DefaultAgentManager {
29
25
  harness;
30
26
  agentIdGenerator;
31
27
  agentConnectivityResolver;
32
28
  clock;
29
+ identityStore;
33
30
  agents = new Map();
31
+ restoreFailures = [];
34
32
  telemetryBus = new EventBus();
35
33
  logBus;
36
34
  router;
37
35
  unroutedUnsubs;
38
36
  disposed = false;
39
- /**
40
- * Creates a new {@link AgentManager}.
41
- *
42
- * This constructor is **exposed for testing only** so unit tests can inject a stubbed
43
- * {@link AgentHarness} and deterministic ID generator.
44
- *
45
- * Production callers should use {@link createAgentManager} instead.
46
- *
47
- * @example
48
- * ```ts
49
- * // Recommended for production usage (constructs the default connectivity resolver):
50
- * const manager = await createAgentManager(storageRootFolder, harnessFactory);
51
- * ```
52
- */
53
- constructor(harness, agentConnectivityResolver, agentIdGenerator = new UUIDGenerator(), clock = new RealClock(), logBus = new LogBus()) {
37
+ constructor(harness, agentConnectivityResolver, identityStore, agentIdGenerator, clock, logBus) {
54
38
  this.harness = harness;
55
39
  this.agentConnectivityResolver = agentConnectivityResolver;
40
+ this.identityStore = identityStore;
56
41
  this.agentIdGenerator = agentIdGenerator;
57
42
  this.clock = clock;
58
43
  this.logBus = logBus;
@@ -62,15 +47,47 @@ export class AgentManager {
62
47
  this.router.unrouted.log.forwardTo(this.logBus),
63
48
  ];
64
49
  }
65
- // Note: harness construction is async (see createAgentManager), so AgentManager itself no longer has initialize().
66
50
  /**
67
- * Shuts down the harness and destroys all managed agents.
51
+ * Module-internal builder used by {@link createAgentManager}. Mirrors
52
+ * {@link Workspace.create}: sync field assignment in the private
53
+ * constructor, then `await init()` to do async startup work
54
+ * (reading the persistence directory + replaying prior agents).
68
55
  *
69
- * @requirements
70
- * - MUST iterate through all agents in the internal `agents` map and call `agent.destroy()` on each.
71
- * - MUST clear the internal `agents` map.
72
- * - MUST delegate to `this.harness.shutdown()` to release harness-level resources.
56
+ * The double-underscore signals "do not call directly" — the constructor
57
+ * is private, so this is the only way to obtain an instance, but
58
+ * consumers should always go through {@link createAgentManager}.
73
59
  */
60
+ static async __build(harness, agentConnectivityResolver, storageRootFolder, agentIdGenerator, clock, logBus) {
61
+ const identityStore = new AgentIdentityStore(storageRootFolder, harness.harnessId, logBus);
62
+ const manager = new DefaultAgentManager(harness, agentConnectivityResolver, identityStore, agentIdGenerator, clock, logBus);
63
+ await manager.init();
64
+ return manager;
65
+ }
66
+ async init() {
67
+ const records = await this.identityStore.list();
68
+ const failures = [];
69
+ await Promise.all(records.map(async (record) => {
70
+ try {
71
+ await this.installAgent(record.agentId, record.projectRoot, record.config, {
72
+ rehydrateThreads: true,
73
+ });
74
+ }
75
+ catch (err) {
76
+ failures.push({
77
+ agentId: record.agentId,
78
+ projectRoot: record.projectRoot,
79
+ config: record.config,
80
+ error: err,
81
+ });
82
+ this.logBus.error('agent restore failed', err instanceof Error ? err : undefined, {
83
+ agentId: record.agentId,
84
+ projectRoot: record.projectRoot,
85
+ message: getErrorMessage(err),
86
+ });
87
+ }
88
+ }));
89
+ this.restoreFailures = failures;
90
+ }
74
91
  async shutdown() {
75
92
  if (this.disposed) {
76
93
  return;
@@ -88,78 +105,99 @@ export class AgentManager {
88
105
  this.logBus.dispose();
89
106
  this.disposed = true;
90
107
  }
91
- /**
92
- * Creates a new `Agent` with the given configuration.
93
- *
94
- * @param projectRoot - Absolute path to the project folder the agent can manipulate files from.
95
- * Relative paths are resolved against `process.cwd()`. The path must exist and be a directory.
96
- * @param config - Agent configuration (instructions, tools, model, etc.).
97
- * @param options - Optional execution options, including abort signals.
98
- * @returns A new `Agent` ready for chat sessions.
99
- *
100
- * @throws If `projectRoot` does not exist or is not a directory.
101
- *
102
- * @requirements
103
- * - MUST resolve `projectRoot` to an absolute, normalized path before use.
104
- * - MUST throw an Error if the resolved `projectRoot` does not exist or is not a directory.
105
- * - MUST throw an Error if `config.agentId` is provided and an agent with that ID already exists in the internal `agents` map.
106
- * - MUST generate a unique ID via `this.agentIdGenerator` if `config.agentId` is omitted.
107
- * - MUST delegate to `this.harness.createAgent(agentId, projectRoot, llmGatewayClient, config, options)` to register the agent in the harness and allow for cancellation.
108
- * - MUST instantiate a `DefaultAgent` with the harness and the complete configuration.
109
- * - MUST store the newly created agent in the internal `agents` map, keyed by its `agentId`.
110
- * - MUST return the newly created agent.
111
- */
112
108
  async createAgent(projectRoot, config = {}, options) {
113
109
  this.assertNotDisposed();
114
110
  const resolvedProjectRoot = resolve(projectRoot);
111
+ const { agentId: providedAgentId, ...agentConfig } = config;
112
+ const agentId = providedAgentId ?? this.agentIdGenerator.getUniqueId();
113
+ if (this.agents.has(agentId)) {
114
+ throw new Error(`Agent with id "${agentId}" already exists`);
115
+ }
116
+ // installAgent validates projectRoot existence — same path as the restore loop.
117
+ const agent = await this.installAgent(agentId, resolvedProjectRoot, agentConfig, {
118
+ abortSignal: options?.abortSignal,
119
+ });
120
+ // If the disk write fails (disk full, permissions, fs error), the in-memory install
121
+ // is now stale — the harness has the agent, the manager has it in `agents`, but no
122
+ // record on disk means a subsequent restart loses it. Roll back the install so the
123
+ // failure is observable and the id stays reusable, then rethrow.
124
+ try {
125
+ await this.identityStore.write(agentId, resolvedProjectRoot, agentConfig);
126
+ }
127
+ catch (err) {
128
+ await this.rollbackInstall(agentId, agent);
129
+ throw err;
130
+ }
131
+ // A successful (re)create clears any prior failed-restore entry for the same id.
132
+ this.restoreFailures = this.restoreFailures.filter((f) => f.agentId !== agentId);
133
+ return agent;
134
+ }
135
+ /**
136
+ * Shared install path for {@link createAgent} and the boot-time restore
137
+ * loop. Resolves connectivity, calls `harness.createAgent`, constructs
138
+ * the {@link DefaultAgent}, registers the telemetry slice, emits
139
+ * `agent-created`, and (when restoring) attaches a chat session per
140
+ * persisted thread id.
141
+ *
142
+ * Returns {@link DefaultAgent} (concrete) rather than {@link Agent}
143
+ * because both call sites are internal — widening to the interface only
144
+ * happens at the public-method return statement.
145
+ */
146
+ async installAgent(agentId, projectRoot, config, options = {}) {
115
147
  let dirStat;
116
148
  try {
117
- dirStat = await stat(resolvedProjectRoot);
149
+ dirStat = await stat(projectRoot);
118
150
  }
119
151
  catch {
120
- throw new Error(`projectRoot does not exist: "${resolvedProjectRoot}"`);
152
+ throw new Error(`projectRoot does not exist: "${projectRoot}"`);
121
153
  }
122
154
  if (!dirStat.isDirectory()) {
123
- throw new Error(`projectRoot is not a directory: "${resolvedProjectRoot}"`);
155
+ throw new Error(`projectRoot is not a directory: "${projectRoot}"`);
124
156
  }
125
- const { agentId: providedAgentId, ...agentConfig } = config;
126
- const agentId = providedAgentId ?? this.agentIdGenerator.getUniqueId();
127
- if (providedAgentId && this.agents.has(providedAgentId)) {
128
- throw new Error(`Agent with id "${providedAgentId}" already exists`);
129
- }
130
- const runtime = await this.agentConnectivityResolver.resolve(resolvedProjectRoot, agentConfig);
131
- await this.harness.createAgent(agentId, resolvedProjectRoot, runtime.llmGatewayClient, toHarnessConfig(agentConfig, runtime.orgJwt), options);
157
+ const runtime = await this.agentConnectivityResolver.resolve(projectRoot, config);
158
+ await this.harness.createAgent(agentId, projectRoot, runtime.llmGatewayClient, toHarnessConfig(config, runtime.orgJwt), options.abortSignal !== undefined ? { abortSignal: options.abortSignal } : undefined);
132
159
  const agentSlice = this.router.registerAgent(agentId);
133
- const agent = new DefaultAgent(this.harness, agentId, resolvedProjectRoot, agentConfig, runtime.llmGatewayClient, runtime.orgConnection, runtime.orgJwt, this.agentConnectivityResolver, this.router, agentSlice, { telemetry: this.telemetryBus, log: this.logBus }, this.clock, this.agentIdGenerator);
160
+ const agent = new DefaultAgent(this.harness, agentId, projectRoot, config, runtime.llmGatewayClient, runtime.orgConnection, runtime.orgJwt, this.agentConnectivityResolver, this.router, agentSlice, { telemetry: this.telemetryBus, log: this.logBus }, this.clock, this.agentIdGenerator);
134
161
  this.agents.set(agentId, agent);
135
162
  this.telemetryBus.emit({
136
163
  type: 'agent-created',
137
164
  timestamp: this.clock.now(),
138
165
  agentId,
139
- projectRoot: resolvedProjectRoot,
166
+ projectRoot,
140
167
  modelName: runtime.llmGatewayClient.getModel().name,
141
168
  });
169
+ if (options.rehydrateThreads) {
170
+ // If thread enumeration or session attachment fails, the restore is a full failure
171
+ // (the user would otherwise see an agent marked `ready` whose chat sessions return 404).
172
+ // We must roll back the in-memory installation so the failure surfaces in
173
+ // `restoreFailures` and the id remains reusable via createAgent.
174
+ try {
175
+ const threadIds = await this.harness.getThreadIds(agentId);
176
+ agent.restoreSessions(threadIds);
177
+ }
178
+ catch (err) {
179
+ await this.rollbackInstall(agentId, agent);
180
+ throw err;
181
+ }
182
+ }
142
183
  return agent;
143
184
  }
144
- /**
145
- * Returns the IDs of all currently managed agents.
146
- *
147
- * @requirements
148
- * - MUST return an array of all string keys (agent IDs) currently tracked in the internal `agents` map.
149
- */
185
+ async rollbackInstall(agentId, agent) {
186
+ // `agent.destroy()` already calls `harness.destroyAgent(agentId)` internally
187
+ // (see `DefaultAgent.destroy` in `agent.ts`), so we don't need a second harness call.
188
+ try {
189
+ await agent.destroy();
190
+ }
191
+ catch {
192
+ // Swallow secondary failure; the original error (passed to caller) is what matters.
193
+ }
194
+ this.router.unregisterAgent(agentId);
195
+ this.agents.delete(agentId);
196
+ }
150
197
  getAgentIds() {
151
198
  this.assertNotDisposed();
152
199
  return Array.from(this.agents.keys());
153
200
  }
154
- /**
155
- * Retrieves a managed agent by its ID.
156
- *
157
- * @param agentId - ID of the agent to retrieve.
158
- *
159
- * @requirements
160
- * - MUST throw an Error if the provided `agentId` is not found in the internal `agents` map.
161
- * - MUST return the `Agent` instance associated with the given `agentId`.
162
- */
163
201
  getAgent(agentId) {
164
202
  this.assertNotDisposed();
165
203
  const agent = this.agents.get(agentId);
@@ -168,36 +206,35 @@ export class AgentManager {
168
206
  }
169
207
  return agent;
170
208
  }
171
- /**
172
- * Destroys a managed agent by its ID, releasing all its resources.
173
- *
174
- * @param agentId - ID of the agent to destroy.
175
- *
176
- * @requirements
177
- * - MUST throw an Error if the provided `agentId` is not found in the internal `agents` map.
178
- * - MUST call `destroy()` on the target agent instance.
179
- * - MUST remove the agent from the internal `agents` map.
180
- */
181
209
  async destroyAgent(agentId) {
182
210
  this.assertNotDisposed();
183
211
  const agent = this.agents.get(agentId);
184
- if (!agent) {
212
+ const failureIndex = this.restoreFailures.findIndex((f) => f.agentId === agentId);
213
+ if (!agent && failureIndex === -1) {
185
214
  throw new AgentSDKError(`No Agent found with id: "${agentId}"`, AgentSDKErrorType.AGENT_NOT_FOUND);
186
215
  }
187
- await agent.destroy();
188
- this.router.unregisterAgent(agentId);
189
- this.agents.delete(agentId);
216
+ if (agent) {
217
+ await agent.destroy();
218
+ this.router.unregisterAgent(agentId);
219
+ this.agents.delete(agentId);
220
+ }
221
+ if (failureIndex !== -1) {
222
+ this.restoreFailures.splice(failureIndex, 1);
223
+ }
224
+ await this.identityStore.remove(agentId);
190
225
  }
191
- /** Subscribe to telemetry events across every managed agent. Returns an unsubscribe function. */
192
226
  onTelemetry(callback) {
193
227
  this.assertNotDisposed();
194
228
  return this.telemetryBus.on(callback);
195
229
  }
196
- /** Subscribe to structured log records across every managed agent. Returns an unsubscribe function. */
197
230
  onLog(callback) {
198
231
  this.assertNotDisposed();
199
232
  return this.logBus.on(callback);
200
233
  }
234
+ getRestoreFailures() {
235
+ this.assertNotDisposed();
236
+ return [...this.restoreFailures];
237
+ }
201
238
  assertNotDisposed() {
202
239
  if (this.disposed) {
203
240
  throw new AgentSDKError('AgentManager has been shut down.', AgentSDKErrorType.DISPOSED);
@@ -205,23 +242,23 @@ export class AgentManager {
205
242
  }
206
243
  }
207
244
  /**
208
- * Creates an {@link AgentManager} using the provided harness factory.
209
- *
210
- * Use this function in production code. It validates the `storageRootFolder`,
211
- * verifies the factory advertises a supported protocol version (failing fast
212
- * before any expensive harness construction), constructs the harness
213
- * asynchronously, sanity-checks that the constructed harness honors the
214
- * factory's advertised version, and returns a ready-to-use manager.
245
+ * Public entry point. Validates the storage root, gates the harness's
246
+ * advertised protocol version, constructs the harness, and returns a
247
+ * fully-restored {@link AgentManager}. Boot-time restore runs before this
248
+ * function returns; failures are queryable via
249
+ * {@link AgentManager.getRestoreFailures}.
215
250
  *
216
- * @param storageRootFolder - Existing directory used for agent persistence.
217
- * @param harnessFactory - Factory that constructs the harness implementation.
251
+ * The optional `connectivityResolver` overrides the default
252
+ * `DefaultAgentConnectivityResolver` used by e2e tests and custom-auth
253
+ * deployments where the SDK should not run sf-CLI-based org resolution.
254
+ * Production callers leave it unset.
218
255
  *
219
256
  * @throws {AgentSDKError} `INCOMPATIBLE_HARNESS` when either the factory or
220
257
  * the constructed harness reports a `protocolVersion` outside
221
258
  * {@link SUPPORTED_PROTOCOL_VERSIONS}, or when the harness's reported
222
259
  * version disagrees with the factory's.
223
260
  */
224
- export async function createAgentManager(storageRootFolder, harnessFactory) {
261
+ export async function createAgentManager(storageRootFolder, harnessFactory, connectivityResolver) {
225
262
  let stats;
226
263
  try {
227
264
  stats = await stat(storageRootFolder);
@@ -255,8 +292,8 @@ export async function createAgentManager(storageRootFolder, harnessFactory) {
255
292
  `advertised version ${factoryVersion} (SDK supports: ${SUPPORTED_PROTOCOL_VERSIONS.join(', ')}). ` +
256
293
  `Update the SDK or harness package.`, AgentSDKErrorType.INCOMPATIBLE_HARNESS);
257
294
  }
258
- const agentConnectivityResolver = new DefaultAgentConnectivityResolver();
259
- return new AgentManager(harness, agentConnectivityResolver, new UUIDGenerator());
295
+ const agentConnectivityResolver = connectivityResolver ?? new DefaultAgentConnectivityResolver();
296
+ return DefaultAgentManager.__build(harness, agentConnectivityResolver, storageRootFolder, new UUIDGenerator(), new RealClock(), new LogBus());
260
297
  }
261
298
  function isSupportedProtocolVersion(version) {
262
299
  return (typeof version === 'number' &&
package/dist/agent.d.ts CHANGED
@@ -212,6 +212,17 @@ export declare class DefaultAgent implements Agent {
212
212
  destroy(): Promise<void>;
213
213
  onTelemetry(callback: TelemetryEventCallback): Unsubscribe;
214
214
  onLog(callback: (record: LogRecord) => void): Unsubscribe;
215
+ /**
216
+ * Re-attach `DefaultChatSession` wrappers for thread ids that already
217
+ * exist in the harness's persistent store. Called by the SDK during
218
+ * boot-time restore. Idempotent — thread ids that already have a session
219
+ * are skipped.
220
+ *
221
+ * Not on the public {@link Agent} interface because it's only meaningful
222
+ * to {@link DefaultAgentManager}; that's also why `installAgent` returns
223
+ * the concrete `DefaultAgent` type rather than `Agent`.
224
+ */
225
+ restoreSessions(threadIds: string[]): void;
215
226
  private attachSession;
216
227
  private detachSession;
217
228
  private assertNotDisposed;
package/dist/agent.js CHANGED
@@ -279,6 +279,24 @@ export class DefaultAgent {
279
279
  this.assertNotDisposed();
280
280
  return this.logBus.on(callback);
281
281
  }
282
+ /**
283
+ * Re-attach `DefaultChatSession` wrappers for thread ids that already
284
+ * exist in the harness's persistent store. Called by the SDK during
285
+ * boot-time restore. Idempotent — thread ids that already have a session
286
+ * are skipped.
287
+ *
288
+ * Not on the public {@link Agent} interface because it's only meaningful
289
+ * to {@link DefaultAgentManager}; that's also why `installAgent` returns
290
+ * the concrete `DefaultAgent` type rather than `Agent`.
291
+ */
292
+ restoreSessions(threadIds) {
293
+ this.assertNotDisposed();
294
+ for (const threadId of threadIds) {
295
+ if (!this.sessions.has(threadId)) {
296
+ this.attachSession(threadId);
297
+ }
298
+ }
299
+ }
282
300
  attachSession(threadId) {
283
301
  const slice = this.router.registerSession(threadId);
284
302
  const session = new DefaultChatSession(this.harness, this.agentId, threadId, slice, {
package/dist/index.d.ts CHANGED
@@ -8,7 +8,7 @@ export type { MCPConfiguration, MCPServerConfig, MCPStdioServerConfig, MCPRemote
8
8
  export { McpServerStatus } from './mcp-config.js';
9
9
  export { ModelName } from '@salesforce/llm-gateway-sdk';
10
10
  export { SfApiEnv } from '@salesforce/agentic-common';
11
- export { AgentManager, createAgentManager } from './agent-manager.js';
11
+ export { type AgentManager, type RestoreFailure, createAgentManager } from './agent-manager.js';
12
12
  export { type Agent } from './agent.js';
13
13
  export { type ChatSession, type ChatOptions } from './chat-session.js';
14
14
  export type { AgentConnectivityResolver, ResolvedConnectivity } from './agent-connectivity-resolver.js';
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ export { McpServerStatus } from './mcp-config.js';
7
7
  export { ModelName } from '@salesforce/llm-gateway-sdk';
8
8
  export { SfApiEnv } from '@salesforce/agentic-common';
9
9
  // ── Agent Layer ─────────────────────────────────────────────────────
10
- export { AgentManager, createAgentManager } from './agent-manager.js';
10
+ export { createAgentManager } from './agent-manager.js';
11
11
  export {} from './agent.js';
12
12
  export {} from './chat-session.js';
13
13
  export { SUPPORTED_PROTOCOL_VERSIONS } from './harness/agent-harness.js';
@@ -0,0 +1,41 @@
1
+ import { type LogBus } from '@salesforce/agentic-common';
2
+ import type { AgentConfig } from '../harness/harness-config.js';
3
+ export type AgentIdentityRecord = {
4
+ agentId: string;
5
+ projectRoot: string;
6
+ config: AgentConfig;
7
+ };
8
+ /**
9
+ * SDK-owned persistence for the agent-identity triple
10
+ * `{ agentId, projectRoot, AgentConfig }`. Stored as one JSON file per agent
11
+ * under `${storageRootFolder}/agents/`. Internal to the SDK; not exported.
12
+ *
13
+ * Each record carries the current harness's `harnessId`. On `list()`, records
14
+ * whose `harnessId` does not match the current harness are skipped with a
15
+ * `LogBus.warn` — restoring an agent into the wrong harness is never the
16
+ * right answer.
17
+ */
18
+ export declare class AgentIdentityStore {
19
+ private readonly storageRootFolder;
20
+ private readonly harnessId;
21
+ private readonly logBus;
22
+ /**
23
+ * Per-agentId queue of in-flight writes. Concurrent `write()` calls for the same agentId
24
+ * chain onto the previous promise so the `writeFile` + `rename` pair runs sequentially.
25
+ *
26
+ * Why this matters: POSIX `rename` atomically overwrites an existing target, but Windows
27
+ * `rename` returns `EPERM` when any handle is open on the target — including a sibling
28
+ * concurrent rename from the same process. Three concurrent writers calling
29
+ * `rename(tmp, 'a.json')` succeed on Linux/macOS and fail on Windows. Serializing per
30
+ * agentId eliminates the race entirely (and removes any need for per-call temp suffixes
31
+ * because at most one write is touching the temp path at a time). Last-writer-wins
32
+ * semantics are preserved.
33
+ */
34
+ private readonly inflightWrites;
35
+ constructor(storageRootFolder: string, harnessId: string, logBus: LogBus);
36
+ write(agentId: string, projectRoot: string, config: AgentConfig): Promise<void>;
37
+ private writeImmediate;
38
+ remove(agentId: string): Promise<void>;
39
+ list(): Promise<AgentIdentityRecord[]>;
40
+ private dir;
41
+ }
@@ -0,0 +1,141 @@
1
+ /*
2
+ * Copyright 2026, Salesforce, Inc. All rights reserved.
3
+ * See LICENSE.txt for license terms.
4
+ */
5
+ import { mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
6
+ import { join } from 'node:path';
7
+ import { getErrorMessage } from '@salesforce/agentic-common';
8
+ const FILE_VERSION = 1;
9
+ const AGENTS_SUBDIR = 'agents';
10
+ /**
11
+ * SDK-owned persistence for the agent-identity triple
12
+ * `{ agentId, projectRoot, AgentConfig }`. Stored as one JSON file per agent
13
+ * under `${storageRootFolder}/agents/`. Internal to the SDK; not exported.
14
+ *
15
+ * Each record carries the current harness's `harnessId`. On `list()`, records
16
+ * whose `harnessId` does not match the current harness are skipped with a
17
+ * `LogBus.warn` — restoring an agent into the wrong harness is never the
18
+ * right answer.
19
+ */
20
+ export class AgentIdentityStore {
21
+ storageRootFolder;
22
+ harnessId;
23
+ logBus;
24
+ /**
25
+ * Per-agentId queue of in-flight writes. Concurrent `write()` calls for the same agentId
26
+ * chain onto the previous promise so the `writeFile` + `rename` pair runs sequentially.
27
+ *
28
+ * Why this matters: POSIX `rename` atomically overwrites an existing target, but Windows
29
+ * `rename` returns `EPERM` when any handle is open on the target — including a sibling
30
+ * concurrent rename from the same process. Three concurrent writers calling
31
+ * `rename(tmp, 'a.json')` succeed on Linux/macOS and fail on Windows. Serializing per
32
+ * agentId eliminates the race entirely (and removes any need for per-call temp suffixes
33
+ * because at most one write is touching the temp path at a time). Last-writer-wins
34
+ * semantics are preserved.
35
+ */
36
+ inflightWrites = new Map();
37
+ constructor(storageRootFolder, harnessId, logBus) {
38
+ this.storageRootFolder = storageRootFolder;
39
+ this.harnessId = harnessId;
40
+ this.logBus = logBus;
41
+ }
42
+ async write(agentId, projectRoot, config) {
43
+ const previous = this.inflightWrites.get(agentId) ?? Promise.resolve();
44
+ // `.catch(() => undefined)` so a previous failure doesn't poison the next caller's await.
45
+ // Each caller still observes its own write's success or failure via the returned promise.
46
+ const next = previous.catch(() => undefined).then(() => this.writeImmediate(agentId, projectRoot, config));
47
+ this.inflightWrites.set(agentId, next);
48
+ try {
49
+ await next;
50
+ }
51
+ finally {
52
+ // Only clear the slot if it still points at this write — a newer write may have
53
+ // already chained onto `next` and replaced the entry.
54
+ if (this.inflightWrites.get(agentId) === next) {
55
+ this.inflightWrites.delete(agentId);
56
+ }
57
+ }
58
+ }
59
+ async writeImmediate(agentId, projectRoot, config) {
60
+ const dir = this.dir();
61
+ await mkdir(dir, { recursive: true });
62
+ const payload = {
63
+ version: FILE_VERSION,
64
+ harnessId: this.harnessId,
65
+ agentId,
66
+ projectRoot,
67
+ config,
68
+ };
69
+ const target = join(dir, `${agentId}.json`);
70
+ const tmp = `${target}.tmp`;
71
+ await writeFile(tmp, JSON.stringify(payload, null, 2), 'utf8');
72
+ await rename(tmp, target);
73
+ }
74
+ async remove(agentId) {
75
+ // Wait for any in-flight write before removing so we don't race a rename and leave
76
+ // the file behind. Same Windows-EPERM hazard as concurrent writes, plus the obvious
77
+ // last-writer-wins concern (a write completing after a remove would resurrect the file).
78
+ const previous = this.inflightWrites.get(agentId);
79
+ if (previous) {
80
+ await previous.catch(() => undefined);
81
+ }
82
+ await rm(join(this.dir(), `${agentId}.json`), { force: true });
83
+ }
84
+ async list() {
85
+ let entries;
86
+ try {
87
+ entries = await readdir(this.dir());
88
+ }
89
+ catch (err) {
90
+ if (err.code === 'ENOENT')
91
+ return [];
92
+ throw err;
93
+ }
94
+ const records = [];
95
+ for (const entry of entries) {
96
+ if (!entry.endsWith('.json'))
97
+ continue;
98
+ const filePath = join(this.dir(), entry);
99
+ let parsed;
100
+ try {
101
+ const raw = await readFile(filePath, 'utf8');
102
+ parsed = JSON.parse(raw);
103
+ }
104
+ catch (err) {
105
+ this.logBus.warn('skipping unreadable persisted agent identity file', {
106
+ filePath,
107
+ error: getErrorMessage(err),
108
+ });
109
+ continue;
110
+ }
111
+ if (!parsed.agentId || !parsed.projectRoot || !parsed.config || !parsed.harnessId) {
112
+ // `harnessId` is in the missing-fields gate (not the harness-mismatch gate below)
113
+ // so a record without it produces a "missing required fields" warn rather than
114
+ // a confusing "different harness" warn with `recordHarnessId: undefined`.
115
+ this.logBus.warn('skipping persisted agent identity file with missing required fields', {
116
+ filePath,
117
+ });
118
+ continue;
119
+ }
120
+ if (parsed.harnessId !== this.harnessId) {
121
+ this.logBus.warn('skipping persisted agent identity file from a different harness', {
122
+ filePath,
123
+ agentId: parsed.agentId,
124
+ recordHarnessId: parsed.harnessId,
125
+ currentHarnessId: this.harnessId,
126
+ });
127
+ continue;
128
+ }
129
+ records.push({
130
+ agentId: parsed.agentId,
131
+ projectRoot: parsed.projectRoot,
132
+ config: parsed.config,
133
+ });
134
+ }
135
+ return records;
136
+ }
137
+ dir() {
138
+ return join(this.storageRootFolder, AGENTS_SUBDIR);
139
+ }
140
+ }
141
+ //# sourceMappingURL=agent-identity-store.js.map
package/package.json CHANGED
@@ -1,10 +1,17 @@
1
1
  {
2
2
  "name": "@salesforce/sfdx-agent-sdk",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Harness-agnostic agentic infrastructure for Salesforce developer experience tooling",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ },
13
+ "./package.json": "./package.json"
14
+ },
8
15
  "scripts": {
9
16
  "build": "tsc --build",
10
17
  "clean": "tsc --build --clean",
@@ -33,7 +40,7 @@
33
40
  },
34
41
  "devDependencies": {
35
42
  "@eslint/js": "^10.0.1",
36
- "@salesforce/sfdx-agent-harness-mastra": "0.3.0-dev.2",
43
+ "@salesforce/sfdx-agent-harness-mastra": "0.5.0",
37
44
  "@types/node": "^22.19.17",
38
45
  "@vitest/coverage-istanbul": "^4.1.5",
39
46
  "@vitest/eslint-plugin": "^1.6.16",