agiagent-dev 2026.1.39 → 2026.1.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,266 @@
1
+ import { spawn } from "node:child_process";
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ function truncateText(s, max = 180) {
8
+ const t = String(s ?? "");
9
+ if (t.length <= max) {
10
+ return t;
11
+ }
12
+ return `${t.slice(0, max - 3)}...`;
13
+ }
14
+ async function fileExists(p) {
15
+ try {
16
+ const st = await fs.stat(p);
17
+ return st.isFile();
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ }
23
+ function normalizeOsPlatform(platform) {
24
+ const p = platform.trim().toLowerCase();
25
+ return p.startsWith("win") ? "win32" : p;
26
+ }
27
+ async function findBundledScript(scriptBasename) {
28
+ const here = path.dirname(fileURLToPath(import.meta.url));
29
+ // dist/node-host/* -> walk up to find package root containing skills/resume-docx/scripts
30
+ let cur = here;
31
+ for (let i = 0; i < 8; i++) {
32
+ const candidate = path.join(cur, "skills", "resume-docx", "scripts", scriptBasename);
33
+ if (await fileExists(candidate)) {
34
+ return candidate;
35
+ }
36
+ const next = path.dirname(cur);
37
+ if (next === cur) {
38
+ break;
39
+ }
40
+ cur = next;
41
+ }
42
+ throw new Error(`Bundled resume-docx script not found: ${scriptBasename}. Please update/reinstall agiagent-dev.`);
43
+ }
44
+ async function runCapture(argv, opts) {
45
+ const cwd = opts?.cwd;
46
+ const timeoutMs = typeof opts?.timeoutMs === "number" ? opts.timeoutMs : undefined;
47
+ return await new Promise((resolve) => {
48
+ let stdout = "";
49
+ let stderr = "";
50
+ let timedOut = false;
51
+ let settled = false;
52
+ let timer;
53
+ let child = null;
54
+ try {
55
+ child = spawn(argv[0], argv.slice(1), {
56
+ cwd,
57
+ stdio: ["ignore", "pipe", "pipe"],
58
+ windowsHide: true,
59
+ });
60
+ }
61
+ catch (err) {
62
+ resolve({
63
+ exitCode: null,
64
+ stdout: "",
65
+ stderr: "",
66
+ error: err instanceof Error ? err.message : String(err),
67
+ timedOut: false,
68
+ });
69
+ return;
70
+ }
71
+ const finalize = (exitCode, error) => {
72
+ if (settled) {
73
+ return;
74
+ }
75
+ settled = true;
76
+ if (timer) {
77
+ clearTimeout(timer);
78
+ }
79
+ resolve({
80
+ exitCode,
81
+ stdout,
82
+ stderr,
83
+ error,
84
+ timedOut,
85
+ });
86
+ };
87
+ child.stdout?.on("data", (chunk) => {
88
+ stdout += String(chunk ?? "");
89
+ });
90
+ child.stderr?.on("data", (chunk) => {
91
+ stderr += String(chunk ?? "");
92
+ });
93
+ if (timeoutMs && timeoutMs > 0) {
94
+ timer = setTimeout(() => {
95
+ timedOut = true;
96
+ try {
97
+ child?.kill("SIGKILL");
98
+ }
99
+ catch {
100
+ // ignore
101
+ }
102
+ }, timeoutMs);
103
+ }
104
+ child.on("error", (err) => finalize(null, err.message));
105
+ child.on("exit", (code) => finalize(code === null ? null : code, null));
106
+ });
107
+ }
108
+ async function resolvePythonCommand() {
109
+ const candidates = [
110
+ { argv: ["python3"], label: "python3" },
111
+ { argv: ["python"], label: "python" },
112
+ ];
113
+ if (normalizeOsPlatform(process.platform) === "win32") {
114
+ candidates.push({ argv: ["py", "-3"], label: "py -3" });
115
+ }
116
+ for (const cand of candidates) {
117
+ const res = await runCapture([...cand.argv, "-c", "import sys; print(sys.version_info[0])"], {
118
+ timeoutMs: 10_000,
119
+ });
120
+ if (res.exitCode === 0 && res.stdout.trim() === "3") {
121
+ return cand;
122
+ }
123
+ }
124
+ throw new Error("Python 3 not found on this device. Install Python 3 and ensure it is on PATH (python3/python or py -3 on Windows).");
125
+ }
126
+ async function ensurePythonDocx(python) {
127
+ const res = await runCapture([...python.argv, "-c", "import docx; print('python-docx OK')"], {
128
+ timeoutMs: 15_000,
129
+ });
130
+ if (res.exitCode === 0) {
131
+ return;
132
+ }
133
+ const platform = normalizeOsPlatform(process.platform);
134
+ const recommended = platform === "win32"
135
+ ? "py -3 -m pip install python-docx"
136
+ : `${python.label.includes("python3") ? "pip3" : "pip"} install python-docx`;
137
+ throw new Error(`Missing dependency: python-docx. Install it on your device, then retry.\nRecommended: ${recommended}`);
138
+ }
139
+ export async function resumeDocxCheck() {
140
+ const python = await resolvePythonCommand();
141
+ await ensurePythonDocx(python);
142
+ return { ok: true, python: python.label };
143
+ }
144
+ export async function resumeDocxExtract(params) {
145
+ const docxPath = String(params.docxPath ?? "").trim();
146
+ if (!docxPath) {
147
+ throw new Error("INVALID_REQUEST: docxPath required");
148
+ }
149
+ if (!docxPath.toLowerCase().endsWith(".docx")) {
150
+ throw new Error(`INVALID_REQUEST: docxPath must be a .docx file: ${docxPath}`);
151
+ }
152
+ const st = await fs.stat(docxPath).catch(() => null);
153
+ if (!st || !st.isFile()) {
154
+ throw new Error(`INVALID_REQUEST: docxPath is not a file: ${docxPath}`);
155
+ }
156
+ const python = await resolvePythonCommand();
157
+ await ensurePythonDocx(python);
158
+ const extractScript = await findBundledScript("resume_docx_extract.py");
159
+ const outPath = path.join(os.tmpdir(), `agiagent-resume-schema-${crypto.randomUUID()}.json`);
160
+ const res = await runCapture([...python.argv, extractScript, docxPath, "--out", outPath], {
161
+ timeoutMs: 120_000,
162
+ });
163
+ if (res.exitCode !== 0) {
164
+ throw new Error(`resume-docx extract failed (exit=${res.exitCode ?? "?"}): ${truncateText((res.stderr || res.stdout || res.error || "").trim(), 500)}`);
165
+ }
166
+ const raw = await fs.readFile(outPath, "utf8");
167
+ await fs.unlink(outPath).catch(() => { });
168
+ const parsed = JSON.parse(raw);
169
+ const mode = params.mode === "full" ? "full" : "digest";
170
+ if (mode === "full") {
171
+ return { ok: true, mode, schema: parsed };
172
+ }
173
+ const headings = Array.isArray(parsed?.headings) ? parsed.headings : [];
174
+ const skillsTable = parsed?.skills_table ?? null;
175
+ const sections = parsed?.sections ?? {};
176
+ const summary = sections?.summary ?? null;
177
+ const experience = sections?.experience ?? null;
178
+ const paragraphs = Array.isArray(parsed?.paragraphs) ? parsed.paragraphs : [];
179
+ const paragraphsSample = paragraphs
180
+ .filter((p) => p && typeof p === "object" && typeof p.text === "string" && p.text.trim())
181
+ .slice(0, 40)
182
+ .map((p) => ({
183
+ paragraph_index: p.paragraph_index,
184
+ text: truncateText(p.text, 220),
185
+ style: typeof p.style === "string" ? p.style : "",
186
+ }));
187
+ const digest = {
188
+ schema_version: parsed?.schema_version ?? 2,
189
+ docx_path: parsed?.docx_path ?? docxPath,
190
+ fingerprint: parsed?.fingerprint ?? null,
191
+ stats: parsed?.stats ?? null,
192
+ skills_table: skillsTable,
193
+ headings,
194
+ sections: {
195
+ summary: summary
196
+ ? {
197
+ heading: summary.heading,
198
+ heading_index: summary.heading_index,
199
+ end_index: summary.end_index,
200
+ bullets: Array.isArray(summary.bullets)
201
+ ? summary.bullets.slice(0, 30).map((b) => ({
202
+ paragraph_index: b.paragraph_index,
203
+ text: truncateText(b.text ?? "", 220),
204
+ isNumbered: Boolean(b.isNumbered),
205
+ }))
206
+ : [],
207
+ }
208
+ : null,
209
+ experience: experience
210
+ ? {
211
+ heading: experience.heading,
212
+ heading_index: experience.heading_index,
213
+ end_index: experience.end_index,
214
+ blocks: Array.isArray(experience.blocks)
215
+ ? experience.blocks.slice(0, 30).map((b) => ({
216
+ id: b.id,
217
+ header: truncateText(b.header ?? "", 220),
218
+ header_index: b.header_index ?? null,
219
+ first_bullet_index: b.first_bullet_index,
220
+ last_bullet_index: b.last_bullet_index,
221
+ bullet_count: b.bullet_count ?? null,
222
+ }))
223
+ : [],
224
+ }
225
+ : null,
226
+ },
227
+ paragraphs_sample: paragraphsSample,
228
+ };
229
+ return { ok: true, mode, schema: digest };
230
+ }
231
+ export async function resumeDocxApply(params) {
232
+ const inPath = String(params.inPath ?? "").trim();
233
+ const outPath = String(params.outPath ?? "").trim();
234
+ if (!inPath || !outPath) {
235
+ throw new Error("INVALID_REQUEST: inPath and outPath required");
236
+ }
237
+ if (!inPath.toLowerCase().endsWith(".docx")) {
238
+ throw new Error(`INVALID_REQUEST: inPath must be a .docx file: ${inPath}`);
239
+ }
240
+ if (!outPath.toLowerCase().endsWith(".docx")) {
241
+ throw new Error(`INVALID_REQUEST: outPath must be a .docx file: ${outPath}`);
242
+ }
243
+ const st = await fs.stat(inPath).catch(() => null);
244
+ if (!st || !st.isFile()) {
245
+ throw new Error(`INVALID_REQUEST: inPath is not a file: ${inPath}`);
246
+ }
247
+ const python = await resolvePythonCommand();
248
+ await ensurePythonDocx(python);
249
+ const applyScript = await findBundledScript("resume_docx_apply.py");
250
+ const patchPath = path.join(os.tmpdir(), `agiagent-resume-patch-${crypto.randomUUID()}.json`);
251
+ await fs.writeFile(patchPath, JSON.stringify(params.patch ?? {}, null, 2), "utf8");
252
+ try {
253
+ const res = await runCapture([...python.argv, applyScript, "--in", inPath, "--patch", patchPath, "--out", outPath], { timeoutMs: 180_000 });
254
+ if (res.exitCode !== 0) {
255
+ throw new Error(`resume-docx apply failed (exit=${res.exitCode ?? "?"}): ${truncateText((res.stderr || res.stdout || res.error || "").trim(), 700)}`);
256
+ }
257
+ }
258
+ finally {
259
+ await fs.unlink(patchPath).catch(() => { });
260
+ }
261
+ const outStat = await fs.stat(outPath).catch(() => null);
262
+ if (!outStat || !outStat.isFile()) {
263
+ throw new Error(`resume-docx apply did not produce output: ${outPath}`);
264
+ }
265
+ return { ok: true, outPath };
266
+ }
@@ -3,6 +3,7 @@ import { spawn } from "node:child_process";
3
3
  import crypto from "node:crypto";
4
4
  import fs from "node:fs";
5
5
  import fsPromises from "node:fs/promises";
6
+ import os from "node:os";
6
7
  import path from "node:path";
7
8
  import { fileURLToPath } from "node:url";
8
9
  import { resolveAgentConfig } from "../agents/agent-scope.js";
@@ -21,6 +22,7 @@ import { detectMime } from "../media/mime.js";
21
22
  import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
22
23
  import { VERSION } from "../version.js";
23
24
  import { ensureNodeHostConfig, saveNodeHostConfig } from "./config.js";
25
+ import { resumeDocxApply, resumeDocxCheck, resumeDocxExtract } from "./resume-docx.js";
24
26
  function resolveExecSecurity(value) {
25
27
  return value === "deny" || value === "allowlist" || value === "full" ? value : "allowlist";
26
28
  }
@@ -289,69 +291,150 @@ async function runCommand(argv, cwd, env, timeoutMs) {
289
291
  let truncated = false;
290
292
  let timedOut = false;
291
293
  let settled = false;
292
- const child = spawn(argv[0], argv.slice(1), {
293
- cwd,
294
- env,
295
- stdio: ["ignore", "pipe", "pipe"],
296
- windowsHide: true,
297
- });
298
- const onChunk = (chunk, target) => {
299
- if (outputLen >= OUTPUT_CAP) {
300
- truncated = true;
301
- return;
302
- }
303
- const remaining = OUTPUT_CAP - outputLen;
304
- const slice = chunk.length > remaining ? chunk.subarray(0, remaining) : chunk;
305
- const str = slice.toString("utf8");
306
- outputLen += slice.length;
307
- if (target === "stdout") {
308
- stdout += str;
294
+ const WINDOWS_INLINE_CMD_SOFT_LIMIT = 7000;
295
+ const POSIX_INLINE_CMD_SOFT_LIMIT = 50_000;
296
+ const platform = process.platform;
297
+ let cleanupTempFile = null;
298
+ const materializeShellIfNeeded = async () => {
299
+ if (!Array.isArray(argv) || argv.length < 2) {
300
+ return argv;
309
301
  }
310
- else {
311
- stderr += str;
302
+ const a0 = String(argv[0] ?? "").toLowerCase();
303
+ if (platform === "win32") {
304
+ const idx = argv.findIndex((a) => String(a).toLowerCase() === "-command");
305
+ const cmd = idx >= 0 ? String(argv[idx + 1] ?? "") : "";
306
+ if (a0.includes("powershell") && idx >= 0 && cmd.length > WINDOWS_INLINE_CMD_SOFT_LIMIT) {
307
+ const tmp = path.join(os.tmpdir(), `agiagent-cmd-${crypto.randomUUID()}.ps1`);
308
+ cleanupTempFile = tmp;
309
+ await fsPromises.writeFile(tmp, cmd, "utf8");
310
+ // Keep PowerShell non-interactive and bypass policy for this one run.
311
+ return [
312
+ argv[0],
313
+ "-NoProfile",
314
+ "-NonInteractive",
315
+ "-ExecutionPolicy",
316
+ "Bypass",
317
+ "-File",
318
+ tmp,
319
+ ];
320
+ }
321
+ return argv;
312
322
  }
313
- if (chunk.length > remaining) {
314
- truncated = true;
323
+ // POSIX shell: `/bin/sh -lc "<script>"` can also hit argv limits on some systems.
324
+ const cmd = argv.length >= 3 && argv[1] === "-lc" ? String(argv[2] ?? "") : "";
325
+ if (a0 === "/bin/sh" && argv[1] === "-lc" && cmd.length > POSIX_INLINE_CMD_SOFT_LIMIT) {
326
+ const tmp = path.join(os.tmpdir(), `agiagent-cmd-${crypto.randomUUID()}.sh`);
327
+ cleanupTempFile = tmp;
328
+ // Use a plain sh script; keep it simple and avoid bash-isms.
329
+ await fsPromises.writeFile(tmp, `${cmd}\n`, "utf8");
330
+ return ["/bin/sh", tmp];
315
331
  }
332
+ return argv;
316
333
  };
317
- child.stdout?.on("data", (chunk) => onChunk(chunk, "stdout"));
318
- child.stderr?.on("data", (chunk) => onChunk(chunk, "stderr"));
319
- let timer;
320
- if (timeoutMs && timeoutMs > 0) {
321
- timer = setTimeout(() => {
322
- timedOut = true;
323
- try {
324
- child.kill("SIGKILL");
334
+ let child;
335
+ try {
336
+ // Note: materializeShellIfNeeded() is async but runCommand() is sync inside Promise ctor.
337
+ // We kick off an async IIFE to keep the outer signature stable.
338
+ void (async () => {
339
+ const effectiveArgv = await materializeShellIfNeeded();
340
+ child = spawn(effectiveArgv[0], effectiveArgv.slice(1), {
341
+ cwd,
342
+ env,
343
+ stdio: ["ignore", "pipe", "pipe"],
344
+ windowsHide: true,
345
+ });
346
+ const onChunk = (chunk, target) => {
347
+ if (outputLen >= OUTPUT_CAP) {
348
+ truncated = true;
349
+ return;
350
+ }
351
+ const remaining = OUTPUT_CAP - outputLen;
352
+ const slice = chunk.length > remaining ? chunk.subarray(0, remaining) : chunk;
353
+ const str = slice.toString("utf8");
354
+ outputLen += slice.length;
355
+ if (target === "stdout") {
356
+ stdout += str;
357
+ }
358
+ else {
359
+ stderr += str;
360
+ }
361
+ if (chunk.length > remaining) {
362
+ truncated = true;
363
+ }
364
+ };
365
+ child.stdout?.on("data", (chunk) => onChunk(chunk, "stdout"));
366
+ child.stderr?.on("data", (chunk) => onChunk(chunk, "stderr"));
367
+ let timer;
368
+ if (timeoutMs && timeoutMs > 0) {
369
+ timer = setTimeout(() => {
370
+ timedOut = true;
371
+ try {
372
+ child.kill("SIGKILL");
373
+ }
374
+ catch {
375
+ // ignore
376
+ }
377
+ }, timeoutMs);
325
378
  }
326
- catch {
327
- // ignore
379
+ const finalize = (exitCode, error) => {
380
+ if (settled) {
381
+ return;
382
+ }
383
+ settled = true;
384
+ if (timer) {
385
+ clearTimeout(timer);
386
+ }
387
+ if (cleanupTempFile) {
388
+ void fsPromises.unlink(cleanupTempFile).catch(() => { });
389
+ }
390
+ resolve({
391
+ exitCode,
392
+ timedOut,
393
+ success: exitCode === 0 && !timedOut && !error,
394
+ stdout,
395
+ stderr,
396
+ error: error ?? null,
397
+ truncated,
398
+ });
399
+ };
400
+ child.on("error", (err) => {
401
+ finalize(undefined, err.message);
402
+ });
403
+ child.on("exit", (code) => {
404
+ finalize(code === null ? undefined : code, null);
405
+ });
406
+ })().catch((err) => {
407
+ // Covers failures in materializeShellIfNeeded().
408
+ if (cleanupTempFile) {
409
+ void fsPromises.unlink(cleanupTempFile).catch(() => { });
328
410
  }
329
- }, timeoutMs);
411
+ resolve({
412
+ exitCode: undefined,
413
+ timedOut: false,
414
+ success: false,
415
+ stdout: "",
416
+ stderr: "",
417
+ error: String(err),
418
+ truncated: false,
419
+ });
420
+ });
421
+ return;
330
422
  }
331
- const finalize = (exitCode, error) => {
332
- if (settled) {
333
- return;
334
- }
335
- settled = true;
336
- if (timer) {
337
- clearTimeout(timer);
423
+ catch (err) {
424
+ if (cleanupTempFile) {
425
+ void fsPromises.unlink(cleanupTempFile).catch(() => { });
338
426
  }
339
427
  resolve({
340
- exitCode,
341
- timedOut,
342
- success: exitCode === 0 && !timedOut && !error,
343
- stdout,
344
- stderr,
345
- error: error ?? null,
346
- truncated,
428
+ exitCode: undefined,
429
+ timedOut: false,
430
+ success: false,
431
+ stdout: "",
432
+ stderr: "",
433
+ error: err instanceof Error ? err.message : String(err),
434
+ truncated: false,
347
435
  });
348
- };
349
- child.on("error", (err) => {
350
- finalize(undefined, err.message);
351
- });
352
- child.on("exit", (code) => {
353
- finalize(code === null ? undefined : code, null);
354
- });
436
+ return;
437
+ }
355
438
  });
356
439
  }
357
440
  function resolveEnvPath(env) {
@@ -473,6 +556,9 @@ export async function runNodeHost(opts) {
473
556
  "system.which",
474
557
  "system.execApprovals.get",
475
558
  "system.execApprovals.set",
559
+ "resume.docx.check",
560
+ "resume.docx.extract",
561
+ "resume.docx.apply",
476
562
  "gmail.profile",
477
563
  "gmail.labels.list",
478
564
  "gmail.messages.search",
@@ -481,6 +567,7 @@ export async function runNodeHost(opts) {
481
567
  "gmail.messages.send",
482
568
  "gmail.messages.reply",
483
569
  "gmail.messages.modify",
570
+ "gmail.drafts.create",
484
571
  "gmail.attachments.get",
485
572
  ...(browserProxyEnabled ? ["browser.proxy", "browser.stagehand"] : []),
486
573
  ],
@@ -493,7 +580,13 @@ export async function runNodeHost(opts) {
493
580
  if (evt.event === "node.invoke.request") {
494
581
  const payload = coerceNodeInvokePayload(evt.payload);
495
582
  if (payload) {
496
- void handleInvoke(payload, client, skillBins);
583
+ void handleInvoke(payload, client, skillBins).catch(async (err) => {
584
+ // Ensure we never crash the node host on tool errors.
585
+ await sendInvokeResult(client, payload, {
586
+ ok: false,
587
+ error: { code: "INTERNAL_ERROR", message: String(err) },
588
+ });
589
+ });
497
590
  }
498
591
  return;
499
592
  }
@@ -538,7 +631,64 @@ export async function runNodeHost(opts) {
538
631
  await new Promise(() => { });
539
632
  }
540
633
  async function handleInvoke(frame, client, skillBins) {
634
+ try {
635
+ await handleInvokeInner(frame, client, skillBins);
636
+ }
637
+ catch (err) {
638
+ // Last-resort safety net: never allow tool errors to crash the node host.
639
+ await sendInvokeResult(client, frame, {
640
+ ok: false,
641
+ error: { code: "INTERNAL_ERROR", message: String(err) },
642
+ });
643
+ }
644
+ }
645
+ async function handleInvokeInner(frame, client, skillBins) {
541
646
  const command = String(frame.command ?? "");
647
+ if (command === "resume.docx.check") {
648
+ try {
649
+ // Params are currently unused but keep decoding for forward-compatibility.
650
+ if (frame.paramsJSON) {
651
+ decodeParams(frame.paramsJSON);
652
+ }
653
+ const result = await resumeDocxCheck();
654
+ await sendInvokeResult(client, frame, { ok: true, payloadJSON: JSON.stringify(result) });
655
+ }
656
+ catch (err) {
657
+ await sendInvokeResult(client, frame, {
658
+ ok: false,
659
+ error: { code: "INVALID_REQUEST", message: String(err) },
660
+ });
661
+ }
662
+ return;
663
+ }
664
+ if (command === "resume.docx.extract") {
665
+ try {
666
+ const params = decodeParams(frame.paramsJSON);
667
+ const result = await resumeDocxExtract(params);
668
+ await sendInvokeResult(client, frame, { ok: true, payloadJSON: JSON.stringify(result) });
669
+ }
670
+ catch (err) {
671
+ await sendInvokeResult(client, frame, {
672
+ ok: false,
673
+ error: { code: "INVALID_REQUEST", message: String(err) },
674
+ });
675
+ }
676
+ return;
677
+ }
678
+ if (command === "resume.docx.apply") {
679
+ try {
680
+ const params = decodeParams(frame.paramsJSON);
681
+ const result = await resumeDocxApply(params);
682
+ await sendInvokeResult(client, frame, { ok: true, payloadJSON: JSON.stringify(result) });
683
+ }
684
+ catch (err) {
685
+ await sendInvokeResult(client, frame, {
686
+ ok: false,
687
+ error: { code: "INVALID_REQUEST", message: String(err) },
688
+ });
689
+ }
690
+ return;
691
+ }
542
692
  if (command.startsWith("gmail.")) {
543
693
  try {
544
694
  const result = await handleGmailInvoke(command, frame, client);
@@ -1466,6 +1616,73 @@ async function handleGmailInvoke(command, frame, client) {
1466
1616
  body: JSON.stringify({ raw, threadId }),
1467
1617
  });
1468
1618
  }
1619
+ // Create a Gmail draft. Supports both new drafts and reply-in-thread drafts.
1620
+ // When messageId is provided the draft is threaded as a reply (fetches headers
1621
+ // automatically). Otherwise a standalone draft is created to the given `to`.
1622
+ if (command === "gmail.drafts.create") {
1623
+ const params = decodeParams(frame.paramsJSON);
1624
+ const body = String(params.body ?? "");
1625
+ const attachments = await loadAttachments(params.attachments);
1626
+ // If messageId is provided, thread as a reply (like messages.reply but draft)
1627
+ if (params.messageId) {
1628
+ const messageId = String(params.messageId).trim();
1629
+ const metaUsp = new URLSearchParams();
1630
+ metaUsp.set("format", "metadata");
1631
+ for (const h of ["Message-ID", "References", "Subject", "From", "Reply-To"]) {
1632
+ metaUsp.append("metadataHeaders", h);
1633
+ }
1634
+ const message = (await gmailFetch(token, `/messages/${encodeURIComponent(messageId)}?${metaUsp.toString()}`, { method: "GET" }));
1635
+ const fetchedThreadId = typeof message?.threadId === "string" ? message.threadId.trim() : "";
1636
+ const headers = Array.isArray(message?.payload?.headers) ? message.payload.headers : [];
1637
+ const from = extractEmailAddress(parseHeaderValue(headers, "Reply-To") || parseHeaderValue(headers, "From"));
1638
+ const origSubject = parseHeaderValue(headers, "Subject");
1639
+ const subject = ensureSubjectReply(origSubject);
1640
+ const inReplyTo = parseHeaderValue(headers, "Message-ID");
1641
+ const references = parseHeaderValue(headers, "References");
1642
+ const nextRefs = [references, inReplyTo]
1643
+ .map((v) => String(v ?? "").trim())
1644
+ .filter(Boolean)
1645
+ .join(" ")
1646
+ .trim();
1647
+ const mime = buildMimeMessage({
1648
+ headers: {
1649
+ To: from,
1650
+ Subject: subject,
1651
+ ...(inReplyTo ? { "In-Reply-To": inReplyTo } : {}),
1652
+ ...(nextRefs ? { References: nextRefs } : {}),
1653
+ },
1654
+ textBody: body,
1655
+ attachments,
1656
+ });
1657
+ const raw = base64UrlEncode(Buffer.from(mime, "utf8"));
1658
+ return await gmailFetch(token, "/drafts", {
1659
+ method: "POST",
1660
+ headers: { "content-type": "application/json" },
1661
+ body: JSON.stringify({
1662
+ message: { raw, ...(fetchedThreadId ? { threadId: fetchedThreadId } : {}) },
1663
+ }),
1664
+ });
1665
+ }
1666
+ // New draft (not a reply) — to a specified recipient
1667
+ const toList = Array.isArray(params.to) ? params.to : [String(params.to ?? "")];
1668
+ const to = toList.map((v) => String(v).trim()).filter(Boolean);
1669
+ if (to.length === 0) {
1670
+ throw new Error("INVALID_REQUEST: to required for new draft");
1671
+ }
1672
+ const subject = String(params.subject ?? "").trim();
1673
+ const threadId = params.threadId ? String(params.threadId).trim() : "";
1674
+ const mime = buildMimeMessage({
1675
+ headers: { To: to.join(", "), Subject: subject },
1676
+ textBody: body,
1677
+ attachments,
1678
+ });
1679
+ const raw = base64UrlEncode(Buffer.from(mime, "utf8"));
1680
+ return await gmailFetch(token, "/drafts", {
1681
+ method: "POST",
1682
+ headers: { "content-type": "application/json" },
1683
+ body: JSON.stringify({ message: { raw, ...(threadId ? { threadId } : {}) } }),
1684
+ });
1685
+ }
1469
1686
  throw new Error(`INVALID_REQUEST: unknown gmail command: ${command}`);
1470
1687
  }
1471
1688
  function decodeParams(raw) {
@@ -84,7 +84,7 @@ describe("lobster plugin tool", () => {
84
84
  const res = await tool.execute("call1", {
85
85
  action: "run",
86
86
  pipeline: "noop",
87
- timeoutMs: 1000,
87
+ timeoutMs: 4000,
88
88
  });
89
89
 
90
90
  expect(res.details).toMatchObject({ ok: true, status: "ok" });
@@ -110,7 +110,7 @@ describe("lobster plugin tool", () => {
110
110
  const res = await tool.execute("call-noisy", {
111
111
  action: "run",
112
112
  pipeline: "noop",
113
- timeoutMs: 1000,
113
+ timeoutMs: 4000,
114
114
  });
115
115
 
116
116
  expect(res.details).toMatchObject({ ok: true, status: "ok" });
@@ -200,7 +200,7 @@ describe("lobster plugin tool", () => {
200
200
  const res = await tool.execute("call-plugin-config", {
201
201
  action: "run",
202
202
  pipeline: "noop",
203
- timeoutMs: 1000,
203
+ timeoutMs: 4000,
204
204
  });
205
205
 
206
206
  expect(res.details).toMatchObject({ ok: true, status: "ok" });