@love-moon/conductor-sdk 0.7.6 → 0.8.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,29 @@
1
1
  # @love-moon/conductor-sdk
2
2
 
3
+ ## 0.8.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 959dd1d: Add project-configured worker and reviewer task groups, task-group discovery in
8
+ the SDK and `conductor task group`, and a lightweight daemon protocol for
9
+ refreshing the project agent registry.
10
+ - fe76139: Add `conductor task create` for creating app tasks with title, prompt, backend,
11
+ project resolution, and optional parent task-card grouping. App tasks now
12
+ require an online compatible daemon, and grouping results are exposed to
13
+ callers so partial success is visible without retrying task creation.
14
+
15
+ ## 0.7.7
16
+
17
+ ### Patch Changes
18
+
19
+ - 400f3a7: Report a terminal task status when a stop request finds no active process, so a
20
+ task whose Fire already died converges instead of sitting in `killing` forever.
21
+
22
+ Drop queued terminal status events before an in-place restart reuses a working
23
+ directory. The durable upstream outbox lives inside that directory, so an
24
+ undelivered `KILLED` from the previous run was flushed on startup and killed the
25
+ task that had just finished resuming.
26
+
3
27
  ## 0.7.6
4
28
 
5
29
  ### Patch Changes
@@ -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) {
@@ -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),
@@ -21,6 +21,30 @@ export declare class DurableUpstreamOutboxStore {
21
21
  payload: Record<string, unknown>;
22
22
  }): DurableUpstreamEvent;
23
23
  remove(stableId: string): void;
24
+ /**
25
+ * Discard queued terminal `task_status_update` events (KILLED / COMPLETED).
26
+ *
27
+ * This outbox lives INSIDE the fire's working directory, and an in-place
28
+ * restart deliberately re-uses that same directory (the daemon sets
29
+ * `CONDUCTOR_RESUME_CWD` so `--resume` finds the original project). That
30
+ * means a terminal status the PREVIOUS run failed to deliver — e.g. its
31
+ * websocket dropped while it was being stopped — is still sitting on disk
32
+ * when the NEXT run starts, and the startup flush delivers it against the
33
+ * task that was just brought back to life. The server marks the task
34
+ * `killed`, then answers the newly-attached fire with
35
+ * `stop_task(task_already_killed)` and shoots it down seconds after it
36
+ * finished resuming.
37
+ *
38
+ * A terminal status from a superseded run is definitionally obsolete: the
39
+ * task has since been restarted, so "that run ended" is no longer news the
40
+ * server can act on correctly. Drop those and keep everything else —
41
+ * `sdk_message` entries are real conversation content that must still be
42
+ * delivered, and `task_stop_ack` / `agent_command_ack` are idempotent
43
+ * bookkeeping whose delivery actually clears stale server-side outbox rows.
44
+ *
45
+ * Returns the dropped entries so callers can log exactly what was discarded.
46
+ */
47
+ dropPendingTerminalStatusEvents(): DurableUpstreamEvent[];
24
48
  markRetry(stableId: string, delayMs: number): DurableUpstreamEvent | null;
25
49
  listReady(nowMs?: number): DurableUpstreamEvent[];
26
50
  nextRetryDelay(nowMs?: number): number | null;
@@ -19,6 +19,16 @@ const sleepSync = (ms) => {
19
19
  }
20
20
  }
21
21
  };
22
+ // Terminal statuses as published by `conductor daemon` (see cli/src/daemon.js,
23
+ // which sends the upper-case wire form). Compared case-insensitively so a
24
+ // future lower-case producer does not silently bypass the check.
25
+ const TERMINAL_TASK_STATUSES = new Set(['killed', 'completed']);
26
+ const isTerminalStatusEvent = (entry) => {
27
+ if (entry.eventType !== 'task_status_update')
28
+ return false;
29
+ const status = entry.payload?.status;
30
+ return typeof status === 'string' && TERMINAL_TASK_STATUSES.has(status.trim().toLowerCase());
31
+ };
22
32
  const sanitizeScopeId = (scopeId) => scopeId
23
33
  .trim()
24
34
  .replace(/[^a-zA-Z0-9._-]+/g, '_')
@@ -66,6 +76,40 @@ export class DurableUpstreamOutboxStore {
66
76
  this.saveUnlocked(entries);
67
77
  });
68
78
  }
79
+ /**
80
+ * Discard queued terminal `task_status_update` events (KILLED / COMPLETED).
81
+ *
82
+ * This outbox lives INSIDE the fire's working directory, and an in-place
83
+ * restart deliberately re-uses that same directory (the daemon sets
84
+ * `CONDUCTOR_RESUME_CWD` so `--resume` finds the original project). That
85
+ * means a terminal status the PREVIOUS run failed to deliver — e.g. its
86
+ * websocket dropped while it was being stopped — is still sitting on disk
87
+ * when the NEXT run starts, and the startup flush delivers it against the
88
+ * task that was just brought back to life. The server marks the task
89
+ * `killed`, then answers the newly-attached fire with
90
+ * `stop_task(task_already_killed)` and shoots it down seconds after it
91
+ * finished resuming.
92
+ *
93
+ * A terminal status from a superseded run is definitionally obsolete: the
94
+ * task has since been restarted, so "that run ended" is no longer news the
95
+ * server can act on correctly. Drop those and keep everything else —
96
+ * `sdk_message` entries are real conversation content that must still be
97
+ * delivered, and `task_stop_ack` / `agent_command_ack` are idempotent
98
+ * bookkeeping whose delivery actually clears stale server-side outbox rows.
99
+ *
100
+ * Returns the dropped entries so callers can log exactly what was discarded.
101
+ */
102
+ dropPendingTerminalStatusEvents() {
103
+ return this.withLock(() => {
104
+ const entries = this.loadUnlocked();
105
+ const dropped = entries.filter(isTerminalStatusEvent);
106
+ if (dropped.length === 0) {
107
+ return [];
108
+ }
109
+ this.saveUnlocked(entries.filter((entry) => !isTerminalStatusEvent(entry)));
110
+ return dropped;
111
+ });
112
+ }
69
113
  markRetry(stableId, delayMs) {
70
114
  return this.withLock(() => {
71
115
  const entries = this.loadUnlocked();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@love-moon/conductor-sdk",
3
- "version": "0.7.6",
3
+ "version": "0.8.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": "c939df2"
36
+ "gitCommitId": "04d4f62"
37
37
  }