@love-moon/conductor-sdk 0.7.7 → 0.9.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,23 @@
1
1
  # @love-moon/conductor-sdk
2
2
 
3
+ ## 0.9.0
4
+
5
+ ### Minor Changes
6
+
7
+ - a15b55d: Add per-turn multi-image and local context-file inputs, plus authenticated attachment materialization from Conductor Web to the executing daemon.
8
+
9
+ ## 0.8.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 959dd1d: Add project-configured worker and reviewer task groups, task-group discovery in
14
+ the SDK and `conductor task group`, and a lightweight daemon protocol for
15
+ refreshing the project agent registry.
16
+ - fe76139: Add `conductor task create` for creating app tasks with title, prompt, backend,
17
+ project resolution, and optional parent task-card grouping. App tasks now
18
+ require an online compatible daemon, and grouping results are exposed to
19
+ callers so partial success is visible without retrying task creation.
20
+
3
21
  ## 0.7.7
4
22
 
5
23
  ### Patch 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
+ }
@@ -1,4 +1,4 @@
1
1
  export { ProjectsApi, type Project, type CreateProjectInput, type ListProjectsOptions, type ResolveProjectInput, } from './projects.js';
2
2
  export { IssuesApi, type Issue, type ListIssuesInput, type CreateIssueInput, type UpdateIssueInput, type UpdateIssueStatusOptions, } from './issues.js';
3
- export { TasksApi, type Task, type Message, type ListTasksInput, type SendTaskMessageOptions, type ListTaskMessagesOptions, type ScheduledMessage, type ScheduledMessageSchedule, type CreateScheduledMessageInput, } from './tasks.js';
3
+ export { TasksApi, type Task, type TaskGroupingResult, type TaskGroup, type TaskGroupMember, type Message, type ListTasksInput, type CreateTaskInput, type SendTaskMessageOptions, type ListTaskMessagesOptions, type ScheduledMessage, type ScheduledMessageSchedule, type CreateScheduledMessageInput, } from './tasks.js';
4
4
  export { ProjectNotResolvedError, buildAuditMetadata, isBackendApiError, type ApiClient, type SdkClientOptions, } from './shared.js';
@@ -1,5 +1,5 @@
1
1
  import { BackendApiClient, BackendApiError } from '../backend/index.js';
2
- export type ApiClient = Pick<BackendApiClient, 'listProjects' | 'createProject' | 'getProject' | 'updateProject' | 'patchProjectByQuery' | 'setDefaultProject' | 'matchProjectByPath' | 'listIssues' | 'getIssue' | 'createIssue' | 'patchIssue' | 'deleteIssue' | 'listTasks' | 'getTask' | 'listTaskMessages' | 'postTaskMessage' | 'postTaskInsert' | 'listScheduledMessages' | 'createScheduledMessage' | 'deleteScheduledMessage'>;
2
+ export type ApiClient = Pick<BackendApiClient, 'listProjects' | 'createProject' | 'getProject' | 'updateProject' | 'patchProjectByQuery' | 'setDefaultProject' | 'matchProjectByPath' | 'listIssues' | 'getIssue' | 'createIssue' | 'patchIssue' | 'deleteIssue' | 'listTasks' | 'createTask' | 'createAppTask' | 'getTask' | 'getTaskGroup' | 'listTaskMessages' | 'postTaskMessage' | 'postTaskInsert' | 'listScheduledMessages' | 'createScheduledMessage' | 'deleteScheduledMessage'>;
3
3
  export interface SdkClientOptions {
4
4
  /**
5
5
  * Optional override for the SDK version stamped onto audit metadata. Falls
@@ -10,8 +10,14 @@ export interface Task {
10
10
  sessionFilePath?: string | null;
11
11
  createdAt?: string | null;
12
12
  updatedAt?: string | null;
13
+ grouping?: TaskGroupingResult | null;
13
14
  raw: Record<string, unknown>;
14
15
  }
16
+ export interface TaskGroupingResult {
17
+ parentTaskId: string;
18
+ grouped: boolean;
19
+ warning?: string | null;
20
+ }
15
21
  export interface Message {
16
22
  id: string;
17
23
  taskId?: string | null;
@@ -21,6 +27,21 @@ export interface Message {
21
27
  createdAt?: string | null;
22
28
  raw: Record<string, unknown>;
23
29
  }
30
+ /** RFC 0033 — one member of a multi-agent task group. */
31
+ export interface TaskGroupMember {
32
+ taskId: string;
33
+ role: string | null;
34
+ agent: string | null;
35
+ title: string | null;
36
+ status: string | null;
37
+ backendType: string | null;
38
+ isSelf: boolean;
39
+ }
40
+ /** RFC 0033 — the group a task belongs to (groupId null when standalone). */
41
+ export interface TaskGroup {
42
+ groupId: string | null;
43
+ members: TaskGroupMember[];
44
+ }
24
45
  export type ScheduledMessageSchedule = {
25
46
  mode: 'delay';
26
47
  amount: number;
@@ -71,6 +92,14 @@ export interface ListTasksInput {
71
92
  issueId?: string;
72
93
  status?: string | string[];
73
94
  }
95
+ export interface CreateTaskInput {
96
+ projectId: string;
97
+ title: string;
98
+ backendType?: string;
99
+ initialContent?: string;
100
+ parentTaskId?: string;
101
+ metadata?: Record<string, unknown>;
102
+ }
74
103
  export interface SendTaskMessageOptions {
75
104
  role?: string;
76
105
  metadata?: Record<string, unknown>;
@@ -99,6 +128,14 @@ export declare class TasksApi {
99
128
  constructor(client: ApiClient, options?: SdkClientOptions);
100
129
  listTasks(input?: ListTasksInput): Promise<Task[]>;
101
130
  getTask(taskId: string): Promise<Task>;
131
+ createTask(input: CreateTaskInput): Promise<Task>;
132
+ /**
133
+ * RFC 0033 — resolve the multi-agent group a task belongs to. Returns the
134
+ * group id (null if the task is standalone) and every sibling task with its
135
+ * role/agent. Agents use this to discover the task(s) they read from / send
136
+ * feedback to.
137
+ */
138
+ getTaskGroup(taskId: string): Promise<TaskGroup>;
102
139
  sendTaskMessage(taskId: string, content: string, options?: SendTaskMessageOptions): Promise<Message>;
103
140
  /**
104
141
  * Insert a mid-turn message into a running task. The message is delivered and
package/dist/api/tasks.js CHANGED
@@ -4,6 +4,21 @@ const normalizeTask = (payload) => {
4
4
  if (!id) {
5
5
  throw new Error('Task payload missing id');
6
6
  }
7
+ const groupingPayload = payload.grouping && typeof payload.grouping === 'object' && !Array.isArray(payload.grouping)
8
+ ? payload.grouping
9
+ : null;
10
+ const groupingParentTaskId = groupingPayload
11
+ ? groupingPayload.parentTaskId ?? groupingPayload.parent_task_id
12
+ : null;
13
+ const grouping = groupingParentTaskId && typeof groupingPayload?.grouped === 'boolean'
14
+ ? {
15
+ parentTaskId: String(groupingParentTaskId),
16
+ grouped: groupingPayload.grouped,
17
+ warning: typeof groupingPayload.warning === 'string'
18
+ ? groupingPayload.warning
19
+ : null,
20
+ }
21
+ : null;
7
22
  return {
8
23
  id,
9
24
  projectId: payload.projectId !== undefined
@@ -23,9 +38,27 @@ const normalizeTask = (payload) => {
23
38
  sessionFilePath: payload.sessionFilePath ?? payload.session_file_path ?? null,
24
39
  createdAt: payload.createdAt ?? payload.created_at ?? null,
25
40
  updatedAt: payload.updatedAt ?? payload.updated_at ?? null,
41
+ grouping,
26
42
  raw: payload,
27
43
  };
28
44
  };
45
+ const asStringOrNull = (value) => typeof value === 'string' && value.trim() ? value.trim() : null;
46
+ const normalizeTaskGroup = (payload) => {
47
+ const rawMembers = Array.isArray(payload?.members) ? payload.members : [];
48
+ const members = rawMembers.map((m) => ({
49
+ taskId: String(m?.task_id ?? m?.taskId ?? ''),
50
+ role: asStringOrNull(m?.role),
51
+ agent: asStringOrNull(m?.agent),
52
+ title: asStringOrNull(m?.title),
53
+ status: asStringOrNull(m?.status),
54
+ backendType: asStringOrNull(m?.backend_type ?? m?.backendType),
55
+ isSelf: Boolean(m?.is_self ?? m?.isSelf),
56
+ }));
57
+ return {
58
+ groupId: asStringOrNull(payload?.group_id ?? payload?.groupId),
59
+ members,
60
+ };
61
+ };
29
62
  const normalizeMessage = (payload) => {
30
63
  const id = payload.id ? String(payload.id) : payload.message_id ? String(payload.message_id) : '';
31
64
  if (!id) {
@@ -129,6 +162,49 @@ export class TasksApi {
129
162
  ? summary.asObject()
130
163
  : summary);
131
164
  }
165
+ async createTask(input) {
166
+ const projectId = String(input?.projectId ?? '').trim();
167
+ const title = String(input?.title ?? '').trim();
168
+ if (!projectId) {
169
+ throw new Error('projectId is required');
170
+ }
171
+ if (!title) {
172
+ throw new Error('title is required');
173
+ }
174
+ const params = {
175
+ projectId,
176
+ title,
177
+ taskType: 'ai_task',
178
+ metadata: buildAuditMetadata(input.metadata, this.options),
179
+ };
180
+ if (input.backendType !== undefined) {
181
+ params.backendType = input.backendType;
182
+ }
183
+ if (input.initialContent !== undefined) {
184
+ params.initialContent = input.initialContent;
185
+ }
186
+ if (input.parentTaskId !== undefined) {
187
+ params.parentTaskId = input.parentTaskId;
188
+ }
189
+ const payload = await this.client.createAppTask(params);
190
+ return normalizeTask(typeof payload.asObject === 'function'
191
+ ? payload.asObject()
192
+ : payload);
193
+ }
194
+ /**
195
+ * RFC 0033 — resolve the multi-agent group a task belongs to. Returns the
196
+ * group id (null if the task is standalone) and every sibling task with its
197
+ * role/agent. Agents use this to discover the task(s) they read from / send
198
+ * feedback to.
199
+ */
200
+ async getTaskGroup(taskId) {
201
+ const trimmed = String(taskId ?? '').trim();
202
+ if (!trimmed) {
203
+ throw new Error('taskId is required');
204
+ }
205
+ const payload = await this.client.getTaskGroup(trimmed);
206
+ return normalizeTaskGroup(payload);
207
+ }
132
208
  async sendTaskMessage(taskId, content, options = {}) {
133
209
  const trimmed = String(taskId ?? '').trim();
134
210
  if (!trimmed) {
@@ -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
+ }
@@ -4,6 +4,20 @@ export interface BackendClientOptions {
4
4
  fetchImpl?: FetchFn;
5
5
  timeoutMs?: number;
6
6
  }
7
+ export interface BackendCreateTaskParams {
8
+ id?: string;
9
+ projectId: string;
10
+ title: string;
11
+ taskType?: string;
12
+ status?: string;
13
+ backendType?: string;
14
+ sessionId?: string | null;
15
+ sessionFilePath?: string | null;
16
+ initialContent?: string;
17
+ agentHost?: string;
18
+ parentTaskId?: string;
19
+ metadata?: Record<string, unknown>;
20
+ }
7
21
  export type AgentCommitEvent = {
8
22
  eventType: 'sdk_message';
9
23
  taskId: string;
@@ -48,6 +62,11 @@ export declare class ProjectSummary {
48
62
  static fromJSON(payload: Record<string, any>): ProjectSummary;
49
63
  asObject(): Record<string, unknown>;
50
64
  }
65
+ export interface TaskGroupingSummary {
66
+ parentTaskId: string;
67
+ grouped: boolean;
68
+ warning?: string | null;
69
+ }
51
70
  export declare class TaskSummary {
52
71
  readonly id: string;
53
72
  readonly projectId: string | null;
@@ -58,7 +77,8 @@ export declare class TaskSummary {
58
77
  readonly sessionFilePath?: string | null | undefined;
59
78
  readonly createdAt?: string | null | undefined;
60
79
  readonly updatedAt?: string | null | undefined;
61
- constructor(id: string, projectId: string | null, title: string, status: string, backendType?: string | null | undefined, sessionId?: string | null | undefined, sessionFilePath?: string | null | undefined, createdAt?: string | null | undefined, updatedAt?: string | null | undefined);
80
+ readonly grouping?: (TaskGroupingSummary | null) | undefined;
81
+ constructor(id: string, projectId: string | null, title: string, status: string, backendType?: string | null | undefined, sessionId?: string | null | undefined, sessionFilePath?: string | null | undefined, createdAt?: string | null | undefined, updatedAt?: string | null | undefined, grouping?: (TaskGroupingSummary | null) | undefined);
62
82
  static fromJSON(payload: Record<string, any>): TaskSummary;
63
83
  }
64
84
  export declare class BackendApiClient {
@@ -85,19 +105,19 @@ export declare class BackendApiClient {
85
105
  projectId?: string;
86
106
  status?: string;
87
107
  }): Promise<TaskSummary[]>;
88
- createTask(params: {
89
- id?: string;
90
- projectId: string;
91
- title: string;
92
- status?: string;
93
- backendType?: string;
94
- sessionId?: string | null;
95
- sessionFilePath?: string | null;
96
- initialContent?: string;
97
- agentHost?: string;
98
- metadata?: Record<string, unknown>;
99
- }): Promise<TaskSummary>;
108
+ createTask(params: BackendCreateTaskParams): Promise<TaskSummary>;
109
+ /**
110
+ * Create an app-managed task through the frontend pipeline. This route
111
+ * assigns daemon ownership and delivers initialContent; it is intentionally
112
+ * separate from the legacy agent/fire POST /tasks endpoint.
113
+ */
114
+ createAppTask(params: BackendCreateTaskParams): Promise<TaskSummary>;
100
115
  getTask(taskId: string): Promise<TaskSummary>;
116
+ /**
117
+ * GET /tasks/[taskId]/group — RFC 0033 task-group discovery. Returns the raw
118
+ * payload `{ group_id, members }`; the higher layer normalizes it.
119
+ */
120
+ getTaskGroup(taskId: string): Promise<Record<string, any>>;
101
121
  updateTask(taskId: string, params: {
102
122
  projectId?: string;
103
123
  title?: string;
@@ -62,7 +62,8 @@ export class TaskSummary {
62
62
  sessionFilePath;
63
63
  createdAt;
64
64
  updatedAt;
65
- constructor(id, projectId, title, status, backendType, sessionId, sessionFilePath, createdAt, updatedAt) {
65
+ grouping;
66
+ constructor(id, projectId, title, status, backendType, sessionId, sessionFilePath, createdAt, updatedAt, grouping) {
66
67
  this.id = id;
67
68
  this.projectId = projectId;
68
69
  this.title = title;
@@ -72,6 +73,7 @@ export class TaskSummary {
72
73
  this.sessionFilePath = sessionFilePath;
73
74
  this.createdAt = createdAt;
74
75
  this.updatedAt = updatedAt;
76
+ this.grouping = grouping;
75
77
  }
76
78
  static fromJSON(payload) {
77
79
  const id = payload.id ? String(payload.id) : '';
@@ -83,7 +85,22 @@ export class TaskSummary {
83
85
  if (!title || !status) {
84
86
  throw new Error('Task payload missing required fields');
85
87
  }
86
- return new TaskSummary(id, payload.project_id ? String(payload.project_id) : null, title, status, payload.backend_type ?? payload.backendType ?? null, payload.session_id ?? payload.sessionId ?? null, payload.session_file_path ?? payload.sessionFilePath ?? null, payload.created_at ?? null, payload.updated_at ?? null);
88
+ const groupingPayload = payload.grouping && typeof payload.grouping === 'object' && !Array.isArray(payload.grouping)
89
+ ? payload.grouping
90
+ : null;
91
+ const groupingParentTaskId = groupingPayload
92
+ ? groupingPayload.parent_task_id ?? groupingPayload.parentTaskId
93
+ : null;
94
+ const grouping = groupingParentTaskId && typeof groupingPayload?.grouped === 'boolean'
95
+ ? {
96
+ parentTaskId: String(groupingParentTaskId),
97
+ grouped: groupingPayload.grouped,
98
+ warning: typeof groupingPayload.warning === 'string'
99
+ ? groupingPayload.warning
100
+ : null,
101
+ }
102
+ : null;
103
+ return new TaskSummary(id, payload.project_id ? String(payload.project_id) : null, title, status, payload.backend_type ?? payload.backendType ?? null, payload.session_id ?? payload.sessionId ?? null, payload.session_file_path ?? payload.sessionFilePath ?? null, payload.created_at ?? null, payload.updated_at ?? null, grouping);
87
104
  }
88
105
  }
89
106
  export class BackendApiClient {
@@ -160,11 +177,35 @@ export class BackendApiClient {
160
177
  const payload = await this.parseJson(response);
161
178
  return TaskSummary.fromJSON(payload);
162
179
  }
180
+ /**
181
+ * Create an app-managed task through the frontend pipeline. This route
182
+ * assigns daemon ownership and delivers initialContent; it is intentionally
183
+ * separate from the legacy agent/fire POST /tasks endpoint.
184
+ */
185
+ async createAppTask(params) {
186
+ const response = await this.request('POST', '/api/tasks', {
187
+ body: JSON.stringify(params),
188
+ });
189
+ const payload = await this.parseJson(response);
190
+ return TaskSummary.fromJSON(payload);
191
+ }
163
192
  async getTask(taskId) {
164
193
  const response = await this.request('GET', `/tasks/${taskId}`);
165
194
  const payload = await this.parseJson(response);
166
195
  return TaskSummary.fromJSON(payload);
167
196
  }
197
+ /**
198
+ * GET /tasks/[taskId]/group — RFC 0033 task-group discovery. Returns the raw
199
+ * payload `{ group_id, members }`; the higher layer normalizes it.
200
+ */
201
+ async getTaskGroup(taskId) {
202
+ const response = await this.request('GET', `/tasks/${taskId}/group`);
203
+ const payload = await this.parseJson(response);
204
+ if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
205
+ return payload;
206
+ }
207
+ throw new BackendApiError('Invalid task group response', response.status, payload);
208
+ }
168
209
  async updateTask(taskId, params) {
169
210
  const response = await this.request('PATCH', `/tasks/${taskId}`, {
170
211
  body: JSON.stringify(params),
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.7.7",
3
+ "version": "0.9.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": "26b2dbb"
36
+ "gitCommitId": "606e5a6"
37
37
  }