@love-moon/conductor-sdk 0.2.42 → 0.3.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.
@@ -0,0 +1,4 @@
1
+ export { ProjectsApi, type Project, type CreateProjectInput, type ListProjectsOptions, type ResolveProjectInput, } from './projects.js';
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, } from './tasks.js';
4
+ export { ProjectNotResolvedError, buildAuditMetadata, isBackendApiError, type ApiClient, type SdkClientOptions, } from './shared.js';
@@ -0,0 +1,4 @@
1
+ export { ProjectsApi, } from './projects.js';
2
+ export { IssuesApi, } from './issues.js';
3
+ export { TasksApi, } from './tasks.js';
4
+ export { ProjectNotResolvedError, buildAuditMetadata, isBackendApiError, } from './shared.js';
@@ -0,0 +1,57 @@
1
+ import { ApiClient, SdkClientOptions } from './shared.js';
2
+ export interface Issue {
3
+ id: string;
4
+ projectId: string;
5
+ title: string;
6
+ description?: string | null;
7
+ status: string;
8
+ priority?: string | null;
9
+ position?: number | null;
10
+ metadata?: Record<string, unknown> | null;
11
+ createdAt?: string | null;
12
+ updatedAt?: string | null;
13
+ /** Unmodified server payload, used by callers that need fields we haven't typed. */
14
+ raw: Record<string, unknown>;
15
+ }
16
+ export interface ListIssuesInput {
17
+ projectId: string;
18
+ status?: string | string[];
19
+ limit?: number;
20
+ }
21
+ export interface CreateIssueInput {
22
+ projectId: string;
23
+ title: string;
24
+ description?: string;
25
+ priority?: string;
26
+ status?: string;
27
+ metadata?: Record<string, unknown>;
28
+ clientRequestId?: string;
29
+ }
30
+ export interface UpdateIssueInput {
31
+ title?: string;
32
+ description?: string;
33
+ priority?: string;
34
+ status?: string;
35
+ metadata?: Record<string, unknown>;
36
+ }
37
+ export interface UpdateIssueStatusOptions {
38
+ evidence?: string;
39
+ /**
40
+ * Extra metadata merged into the patch; caller's `audit.*` fields win over
41
+ * SDK defaults (see shared.ts). Useful for the CLI to thread its
42
+ * `audit: { actor: "cli", ... }` namespace through without needing a custom
43
+ * call path.
44
+ */
45
+ metadata?: Record<string, unknown>;
46
+ }
47
+ export declare class IssuesApi {
48
+ private readonly client;
49
+ private readonly options;
50
+ constructor(client: ApiClient, options?: SdkClientOptions);
51
+ listIssues(input: ListIssuesInput): Promise<Issue[]>;
52
+ getIssue(issueId: string): Promise<Issue>;
53
+ createIssue(input: CreateIssueInput): Promise<Issue>;
54
+ updateIssue(issueId: string, patch: UpdateIssueInput): Promise<Issue>;
55
+ updateIssueStatus(issueId: string, status: string, options?: UpdateIssueStatusOptions): Promise<Issue>;
56
+ deleteIssue(issueId: string): Promise<void>;
57
+ }
@@ -0,0 +1,177 @@
1
+ import { buildAuditMetadata } from './shared.js';
2
+ const normalizeIssue = (payload) => {
3
+ const id = payload.id ? String(payload.id) : '';
4
+ if (!id) {
5
+ throw new Error('Issue payload missing id');
6
+ }
7
+ return {
8
+ id,
9
+ projectId: String(payload.projectId ?? payload.project_id ?? ''),
10
+ title: String(payload.title ?? ''),
11
+ description: payload.description ?? null,
12
+ status: String(payload.status ?? ''),
13
+ priority: payload.priority ?? null,
14
+ position: typeof payload.position === 'number' ? payload.position : null,
15
+ metadata: payload.metadata && typeof payload.metadata === 'object' && !Array.isArray(payload.metadata)
16
+ ? payload.metadata
17
+ : null,
18
+ createdAt: payload.createdAt ?? payload.created_at ?? null,
19
+ updatedAt: payload.updatedAt ?? payload.updated_at ?? null,
20
+ raw: payload,
21
+ };
22
+ };
23
+ const extractIssue = (payload) => {
24
+ // The PATCH /issues/[issueId] route returns `{ issue, activeTask, ... }`.
25
+ // Other routes return the issue itself. Normalize both shapes here so
26
+ // facade callers always get the issue back regardless of source endpoint.
27
+ if (payload && typeof payload === 'object' && payload.issue && typeof payload.issue === 'object') {
28
+ return payload.issue;
29
+ }
30
+ return payload;
31
+ };
32
+ const mergeMetadataAtPath = (target, pathParts, value) => {
33
+ if (pathParts.length === 0) {
34
+ return target;
35
+ }
36
+ const [head, ...rest] = pathParts;
37
+ const next = { ...target };
38
+ if (rest.length === 0) {
39
+ next[head] = value;
40
+ return next;
41
+ }
42
+ const existing = target[head];
43
+ const child = existing && typeof existing === 'object' && !Array.isArray(existing)
44
+ ? existing
45
+ : {};
46
+ next[head] = mergeMetadataAtPath(child, rest, value);
47
+ return next;
48
+ };
49
+ export class IssuesApi {
50
+ client;
51
+ options;
52
+ constructor(client, options = {}) {
53
+ this.client = client;
54
+ this.options = options;
55
+ }
56
+ async listIssues(input) {
57
+ if (!input.projectId) {
58
+ throw new Error('projectId is required');
59
+ }
60
+ // The server `/api/issues` route accepts a single `status` query string;
61
+ // it doesn't (yet) parse comma-separated lists, so we filter on the
62
+ // client side for the multi-status case to match RFC 0025 §3 wording.
63
+ const statusFilter = Array.isArray(input.status)
64
+ ? input.status.map((value) => String(value).trim()).filter(Boolean)
65
+ : input.status
66
+ ? [String(input.status).trim()].filter(Boolean)
67
+ : [];
68
+ const issues = await this.client.listIssues({
69
+ projectId: input.projectId,
70
+ status: statusFilter.length === 1 ? statusFilter[0] : undefined,
71
+ });
72
+ let normalized = issues.map((entry) => normalizeIssue(entry));
73
+ if (statusFilter.length > 1) {
74
+ const statusSet = new Set(statusFilter);
75
+ normalized = normalized.filter((issue) => statusSet.has(issue.status));
76
+ }
77
+ if (typeof input.limit === 'number' && Number.isFinite(input.limit)) {
78
+ normalized = normalized.slice(0, Math.max(0, Math.floor(input.limit)));
79
+ }
80
+ return normalized;
81
+ }
82
+ async getIssue(issueId) {
83
+ const trimmed = String(issueId ?? '').trim();
84
+ if (!trimmed) {
85
+ throw new Error('issueId is required');
86
+ }
87
+ const payload = await this.client.getIssue(trimmed);
88
+ return normalizeIssue(extractIssue(payload));
89
+ }
90
+ async createIssue(input) {
91
+ if (!input.projectId) {
92
+ throw new Error('projectId is required');
93
+ }
94
+ if (!input.title) {
95
+ throw new Error('title is required');
96
+ }
97
+ const userMetadata = input.clientRequestId
98
+ ? { clientRequestId: input.clientRequestId, ...(input.metadata ?? {}) }
99
+ : input.metadata;
100
+ const metadata = buildAuditMetadata(userMetadata, this.options);
101
+ const params = {
102
+ projectId: input.projectId,
103
+ title: input.title,
104
+ metadata,
105
+ };
106
+ if (input.description !== undefined) {
107
+ params.description = input.description;
108
+ }
109
+ if (input.priority !== undefined) {
110
+ params.priority = input.priority;
111
+ }
112
+ if (input.status !== undefined) {
113
+ params.status = input.status;
114
+ }
115
+ const payload = await this.client.createIssue(params);
116
+ return normalizeIssue(extractIssue(payload));
117
+ }
118
+ async updateIssue(issueId, patch) {
119
+ const trimmed = String(issueId ?? '').trim();
120
+ if (!trimmed) {
121
+ throw new Error('issueId is required');
122
+ }
123
+ const params = {};
124
+ if (patch.title !== undefined) {
125
+ params.title = patch.title;
126
+ }
127
+ if (patch.description !== undefined) {
128
+ params.description = patch.description;
129
+ }
130
+ if (patch.priority !== undefined) {
131
+ params.priority = patch.priority;
132
+ }
133
+ if (patch.status !== undefined) {
134
+ params.status = patch.status;
135
+ }
136
+ // We always include audit metadata on writes. If the caller passes a
137
+ // `metadata` patch, we merge our audit keys with theirs (caller wins).
138
+ params.metadata = buildAuditMetadata(patch.metadata, this.options);
139
+ const payload = await this.client.patchIssue(trimmed, params);
140
+ return normalizeIssue(extractIssue(payload));
141
+ }
142
+ async updateIssueStatus(issueId, status, options = {}) {
143
+ const callerMetadata = options.metadata && typeof options.metadata === 'object' && !Array.isArray(options.metadata)
144
+ ? { ...options.metadata }
145
+ : null;
146
+ if (!options.evidence) {
147
+ return this.updateIssue(issueId, {
148
+ status,
149
+ ...(callerMetadata ? { metadata: callerMetadata } : {}),
150
+ });
151
+ }
152
+ // Pull the existing metadata so we can merge `qa.evidence` without
153
+ // clobbering unrelated keys. The PATCH body fully replaces metadata, so
154
+ // we have to round-trip the current state (matches RFC 0025 §3 wording
155
+ // for `done --evidence`).
156
+ const existing = await this.getIssue(issueId);
157
+ const existingMetadata = existing.metadata && typeof existing.metadata === 'object'
158
+ ? existing.metadata
159
+ : {};
160
+ // Merge order: existing DB metadata < caller's patch metadata < qa.evidence.
161
+ // The `audit` key under `callerMetadata` is preserved when present so the
162
+ // CLI's `actor:"cli"` namespace flows through to `updateIssue` (and from
163
+ // there to `buildAuditMetadata`, where caller wins).
164
+ const baseMetadata = callerMetadata
165
+ ? { ...existingMetadata, ...callerMetadata }
166
+ : { ...existingMetadata };
167
+ const merged = mergeMetadataAtPath(baseMetadata, ['qa', 'evidence'], options.evidence);
168
+ return this.updateIssue(issueId, { status, metadata: merged });
169
+ }
170
+ async deleteIssue(issueId) {
171
+ const trimmed = String(issueId ?? '').trim();
172
+ if (!trimmed) {
173
+ throw new Error('issueId is required');
174
+ }
175
+ await this.client.deleteIssue(trimmed);
176
+ }
177
+ }
@@ -0,0 +1,71 @@
1
+ import { ApiClient, SdkClientOptions } from './shared.js';
2
+ /**
3
+ * Project record as returned by the REST API. We deliberately don't subclass
4
+ * `ProjectSummary` here because the REST surface returns more fields (notably
5
+ * `hidden`, `isDefault`, `metadata`) than the WS-flavored summary, and we
6
+ * want CLI / AI consumers to see the full picture.
7
+ */
8
+ export interface Project {
9
+ id: string;
10
+ name?: string | null;
11
+ daemonHost?: string | null;
12
+ workspacePath?: string | null;
13
+ repoRoot?: string | null;
14
+ worktreeBranch?: string | null;
15
+ lastCommit?: string | null;
16
+ fileCount?: number | null;
17
+ isDefault: boolean;
18
+ hidden: boolean;
19
+ hiddenAt?: string | null;
20
+ metadata?: Record<string, unknown> | null;
21
+ createdAt?: string | null;
22
+ updatedAt?: string | null;
23
+ /**
24
+ * The unmodified server payload, kept for callers that need fields the
25
+ * typed surface hasn't promoted yet (e.g. `gitRemoteUrl`, `mergeOptOut`).
26
+ */
27
+ raw: Record<string, unknown>;
28
+ }
29
+ export interface ResolveProjectInput {
30
+ id?: string;
31
+ name?: string;
32
+ cwd?: string;
33
+ env?: NodeJS.ProcessEnv;
34
+ /**
35
+ * Daemon host to use when resolving by `cwd`. Required for the path-match
36
+ * fallback (the server keys workspace bindings on `(daemonHost, path)`).
37
+ * Defaults to `env.CONDUCTOR_DAEMON_NAME` if unset.
38
+ */
39
+ daemonHost?: string;
40
+ }
41
+ export interface CreateProjectInput {
42
+ name?: string;
43
+ workspacePath?: string;
44
+ daemonHost?: string;
45
+ isDefault?: boolean;
46
+ clientRequestId?: string;
47
+ metadata?: Record<string, unknown>;
48
+ }
49
+ export interface ListProjectsOptions {
50
+ includeHidden?: boolean;
51
+ }
52
+ export interface ProjectWriteOptions {
53
+ /**
54
+ * Extra metadata merged into the request (caller-supplied audit fields
55
+ * under `metadata.audit` win over the SDK defaults, see shared.ts).
56
+ */
57
+ metadata?: Record<string, unknown>;
58
+ }
59
+ export declare class ProjectsApi {
60
+ private readonly client;
61
+ private readonly options;
62
+ constructor(client: ApiClient, options?: SdkClientOptions);
63
+ listProjects(options?: ListProjectsOptions): Promise<Project[]>;
64
+ getProject(idOrName: string): Promise<Project>;
65
+ createProject(input: CreateProjectInput): Promise<Project>;
66
+ setDefaultProject(idOrName: string, options?: ProjectWriteOptions): Promise<Project>;
67
+ setProjectHidden(idOrName: string, hidden: boolean, options?: ProjectWriteOptions): Promise<Project>;
68
+ resolveProject(input?: ResolveProjectInput): Promise<Project>;
69
+ private findUniqueByName;
70
+ private resolveById;
71
+ }
@@ -0,0 +1,247 @@
1
+ import { BackendApiError } from '../backend/index.js';
2
+ import { ProjectNotResolvedError, buildAuditMetadata, } from './shared.js';
3
+ const stripTrailingSlash = (value) => value.replace(/\/+$/, '');
4
+ /**
5
+ * Heuristic for "this string looks like a project id, not a name".
6
+ *
7
+ * The server uses cuid-like ids (e.g. `clxk1y0fz0000abc123`) and UUIDs. Names
8
+ * are arbitrary strings, but in practice users name their projects with
9
+ * spaces, slashes, accents, etc. — i.e. they almost never match the
10
+ * `^[a-zA-Z0-9_-]{12,}$` shape we treat as an id below. This lets the SDK
11
+ * skip a redundant `getProject(id)` round-trip when the caller (CLI) has
12
+ * already resolved to an id (review L-NEW-1). On a miss we fall back to the
13
+ * full resolve, so the only downside of a false positive is one extra
14
+ * 404 → re-list round trip.
15
+ */
16
+ const looksLikeProjectId = (value) => {
17
+ if (!value || typeof value !== 'string')
18
+ return false;
19
+ const trimmed = value.trim();
20
+ if (trimmed.length < 12)
21
+ return false;
22
+ return /^[A-Za-z0-9_-]+$/.test(trimmed);
23
+ };
24
+ /**
25
+ * Local longest-prefix match used by `resolveProject` step 4 when no daemon
26
+ * host is available to delegate to the server-side matcher. We treat each
27
+ * project's `workspacePath` as a prefix and pick the longest one that's a
28
+ * proper directory ancestor of `cwd`. Mirrors the server's
29
+ * match-path/route.ts logic for the simple single-daemon case.
30
+ */
31
+ const matchProjectByLocalPrefix = (projects, cwd) => {
32
+ const target = stripTrailingSlash(cwd.trim());
33
+ if (!target)
34
+ return null;
35
+ let best = null;
36
+ for (const project of projects) {
37
+ const path = project.workspacePath;
38
+ if (typeof path !== 'string' || !path.trim())
39
+ continue;
40
+ const normalized = stripTrailingSlash(path.trim());
41
+ if (target === normalized || target.startsWith(`${normalized}/`)) {
42
+ if (!best || normalized.length > best.length) {
43
+ best = { project, length: normalized.length };
44
+ }
45
+ }
46
+ }
47
+ return best?.project ?? null;
48
+ };
49
+ const normalizeProject = (payload) => {
50
+ const id = payload.id ? String(payload.id) : '';
51
+ if (!id) {
52
+ throw new Error('Project payload missing id');
53
+ }
54
+ const hiddenAt = payload.hiddenAt ?? payload.hidden_at ?? null;
55
+ return {
56
+ id,
57
+ name: payload.name ?? null,
58
+ daemonHost: payload.daemonHost ?? payload.daemon_host ?? null,
59
+ workspacePath: payload.workspacePath ?? payload.workspace_path ?? null,
60
+ repoRoot: payload.repoRoot ?? payload.repo_root ?? null,
61
+ worktreeBranch: payload.worktreeBranch ?? payload.worktree_branch ?? null,
62
+ lastCommit: payload.lastCommit ?? payload.last_commit ?? null,
63
+ fileCount: payload.fileCount ?? payload.file_count ?? null,
64
+ isDefault: Boolean(payload.isDefault ?? payload.is_default ?? false),
65
+ hidden: Boolean(payload.hidden ?? Boolean(hiddenAt)),
66
+ hiddenAt: hiddenAt ?? null,
67
+ metadata: payload.metadata && typeof payload.metadata === 'object' && !Array.isArray(payload.metadata)
68
+ ? payload.metadata
69
+ : null,
70
+ createdAt: payload.createdAt ?? payload.created_at ?? null,
71
+ updatedAt: payload.updatedAt ?? payload.updated_at ?? null,
72
+ raw: payload,
73
+ };
74
+ };
75
+ export class ProjectsApi {
76
+ client;
77
+ options;
78
+ constructor(client, options = {}) {
79
+ this.client = client;
80
+ this.options = options;
81
+ }
82
+ async listProjects(options = {}) {
83
+ const summaries = await this.client.listProjects();
84
+ // `BackendApiClient.listProjects` already pulls /projects, but it loses
85
+ // the `hidden` and `isDefault` fields when collapsing to ProjectSummary.
86
+ // Promote each summary's `asObject()` payload back to a Project so we
87
+ // surface those fields. Hidden projects are filtered out here unless
88
+ // `includeHidden: true` is passed (RFC 0025 §3).
89
+ const promoted = summaries.map((summary) => normalizeProject(
90
+ // ProjectSummary `asObject()` always exists in the backend module.
91
+ summary.asObject ? summary.asObject() : summary));
92
+ if (options.includeHidden) {
93
+ return promoted;
94
+ }
95
+ return promoted.filter((project) => !project.hidden);
96
+ }
97
+ async getProject(idOrName) {
98
+ const trimmed = String(idOrName ?? '').trim();
99
+ if (!trimmed) {
100
+ throw new Error('idOrName is required');
101
+ }
102
+ // First try by id; if 404, fall back to a unique name match against the
103
+ // project list. Splitting these two paths keeps the happy-path single
104
+ // round-trip and only pulls the full list on miss.
105
+ try {
106
+ const payload = await this.client.getProject(trimmed);
107
+ return normalizeProject(payload);
108
+ }
109
+ catch (error) {
110
+ if (!(error instanceof BackendApiError) || error.statusCode !== 404) {
111
+ throw error;
112
+ }
113
+ }
114
+ return this.findUniqueByName(trimmed);
115
+ }
116
+ async createProject(input) {
117
+ const metadata = buildAuditMetadata(
118
+ // `clientRequestId` is stamped onto metadata per RFC 0025 §5 idempotency
119
+ // spec; the server treats it as the idempotency key.
120
+ input.clientRequestId
121
+ ? { clientRequestId: input.clientRequestId, ...(input.metadata ?? {}) }
122
+ : input.metadata, this.options);
123
+ const params = {};
124
+ if (input.name) {
125
+ params.name = input.name;
126
+ }
127
+ if (input.isDefault) {
128
+ params.isDefault = true;
129
+ params.is_default = true;
130
+ }
131
+ else {
132
+ // Non-default projects need a binding. The server validates with the
133
+ // daemon synchronously when `daemonHost + workspacePath` are present,
134
+ // which is the path AI / CLI will use most of the time.
135
+ if (input.workspacePath) {
136
+ params.workspacePath = input.workspacePath;
137
+ }
138
+ if (input.daemonHost) {
139
+ params.daemonHost = input.daemonHost;
140
+ }
141
+ }
142
+ params.metadata = metadata;
143
+ const summary = await this.client.createProject(params);
144
+ return normalizeProject(summary.asObject ? summary.asObject() : summary);
145
+ }
146
+ async setDefaultProject(idOrName, options = {}) {
147
+ // Short-circuit the pre-flight `getProject` when the caller already has
148
+ // a project id (review L-NEW-1). On a miss the server returns 404 and
149
+ // the caller can retry via `getProject(idOrName)` themselves, but the
150
+ // common CLI path passes the id directly.
151
+ const targetId = looksLikeProjectId(idOrName) ? idOrName.trim() : (await this.resolveById(idOrName)).id;
152
+ const metadata = buildAuditMetadata(options.metadata, this.options);
153
+ const payload = await this.client.setDefaultProject(targetId, { metadata });
154
+ return normalizeProject(payload);
155
+ }
156
+ async setProjectHidden(idOrName, hidden, options = {}) {
157
+ const targetId = looksLikeProjectId(idOrName) ? idOrName.trim() : (await this.resolveById(idOrName)).id;
158
+ const metadata = buildAuditMetadata(options.metadata, this.options);
159
+ const payload = await this.client.patchProjectByQuery(targetId, { hidden, metadata });
160
+ return normalizeProject(payload);
161
+ }
162
+ async resolveProject(input = {}) {
163
+ const env = input.env ?? this.options.env ?? process.env;
164
+ // 1. explicit id wins
165
+ if (input.id && input.id.trim()) {
166
+ try {
167
+ const payload = await this.client.getProject(input.id.trim());
168
+ return normalizeProject(payload);
169
+ }
170
+ catch (error) {
171
+ if (error instanceof BackendApiError && error.statusCode === 404) {
172
+ throw new ProjectNotResolvedError(`Project not found: id=${input.id}`, 'not_found');
173
+ }
174
+ throw error;
175
+ }
176
+ }
177
+ // 2. explicit name
178
+ if (input.name && input.name.trim()) {
179
+ return this.findUniqueByName(input.name.trim());
180
+ }
181
+ // 3. env override
182
+ const envId = env.CONDUCTOR_PROJECT_ID;
183
+ if (typeof envId === 'string' && envId.trim()) {
184
+ try {
185
+ const payload = await this.client.getProject(envId.trim());
186
+ return normalizeProject(payload);
187
+ }
188
+ catch (error) {
189
+ if (error instanceof BackendApiError && error.statusCode === 404) {
190
+ throw new ProjectNotResolvedError(`Project not found from CONDUCTOR_PROJECT_ID=${envId}`, 'not_found');
191
+ }
192
+ throw error;
193
+ }
194
+ }
195
+ // 4. cwd path match. We try two routes:
196
+ // a. If a daemon host is known, delegate to the server-side matcher
197
+ // (it understands binding-pending / repoRoot / multi-daemon rules).
198
+ // b. Otherwise, do a local longest-prefix match against the project
199
+ // list. This keeps the CLI usable when the user hasn't set
200
+ // CONDUCTOR_DAEMON_NAME — review finding M1: never silently skip
201
+ // cwd resolution just because a daemon hint is missing.
202
+ const projects = await this.listProjects({ includeHidden: true });
203
+ if (input.cwd && input.cwd.trim()) {
204
+ const daemonHost = input.daemonHost ?? env.CONDUCTOR_DAEMON_NAME ?? env.CONDUCTOR_AGENT_NAME;
205
+ if (daemonHost && daemonHost.trim()) {
206
+ const result = await this.client.matchProjectByPath({
207
+ daemon_host: daemonHost.trim(),
208
+ path: input.cwd,
209
+ });
210
+ if (result.project) {
211
+ return normalizeProject(result.project.asObject());
212
+ }
213
+ }
214
+ else {
215
+ const localMatch = matchProjectByLocalPrefix(projects, input.cwd);
216
+ if (localMatch) {
217
+ return localMatch;
218
+ }
219
+ }
220
+ }
221
+ // 5. default project — derived from the list we already fetched in (4).
222
+ // Every healthy account has at most one default; we return it when
223
+ // found.
224
+ const defaults = projects.filter((project) => project.isDefault);
225
+ if (defaults.length === 1) {
226
+ return defaults[0];
227
+ }
228
+ if (defaults.length > 1) {
229
+ throw new ProjectNotResolvedError('Multiple default projects exist; specify --project explicitly', 'multiple_matches');
230
+ }
231
+ throw new ProjectNotResolvedError('No project specified and no default project found. Pass --project <id|name>, set CONDUCTOR_PROJECT_ID, cd into a project directory, or configure a default project.', 'no_signal');
232
+ }
233
+ async findUniqueByName(name) {
234
+ const projects = await this.listProjects({ includeHidden: true });
235
+ const matches = projects.filter((project) => project.name === name);
236
+ if (matches.length === 1) {
237
+ return matches[0];
238
+ }
239
+ if (matches.length === 0) {
240
+ throw new ProjectNotResolvedError(`No project found with name "${name}"`, 'not_found');
241
+ }
242
+ throw new ProjectNotResolvedError(`Multiple projects found with name "${name}" (${matches.length}); pass --project <id> to disambiguate`, 'multiple_matches');
243
+ }
244
+ async resolveById(idOrName) {
245
+ return this.getProject(idOrName);
246
+ }
247
+ }
@@ -0,0 +1,45 @@
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'>;
3
+ export interface SdkClientOptions {
4
+ /**
5
+ * Optional override for the SDK version stamped onto audit metadata. Falls
6
+ * back to the package.json version. Tests may want to pin this so output is
7
+ * stable.
8
+ */
9
+ sdkVersion?: string;
10
+ /**
11
+ * Optional environment provider for `CONDUCTOR_INVOKED_BY` lookup. Defaults
12
+ * to `process.env`. Useful for tests that want to inject env without
13
+ * mutating globals.
14
+ */
15
+ env?: NodeJS.ProcessEnv;
16
+ }
17
+ /**
18
+ * Build the metadata payload every SDK write attaches to the request.
19
+ *
20
+ * Audit fields are namespaced under `metadata.audit` (RFC 0025 §5.2 + review
21
+ * findings M3/H1). This avoids two failure modes the original top-level shape
22
+ * had:
23
+ * 1. A caller passing `--metadata-json '{"actor":"system"}'` could spoof the
24
+ * audit actor by colliding with our top-level key.
25
+ * 2. Server-side strippers had to know the full audit-key list to filter.
26
+ *
27
+ * Merge order:
28
+ * - Non-audit user keys are passed through verbatim.
29
+ * - Audit defaults (`actor:"sdk"`, `sdkVersion`, `invokedBy`) are layered
30
+ * UNDER the caller's `audit` (so the CLI's `actor:"cli"` wins).
31
+ * - The caller's top-level `audit` key, if any, takes precedence over the
32
+ * SDK defaults but never replaces all of them — `cliVersion`, etc., are
33
+ * additive.
34
+ */
35
+ export declare const buildAuditMetadata: (userMetadata: Record<string, unknown> | undefined, options?: SdkClientOptions) => Record<string, unknown>;
36
+ export declare class ProjectNotResolvedError extends Error {
37
+ readonly reason: 'not_found' | 'multiple_matches' | 'no_default' | 'no_signal';
38
+ constructor(message: string, reason: 'not_found' | 'multiple_matches' | 'no_default' | 'no_signal');
39
+ }
40
+ /**
41
+ * Best-effort mapping for HTTP-shaped failures so the CLI exit-code matrix
42
+ * (RFC 0025 §4) has a stable signal. Callers can choose to inspect
43
+ * `BackendApiError.statusCode` directly instead.
44
+ */
45
+ export declare const isBackendApiError: (value: unknown) => value is BackendApiError;
@@ -0,0 +1,78 @@
1
+ import { createRequire } from 'node:module';
2
+ import { BackendApiError } from '../backend/index.js';
3
+ // Loaded eagerly so we don't pay a require() on every audit metadata stamp.
4
+ // Falls back to a stable string when the package.json isn't reachable (eg.
5
+ // bundled scenarios). The version is only ever embedded into outbound
6
+ // metadata, so a fallback is fine.
7
+ let cachedSdkVersion = null;
8
+ const readSdkVersion = () => {
9
+ if (cachedSdkVersion !== null) {
10
+ return cachedSdkVersion;
11
+ }
12
+ try {
13
+ const localRequire = createRequire(import.meta.url);
14
+ const pkg = localRequire('../../package.json');
15
+ cachedSdkVersion =
16
+ pkg && typeof pkg.version === 'string' ? pkg.version : 'unknown';
17
+ }
18
+ catch {
19
+ cachedSdkVersion = 'unknown';
20
+ }
21
+ return cachedSdkVersion;
22
+ };
23
+ /**
24
+ * Build the metadata payload every SDK write attaches to the request.
25
+ *
26
+ * Audit fields are namespaced under `metadata.audit` (RFC 0025 §5.2 + review
27
+ * findings M3/H1). This avoids two failure modes the original top-level shape
28
+ * had:
29
+ * 1. A caller passing `--metadata-json '{"actor":"system"}'` could spoof the
30
+ * audit actor by colliding with our top-level key.
31
+ * 2. Server-side strippers had to know the full audit-key list to filter.
32
+ *
33
+ * Merge order:
34
+ * - Non-audit user keys are passed through verbatim.
35
+ * - Audit defaults (`actor:"sdk"`, `sdkVersion`, `invokedBy`) are layered
36
+ * UNDER the caller's `audit` (so the CLI's `actor:"cli"` wins).
37
+ * - The caller's top-level `audit` key, if any, takes precedence over the
38
+ * SDK defaults but never replaces all of them — `cliVersion`, etc., are
39
+ * additive.
40
+ */
41
+ export const buildAuditMetadata = (userMetadata, options = {}) => {
42
+ const env = options.env ?? process.env;
43
+ const invokedByRaw = env.CONDUCTOR_INVOKED_BY;
44
+ const invokedBy = typeof invokedByRaw === 'string' && invokedByRaw.trim()
45
+ ? invokedByRaw.trim()
46
+ : null;
47
+ const sdkAudit = {
48
+ actor: 'sdk',
49
+ sdkVersion: options.sdkVersion ?? readSdkVersion(),
50
+ invokedBy,
51
+ };
52
+ const safeUserMetadata = userMetadata && typeof userMetadata === 'object' && !Array.isArray(userMetadata)
53
+ ? { ...userMetadata }
54
+ : {};
55
+ const callerAuditRaw = safeUserMetadata.audit;
56
+ delete safeUserMetadata.audit;
57
+ const callerAudit = callerAuditRaw && typeof callerAuditRaw === 'object' && !Array.isArray(callerAuditRaw)
58
+ ? callerAuditRaw
59
+ : {};
60
+ // Caller-supplied audit fields win over SDK defaults so a CLI invocation
61
+ // tagging `actor:"cli"` ends up authoritative.
62
+ const audit = { ...sdkAudit, ...callerAudit };
63
+ return { ...safeUserMetadata, audit };
64
+ };
65
+ export class ProjectNotResolvedError extends Error {
66
+ reason;
67
+ constructor(message, reason) {
68
+ super(message);
69
+ this.reason = reason;
70
+ this.name = 'ProjectNotResolvedError';
71
+ }
72
+ }
73
+ /**
74
+ * Best-effort mapping for HTTP-shaped failures so the CLI exit-code matrix
75
+ * (RFC 0025 §4) has a stable signal. Callers can choose to inspect
76
+ * `BackendApiError.statusCode` directly instead.
77
+ */
78
+ export const isBackendApiError = (value) => value instanceof BackendApiError;
@@ -0,0 +1,46 @@
1
+ import { ApiClient, SdkClientOptions } from './shared.js';
2
+ export interface Task {
3
+ id: string;
4
+ projectId: string | null;
5
+ issueId?: string | null;
6
+ title: string;
7
+ status: string;
8
+ backendType?: string | null;
9
+ sessionId?: string | null;
10
+ sessionFilePath?: string | null;
11
+ createdAt?: string | null;
12
+ updatedAt?: string | null;
13
+ raw: Record<string, unknown>;
14
+ }
15
+ export interface Message {
16
+ id: string;
17
+ taskId?: string | null;
18
+ role: string;
19
+ content: string;
20
+ metadata?: Record<string, unknown> | null;
21
+ createdAt?: string | null;
22
+ raw: Record<string, unknown>;
23
+ }
24
+ export interface ListTasksInput {
25
+ projectId?: string;
26
+ issueId?: string;
27
+ status?: string | string[];
28
+ }
29
+ export interface SendTaskMessageOptions {
30
+ role?: string;
31
+ metadata?: Record<string, unknown>;
32
+ clientRequestId?: string;
33
+ }
34
+ export interface ListTaskMessagesOptions {
35
+ limit?: number;
36
+ before?: string;
37
+ }
38
+ export declare class TasksApi {
39
+ private readonly client;
40
+ private readonly options;
41
+ constructor(client: ApiClient, options?: SdkClientOptions);
42
+ listTasks(input?: ListTasksInput): Promise<Task[]>;
43
+ getTask(taskId: string): Promise<Task>;
44
+ sendTaskMessage(taskId: string, content: string, options?: SendTaskMessageOptions): Promise<Message>;
45
+ listTaskMessages(taskId: string, options?: ListTaskMessagesOptions): Promise<Message[]>;
46
+ }
@@ -0,0 +1,118 @@
1
+ import { buildAuditMetadata } from './shared.js';
2
+ const normalizeTask = (payload) => {
3
+ const id = payload.id ? String(payload.id) : '';
4
+ if (!id) {
5
+ throw new Error('Task payload missing id');
6
+ }
7
+ return {
8
+ id,
9
+ projectId: payload.projectId !== undefined
10
+ ? payload.projectId === null
11
+ ? null
12
+ : String(payload.projectId)
13
+ : payload.project_id !== undefined
14
+ ? payload.project_id === null
15
+ ? null
16
+ : String(payload.project_id)
17
+ : null,
18
+ issueId: payload.issueId ?? payload.issue_id ?? null,
19
+ title: String(payload.title ?? ''),
20
+ status: String(payload.status ?? ''),
21
+ backendType: payload.backendType ?? payload.backend_type ?? null,
22
+ sessionId: payload.sessionId ?? payload.session_id ?? null,
23
+ sessionFilePath: payload.sessionFilePath ?? payload.session_file_path ?? null,
24
+ createdAt: payload.createdAt ?? payload.created_at ?? null,
25
+ updatedAt: payload.updatedAt ?? payload.updated_at ?? null,
26
+ raw: payload,
27
+ };
28
+ };
29
+ const normalizeMessage = (payload) => {
30
+ const id = payload.id ? String(payload.id) : payload.message_id ? String(payload.message_id) : '';
31
+ if (!id) {
32
+ throw new Error('Message payload missing id');
33
+ }
34
+ return {
35
+ id,
36
+ taskId: payload.taskId ?? payload.task_id ?? null,
37
+ role: String(payload.role ?? ''),
38
+ content: typeof payload.content === 'string' ? payload.content : '',
39
+ metadata: payload.metadata && typeof payload.metadata === 'object' && !Array.isArray(payload.metadata)
40
+ ? payload.metadata
41
+ : null,
42
+ createdAt: payload.createdAt ?? payload.created_at ?? null,
43
+ raw: payload,
44
+ };
45
+ };
46
+ export class TasksApi {
47
+ client;
48
+ options;
49
+ constructor(client, options = {}) {
50
+ this.client = client;
51
+ this.options = options;
52
+ }
53
+ async listTasks(input = {}) {
54
+ // The server `/api/tasks` only filters on `project_id`; status / issue
55
+ // filtering happens client-side here so we can support both string and
56
+ // array `status` shapes from the facade input.
57
+ const tasks = await this.client.listTasks({
58
+ projectId: input.projectId,
59
+ });
60
+ let normalized = tasks.map((entry) => normalizeTask(typeof entry.asObject === 'function' ? entry.asObject() : entry));
61
+ if (input.issueId) {
62
+ normalized = normalized.filter((task) => task.issueId === input.issueId);
63
+ }
64
+ const statusFilter = Array.isArray(input.status)
65
+ ? input.status.map((value) => String(value).trim()).filter(Boolean)
66
+ : input.status
67
+ ? [String(input.status).trim()].filter(Boolean)
68
+ : [];
69
+ if (statusFilter.length > 0) {
70
+ const statusSet = new Set(statusFilter);
71
+ normalized = normalized.filter((task) => statusSet.has(task.status));
72
+ }
73
+ return normalized;
74
+ }
75
+ async getTask(taskId) {
76
+ const trimmed = String(taskId ?? '').trim();
77
+ if (!trimmed) {
78
+ throw new Error('taskId is required');
79
+ }
80
+ const summary = await this.client.getTask(trimmed);
81
+ return normalizeTask(typeof summary.asObject === 'function'
82
+ ? summary.asObject()
83
+ : summary);
84
+ }
85
+ async sendTaskMessage(taskId, content, options = {}) {
86
+ const trimmed = String(taskId ?? '').trim();
87
+ if (!trimmed) {
88
+ throw new Error('taskId is required');
89
+ }
90
+ if (typeof content !== 'string' || content.length === 0) {
91
+ throw new Error('content is required');
92
+ }
93
+ const userMetadata = options.clientRequestId
94
+ ? { clientRequestId: options.clientRequestId, ...(options.metadata ?? {}) }
95
+ : options.metadata;
96
+ const metadata = buildAuditMetadata(userMetadata, this.options);
97
+ const body = {
98
+ content,
99
+ metadata,
100
+ };
101
+ if (options.role) {
102
+ body.role = options.role;
103
+ }
104
+ const payload = await this.client.postTaskMessage(trimmed, body);
105
+ return normalizeMessage(payload);
106
+ }
107
+ async listTaskMessages(taskId, options = {}) {
108
+ const trimmed = String(taskId ?? '').trim();
109
+ if (!trimmed) {
110
+ throw new Error('taskId is required');
111
+ }
112
+ const messages = await this.client.listTaskMessages(trimmed, {
113
+ limit: options.limit,
114
+ before: options.before,
115
+ });
116
+ return messages.map((entry) => normalizeMessage(entry));
117
+ }
118
+ }
@@ -95,6 +95,7 @@ export declare class BackendApiClient {
95
95
  agentHost?: string;
96
96
  metadata?: Record<string, unknown>;
97
97
  }): Promise<TaskSummary>;
98
+ getTask(taskId: string): Promise<TaskSummary>;
98
99
  updateTask(taskId: string, params: {
99
100
  projectId?: string;
100
101
  title?: string;
@@ -176,6 +177,41 @@ export declare class BackendApiClient {
176
177
  lastCommit?: string;
177
178
  fileCount?: number;
178
179
  }): Promise<ProjectSummary>;
180
+ /**
181
+ * PATCH /projects?projectId=... — used for fields the per-id PATCH endpoint
182
+ * doesn't accept (today: `hidden`). The two PATCH endpoints intentionally
183
+ * have different surfaces; see web/src/app/api/projects/route.ts vs
184
+ * web/src/app/api/projects/[projectId]/route.ts.
185
+ */
186
+ patchProjectByQuery(projectId: string, params: Record<string, unknown>): Promise<Record<string, any>>;
187
+ /**
188
+ * POST /projects/default — switches the user's default project to
189
+ * `projectId`. Returns the serialized Project (with `is_default: true`).
190
+ * Extra body fields (e.g., `metadata`) are merged into the request so the
191
+ * facade can pass audit info through.
192
+ */
193
+ setDefaultProject(projectId: string, extraBody?: Record<string, unknown>): Promise<Record<string, any>>;
194
+ listIssues(params?: {
195
+ projectId?: string;
196
+ projectIds?: string[];
197
+ status?: string;
198
+ }): Promise<Record<string, any>[]>;
199
+ getIssue(issueId: string): Promise<Record<string, any>>;
200
+ createIssue(params: Record<string, unknown>): Promise<Record<string, any>>;
201
+ patchIssue(issueId: string, params: Record<string, unknown>): Promise<Record<string, any>>;
202
+ deleteIssue(issueId: string): Promise<void>;
203
+ listTaskMessages(taskId: string, params?: {
204
+ limit?: number;
205
+ before?: string;
206
+ }): Promise<Record<string, any>[]>;
207
+ /**
208
+ * POST /tasks/[taskId]/messages — direct REST call. Note: `BackendApiClient`
209
+ * has a higher-level `commitSdkMessage` that goes through the agent-events
210
+ * pipeline (durable outbox + WS scope), but the CLI/AI Agent flow is a
211
+ * one-shot HTTP write with no agent host context, so we use the REST
212
+ * endpoint directly here. (RFC 0025 §2 picks the simpler path.)
213
+ */
214
+ postTaskMessage(taskId: string, params: Record<string, unknown>): Promise<Record<string, any>>;
179
215
  private request;
180
216
  private buildUrl;
181
217
  private sendRequest;
@@ -157,6 +157,11 @@ export class BackendApiClient {
157
157
  const payload = await this.parseJson(response);
158
158
  return TaskSummary.fromJSON(payload);
159
159
  }
160
+ async getTask(taskId) {
161
+ const response = await this.request('GET', `/tasks/${taskId}`);
162
+ const payload = await this.parseJson(response);
163
+ return TaskSummary.fromJSON(payload);
164
+ }
160
165
  async updateTask(taskId, params) {
161
166
  const response = await this.request('PATCH', `/tasks/${taskId}`, {
162
167
  body: JSON.stringify(params),
@@ -305,6 +310,102 @@ export class BackendApiClient {
305
310
  const payload = await this.parseJson(response);
306
311
  return ProjectSummary.fromJSON(payload);
307
312
  }
313
+ /**
314
+ * PATCH /projects?projectId=... — used for fields the per-id PATCH endpoint
315
+ * doesn't accept (today: `hidden`). The two PATCH endpoints intentionally
316
+ * have different surfaces; see web/src/app/api/projects/route.ts vs
317
+ * web/src/app/api/projects/[projectId]/route.ts.
318
+ */
319
+ async patchProjectByQuery(projectId, params) {
320
+ const query = new URLSearchParams();
321
+ query.set('projectId', projectId);
322
+ const response = await this.request('PATCH', '/projects', {
323
+ query,
324
+ body: JSON.stringify(params),
325
+ });
326
+ return this.parseJson(response);
327
+ }
328
+ /**
329
+ * POST /projects/default — switches the user's default project to
330
+ * `projectId`. Returns the serialized Project (with `is_default: true`).
331
+ * Extra body fields (e.g., `metadata`) are merged into the request so the
332
+ * facade can pass audit info through.
333
+ */
334
+ async setDefaultProject(projectId, extraBody = {}) {
335
+ const response = await this.request('POST', '/projects/default', {
336
+ body: JSON.stringify({ projectId, ...extraBody }),
337
+ });
338
+ return this.parseJson(response);
339
+ }
340
+ async listIssues(params = {}) {
341
+ const query = new URLSearchParams();
342
+ if (params.projectId) {
343
+ query.set('project_id', params.projectId);
344
+ }
345
+ if (params.projectIds && params.projectIds.length > 0) {
346
+ query.set('project_ids', params.projectIds.join(','));
347
+ }
348
+ if (params.status) {
349
+ query.set('status', params.status);
350
+ }
351
+ const response = await this.request('GET', '/issues', { query });
352
+ const payload = await this.parseJson(response);
353
+ if (!Array.isArray(payload)) {
354
+ throw new BackendApiError('Invalid issues response: expected list', response.status, payload);
355
+ }
356
+ return payload;
357
+ }
358
+ async getIssue(issueId) {
359
+ const response = await this.request('GET', `/issues/${issueId}`);
360
+ return this.parseJson(response);
361
+ }
362
+ async createIssue(params) {
363
+ const response = await this.request('POST', '/issues', {
364
+ body: JSON.stringify(params),
365
+ });
366
+ return this.parseJson(response);
367
+ }
368
+ async patchIssue(issueId, params) {
369
+ const response = await this.request('PATCH', `/issues/${issueId}`, {
370
+ body: JSON.stringify(params),
371
+ });
372
+ return this.parseJson(response);
373
+ }
374
+ async deleteIssue(issueId) {
375
+ await this.request('DELETE', `/issues/${issueId}`);
376
+ }
377
+ async listTaskMessages(taskId, params = {}) {
378
+ const query = new URLSearchParams();
379
+ query.set('pagination', '1');
380
+ if (typeof params.limit === 'number' && Number.isFinite(params.limit)) {
381
+ query.set('limit', String(params.limit));
382
+ }
383
+ if (params.before) {
384
+ query.set('before_id', params.before);
385
+ }
386
+ const response = await this.request('GET', `/tasks/${taskId}/messages`, { query });
387
+ const payload = await this.parseJson(response);
388
+ if (Array.isArray(payload)) {
389
+ return payload;
390
+ }
391
+ if (payload && typeof payload === 'object' && Array.isArray(payload.messages)) {
392
+ return payload.messages;
393
+ }
394
+ throw new BackendApiError('Invalid messages response', response.status, payload);
395
+ }
396
+ /**
397
+ * POST /tasks/[taskId]/messages — direct REST call. Note: `BackendApiClient`
398
+ * has a higher-level `commitSdkMessage` that goes through the agent-events
399
+ * pipeline (durable outbox + WS scope), but the CLI/AI Agent flow is a
400
+ * one-shot HTTP write with no agent host context, so we use the REST
401
+ * endpoint directly here. (RFC 0025 §2 picks the simpler path.)
402
+ */
403
+ async postTaskMessage(taskId, params) {
404
+ const response = await this.request('POST', `/tasks/${taskId}/messages`, {
405
+ body: JSON.stringify(params),
406
+ });
407
+ return this.parseJson(response);
408
+ }
308
409
  async request(method, pathname, opts = {}) {
309
410
  const url = this.buildUrl(pathname, opts.query);
310
411
  const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
package/dist/client.d.ts CHANGED
@@ -4,7 +4,7 @@ import { MessageRouter } from './message/index.js';
4
4
  import { DownstreamCursorStore, DurableUpstreamOutboxStore } from './outbox/index.js';
5
5
  import { SessionDiskStore, SessionManager } from './session/index.js';
6
6
  import { ConductorWebSocketClient, WebSocketConnectedEvent, WebSocketDisconnectEvent, WebSocketPongEvent } from './ws/index.js';
7
- type BackendApiLike = Pick<BackendApiClient, 'listProjects' | 'createProject' | 'listTasks' | 'createTask' | 'updateTask' | 'commitSdkMessage' | 'commitTaskStatusUpdate' | 'commitAgentCommandAck' | 'commitTaskStopAck' | 'matchProjectByPath' | 'getProject' | 'updateProject'>;
7
+ type BackendApiLike = Pick<BackendApiClient, 'listProjects' | 'createProject' | 'listTasks' | 'getTask' | 'createTask' | 'updateTask' | 'commitSdkMessage' | 'commitTaskStatusUpdate' | 'commitAgentCommandAck' | 'commitTaskStopAck' | 'matchProjectByPath' | 'getProject' | 'updateProject'>;
8
8
  type RealtimeClientLike = Pick<ConductorWebSocketClient, 'registerHandler' | 'connect' | 'disconnect' | 'sendJson'> & Partial<Pick<ConductorWebSocketClient, 'forceReconnect'>>;
9
9
  export interface ConductorClientConnectOptions {
10
10
  config?: ConductorConfig;
@@ -126,6 +126,7 @@ export declare class ConductorClient {
126
126
  createProject(name: string, metadata?: Record<string, unknown>): Promise<Record<string, any>>;
127
127
  createProject(payload: Record<string, any>): Promise<Record<string, any>>;
128
128
  listTasks(payload?: Record<string, any>): Promise<Record<string, any>>;
129
+ getTask(taskId: string): Promise<Record<string, any>>;
129
130
  getLocalProjectRecord(payload?: Record<string, any>): Promise<Record<string, any>>;
130
131
  getLocalTaskRecord(payload?: Record<string, any>): Promise<Record<string, any>>;
131
132
  bindTaskSession(taskId: string, payload?: Record<string, any>): Promise<Record<string, any>>;
package/dist/client.js CHANGED
@@ -376,6 +376,24 @@ export class ConductorClient {
376
376
  })),
377
377
  };
378
378
  }
379
+ async getTask(taskId) {
380
+ const normalizedTaskId = String(taskId || '').trim();
381
+ if (!normalizedTaskId) {
382
+ throw new Error('task_id is required');
383
+ }
384
+ const task = await this.backendApi.getTask(normalizedTaskId);
385
+ return {
386
+ id: task.id,
387
+ project_id: task.projectId,
388
+ title: task.title,
389
+ status: task.status,
390
+ backend_type: task.backendType ?? null,
391
+ session_id: task.sessionId ?? null,
392
+ session_file_path: task.sessionFilePath ?? null,
393
+ created_at: task.createdAt ?? null,
394
+ updated_at: task.updatedAt ?? null,
395
+ };
396
+ }
379
397
  async getLocalProjectRecord(payload = {}) {
380
398
  const projectPath = typeof payload.project_path === 'string' && payload.project_path
381
399
  ? payload.project_path
@@ -7,6 +7,13 @@ export interface WorkspaceSnapshot {
7
7
  repoRoot?: string;
8
8
  worktreeBranch?: string;
9
9
  lastCommit?: string;
10
+ /**
11
+ * Normalized origin remote URL (lower-cased, trailing `.git` stripped).
12
+ * Only populated when the workspace is a git repository AND has an
13
+ * `origin` remote configured. Used by the web UI to merge same-name
14
+ * projects across daemons that point to the same upstream repo.
15
+ */
16
+ gitRemoteUrl?: string;
10
17
  fileCount?: number;
11
18
  }
12
19
  export declare class ProjectContext {
@@ -20,6 +27,25 @@ export declare class ProjectContext {
20
27
  private gitRoot;
21
28
  private gitBranch;
22
29
  private gitHead;
30
+ private gitRemoteUrl;
23
31
  private gitFileCount;
24
32
  private gitListFiles;
25
33
  }
34
+ /**
35
+ * Normalize a git remote URL so that variations of the same upstream
36
+ * compare equal. Examples:
37
+ * git@github.com:foo/bar.git -> github.com/foo/bar
38
+ * https://github.com/foo/bar/ -> github.com/foo/bar
39
+ * ssh://git@github.com/foo/bar -> github.com/foo/bar
40
+ * https://gitea.local:3000/foo/bar.git -> gitea.local:3000/foo/bar
41
+ * https://user:pass@gitlab.com/foo/bar -> gitlab.com/foo/bar
42
+ *
43
+ * Two normalization paths:
44
+ * 1. URL form (anything with `://`): parsed via WHATWG URL so host, port,
45
+ * and path are preserved correctly while user-info is dropped.
46
+ * 2. scp form (`user@host:path`): handled with a small regex; the colon
47
+ * after host becomes a `/` so it lines up with the URL form. IPv6 hosts
48
+ * should use the `ssh://` URL form; git's scp-like syntax does not
49
+ * reliably represent bracketed IPv6 hosts.
50
+ */
51
+ export declare function normalizeGitRemoteUrl(raw: string | null | undefined): string | null;
@@ -26,6 +26,7 @@ export class ProjectContext {
26
26
  repoRoot: guess.repoRoot,
27
27
  worktreeBranch: this.gitBranch(guess.repoRoot) ?? undefined,
28
28
  lastCommit: this.gitHead(guess.repoRoot) ?? undefined,
29
+ gitRemoteUrl: this.gitRemoteUrl(guess.repoRoot) ?? undefined,
29
30
  fileCount: this.gitFileCount(guess.repoRoot) ?? undefined,
30
31
  };
31
32
  }
@@ -91,6 +92,15 @@ export class ProjectContext {
91
92
  return null;
92
93
  }
93
94
  }
95
+ gitRemoteUrl(repoRoot) {
96
+ try {
97
+ const url = runGit(['config', '--get', 'remote.origin.url'], repoRoot).trim();
98
+ return normalizeGitRemoteUrl(url);
99
+ }
100
+ catch {
101
+ return null;
102
+ }
103
+ }
94
104
  gitFileCount(repoRoot) {
95
105
  try {
96
106
  return this.gitListFiles(repoRoot).length;
@@ -122,6 +132,60 @@ function runGit(args, cwd) {
122
132
  }
123
133
  return result.stdout;
124
134
  }
135
+ /**
136
+ * Normalize a git remote URL so that variations of the same upstream
137
+ * compare equal. Examples:
138
+ * git@github.com:foo/bar.git -> github.com/foo/bar
139
+ * https://github.com/foo/bar/ -> github.com/foo/bar
140
+ * ssh://git@github.com/foo/bar -> github.com/foo/bar
141
+ * https://gitea.local:3000/foo/bar.git -> gitea.local:3000/foo/bar
142
+ * https://user:pass@gitlab.com/foo/bar -> gitlab.com/foo/bar
143
+ *
144
+ * Two normalization paths:
145
+ * 1. URL form (anything with `://`): parsed via WHATWG URL so host, port,
146
+ * and path are preserved correctly while user-info is dropped.
147
+ * 2. scp form (`user@host:path`): handled with a small regex; the colon
148
+ * after host becomes a `/` so it lines up with the URL form. IPv6 hosts
149
+ * should use the `ssh://` URL form; git's scp-like syntax does not
150
+ * reliably represent bracketed IPv6 hosts.
151
+ */
152
+ export function normalizeGitRemoteUrl(raw) {
153
+ if (!raw)
154
+ return null;
155
+ const trimmed = raw.trim();
156
+ if (!trimmed)
157
+ return null;
158
+ // URL form: ssh://, https://, http://, git://, ftp://, file://, etc.
159
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
160
+ let parsed;
161
+ try {
162
+ parsed = new URL(trimmed);
163
+ }
164
+ catch {
165
+ return null;
166
+ }
167
+ // `host` keeps the explicit port when present (e.g. `gitea.local:3000`).
168
+ // `pathname` preserves the leading slash so `${host}${pathname}` reads
169
+ // naturally; we drop trailing `/` and trailing `.git` afterwards.
170
+ const path = parsed.pathname.replace(/\/+$/, '');
171
+ const combined = `${parsed.host}${path}`.replace(/\.git$/i, '');
172
+ return combined.toLowerCase() || null;
173
+ }
174
+ // scp form: `[user@]host:path`. The host segment must not contain `/`
175
+ // (otherwise the first `/` would have shown up before the `:` and we'd
176
+ // be looking at a relative-path URL, which git doesn't support as a
177
+ // remote URL anyway).
178
+ const scpMatch = trimmed.match(/^(?:[^@/:]+@)?([^/:]+):(?!\/)(.*)$/);
179
+ if (scpMatch) {
180
+ const host = scpMatch[1];
181
+ const path = scpMatch[2].replace(/\/+$/, '').replace(/\.git$/i, '');
182
+ return `${host}/${path}`.toLowerCase() || null;
183
+ }
184
+ // Anything else (e.g. plain path) — give up so we don't fabricate a
185
+ // misleading "normalized" form that could accidentally collide with a
186
+ // real upstream URL.
187
+ return null;
188
+ }
125
189
  function* walkFiles(root) {
126
190
  const entries = fs.readdirSync(root, { withFileTypes: true });
127
191
  for (const entry of entries) {
package/dist/index.d.ts CHANGED
@@ -7,3 +7,4 @@ export * from './client.js';
7
7
  export * from './context/index.js';
8
8
  export * from './limits/index.js';
9
9
  export * from './outbox/index.js';
10
+ export * from './api/index.js';
package/dist/index.js CHANGED
@@ -7,3 +7,4 @@ export * from './client.js';
7
7
  export * from './context/index.js';
8
8
  export * from './limits/index.js';
9
9
  export * from './outbox/index.js';
10
+ export * from './api/index.js';
package/dist/ws/client.js CHANGED
@@ -87,13 +87,27 @@ export class ConductorWebSocketClient {
87
87
  await this.terminateConnection(conn);
88
88
  }
89
89
  async sendJson(payload) {
90
+ // Once disconnect() has flipped `stop`, sending is a no-op. Callers
91
+ // commonly queue sends via fire-and-forget helpers (`.catch(() => {})`),
92
+ // but late rejections that slip through were being promoted to
93
+ // unhandledRejection / uncaughtException and crashed the daemon during
94
+ // restart. Treat a send-after-disconnect as a silent no-op instead.
95
+ if (this.stop) {
96
+ return;
97
+ }
90
98
  await this.ensureConnection();
99
+ if (this.stop) {
100
+ return;
101
+ }
91
102
  await this.sendWithReconnect(JSON.stringify(payload));
92
103
  }
93
104
  async ensureConnection() {
94
105
  if (this.conn && !this.isConnectionClosed(this.conn)) {
95
106
  return;
96
107
  }
108
+ if (this.stop) {
109
+ return;
110
+ }
97
111
  await this.openConnection(true);
98
112
  }
99
113
  async openConnection(force = false) {
@@ -261,10 +275,19 @@ export class ConductorWebSocketClient {
261
275
  let attemptedReconnect = false;
262
276
  // Loop at most twice: initial send, then one reconnect + retry
263
277
  while (true) {
278
+ if (this.stop) {
279
+ // `disconnect()` has been called; swallow the send to avoid raising
280
+ // "WebSocket not connected" from an already-silenced client. Any
281
+ // send queued before disconnect() finishes is best-effort.
282
+ return;
283
+ }
264
284
  const conn = this.conn;
265
285
  if (!conn || this.isConnectionClosed(conn)) {
266
286
  await this.openConnection(true);
267
287
  }
288
+ if (this.stop) {
289
+ return;
290
+ }
268
291
  if (!this.conn || this.isConnectionClosed(this.conn)) {
269
292
  throw new Error('WebSocket not connected');
270
293
  }
package/package.json CHANGED
@@ -1,6 +1,10 @@
1
1
  {
2
2
  "name": "@love-moon/conductor-sdk",
3
- "version": "0.2.42",
3
+ "version": "0.3.0",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "git+https://github.com/lovemoon-ai/conductor.git"
7
+ },
4
8
  "type": "module",
5
9
  "main": "dist/index.js",
6
10
  "types": "dist/index.d.ts",
@@ -8,7 +12,8 @@
8
12
  "dist"
9
13
  ],
10
14
  "publishConfig": {
11
- "access": "public"
15
+ "access": "public",
16
+ "provenance": true
12
17
  },
13
18
  "scripts": {
14
19
  "build": "tsc -p tsconfig.json",
@@ -27,5 +32,5 @@
27
32
  "typescript": "^5.6.3",
28
33
  "vitest": "^2.1.4"
29
34
  },
30
- "gitCommitId": "f79f36f"
35
+ "gitCommitId": "b9f33cd"
31
36
  }