@love-moon/conductor-cli 0.2.38 → 0.2.40

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,64 @@
1
+ // Small helpers for masking handoff-share URLs in logs / error messages /
2
+ // task status summaries. Extracted into a dependency-free module so they
3
+ // can be unit-tested without loading the rest of the daemon (which would
4
+ // otherwise require @love-moon/conductor-sdk and its build artifacts).
5
+
6
+ // The handoff URL embeds a bearer-style share token in its path. We mask it
7
+ // before writing to logs so daemon log files / `ps`-visible output don't leak
8
+ // a 24h read-grant for the entire transcript. The full URL still goes into
9
+ // the spawned CLI's argv (necessarily — that's how the AI fetches it), but
10
+ // the persistent log surface is safe.
11
+ //
12
+ // The character class `[^/?#\s]+` stops the token at query-string, fragment,
13
+ // or whitespace boundaries, so URLs like `.../share/<tok>/plain?x=1` or
14
+ // `.../share/<tok>/plain#foo` are still masked instead of leaking verbatim.
15
+ // The trailing portion (query/fragment/path tail) is preserved after masking.
16
+ export function maskHandoffUrlForLogs(value) {
17
+ if (typeof value !== "string" || !value) {
18
+ return value;
19
+ }
20
+ return value.replace(/\/share\/([^/?#\s]+)/g, (_, token) => {
21
+ const tail = token.length > 4 ? token.slice(-4) : token;
22
+ return `/share/<masked:…${tail}>`;
23
+ });
24
+ }
25
+
26
+ // Scrub any handoff URL embedded in an error message before surfacing it via
27
+ // logs or task status summaries. Belt-and-suspenders for the case where an
28
+ // internal error stringifies the outbox payload.
29
+ //
30
+ // Metadata (`name`, `code`, `cause`, any own props the caller attached) is
31
+ // preserved on the clone so downstream consumers that switch on those fields
32
+ // (retry policies, status mappers) keep working after scrubbing. The
33
+ // prototype chain is also preserved so `instanceof` checks survive.
34
+ //
35
+ // Stack frames in some runtimes embed argument strings in the source
36
+ // snippet; the stack is passed through the same masker so a URL that
37
+ // happens to appear in a frame cannot leak through the log that prints
38
+ // the stack.
39
+ export function maskErrorForLogs(error) {
40
+ if (!error) {
41
+ return error;
42
+ }
43
+ const message = typeof error === "string" ? error : error.message;
44
+ if (typeof message !== "string") {
45
+ return error;
46
+ }
47
+ const masked = maskHandoffUrlForLogs(message);
48
+ if (masked === message) {
49
+ return error;
50
+ }
51
+ if (typeof error === "string") {
52
+ return masked;
53
+ }
54
+ const proto = Object.getPrototypeOf(error) || Error.prototype;
55
+ const clone = Object.create(proto);
56
+ Object.assign(clone, error);
57
+ clone.message = masked;
58
+ clone.name = error.name || clone.name || "Error";
59
+ clone.stack =
60
+ typeof error.stack === "string"
61
+ ? maskHandoffUrlForLogs(error.stack)
62
+ : error.stack;
63
+ return clone;
64
+ }
@@ -4,8 +4,10 @@ import { pathToFileURL } from "node:url";
4
4
 
5
5
  import yaml from "js-yaml";
6
6
 
7
- const BUILT_IN_RUNTIME_BACKENDS = ["codex", "claude", "kimi", "opencode"];
7
+ const BUILT_IN_RUNTIME_BACKENDS = ["codex", "claude", "kimi", "opencode", "copilot"];
8
8
  const BUILT_IN_RUNTIME_BACKEND_SET = new Set(BUILT_IN_RUNTIME_BACKENDS);
9
+ const COMMAND_OPTIONAL_BUILT_IN_RUNTIME_BACKENDS = ["copilot"];
10
+ const COMMAND_OPTIONAL_BUILT_IN_RUNTIME_BACKEND_SET = new Set(COMMAND_OPTIONAL_BUILT_IN_RUNTIME_BACKENDS);
9
11
  const LEGACY_RUNTIME_BACKEND_ALIASES = new Set([
10
12
  "code",
11
13
  "claude-code",
@@ -28,7 +30,7 @@ function appendProviderModulePaths(parts, value) {
28
30
  if (!raw) {
29
31
  return;
30
32
  }
31
- for (const item of raw.split(process.platform === "win32" ? ";" : ":")) {
33
+ for (const item of splitProviderModulePathString(raw)) {
32
34
  const normalized = item.trim();
33
35
  if (normalized) {
34
36
  parts.push(normalized);
@@ -36,6 +38,49 @@ function appendProviderModulePaths(parts, value) {
36
38
  }
37
39
  }
38
40
 
41
+ function looksLikeProviderModulePath(value) {
42
+ const normalized = String(value || "").trim();
43
+ if (!normalized) {
44
+ return false;
45
+ }
46
+ return (
47
+ normalized.startsWith("/") ||
48
+ normalized.startsWith("./") ||
49
+ normalized.startsWith("../") ||
50
+ normalized.startsWith("~/") ||
51
+ normalized.startsWith("file:") ||
52
+ normalized.includes("/") ||
53
+ normalized.includes("\\") ||
54
+ /\.[cm]?[jt]sx?$/i.test(normalized) ||
55
+ /^[A-Za-z]:[\\/]/.test(normalized)
56
+ );
57
+ }
58
+
59
+ function splitProviderModulePathString(raw) {
60
+ const normalized = String(raw || "").trim();
61
+ if (!normalized) {
62
+ return [];
63
+ }
64
+
65
+ const platformParts = normalized
66
+ .split(path.delimiter)
67
+ .map((item) => item.trim())
68
+ .filter(Boolean);
69
+ if (platformParts.length > 1 || !normalized.includes(",")) {
70
+ return platformParts;
71
+ }
72
+
73
+ const commaParts = normalized
74
+ .split(",")
75
+ .map((item) => item.trim())
76
+ .filter(Boolean);
77
+ if (commaParts.length > 1 && commaParts.every(looksLikeProviderModulePath)) {
78
+ return commaParts;
79
+ }
80
+
81
+ return platformParts;
82
+ }
83
+
39
84
  function listProviderModulePaths(providerPathEnv) {
40
85
  const parts = [];
41
86
  appendProviderModulePaths(parts, providerPathEnv);
@@ -53,7 +98,7 @@ function stripExecutableSuffix(name) {
53
98
  .replace(/\.(cmd|bat|exe)$/i, "");
54
99
  }
55
100
 
56
- function parseCommandParts(commandLine) {
101
+ export function parseCommandParts(commandLine) {
57
102
  const input = String(commandLine || "").trim();
58
103
  if (!input) {
59
104
  return { command: "", args: [], parts: [] };
@@ -62,29 +107,39 @@ function parseCommandParts(commandLine) {
62
107
  const parts = [];
63
108
  let current = "";
64
109
  let quote = "";
65
- let escaping = false;
66
110
  let tokenStarted = false;
67
111
 
68
- for (const char of input) {
69
- if (escaping) {
70
- current += char;
71
- tokenStarted = true;
72
- escaping = false;
73
- continue;
74
- }
112
+ for (let index = 0; index < input.length; index += 1) {
113
+ const char = input[index];
114
+ const nextChar = input[index + 1];
75
115
 
76
- if (char === "\\") {
77
- escaping = true;
116
+ if (quote === "'") {
117
+ if (char === "'") {
118
+ quote = "";
119
+ } else {
120
+ current += char;
121
+ }
78
122
  tokenStarted = true;
79
123
  continue;
80
124
  }
81
125
 
82
- if (quote) {
83
- if (char === quote) {
126
+ if (quote === "\"") {
127
+ if (char === "\"") {
84
128
  quote = "";
85
- } else {
86
- current += char;
129
+ continue;
130
+ }
131
+ if (char === "\\") {
132
+ if (nextChar === "\"" || nextChar === "\\") {
133
+ current += nextChar;
134
+ tokenStarted = true;
135
+ index += 1;
136
+ continue;
137
+ }
138
+ current += "\\";
139
+ tokenStarted = true;
140
+ continue;
87
141
  }
142
+ current += char;
88
143
  tokenStarted = true;
89
144
  continue;
90
145
  }
@@ -95,6 +150,18 @@ function parseCommandParts(commandLine) {
95
150
  continue;
96
151
  }
97
152
 
153
+ if (char === "\\") {
154
+ if (nextChar && (/\s/.test(nextChar) || nextChar === "\"" || nextChar === "'" || nextChar === "\\")) {
155
+ current += nextChar;
156
+ tokenStarted = true;
157
+ index += 1;
158
+ continue;
159
+ }
160
+ current += "\\";
161
+ tokenStarted = true;
162
+ continue;
163
+ }
164
+
98
165
  if (/\s/.test(char)) {
99
166
  if (tokenStarted) {
100
167
  parts.push(current);
@@ -108,6 +175,10 @@ function parseCommandParts(commandLine) {
108
175
  tokenStarted = true;
109
176
  }
110
177
 
178
+ if (quote) {
179
+ throw new Error(`Invalid command line: unterminated ${quote === "\"" ? "double" : "single"} quote`);
180
+ }
181
+
111
182
  if (tokenStarted) {
112
183
  parts.push(current);
113
184
  }
@@ -221,6 +292,10 @@ export function isBuiltInRuntimeBackend(backend) {
221
292
  return BUILT_IN_RUNTIME_BACKEND_SET.has(normalizeRuntimeBackendName(backend));
222
293
  }
223
294
 
295
+ export function isCommandOptionalBuiltInRuntimeBackend(backend) {
296
+ return COMMAND_OPTIONAL_BUILT_IN_RUNTIME_BACKEND_SET.has(normalizeRuntimeBackendName(backend));
297
+ }
298
+
224
299
  function readConfigEnvValue(configFilePath, key) {
225
300
  const targetPath =
226
301
  typeof configFilePath === "string" && configFilePath.trim()
@@ -480,6 +555,14 @@ export async function resolveConfiguredRuntimeBackend(backend, allowCliList, opt
480
555
  };
481
556
  }
482
557
 
558
+ if (!hasConfiguredEntry && isCommandOptionalBuiltInRuntimeBackend(resolvedBackend)) {
559
+ return {
560
+ requestedBackend: normalizedBackend,
561
+ runtimeBackend: resolvedBackend,
562
+ commandLine: "",
563
+ };
564
+ }
565
+
483
566
  if (!hasConfiguredEntry && !isBuiltInRuntimeBackend(resolvedBackend) && await isRuntimeSupportedBackend(resolvedBackend, options)) {
484
567
  return {
485
568
  requestedBackend: normalizedBackend,
@@ -543,12 +626,22 @@ export async function listAdvertisedBackends(allowCliList, options = {}) {
543
626
  const externalBackends = discoveredExternalBackends.filter(
544
627
  (backend) => !shadowedExternalBackends.has(backend) && !explicitlyConfiguredBackends.has(backend),
545
628
  );
546
- const supportedBackends = [...new Set([...advertisedConfiguredBackends, ...externalBackends])];
547
629
 
548
630
  for (const backend of externalBackends) {
549
631
  runtimeBackendMap[backend] = backend;
550
632
  }
551
633
 
634
+ const commandOptionalBuiltIns = BUILT_IN_RUNTIME_BACKENDS.filter(
635
+ (backend) => isCommandOptionalBuiltInRuntimeBackend(backend) && !runtimeBackendMap[backend],
636
+ );
637
+ for (const backend of commandOptionalBuiltIns) {
638
+ runtimeBackendMap[backend] = backend;
639
+ }
640
+
641
+ const supportedBackends = [
642
+ ...new Set([...advertisedConfiguredBackends, ...externalBackends, ...commandOptionalBuiltIns]),
643
+ ];
644
+
552
645
  return {
553
646
  configuredBackends: advertisedConfiguredBackends,
554
647
  externalBackends,
@@ -0,0 +1,383 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ function normalizeRole(role) {
4
+ const normalized = String(role || "").trim().toLowerCase();
5
+ return normalized || "user";
6
+ }
7
+
8
+ function extractImageUrlFromPart(part) {
9
+ if (!part || typeof part !== "object") {
10
+ return "";
11
+ }
12
+ if (part.type === "image_url") {
13
+ if (typeof part.image_url === "string") {
14
+ return part.image_url.trim();
15
+ }
16
+ if (part.image_url && typeof part.image_url === "object" && typeof part.image_url.url === "string") {
17
+ return part.image_url.url.trim();
18
+ }
19
+ }
20
+ if (part.type === "input_image") {
21
+ if (typeof part.image_url === "string") {
22
+ return part.image_url.trim();
23
+ }
24
+ if (typeof part.url === "string") {
25
+ return part.url.trim();
26
+ }
27
+ }
28
+ return "";
29
+ }
30
+
31
+ function extractTextFromPart(part) {
32
+ if (!part || typeof part !== "object") {
33
+ return "";
34
+ }
35
+ if (part.type === "text" && typeof part.text === "string") {
36
+ return part.text;
37
+ }
38
+ if (part.type === "input_text" && typeof part.text === "string") {
39
+ return part.text;
40
+ }
41
+ return "";
42
+ }
43
+
44
+ export function extractMessageParts(content) {
45
+ if (typeof content === "string") {
46
+ return {
47
+ text: content.trim(),
48
+ imageUrls: [],
49
+ };
50
+ }
51
+
52
+ if (!Array.isArray(content)) {
53
+ return {
54
+ text: "",
55
+ imageUrls: [],
56
+ };
57
+ }
58
+
59
+ const texts = [];
60
+ const imageUrls = [];
61
+ for (const part of content) {
62
+ const text = extractTextFromPart(part);
63
+ if (text) {
64
+ texts.push(text);
65
+ }
66
+ const imageUrl = extractImageUrlFromPart(part);
67
+ if (imageUrl) {
68
+ imageUrls.push(imageUrl);
69
+ }
70
+ }
71
+
72
+ return {
73
+ text: texts.join("").trim(),
74
+ imageUrls,
75
+ };
76
+ }
77
+
78
+ function serializeMessageForHistory(message) {
79
+ const role = normalizeRole(message?.role);
80
+ const { text, imageUrls } = extractMessageParts(message?.content);
81
+ const historyRole = role === "assistant" ? "assistant" : "user";
82
+ const segments = [];
83
+
84
+ if (role === "system") {
85
+ segments.push("[System]");
86
+ } else if (role === "tool") {
87
+ segments.push("[Tool]");
88
+ }
89
+
90
+ if (text) {
91
+ segments.push(text);
92
+ }
93
+
94
+ if (imageUrls.length > 0) {
95
+ segments.push(
96
+ imageUrls.length === 1
97
+ ? "[Attached image omitted from prior turn]"
98
+ : `[${imageUrls.length} attached images omitted from prior turn]`,
99
+ );
100
+ }
101
+
102
+ const content = segments.filter(Boolean).join("\n\n").trim();
103
+ return content ? { role: historyRole, content } : null;
104
+ }
105
+
106
+ export function buildChatTurn(messages) {
107
+ const normalizedMessages = Array.isArray(messages) ? messages : [];
108
+ if (normalizedMessages.length === 0) {
109
+ throw new Error("messages must be a non-empty array");
110
+ }
111
+
112
+ const history = [];
113
+ for (const message of normalizedMessages.slice(0, -1)) {
114
+ const entry = serializeMessageForHistory(message);
115
+ if (entry) {
116
+ history.push(entry);
117
+ }
118
+ }
119
+
120
+ const lastMessage = normalizedMessages.at(-1);
121
+ const lastRole = normalizeRole(lastMessage?.role);
122
+ const { text, imageUrls } = extractMessageParts(lastMessage?.content);
123
+
124
+ let promptText = text;
125
+ if (lastRole === "system") {
126
+ promptText = promptText ? `[System]\n\n${promptText}` : "[System]";
127
+ } else if (lastRole === "assistant") {
128
+ promptText = promptText ? `[Assistant]\n\n${promptText}` : "[Assistant]";
129
+ } else if (lastRole === "tool") {
130
+ promptText = promptText ? `[Tool]\n\n${promptText}` : "[Tool]";
131
+ }
132
+
133
+ if (!promptText && imageUrls.length > 0) {
134
+ promptText = "Analyze the attached image.";
135
+ }
136
+
137
+ if (!promptText) {
138
+ throw new Error("last message must include text or image content");
139
+ }
140
+
141
+ return {
142
+ promptText,
143
+ imageUrls,
144
+ initialHistory: history,
145
+ };
146
+ }
147
+
148
+ export function normalizeResponseFormat(responseFormat) {
149
+ if (!responseFormat || responseFormat === "text") {
150
+ return {
151
+ type: "text",
152
+ jsonSchema: null,
153
+ outputFormat: null,
154
+ };
155
+ }
156
+
157
+ if (typeof responseFormat !== "object") {
158
+ throw new Error("response_format must be an object");
159
+ }
160
+
161
+ const type = String(responseFormat.type || "").trim().toLowerCase();
162
+ if (!type || type === "text") {
163
+ return {
164
+ type: "text",
165
+ jsonSchema: null,
166
+ outputFormat: null,
167
+ };
168
+ }
169
+
170
+ if (type === "json_object") {
171
+ return {
172
+ type,
173
+ jsonSchema: {
174
+ type: "object",
175
+ },
176
+ outputFormat: {
177
+ type: "json_object",
178
+ },
179
+ };
180
+ }
181
+
182
+ if (type === "json_schema") {
183
+ const jsonSchema =
184
+ responseFormat?.json_schema?.schema && typeof responseFormat.json_schema.schema === "object"
185
+ ? responseFormat.json_schema.schema
186
+ : responseFormat?.schema && typeof responseFormat.schema === "object"
187
+ ? responseFormat.schema
188
+ : null;
189
+ if (!jsonSchema) {
190
+ throw new Error("response_format.json_schema.schema is required");
191
+ }
192
+ return {
193
+ type,
194
+ jsonSchema,
195
+ outputFormat: {
196
+ type: "json_schema",
197
+ schema: jsonSchema,
198
+ },
199
+ };
200
+ }
201
+
202
+ throw new Error(`unsupported response_format.type: ${type}`);
203
+ }
204
+
205
+ function tryParseJson(text) {
206
+ try {
207
+ return {
208
+ ok: true,
209
+ value: JSON.parse(text),
210
+ };
211
+ } catch {
212
+ return {
213
+ ok: false,
214
+ value: null,
215
+ };
216
+ }
217
+ }
218
+
219
+ function unwrapJsonCodeFence(text) {
220
+ const normalized = String(text || "").trim();
221
+ const match = normalized.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
222
+ return match ? match[1].trim() : normalized;
223
+ }
224
+
225
+ function findBalancedJsonSubstring(text) {
226
+ const source = String(text || "");
227
+ for (let start = 0; start < source.length; start += 1) {
228
+ const opener = source[start];
229
+ if (opener !== "{" && opener !== "[") {
230
+ continue;
231
+ }
232
+
233
+ const stack = [opener === "{" ? "}" : "]"];
234
+ let inString = false;
235
+ let escaped = false;
236
+
237
+ for (let index = start + 1; index < source.length; index += 1) {
238
+ const char = source[index];
239
+ if (inString) {
240
+ if (escaped) {
241
+ escaped = false;
242
+ continue;
243
+ }
244
+ if (char === "\\") {
245
+ escaped = true;
246
+ continue;
247
+ }
248
+ if (char === "\"") {
249
+ inString = false;
250
+ }
251
+ continue;
252
+ }
253
+
254
+ if (char === "\"") {
255
+ inString = true;
256
+ continue;
257
+ }
258
+ if (char === "{") {
259
+ stack.push("}");
260
+ continue;
261
+ }
262
+ if (char === "[") {
263
+ stack.push("]");
264
+ continue;
265
+ }
266
+ if ((char === "}" || char === "]") && stack.at(-1) === char) {
267
+ stack.pop();
268
+ if (stack.length === 0) {
269
+ const candidate = source.slice(start, index + 1).trim();
270
+ const parsed = tryParseJson(candidate);
271
+ if (parsed.ok) {
272
+ return parsed.value;
273
+ }
274
+ break;
275
+ }
276
+ }
277
+ }
278
+ }
279
+ return null;
280
+ }
281
+
282
+ function extractStructuredOutputValue(result) {
283
+ if (!result || typeof result !== "object") {
284
+ return undefined;
285
+ }
286
+ if (
287
+ result.metadata &&
288
+ typeof result.metadata === "object" &&
289
+ Object.prototype.hasOwnProperty.call(result.metadata, "structuredOutput")
290
+ ) {
291
+ return result.metadata.structuredOutput;
292
+ }
293
+ return undefined;
294
+ }
295
+
296
+ function parseStructuredOutputText(text) {
297
+ const normalized = String(text || "").trim();
298
+ if (!normalized) {
299
+ throw new Error("structured output is empty");
300
+ }
301
+
302
+ const direct = tryParseJson(normalized);
303
+ if (direct.ok) {
304
+ return direct.value;
305
+ }
306
+
307
+ const unfenced = unwrapJsonCodeFence(normalized);
308
+ if (unfenced !== normalized) {
309
+ const fenced = tryParseJson(unfenced);
310
+ if (fenced.ok) {
311
+ return fenced.value;
312
+ }
313
+ }
314
+
315
+ const extracted = findBalancedJsonSubstring(unfenced);
316
+ if (extracted !== null) {
317
+ return extracted;
318
+ }
319
+
320
+ throw new Error("structured output is not valid JSON");
321
+ }
322
+
323
+ function normalizeUsage(usage) {
324
+ const promptTokens =
325
+ Number(usage?.prompt_tokens ?? usage?.input_tokens ?? usage?.inputTokens ?? usage?.input ?? 0) || 0;
326
+ const completionTokens =
327
+ Number(usage?.completion_tokens ?? usage?.output_tokens ?? usage?.outputTokens ?? usage?.output ?? 0) || 0;
328
+ const totalTokens =
329
+ Number(usage?.total_tokens ?? usage?.totalTokens ?? promptTokens + completionTokens) || 0;
330
+
331
+ return {
332
+ prompt_tokens: promptTokens,
333
+ completion_tokens: completionTokens,
334
+ total_tokens: totalTokens,
335
+ };
336
+ }
337
+
338
+ export function assertStructuredOutputText(text, responseFormat) {
339
+ if (!responseFormat || responseFormat.type === "text") {
340
+ return;
341
+ }
342
+ JSON.parse(String(text || ""));
343
+ }
344
+
345
+ export function normalizeStructuredOutputResult(result, responseFormat) {
346
+ if (!responseFormat || responseFormat.type === "text") {
347
+ return result;
348
+ }
349
+
350
+ const metadataStructuredOutput = extractStructuredOutputValue(result);
351
+ if (metadataStructuredOutput !== undefined) {
352
+ return {
353
+ ...(result && typeof result === "object" ? result : {}),
354
+ text: JSON.stringify(metadataStructuredOutput),
355
+ };
356
+ }
357
+
358
+ return {
359
+ ...(result && typeof result === "object" ? result : {}),
360
+ text: JSON.stringify(parseStructuredOutputText(result?.text)),
361
+ };
362
+ }
363
+
364
+ export function toOpenAiChatCompletion(result, { model }) {
365
+ const text = typeof result?.text === "string" ? result.text : "";
366
+ return {
367
+ id: `chatcmpl-${randomUUID()}`,
368
+ object: "chat.completion",
369
+ created: Math.floor(Date.now() / 1000),
370
+ model,
371
+ choices: [
372
+ {
373
+ index: 0,
374
+ message: {
375
+ role: "assistant",
376
+ content: text,
377
+ },
378
+ finish_reason: "stop",
379
+ },
380
+ ],
381
+ usage: normalizeUsage(result?.usage),
382
+ };
383
+ }