@love-moon/conductor-sdk 0.8.0 → 0.10.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
@@ -1,5 +1,24 @@
1
1
  # @love-moon/conductor-sdk
2
2
 
3
+ ## 0.10.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 43c4f87: Runtime health preflight and agent schedule access control. The daemon now
8
+ advertises positive backend runtime health (`x-conductor-runtime-health`) so the
9
+ backend can reject task creation with `503 runtime_unavailable` before any
10
+ timeline activity, and adds `disable_built_in_cli_list` to opt out of built-in
11
+ SDK backends. The SDK attributes agent-originated scheduled-message calls with
12
+ `X-Conductor-Actor: agent` so per-task `agent_schedule_access`
13
+ (full/read_only/blocked) can govern `conductor task schedule` from agents while
14
+ human/UI calls stay unrestricted.
15
+
16
+ ## 0.9.0
17
+
18
+ ### Minor Changes
19
+
20
+ - a15b55d: Add per-turn multi-image and local context-file inputs, plus authenticated attachment materialization from Conductor Web to the executing daemon.
21
+
3
22
  ## 0.8.0
4
23
 
5
24
  ### Minor Changes
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Build the WebSocket identity of a fire process.
3
+ *
4
+ * Two properties are load-bearing:
5
+ *
6
+ * 1. It must be identical across restarts of the same task. The server pins
7
+ * undelivered commands to `agentOutbox.agentHost` and signs attachment
8
+ * transfer tokens against this exact string, so a per-process identity
9
+ * strands anything still in flight when the process dies.
10
+ * 2. It must stay unique per concurrent fire. One daemon runs a fire per task
11
+ * and the realtime hub evicts an existing connection that reuses a name, so
12
+ * a shared identity would make each new fire kick the previous one offline.
13
+ *
14
+ * The `conductor-fire-` prefix is also meaningful: it selects the `manual_fire`
15
+ * plan bucket and keeps fire hosts out of daemon task routing.
16
+ */
17
+ export declare function buildFireHostName(env: Record<string, string | undefined>, pid?: number): string;
@@ -0,0 +1,32 @@
1
+ const FIRE_HOST_PREFIX = 'conductor-fire-';
2
+ const firstNonEmpty = (...values) => {
3
+ for (const value of values) {
4
+ if (typeof value === 'string' && value.trim())
5
+ return value.trim();
6
+ }
7
+ return null;
8
+ };
9
+ const sanitizeHostSegment = (value) => value.replace(/[^A-Za-z0-9._-]+/g, '-');
10
+ /**
11
+ * Build the WebSocket identity of a fire process.
12
+ *
13
+ * Two properties are load-bearing:
14
+ *
15
+ * 1. It must be identical across restarts of the same task. The server pins
16
+ * undelivered commands to `agentOutbox.agentHost` and signs attachment
17
+ * transfer tokens against this exact string, so a per-process identity
18
+ * strands anything still in flight when the process dies.
19
+ * 2. It must stay unique per concurrent fire. One daemon runs a fire per task
20
+ * and the realtime hub evicts an existing connection that reuses a name, so
21
+ * a shared identity would make each new fire kick the previous one offline.
22
+ *
23
+ * The `conductor-fire-` prefix is also meaningful: it selects the `manual_fire`
24
+ * plan bucket and keeps fire hosts out of daemon task routing.
25
+ */
26
+ export function buildFireHostName(env, pid = process.pid) {
27
+ const owner = firstNonEmpty(env.CONDUCTOR_DAEMON_NAME, env.HOSTNAME, env.COMPUTERNAME) ?? 'unknown-host';
28
+ // Before a task exists there is nothing durable to key on, so an unattached
29
+ // fire keeps a per-process name until it owns a task.
30
+ const scope = firstNonEmpty(env.CONDUCTOR_TASK_ID) ?? `pid-${pid}`;
31
+ return `${FIRE_HOST_PREFIX}${sanitizeHostSegment(owner)}-${sanitizeHostSegment(scope)}`;
32
+ }
@@ -0,0 +1 @@
1
+ export * from './materializer.js';
@@ -0,0 +1 @@
1
+ export * from './materializer.js';
@@ -0,0 +1,28 @@
1
+ import { ConductorConfig } from '../config/index.js';
2
+ type FetchLike = typeof fetch;
3
+ type JsonRecord = Record<string, unknown>;
4
+ export interface AttachmentMaterializerOptions {
5
+ config: ConductorConfig;
6
+ projectPath: string;
7
+ agentHost: string;
8
+ fetchImpl?: FetchLike;
9
+ transferTimeoutMs?: number;
10
+ }
11
+ export declare class AttachmentMaterializer {
12
+ private readonly config;
13
+ private readonly projectPath;
14
+ private readonly agentHost;
15
+ private readonly fetchImpl;
16
+ private readonly transferTimeoutMs;
17
+ private readonly inFlight;
18
+ private readonly shutdownController;
19
+ constructor(options: AttachmentMaterializerOptions);
20
+ abortAll(): void;
21
+ validateEnvelopeForStaging(envelope: JsonRecord): boolean;
22
+ materializeEnvelope(envelope: JsonRecord): Promise<JsonRecord>;
23
+ private materializeAttachments;
24
+ private materializeOne;
25
+ private reportMaterialized;
26
+ private fetchWithTimeout;
27
+ }
28
+ export {};
@@ -0,0 +1,258 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { Readable, Transform } from 'node:stream';
5
+ import { pipeline } from 'node:stream/promises';
6
+ const MAX_ATTACHMENT_BYTES = 100 * 1024 * 1024;
7
+ const MAX_ATTACHMENTS = 20;
8
+ const DEFAULT_TRANSFER_TIMEOUT_MS = 120_000;
9
+ function cleanSegment(value, fallback) {
10
+ const basename = path.basename(typeof value === 'string' ? value : '').replace(/[^A-Za-z0-9._-]+/g, '-');
11
+ return basename || fallback;
12
+ }
13
+ function normalizeSha256(value) {
14
+ const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
15
+ return /^[a-f0-9]{64}$/.test(normalized) ? normalized : '';
16
+ }
17
+ function withoutTransferToken(attachment) {
18
+ const { transferToken: _transferToken, ...safe } = attachment;
19
+ return safe;
20
+ }
21
+ function atomicWriteJson(filePath, value) {
22
+ const temporary = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
23
+ fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
24
+ fs.renameSync(temporary, filePath);
25
+ }
26
+ async function hashFile(filePath) {
27
+ const hash = crypto.createHash('sha256');
28
+ for await (const chunk of fs.createReadStream(filePath))
29
+ hash.update(chunk);
30
+ return hash.digest('hex');
31
+ }
32
+ export class AttachmentMaterializer {
33
+ config;
34
+ projectPath;
35
+ agentHost;
36
+ fetchImpl;
37
+ transferTimeoutMs;
38
+ inFlight = new Map();
39
+ shutdownController = new AbortController();
40
+ constructor(options) {
41
+ this.config = options.config;
42
+ this.projectPath = path.resolve(options.projectPath);
43
+ this.agentHost = options.agentHost;
44
+ this.fetchImpl = options.fetchImpl ?? fetch;
45
+ this.transferTimeoutMs = options.transferTimeoutMs ?? DEFAULT_TRANSFER_TIMEOUT_MS;
46
+ }
47
+ abortAll() {
48
+ this.shutdownController.abort(new Error('Attachment materializer closed'));
49
+ }
50
+ validateEnvelopeForStaging(envelope) {
51
+ if (envelope.type !== 'task_user_message')
52
+ return false;
53
+ const payload = envelope.payload && typeof envelope.payload === 'object' ? envelope.payload : null;
54
+ if (!payload || !Array.isArray(payload.attachments) || payload.attachments.length === 0)
55
+ return false;
56
+ const attachments = payload.attachments;
57
+ if (attachments.some((entry) => !entry || typeof entry !== 'object' || Array.isArray(entry)
58
+ || !normalizeSha256(entry.sha256)))
59
+ return false;
60
+ if (attachments.length > MAX_ATTACHMENTS)
61
+ throw new Error('Too many message attachments');
62
+ const ids = attachments.map((entry) => typeof entry.id === 'string'
63
+ ? String(entry.id).trim() : '');
64
+ if (ids.some((id) => !id) || new Set(ids).size !== ids.length)
65
+ throw new Error('Message attachments must have unique IDs');
66
+ for (const raw of attachments) {
67
+ const size = typeof raw.sizeBytes === 'number' ? raw.sizeBytes : Number.NaN;
68
+ const transferToken = typeof raw.transferToken === 'string' ? raw.transferToken.trim() : '';
69
+ if (!transferToken || (raw.kind !== 'image' && raw.kind !== 'file') || !Number.isSafeInteger(size) || size < 0 || size > MAX_ATTACHMENT_BYTES) {
70
+ throw new Error(`Invalid attachment descriptor for ${String(raw.id || 'attachment')}`);
71
+ }
72
+ }
73
+ const taskId = typeof payload.task_id === 'string' ? payload.task_id.trim() : '';
74
+ const messageId = typeof payload.message_id === 'string' ? payload.message_id.trim() : '';
75
+ if (!taskId || !messageId)
76
+ throw new Error('Attachment message is missing task_id or message_id');
77
+ return true;
78
+ }
79
+ async materializeEnvelope(envelope) {
80
+ if (envelope.type !== 'task_user_message')
81
+ return envelope;
82
+ const payload = envelope.payload && typeof envelope.payload === 'object'
83
+ ? { ...envelope.payload }
84
+ : null;
85
+ if (!payload || !Array.isArray(payload.attachments) || payload.attachments.length === 0)
86
+ return envelope;
87
+ const attachments = payload.attachments;
88
+ // Attachments created before the reliable-transfer protocol did not carry a
89
+ // digest. Preserve those legacy display-only descriptors instead of
90
+ // turning an old message into a permanently unacknowledgeable command.
91
+ if (payload.attachments.some((entry) => !entry || typeof entry !== 'object' || Array.isArray(entry) || !normalizeSha256(entry.sha256)))
92
+ return envelope;
93
+ if (payload.attachments.length > MAX_ATTACHMENTS)
94
+ throw new Error('Too many message attachments');
95
+ const attachmentIds = payload.attachments.map((entry) => typeof entry.id === 'string' ? String(entry.id).trim() : '');
96
+ if (attachmentIds.some((id) => !id) || new Set(attachmentIds).size !== attachmentIds.length) {
97
+ throw new Error('Message attachments must have unique IDs');
98
+ }
99
+ const taskId = typeof payload.task_id === 'string' ? payload.task_id.trim() : '';
100
+ const messageId = typeof payload.message_id === 'string' ? payload.message_id.trim() : '';
101
+ if (!taskId || !messageId)
102
+ throw new Error('Attachment message is missing task_id or message_id');
103
+ const key = `${taskId}:${messageId}`;
104
+ const current = this.inFlight.get(key);
105
+ if (current)
106
+ return current;
107
+ const operation = this.materializeAttachments(envelope, payload, attachments, taskId, messageId);
108
+ this.inFlight.set(key, operation);
109
+ try {
110
+ return await operation;
111
+ }
112
+ finally {
113
+ this.inFlight.delete(key);
114
+ }
115
+ }
116
+ async materializeAttachments(envelope, payload, attachments, taskId, messageId) {
117
+ const messageDir = path.join(this.projectPath, '.conductor', 'attachments', cleanSegment(messageId, 'message'));
118
+ fs.mkdirSync(messageDir, { recursive: true, mode: 0o700 });
119
+ const manifestPath = path.join(messageDir, 'manifest.json');
120
+ atomicWriteJson(manifestPath, {
121
+ taskId,
122
+ messageId,
123
+ status: 'materializing',
124
+ attachments: attachments.map((entry) => withoutTransferToken(entry)),
125
+ updatedAt: new Date().toISOString(),
126
+ });
127
+ const localAttachments = [];
128
+ for (const raw of attachments) {
129
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
130
+ throw new Error('Invalid attachment descriptor');
131
+ localAttachments.push(await this.materializeOne(taskId, messageDir, raw));
132
+ }
133
+ atomicWriteJson(manifestPath, {
134
+ taskId,
135
+ messageId,
136
+ status: 'ready',
137
+ attachments: localAttachments,
138
+ updatedAt: new Date().toISOString(),
139
+ });
140
+ await this.reportMaterialized(taskId, messageId, localAttachments, attachments);
141
+ const metadata = payload.metadata && typeof payload.metadata === 'object' && !Array.isArray(payload.metadata)
142
+ ? { ...payload.metadata, attachments: localAttachments }
143
+ : { attachments: localAttachments };
144
+ return { ...envelope, payload: { ...payload, metadata, attachments: localAttachments } };
145
+ }
146
+ async materializeOne(taskId, messageDir, attachment) {
147
+ const id = typeof attachment.id === 'string' ? attachment.id.trim() : '';
148
+ const name = cleanSegment(attachment.name, 'attachment');
149
+ const sha256 = normalizeSha256(attachment.sha256);
150
+ const sizeBytes = typeof attachment.sizeBytes === 'number' ? attachment.sizeBytes : Number.NaN;
151
+ const kind = attachment.kind === 'image' ? 'image' : attachment.kind === 'file' ? 'file' : '';
152
+ if (!id || !sha256 || !kind || !Number.isSafeInteger(sizeBytes) || sizeBytes < 0 || sizeBytes > MAX_ATTACHMENT_BYTES) {
153
+ throw new Error(`Invalid attachment descriptor for ${id || name}`);
154
+ }
155
+ const localPath = path.join(messageDir, `${cleanSegment(id, 'attachment')}--${name}`);
156
+ if (fs.existsSync(localPath)) {
157
+ const stat = fs.statSync(localPath);
158
+ if (stat.isFile() && stat.size === sizeBytes && await hashFile(localPath) === sha256) {
159
+ return { ...withoutTransferToken(attachment), path: localPath, localPath, status: 'ready' };
160
+ }
161
+ fs.rmSync(localPath, { force: true });
162
+ }
163
+ const url = new URL(`/api/agent/tasks/${encodeURIComponent(taskId)}/attachments/${encodeURIComponent(id)}/content`, this.config.backendUrl);
164
+ const transferToken = typeof attachment.transferToken === 'string' ? attachment.transferToken.trim() : '';
165
+ if (!transferToken)
166
+ throw new Error(`Attachment transfer token missing for ${id}`);
167
+ const transferHeaders = {
168
+ Authorization: `Bearer ${this.config.agentToken}`,
169
+ 'X-Conductor-Host': this.agentHost,
170
+ 'X-Conductor-Attachment-Token': transferToken,
171
+ Accept: 'application/octet-stream',
172
+ };
173
+ const transferSignal = AbortSignal.any([
174
+ AbortSignal.timeout(this.transferTimeoutMs),
175
+ this.shutdownController.signal,
176
+ ]);
177
+ const response = await this.fetchImpl(url, {
178
+ headers: transferHeaders,
179
+ signal: transferSignal,
180
+ });
181
+ if (!response.ok || !response.body) {
182
+ const error = new Error(`Attachment download failed with HTTP ${response.status}`);
183
+ // The attachment was released, deleted or is no longer readable by this
184
+ // host. Retrying cannot fix that, so let the caller stop instead of
185
+ // looping on a 404 until the process dies.
186
+ if (response.status === 403 || response.status === 404 || response.status === 410)
187
+ error.permanent = true;
188
+ throw error;
189
+ }
190
+ const contentLength = response.headers.get('content-length');
191
+ const declaredLength = contentLength === null ? null : Number(contentLength);
192
+ if (declaredLength !== null && Number.isFinite(declaredLength) && declaredLength !== sizeBytes) {
193
+ throw new Error('Attachment Content-Length mismatch');
194
+ }
195
+ const temporaryPath = `${localPath}.${process.pid}.${crypto.randomUUID()}.part`;
196
+ const hash = crypto.createHash('sha256');
197
+ let received = 0;
198
+ const verifier = new Transform({
199
+ transform(chunk, _encoding, callback) {
200
+ received += chunk.length;
201
+ if (received > sizeBytes || received > MAX_ATTACHMENT_BYTES) {
202
+ callback(new Error('Attachment exceeded declared size'));
203
+ return;
204
+ }
205
+ hash.update(chunk);
206
+ callback(null, chunk);
207
+ },
208
+ });
209
+ try {
210
+ await pipeline(Readable.fromWeb(response.body), verifier, fs.createWriteStream(temporaryPath, { mode: 0o600 }), { signal: transferSignal });
211
+ if (received !== sizeBytes)
212
+ throw new Error('Attachment size mismatch');
213
+ if (hash.digest('hex') !== sha256)
214
+ throw new Error('Attachment SHA-256 mismatch');
215
+ fs.renameSync(temporaryPath, localPath);
216
+ }
217
+ catch (error) {
218
+ fs.rmSync(temporaryPath, { force: true });
219
+ throw error;
220
+ }
221
+ return { ...withoutTransferToken(attachment), path: localPath, localPath, status: 'ready' };
222
+ }
223
+ async reportMaterialized(taskId, messageId, attachments, sourceAttachments) {
224
+ const url = new URL(`/api/agent/tasks/${encodeURIComponent(taskId)}/messages/${encodeURIComponent(messageId)}/materialized`, this.config.backendUrl);
225
+ const response = await this.fetchWithTimeout(url, {
226
+ method: 'POST',
227
+ headers: {
228
+ Authorization: `Bearer ${this.config.agentToken}`,
229
+ 'X-Conductor-Host': this.agentHost,
230
+ 'Content-Type': 'application/json',
231
+ },
232
+ body: JSON.stringify({
233
+ attachments: attachments.map((attachment, index) => ({
234
+ id: attachment.id,
235
+ sha256: attachment.sha256,
236
+ status: 'ready',
237
+ transferToken: sourceAttachments[index]?.transferToken,
238
+ })),
239
+ }),
240
+ });
241
+ if (!response.ok)
242
+ throw new Error(`Attachment materialization report failed with HTTP ${response.status}`);
243
+ }
244
+ async fetchWithTimeout(input, init) {
245
+ const controller = new AbortController();
246
+ const timeout = setTimeout(() => controller.abort(new Error('Attachment transfer timed out')), this.transferTimeoutMs);
247
+ const abortForShutdown = () => controller.abort(this.shutdownController.signal.reason);
248
+ this.shutdownController.signal.addEventListener('abort', abortForShutdown, { once: true });
249
+ timeout.unref?.();
250
+ try {
251
+ return await this.fetchImpl(input, { ...init, signal: controller.signal });
252
+ }
253
+ finally {
254
+ clearTimeout(timeout);
255
+ this.shutdownController.signal.removeEventListener('abort', abortForShutdown);
256
+ }
257
+ }
258
+ }
@@ -1,3 +1,15 @@
1
+ /**
2
+ * Mark a request as originating from an autonomous agent turn. The daemon
3
+ * exports `CONDUCTOR_LAUNCHED_BY_DAEMON=1` into the fire process (and the shell
4
+ * commands it runs), so any scheduling call made while the AI is driving the
5
+ * turn is attributed to the agent. The backend uses this to enforce the
6
+ * per-task agent schedule access level; ordinary human/app calls omit it and
7
+ * are never restricted.
8
+ */
9
+ function agentActorHeaders() {
10
+ const launchedByDaemon = typeof process !== 'undefined' && process.env?.CONDUCTOR_LAUNCHED_BY_DAEMON === '1';
11
+ return launchedByDaemon ? { 'X-Conductor-Actor': 'agent' } : {};
12
+ }
1
13
  export class BackendApiError extends Error {
2
14
  statusCode;
3
15
  details;
@@ -463,7 +475,9 @@ export class BackendApiClient {
463
475
  return this.parseJson(response);
464
476
  }
465
477
  async listScheduledMessages(taskId) {
466
- const response = await this.request('GET', `/tasks/${taskId}/scheduled-messages`);
478
+ const response = await this.request('GET', `/tasks/${taskId}/scheduled-messages`, {
479
+ headers: agentActorHeaders(),
480
+ });
467
481
  const payload = await this.parseJson(response);
468
482
  if (payload && typeof payload === 'object' && Array.isArray(payload.schedules)) {
469
483
  return payload.schedules;
@@ -476,22 +490,25 @@ export class BackendApiClient {
476
490
  async createScheduledMessage(taskId, params) {
477
491
  const response = await this.request('POST', `/tasks/${taskId}/scheduled-messages`, {
478
492
  body: JSON.stringify(params),
493
+ headers: agentActorHeaders(),
479
494
  });
480
495
  return this.parseJson(response);
481
496
  }
482
497
  async deleteScheduledMessage(taskId, scheduleId) {
483
- await this.request('DELETE', `/tasks/${taskId}/scheduled-messages/${scheduleId}`);
498
+ await this.request('DELETE', `/tasks/${taskId}/scheduled-messages/${scheduleId}`, {
499
+ headers: agentActorHeaders(),
500
+ });
484
501
  }
485
502
  async request(method, pathname, opts = {}) {
486
503
  const url = this.buildUrl(pathname, opts.query);
487
504
  const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
488
505
  const timeout = controller ? setTimeout(() => controller.abort(), this.timeoutMs) : null;
489
506
  try {
490
- let response = await this.sendRequest(url, method, opts.body, controller);
507
+ let response = await this.sendRequest(url, method, opts.body, controller, opts.headers);
491
508
  if (response.status === 404 &&
492
509
  this.shouldRetryWithApiPrefix(pathname, url)) {
493
510
  const retryUrl = this.buildUrl(this.withApiPrefix(pathname), opts.query);
494
- response = await this.sendRequest(retryUrl, method, opts.body, controller);
511
+ response = await this.sendRequest(retryUrl, method, opts.body, controller, opts.headers);
495
512
  }
496
513
  if (!response.ok) {
497
514
  const details = await this.safeJson(response);
@@ -518,13 +535,14 @@ export class BackendApiClient {
518
535
  }
519
536
  return url;
520
537
  }
521
- async sendRequest(url, method, body, controller) {
538
+ async sendRequest(url, method, body, controller, extraHeaders) {
522
539
  return this.fetchImpl(url.toString(), {
523
540
  method,
524
541
  headers: {
525
542
  Authorization: `Bearer ${this.config.agentToken}`,
526
543
  Accept: 'application/json',
527
544
  ...(body ? { 'Content-Type': 'application/json' } : {}),
545
+ ...(extraHeaders ?? {}),
528
546
  },
529
547
  body,
530
548
  signal: controller?.signal,
package/dist/client.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { BackendApiClient } from './backend/index.js';
2
+ import { AttachmentMaterializer } from './attachments/index.js';
2
3
  import { ConductorConfig } from './config/index.js';
3
4
  import { MessageRouter } from './message/index.js';
4
- import { DownstreamCursorStore, DurableUpstreamOutboxStore } from './outbox/index.js';
5
+ import { DownstreamInboxStore, DownstreamCursorStore, DurableUpstreamOutboxStore } from './outbox/index.js';
5
6
  import { SessionDiskStore, SessionManager } from './session/index.js';
6
7
  import { ConductorWebSocketClient, WebSocketConnectedEvent, WebSocketDisconnectEvent, WebSocketPongEvent } from './ws/index.js';
7
8
  type BackendApiLike = Pick<BackendApiClient, 'listProjects' | 'createProject' | 'listTasks' | 'getTask' | 'createTask' | 'updateTask' | 'commitSdkMessage' | 'commitTaskStatusUpdate' | 'commitAgentCommandAck' | 'commitTaskStopAck' | 'matchProjectByPath' | 'getProject' | 'updateProject'>;
@@ -20,6 +21,8 @@ export interface ConductorClientConnectOptions {
20
21
  messageRouter?: MessageRouter;
21
22
  upstreamOutbox?: DurableUpstreamOutboxStore;
22
23
  downstreamCursorStore?: DownstreamCursorStore;
24
+ downstreamInboxStore?: DownstreamInboxStore;
25
+ attachmentMaterializer?: AttachmentMaterializer;
23
26
  agentHost?: string;
24
27
  extraHeaders?: Record<string, string>;
25
28
  onConnected?: (event: WebSocketConnectedEvent) => void;
@@ -41,6 +44,8 @@ interface ConductorClientInit {
41
44
  messageRouter: MessageRouter;
42
45
  upstreamOutbox: DurableUpstreamOutboxStore;
43
46
  downstreamCursorStore: DownstreamCursorStore;
47
+ downstreamInboxStore: DownstreamInboxStore;
48
+ attachmentMaterializer: AttachmentMaterializer;
44
49
  agentHost: string;
45
50
  onStopTask?: (event: StopTaskEvent) => Promise<void> | void;
46
51
  onInterruptTurn?: (event: InterruptTurnEvent) => Promise<boolean | void> | boolean | void;
@@ -79,6 +84,8 @@ export declare class ConductorClient {
79
84
  private readonly messageRouter;
80
85
  private upstreamOutbox;
81
86
  private downstreamCursorStore;
87
+ private downstreamInboxStore;
88
+ private readonly attachmentMaterializer;
82
89
  private readonly agentHost;
83
90
  private readonly onStopTask?;
84
91
  private readonly onInterruptTurn?;
@@ -88,6 +95,11 @@ export declare class ConductorClient {
88
95
  private durableOutboxFlushPromise;
89
96
  private durableOutboxTimer;
90
97
  private durableOutboxTimerDueAt;
98
+ private readonly downstreamTasks;
99
+ private readonly queuedDownstreamRequests;
100
+ private readonly cancelledDownstreamTasks;
101
+ private readonly downstreamWorkerAbort;
102
+ private downstreamClosing;
91
103
  constructor(init: ConductorClientInit);
92
104
  static connect(options?: ConductorClientConnectOptions): Promise<ConductorClient>;
93
105
  close(): Promise<void>;
@@ -133,6 +145,13 @@ export declare class ConductorClient {
133
145
  matchProjectByPath(payload?: Record<string, any>): Promise<Record<string, any>>;
134
146
  bindProjectPath(projectId: string, payload?: Record<string, any>): Promise<Record<string, any>>;
135
147
  private readonly handleBackendEvent;
148
+ private replayDurableDownstreamInbox;
149
+ private enqueueDurableDownstreamCommand;
150
+ /**
151
+ * Tell the user why an attachment message will never run. Silently dropping
152
+ * it would leave a question in the chat that never gets an answer.
153
+ */
154
+ private reportUnrecoverableAttachmentCommand;
136
155
  private extractDownstreamCommandContext;
137
156
  private handleStopTaskCommand;
138
157
  private invokeStopTaskHandler;
package/dist/client.js CHANGED
@@ -1,15 +1,24 @@
1
1
  import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
+ import { buildFireHostName } from './agent-host.js';
4
5
  import { BackendApiClient, BackendApiError } from './backend/index.js';
6
+ import { AttachmentMaterializer } from './attachments/index.js';
5
7
  import { loadConfig } from './config/index.js';
6
8
  import { ProjectContext } from './context/index.js';
7
9
  import { getPlanLimitMessageFromError } from './limits/index.js';
8
10
  import { MessageRouter } from './message/index.js';
9
- import { DownstreamCursorStore, DurableUpstreamOutboxStore, normalizeDownstreamCommandCursor, } from './outbox/index.js';
11
+ import { DownstreamInboxStore, DownstreamCursorStore, DurableUpstreamOutboxStore, normalizeDownstreamCommandCursor, } from './outbox/index.js';
10
12
  import { SessionDiskStore, SessionManager, currentHostname } from './session/index.js';
11
13
  import { ConductorWebSocketClient, } from './ws/index.js';
12
14
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
15
+ const sleepWithSignal = (ms, signal) => new Promise((resolve) => {
16
+ if (signal.aborted)
17
+ return resolve();
18
+ const timer = setTimeout(resolve, Math.max(0, ms));
19
+ timer.unref?.();
20
+ signal.addEventListener('abort', () => { clearTimeout(timer); resolve(); }, { once: true });
21
+ });
13
22
  export class ConductorClient {
14
23
  config;
15
24
  env;
@@ -21,6 +30,8 @@ export class ConductorClient {
21
30
  messageRouter;
22
31
  upstreamOutbox;
23
32
  downstreamCursorStore;
33
+ downstreamInboxStore;
34
+ attachmentMaterializer;
24
35
  agentHost;
25
36
  onStopTask;
26
37
  onInterruptTurn;
@@ -30,6 +41,11 @@ export class ConductorClient {
30
41
  durableOutboxFlushPromise = null;
31
42
  durableOutboxTimer = null;
32
43
  durableOutboxTimerDueAt = null;
44
+ downstreamTasks = new Map();
45
+ queuedDownstreamRequests = new Set();
46
+ cancelledDownstreamTasks = new Set();
47
+ downstreamWorkerAbort = new AbortController();
48
+ downstreamClosing = false;
33
49
  constructor(init) {
34
50
  this.config = init.config;
35
51
  this.env = init.env;
@@ -41,6 +57,8 @@ export class ConductorClient {
41
57
  this.messageRouter = init.messageRouter;
42
58
  this.upstreamOutbox = init.upstreamOutbox;
43
59
  this.downstreamCursorStore = init.downstreamCursorStore;
60
+ this.downstreamInboxStore = init.downstreamInboxStore;
61
+ this.attachmentMaterializer = init.attachmentMaterializer;
44
62
  this.agentHost = init.agentHost;
45
63
  this.onStopTask = init.onStopTask;
46
64
  this.onInterruptTurn = init.onInterruptTurn;
@@ -60,6 +78,12 @@ export class ConductorClient {
60
78
  const deliveryScopeId = normalizeDeliveryScopeId(options.deliveryScopeId ?? `agent:${agentHost}`);
61
79
  const upstreamOutbox = options.upstreamOutbox ?? DurableUpstreamOutboxStore.forProjectPath(projectPath, deliveryScopeId);
62
80
  const downstreamCursorStore = options.downstreamCursorStore ?? DownstreamCursorStore.forProjectPath(projectPath, deliveryScopeId);
81
+ const downstreamInboxStore = options.downstreamInboxStore ?? DownstreamInboxStore.forProjectPath(projectPath, deliveryScopeId);
82
+ const attachmentMaterializer = options.attachmentMaterializer ?? new AttachmentMaterializer({
83
+ config,
84
+ projectPath,
85
+ agentHost,
86
+ });
63
87
  const wsClient = options.wsClient ??
64
88
  new ConductorWebSocketClient(config, {
65
89
  hostName: agentHost,
@@ -85,12 +109,23 @@ export class ConductorClient {
85
109
  messageRouter,
86
110
  upstreamOutbox,
87
111
  downstreamCursorStore,
112
+ downstreamInboxStore,
113
+ attachmentMaterializer,
88
114
  agentHost,
89
115
  onStopTask: options.onStopTask,
90
116
  onInterruptTurn: options.onInterruptTurn,
91
117
  onRefreshSession: options.onRefreshSession,
92
118
  });
93
- await client.wsClient.connect();
119
+ client.replayDurableDownstreamInbox();
120
+ try {
121
+ await client.wsClient.connect();
122
+ }
123
+ catch (error) {
124
+ client.downstreamClosing = true;
125
+ client.downstreamWorkerAbort.abort();
126
+ client.attachmentMaterializer.abortAll();
127
+ throw error;
128
+ }
94
129
  if (client.shouldAutoFlushDurableOutbox()) {
95
130
  void client.requestDurableOutboxFlush(true);
96
131
  }
@@ -100,6 +135,9 @@ export class ConductorClient {
100
135
  if (this.closed) {
101
136
  return;
102
137
  }
138
+ this.downstreamClosing = true;
139
+ this.downstreamWorkerAbort.abort();
140
+ this.attachmentMaterializer.abortAll();
103
141
  this.clearDurableOutboxTimer();
104
142
  await this.flushPendingUpstreamEvents({
105
143
  timeoutMs: 2_000,
@@ -576,12 +614,28 @@ export class ConductorClient {
576
614
  }
577
615
  const command = this.extractDownstreamCommandContext(payload);
578
616
  if (command?.eventType === 'stop_task') {
617
+ this.cancelledDownstreamTasks.add(command.taskId);
579
618
  await this.handleStopTaskCommand(payload, command);
580
619
  return;
581
620
  }
582
621
  const alreadyApplied = command ? this.downstreamCursorStore.hasApplied(this.agentHost, command.cursor) : false;
583
622
  if (!alreadyApplied) {
584
- await this.messageRouter.handleBackendEvent(payload);
623
+ if (command && this.attachmentMaterializer.validateEnvelopeForStaging(payload)) {
624
+ const durable = this.downstreamInboxStore.upsert({
625
+ requestId: command.requestId,
626
+ taskId: command.taskId,
627
+ cursor: command.cursor,
628
+ envelope: payload,
629
+ });
630
+ this.enqueueDurableDownstreamCommand(durable);
631
+ await this.maybeAckInboundCommand(payload, { accepted: true });
632
+ return;
633
+ }
634
+ if (command) {
635
+ await this.downstreamTasks.get(command.taskId);
636
+ }
637
+ const materializedPayload = await this.attachmentMaterializer.materializeEnvelope(payload);
638
+ await this.messageRouter.handleBackendEvent(materializedPayload);
585
639
  if (command) {
586
640
  this.downstreamCursorStore.advance(this.agentHost, command.cursor);
587
641
  }
@@ -590,6 +644,93 @@ export class ConductorClient {
590
644
  accepted: true,
591
645
  });
592
646
  };
647
+ replayDurableDownstreamInbox() {
648
+ const entries = this.downstreamInboxStore.list().sort((left, right) => {
649
+ const byTime = Date.parse(left.cursor.createdAt) - Date.parse(right.cursor.createdAt);
650
+ return byTime || left.cursor.requestId.localeCompare(right.cursor.requestId);
651
+ });
652
+ for (const entry of entries)
653
+ this.enqueueDurableDownstreamCommand(entry);
654
+ }
655
+ enqueueDurableDownstreamCommand(entry) {
656
+ if (this.queuedDownstreamRequests.has(entry.requestId))
657
+ return;
658
+ this.queuedDownstreamRequests.add(entry.requestId);
659
+ const previous = this.downstreamTasks.get(entry.taskId) ?? Promise.resolve();
660
+ const current = previous.catch(() => undefined).then(async () => {
661
+ let attempt = entry.attemptCount;
662
+ while (!this.closed && !this.downstreamClosing) {
663
+ if (this.cancelledDownstreamTasks.has(entry.taskId)) {
664
+ this.downstreamInboxStore.remove(entry.requestId);
665
+ return;
666
+ }
667
+ const nextAttemptMs = Date.parse(entry.nextAttemptAt);
668
+ if (Number.isFinite(nextAttemptMs) && nextAttemptMs > Date.now()) {
669
+ await sleepWithSignal(nextAttemptMs - Date.now(), this.downstreamWorkerAbort.signal);
670
+ }
671
+ try {
672
+ const materialized = await this.attachmentMaterializer.materializeEnvelope(entry.envelope);
673
+ if (this.cancelledDownstreamTasks.has(entry.taskId)) {
674
+ this.downstreamInboxStore.remove(entry.requestId);
675
+ return;
676
+ }
677
+ // A shutdown after download must leave the already-ACKed command in
678
+ // the durable inbox so the next process can route it. Removing it
679
+ // here would make the Web outbox and the Daemon both forget it.
680
+ if (this.closed || this.downstreamClosing)
681
+ return;
682
+ await this.messageRouter.handleBackendEvent(materialized);
683
+ this.downstreamCursorStore.advance(this.agentHost, entry.cursor);
684
+ this.downstreamInboxStore.remove(entry.requestId);
685
+ return;
686
+ }
687
+ catch (error) {
688
+ if (error?.permanent) {
689
+ const reported = await this.reportUnrecoverableAttachmentCommand(entry, error);
690
+ // Only drop the command once the user-facing explanation is durably
691
+ // queued; otherwise keep retrying so the failure cannot vanish.
692
+ if (reported) {
693
+ this.downstreamCursorStore.advance(this.agentHost, entry.cursor);
694
+ this.downstreamInboxStore.remove(entry.requestId);
695
+ return;
696
+ }
697
+ }
698
+ attempt += 1;
699
+ const delayMs = Math.min(1_000 * (2 ** Math.min(attempt, 5)), 30_000);
700
+ try {
701
+ this.downstreamInboxStore.markRetry(entry.requestId, delayMs);
702
+ }
703
+ catch (persistError) {
704
+ console.warn(`[sdk] failed to persist downstream retry state for ${entry.requestId}: ${persistError instanceof Error ? persistError.message : String(persistError)}`);
705
+ }
706
+ console.warn(`[sdk] downstream attachment command ${entry.requestId} failed; retrying in ${delayMs}ms: ${error instanceof Error ? error.message : String(error)}`);
707
+ await sleepWithSignal(delayMs, this.downstreamWorkerAbort.signal);
708
+ }
709
+ }
710
+ }).catch((error) => {
711
+ console.warn(`[sdk] downstream worker ${entry.requestId} stopped unexpectedly: ${error instanceof Error ? error.message : String(error)}`);
712
+ }).finally(() => {
713
+ this.queuedDownstreamRequests.delete(entry.requestId);
714
+ if (this.downstreamTasks.get(entry.taskId) === current)
715
+ this.downstreamTasks.delete(entry.taskId);
716
+ });
717
+ this.downstreamTasks.set(entry.taskId, current);
718
+ }
719
+ /**
720
+ * Tell the user why an attachment message will never run. Silently dropping
721
+ * it would leave a question in the chat that never gets an answer.
722
+ */
723
+ async reportUnrecoverableAttachmentCommand(entry, error) {
724
+ const reason = error instanceof Error ? error.message : String(error);
725
+ try {
726
+ await this.sendMessage(entry.taskId, `The attachments on this message are no longer available, so it could not be processed (${reason}). Please send them again.`, { attachment_delivery: 'failed', reply_to: entry.requestId });
727
+ return true;
728
+ }
729
+ catch (reportError) {
730
+ console.warn(`[sdk] failed to report unrecoverable attachment command ${entry.requestId}: ${reportError instanceof Error ? reportError.message : String(reportError)}`);
731
+ return false;
732
+ }
733
+ }
593
734
  extractDownstreamCommandContext(payload) {
594
735
  const eventType = typeof payload?.type === 'string' ? payload.type : '';
595
736
  if (eventType !== 'task_user_message' && eventType !== 'task_action' && eventType !== 'stop_task') {
@@ -966,8 +1107,10 @@ export class ConductorClient {
966
1107
  }
967
1108
  const currentUpstream = this.upstreamOutbox;
968
1109
  const currentCursorStore = this.downstreamCursorStore;
1110
+ const currentInboxStore = this.downstreamInboxStore;
969
1111
  const nextUpstream = DurableUpstreamOutboxStore.forProjectPath(this.projectPath, nextScopeId);
970
1112
  const nextCursorStore = DownstreamCursorStore.forProjectPath(this.projectPath, nextScopeId);
1113
+ const nextInboxStore = DownstreamInboxStore.forProjectPath(this.projectPath, nextScopeId);
971
1114
  for (const entry of currentUpstream.load()) {
972
1115
  nextUpstream.upsert({
973
1116
  stableId: entry.stableId,
@@ -977,11 +1120,16 @@ export class ConductorClient {
977
1120
  currentUpstream.remove(entry.stableId);
978
1121
  }
979
1122
  const currentCursor = currentCursorStore.get(this.agentHost);
980
- if (currentCursor && !nextCursorStore.get(this.agentHost)) {
1123
+ if (currentCursor) {
981
1124
  nextCursorStore.advance(this.agentHost, currentCursor);
982
1125
  }
1126
+ for (const entry of currentInboxStore.list()) {
1127
+ nextInboxStore.import(entry);
1128
+ currentInboxStore.remove(entry.requestId);
1129
+ }
983
1130
  this.upstreamOutbox = nextUpstream;
984
1131
  this.downstreamCursorStore = nextCursorStore;
1132
+ this.downstreamInboxStore = nextInboxStore;
985
1133
  this.deliveryScopeId = nextScopeId;
986
1134
  void this.requestDurableOutboxFlush(true);
987
1135
  }
@@ -1059,13 +1207,12 @@ function resolveAgentHost(env, explicit) {
1059
1207
  if (typeof fromAgent === 'string' && fromAgent.trim()) {
1060
1208
  return fromAgent.trim();
1061
1209
  }
1062
- const fromDaemon = env.CONDUCTOR_DAEMON_NAME;
1063
- if (typeof fromDaemon === 'string' && fromDaemon.trim()) {
1064
- return fromDaemon.trim();
1065
- }
1066
- const pid = process.pid;
1067
- const host = env.HOSTNAME || env.COMPUTERNAME || 'unknown-host';
1068
- return `conductor-fire-${host}-${pid}`;
1210
+ // CONDUCTOR_DAEMON_NAME is inherited from the daemon that spawned this fire,
1211
+ // so it names the daemon, not this process. Returning it verbatim would make
1212
+ // the fire impersonate its own daemon: same identity on the hub (mutual
1213
+ // eviction) and no `conductor-fire-` prefix (wrong plan bucket, eligible for
1214
+ // daemon task routing). Use it as the owner segment instead.
1215
+ return buildFireHostName(env);
1069
1216
  }
1070
1217
  function normalizeDeliveryScopeId(scopeId) {
1071
1218
  const normalized = String(scopeId || '').trim();
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export * from './agent-host.js';
1
2
  export * from './paths.js';
2
3
  export * from './config/index.js';
3
4
  export * from './backend/index.js';
@@ -9,3 +10,4 @@ export * from './context/index.js';
9
10
  export * from './limits/index.js';
10
11
  export * from './outbox/index.js';
11
12
  export * from './api/index.js';
13
+ export * from './attachments/index.js';
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ export * from './agent-host.js';
1
2
  export * from './paths.js';
2
3
  export * from './config/index.js';
3
4
  export * from './backend/index.js';
@@ -9,3 +10,4 @@ export * from './context/index.js';
9
10
  export * from './limits/index.js';
10
11
  export * from './outbox/index.js';
11
12
  export * from './api/index.js';
13
+ export * from './attachments/index.js';
@@ -0,0 +1,20 @@
1
+ import type { DownstreamCommandCursor } from './downstream-cursor-store.js';
2
+ export interface DurableDownstreamCommand {
3
+ requestId: string;
4
+ taskId: string;
5
+ cursor: DownstreamCommandCursor;
6
+ envelope: Record<string, unknown>;
7
+ attemptCount: number;
8
+ nextAttemptAt: string;
9
+ }
10
+ export declare class DownstreamInboxStore {
11
+ private readonly filePath;
12
+ constructor(filePath: string);
13
+ static forProjectPath(projectPath: string, scopeId: string): DownstreamInboxStore;
14
+ list(): DurableDownstreamCommand[];
15
+ upsert(command: Omit<DurableDownstreamCommand, 'attemptCount' | 'nextAttemptAt'>): DurableDownstreamCommand;
16
+ import(entry: DurableDownstreamCommand): DurableDownstreamCommand;
17
+ markRetry(requestId: string, delayMs: number): void;
18
+ remove(requestId: string): void;
19
+ private save;
20
+ }
@@ -0,0 +1,90 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ const STATE_DIR = path.join('.conductor', 'state');
4
+ const BASENAME = 'agent-downstream-inbox';
5
+ function isDurableCommand(value) {
6
+ if (!value || typeof value !== 'object')
7
+ return false;
8
+ const entry = value;
9
+ return typeof entry.requestId === 'string' && Boolean(entry.requestId.trim())
10
+ && typeof entry.taskId === 'string' && Boolean(entry.taskId.trim())
11
+ && Boolean(entry.cursor) && typeof entry.cursor?.createdAt === 'string' && typeof entry.cursor?.requestId === 'string'
12
+ && Boolean(entry.envelope) && typeof entry.envelope === 'object'
13
+ && Number.isSafeInteger(entry.attemptCount) && (entry.attemptCount ?? -1) >= 0
14
+ && typeof entry.nextAttemptAt === 'string';
15
+ }
16
+ const sanitizeScopeId = (value) => value.trim().replace(/[^A-Za-z0-9._-]+/g, '_') || 'default';
17
+ export class DownstreamInboxStore {
18
+ filePath;
19
+ constructor(filePath) {
20
+ this.filePath = filePath;
21
+ }
22
+ static forProjectPath(projectPath, scopeId) {
23
+ return new DownstreamInboxStore(path.join(projectPath, STATE_DIR, `${BASENAME}.${sanitizeScopeId(scopeId)}.json`));
24
+ }
25
+ list() {
26
+ try {
27
+ const parsed = JSON.parse(fs.readFileSync(this.filePath, 'utf8'));
28
+ if (!Array.isArray(parsed.entries))
29
+ throw new Error(`Invalid downstream inbox: ${this.filePath}`);
30
+ if (!parsed.entries.every(isDurableCommand))
31
+ throw new Error(`Invalid downstream inbox entry: ${this.filePath}`);
32
+ return parsed.entries;
33
+ }
34
+ catch (error) {
35
+ if (error.code === 'ENOENT')
36
+ return [];
37
+ throw error;
38
+ }
39
+ }
40
+ upsert(command) {
41
+ const entries = this.list();
42
+ const existing = entries.find((entry) => entry.requestId === command.requestId);
43
+ if (existing)
44
+ return existing;
45
+ const entry = { ...command, attemptCount: 0, nextAttemptAt: new Date().toISOString() };
46
+ this.save([...entries, entry]);
47
+ return entry;
48
+ }
49
+ import(entry) {
50
+ const entries = this.list();
51
+ const existing = entries.find((candidate) => candidate.requestId === entry.requestId);
52
+ if (existing)
53
+ return existing;
54
+ this.save([...entries, entry]);
55
+ return entry;
56
+ }
57
+ markRetry(requestId, delayMs) {
58
+ const entries = this.list().map((entry) => entry.requestId === requestId ? {
59
+ ...entry,
60
+ attemptCount: entry.attemptCount + 1,
61
+ nextAttemptAt: new Date(Date.now() + delayMs).toISOString(),
62
+ } : entry);
63
+ this.save(entries);
64
+ }
65
+ remove(requestId) {
66
+ this.save(this.list().filter((entry) => entry.requestId !== requestId));
67
+ }
68
+ save(entries) {
69
+ const resolved = path.resolve(this.filePath);
70
+ const directory = path.dirname(resolved);
71
+ fs.mkdirSync(directory, { recursive: true });
72
+ const temporary = `${resolved}.${process.pid}.${Date.now()}.tmp`;
73
+ const fd = fs.openSync(temporary, 'wx', 0o600);
74
+ try {
75
+ fs.writeFileSync(fd, JSON.stringify({ entries }, null, 2));
76
+ fs.fsyncSync(fd);
77
+ }
78
+ finally {
79
+ fs.closeSync(fd);
80
+ }
81
+ fs.renameSync(temporary, resolved);
82
+ const directoryFd = fs.openSync(directory, 'r');
83
+ try {
84
+ fs.fsyncSync(directoryFd);
85
+ }
86
+ finally {
87
+ fs.closeSync(directoryFd);
88
+ }
89
+ }
90
+ }
@@ -1,2 +1,3 @@
1
1
  export * from './store.js';
2
2
  export * from './downstream-cursor-store.js';
3
+ export * from './downstream-inbox-store.js';
@@ -1,2 +1,3 @@
1
1
  export * from './store.js';
2
2
  export * from './downstream-cursor-store.js';
3
+ export * from './downstream-inbox-store.js';
@@ -25,6 +25,7 @@ export declare class SessionState {
25
25
  lastMessageAt: Date;
26
26
  status: string;
27
27
  pendingMessages: MessageRecord[];
28
+ readonly seenMessageIds: Set<string>;
28
29
  ackToken: string | null;
29
30
  constructor(taskId: string, sessionId: string, projectId: string, createdAt?: Date);
30
31
  }
@@ -39,6 +39,7 @@ export class SessionState {
39
39
  lastMessageAt;
40
40
  status = 'ACTIVE';
41
41
  pendingMessages = [];
42
+ seenMessageIds = new Set();
42
43
  ackToken = null;
43
44
  constructor(taskId, sessionId, projectId, createdAt = new Date()) {
44
45
  this.taskId = taskId;
@@ -102,7 +103,10 @@ export class SessionManager {
102
103
  if (!session) {
103
104
  return;
104
105
  }
106
+ if (session.seenMessageIds.has(message.messageId))
107
+ return;
105
108
  const record = new MessageRecord(message);
109
+ session.seenMessageIds.add(record.messageId);
106
110
  session.pendingMessages.push(record);
107
111
  session.lastMessageAt = record.createdAt;
108
112
  this.ensureEvent(taskId).signal();
package/dist/ws/client.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { buildFireHostName } from '../agent-host.js';
1
2
  class AsyncLock {
2
3
  tail = Promise.resolve();
3
4
  async runExclusive(fn) {
@@ -443,9 +444,7 @@ async function defaultConnectImpl(url, options) {
443
444
  });
444
445
  }
445
446
  function defaultHostName() {
446
- const pid = process.pid;
447
- const host = process.env.HOSTNAME || process.env.COMPUTERNAME || 'unknown-host';
448
- return `conductor-fire-${host}-${pid}`;
447
+ return buildFireHostName(process.env);
449
448
  }
450
449
  class WsAdapter {
451
450
  ws;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@love-moon/conductor-sdk",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/lovemoon-ai/conductor.git"
@@ -33,5 +33,5 @@
33
33
  "typescript": "^5.6.3",
34
34
  "vitest": "^2.1.4"
35
35
  },
36
- "gitCommitId": "04d4f62"
36
+ "gitCommitId": "bc7b3a5"
37
37
  }