@love-moon/conductor-cli 0.2.39 → 0.2.41

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",
@@ -96,7 +98,7 @@ function stripExecutableSuffix(name) {
96
98
  .replace(/\.(cmd|bat|exe)$/i, "");
97
99
  }
98
100
 
99
- function parseCommandParts(commandLine) {
101
+ export function parseCommandParts(commandLine) {
100
102
  const input = String(commandLine || "").trim();
101
103
  if (!input) {
102
104
  return { command: "", args: [], parts: [] };
@@ -105,29 +107,39 @@ function parseCommandParts(commandLine) {
105
107
  const parts = [];
106
108
  let current = "";
107
109
  let quote = "";
108
- let escaping = false;
109
110
  let tokenStarted = false;
110
111
 
111
- for (const char of input) {
112
- if (escaping) {
113
- current += char;
114
- tokenStarted = true;
115
- escaping = false;
116
- continue;
117
- }
112
+ for (let index = 0; index < input.length; index += 1) {
113
+ const char = input[index];
114
+ const nextChar = input[index + 1];
118
115
 
119
- if (char === "\\") {
120
- escaping = true;
116
+ if (quote === "'") {
117
+ if (char === "'") {
118
+ quote = "";
119
+ } else {
120
+ current += char;
121
+ }
121
122
  tokenStarted = true;
122
123
  continue;
123
124
  }
124
125
 
125
- if (quote) {
126
- if (char === quote) {
126
+ if (quote === "\"") {
127
+ if (char === "\"") {
127
128
  quote = "";
128
- } else {
129
- current += char;
129
+ continue;
130
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;
141
+ }
142
+ current += char;
131
143
  tokenStarted = true;
132
144
  continue;
133
145
  }
@@ -138,6 +150,18 @@ function parseCommandParts(commandLine) {
138
150
  continue;
139
151
  }
140
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
+
141
165
  if (/\s/.test(char)) {
142
166
  if (tokenStarted) {
143
167
  parts.push(current);
@@ -151,6 +175,10 @@ function parseCommandParts(commandLine) {
151
175
  tokenStarted = true;
152
176
  }
153
177
 
178
+ if (quote) {
179
+ throw new Error(`Invalid command line: unterminated ${quote === "\"" ? "double" : "single"} quote`);
180
+ }
181
+
154
182
  if (tokenStarted) {
155
183
  parts.push(current);
156
184
  }
@@ -264,6 +292,10 @@ export function isBuiltInRuntimeBackend(backend) {
264
292
  return BUILT_IN_RUNTIME_BACKEND_SET.has(normalizeRuntimeBackendName(backend));
265
293
  }
266
294
 
295
+ export function isCommandOptionalBuiltInRuntimeBackend(backend) {
296
+ return COMMAND_OPTIONAL_BUILT_IN_RUNTIME_BACKEND_SET.has(normalizeRuntimeBackendName(backend));
297
+ }
298
+
267
299
  function readConfigEnvValue(configFilePath, key) {
268
300
  const targetPath =
269
301
  typeof configFilePath === "string" && configFilePath.trim()
@@ -523,6 +555,14 @@ export async function resolveConfiguredRuntimeBackend(backend, allowCliList, opt
523
555
  };
524
556
  }
525
557
 
558
+ if (!hasConfiguredEntry && isCommandOptionalBuiltInRuntimeBackend(resolvedBackend)) {
559
+ return {
560
+ requestedBackend: normalizedBackend,
561
+ runtimeBackend: resolvedBackend,
562
+ commandLine: "",
563
+ };
564
+ }
565
+
526
566
  if (!hasConfiguredEntry && !isBuiltInRuntimeBackend(resolvedBackend) && await isRuntimeSupportedBackend(resolvedBackend, options)) {
527
567
  return {
528
568
  requestedBackend: normalizedBackend,
@@ -586,12 +626,22 @@ export async function listAdvertisedBackends(allowCliList, options = {}) {
586
626
  const externalBackends = discoveredExternalBackends.filter(
587
627
  (backend) => !shadowedExternalBackends.has(backend) && !explicitlyConfiguredBackends.has(backend),
588
628
  );
589
- const supportedBackends = [...new Set([...advertisedConfiguredBackends, ...externalBackends])];
590
629
 
591
630
  for (const backend of externalBackends) {
592
631
  runtimeBackendMap[backend] = backend;
593
632
  }
594
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
+
595
645
  return {
596
646
  configuredBackends: advertisedConfiguredBackends,
597
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
+ }