@love-moon/conductor-sdk 0.2.42 → 0.3.1

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,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,14 +1,20 @@
1
1
  {
2
2
  "name": "@love-moon/conductor-sdk",
3
- "version": "0.2.42",
3
+ "version": "0.3.1",
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",
7
11
  "files": [
8
- "dist"
12
+ "dist",
13
+ "CHANGELOG.md"
9
14
  ],
10
15
  "publishConfig": {
11
- "access": "public"
16
+ "access": "public",
17
+ "provenance": true
12
18
  },
13
19
  "scripts": {
14
20
  "build": "tsc -p tsconfig.json",
@@ -27,5 +33,5 @@
27
33
  "typescript": "^5.6.3",
28
34
  "vitest": "^2.1.4"
29
35
  },
30
- "gitCommitId": "f79f36f"
36
+ "gitCommitId": "03b4582"
31
37
  }