@tea-agent/loop-agent 0.24.10 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/executors/dag-pi-executor.js +74 -13
  3. package/dist/executors/pi-executor.js +25 -7
  4. package/dist/executors/pi-prompt-transport.js +198 -0
  5. package/dist/executors/pi-sdk-executor.js +1 -1
  6. package/dist/executors/process-tree.js +33 -0
  7. package/dist/executors/shell-executor.js +239 -47
  8. package/dist/executors/shell-verification.js +4 -2
  9. package/dist/infrastructure/harness/task-store.js +31 -0
  10. package/dist/shared/operator/capabilities.js +6 -0
  11. package/dist/verification/maven/cache.js +142 -0
  12. package/dist/verification/maven/index.js +120 -0
  13. package/dist/verification/maven/plan-commands.js +421 -0
  14. package/dist/verification/maven/pom-static.js +136 -0
  15. package/dist/verification/maven/scope-resolve.js +153 -0
  16. package/dist/verification/maven/stale.js +130 -0
  17. package/dist/verification/maven/types.js +23 -0
  18. package/dist/verification/maven/workspace-graph.js +322 -0
  19. package/dist/worker/cli.js +177 -7
  20. package/dist/worker/delivery/final-verification.js +12 -0
  21. package/dist/worker/feature/advance.js +301 -0
  22. package/dist/worker/feature/doctor.js +223 -0
  23. package/dist/worker/feature/next-action.js +11 -3
  24. package/dist/worker/feature/scaffold.js +798 -0
  25. package/dist/workflows/dag/backend-test-markdown-workflow.js +127 -0
  26. package/dist/workflows/dag/failure-category.js +5 -1
  27. package/dist/workflows/dag/frontend-implementation-contract.js +4 -0
  28. package/dist/workflows/dag/frontend-prewrite-gate.js +0 -17
  29. package/dist/workflows/dag/init-hybrid.js +208 -19
  30. package/dist/workflows/dag/reconcile-run.js +24 -0
  31. package/dist/workflows/dag/types.js +44 -0
  32. package/dist/workflows/dag/validate.js +16 -0
  33. package/docs/architecture/dag-execution.md +5 -1
  34. package/docs/architecture/runtime-boundaries.md +11 -1
  35. package/docs/architecture/worker-and-feature.md +13 -0
  36. package/docs/init-surface.manifest.json +3 -0
  37. package/docs/templates/agent-dag.schema.json +9 -0
  38. package/docs/templates/backend-test-dag.json +3 -3
  39. package/docs/templates/frontend-implementation-contract.schema.json +2 -2
  40. package/docs/templates/product-line/README.md +17 -1
  41. package/docs/templates/product-line/feature-scaffold-batch.example.yaml +31 -0
  42. package/docs/templates/product-line/scaffold-samples/README.md +36 -0
  43. package/docs/templates/product-line/scaffold-samples/backend-only/acceptance.yaml +13 -0
  44. package/docs/templates/product-line/scaffold-samples/backend-only/design.md +14 -0
  45. package/docs/templates/product-line/scaffold-samples/backend-only/feature.yaml +3 -0
  46. package/docs/templates/product-line/scaffold-samples/backend-only/requirement.md +6 -0
  47. package/docs/templates/product-line/scaffold-samples/backend-only/tasks/BE-IMPL-001.yaml +83 -0
  48. package/docs/templates/product-line/scaffold-samples/backend-only/tasks/task-graph.yaml +14 -0
  49. package/docs/templates/product-line/scaffold-samples/fe-with-api/acceptance.yaml +15 -0
  50. package/docs/templates/product-line/scaffold-samples/fe-with-api/design.md +18 -0
  51. package/docs/templates/product-line/scaffold-samples/fe-with-api/feature.yaml +3 -0
  52. package/docs/templates/product-line/scaffold-samples/fe-with-api/requirement.md +6 -0
  53. package/docs/templates/product-line/scaffold-samples/fe-with-api/tasks/BE-IMPL-001.yaml +84 -0
  54. package/docs/templates/product-line/scaffold-samples/fe-with-api/tasks/CONTRACT-001.yaml +84 -0
  55. package/docs/templates/product-line/scaffold-samples/fe-with-api/tasks/FE-IMPL-001.yaml +86 -0
  56. package/docs/templates/product-line/scaffold-samples/fe-with-api/tasks/task-graph.yaml +40 -0
  57. package/docs/templates/product-line/scaffold-samples/frontend-only/acceptance.yaml +13 -0
  58. package/docs/templates/product-line/scaffold-samples/frontend-only/design.md +14 -0
  59. package/docs/templates/product-line/scaffold-samples/frontend-only/feature.yaml +3 -0
  60. package/docs/templates/product-line/scaffold-samples/frontend-only/requirement.md +6 -0
  61. package/docs/templates/product-line/scaffold-samples/frontend-only/tasks/FE-IMPL-001.yaml +85 -0
  62. package/docs/templates/product-line/scaffold-samples/frontend-only/tasks/task-graph.yaml +14 -0
  63. package/examples/l5-report-coms-process-definition.html +322 -0
  64. package/package.json +1 -1
  65. package/skills/agent-worker/references/agent-worker-operator.md +84 -0
  66. package/skills/frontend-bounded-implement/SKILL.md +53 -0
  67. package/skills/frontend-implementation/SKILL.md +3 -7
  68. package/skills/frontend-implementation/references/node-contracts.md +3 -3
  69. package/skills/loop-agent/SKILL.md +8 -8
  70. package/skills/loop-agent/references/command-reference.md +14 -2
  71. package/skills/loop-agent/references/harness-policy.md +22 -0
  72. package/skills/loop-agent/references/task-workflow.md +5 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,39 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.25.0] - 2026-07-30
6
+
7
+ ### 重点更新
8
+
9
+ - 新增确定性 Feature Packet 脚手架,支持后端、前端及含 API 前端模板,并具备原子批量写入与预检能力
10
+ - 新增 Feature 交付编排命令,自动串联最终验证、交付与收口流程
11
+ - 显著提升 DAG 执行稳定性,修复超时残留子进程、重复验证及耗时记录不完整等问题
12
+ - 新增任务级 Maven 验证规划,自动定位并仅针对相关模块执行验证
13
+ - 后端测试节点现可自动生成指标机器计算的自包含 L-5 可视化报告
14
+
15
+ ### 新增
16
+
17
+ - 提供 agent-worker Feature Packet 脚手架,内置 backend-only、frontend-only 和 fe-with-api 模板,支持原子批量写入与 validate-feature 预检
18
+ - 新增 agent-worker feature advance 命令,编排 verify-final、delivery 到 closeout 的完整交付路径
19
+ - 新增 agent-worker feature doctor 命令,提供只读的交付就绪诊断(覆盖证据、Pool 状态与工作区改动)
20
+ - Maven 验证现支持任务级范围规划,根据任务涉及的目录自动定位并验证正确的 POM 模块
21
+ - 后端测试 DAG 节点 7 现可确定性生成美观的自包含 L-5 可视化报告,指标完全由机器计算
22
+
23
+ ### 改进
24
+
25
+ - Feature Delivery 在省略特定参数时,默认使用 feature verify-final 写入的标准证据路径
26
+ - TaskConfig 持久化入口在写入前执行 schema 校验,拒绝不符合规范的数据格式
27
+ - Dogfood 前端测试服务器在检测到旧缓存导致接口数据异常时,现会自动强制重启恢复
28
+
29
+ ### 修复
30
+
31
+ - 显著加固 DAG 执行可靠性,修复超时或中断后子进程残留问题,降低文件占用与测试干扰概率
32
+ - 修复受监督流程可能重复执行完整验证的问题,避免高成本检查在不同阶段反复运行
33
+ - 修复 DAG 被放弃或替换时,运行中节点实际耗时记录不完整的问题
34
+ - 修复 Windows 环境下因提示内容过长导致 DAG 无法启动的问题,现安全采用受控临时文件传输
35
+ - 修复前端实现 DAG 在未确认项目 Mock 能力时误报缺失 Mock 文件的问题
36
+ - 修复结构化任务配置中数组类型校验不严格的问题
37
+
5
38
  ## [0.24.10] - 2026-07-29
6
39
 
7
40
  ### 修复
@@ -53,6 +86,8 @@
53
86
 
54
87
  ### 修复
55
88
 
89
+ - 修复前端证据校验在 Windows 下通过长 `node -e` 命令执行时的终端转义崩溃:改为由 runtime 内部直接校验,兼容 CMD、Git Bash、PowerShell 与 Unix shell
90
+ - 修复 `frontend-implement-pi` 因复用需求发现 skill 而长时间侦查后空实现:contract 现在强制 requirement outcome 与 interaction 行为语义,implement/repair 改用不含递归 OpenSpec/AI workspace 搜索的 bounded skill,并由 `implementation-outcome-v1` 机器校验声明结果与实际 diff;只有显式 already-satisfied 才允许空 diff
56
91
  - Outcome 投影强制写出 repo-relative artifact 路径
57
92
  - Delivery `verifyArtifactRefs` 接受位于仓库内的绝对路径(兼容旧 envelope)
58
93
  - `verifyBundleTaskSpecBindings` 接受 `qa-testcode`(backend-test)与 `qa-execute`(frontend-test)
@@ -130,17 +130,23 @@ export function buildDagPiUserMessage(task, persona, step) {
130
130
  const role = task.role ?? "unspecified";
131
131
  const writePolicy = task.writePolicy ?? "read-only (default)";
132
132
  if (isDagPiWriteTask(task)) {
133
+ const outcomeInstruction = task.writerOutcomePolicy
134
+ ? "The first non-empty response line must be exactly IMPLEMENTATION_OUTCOME: changed, IMPLEMENTATION_OUTCOME: already-satisfied, or IMPLEMENTATION_OUTCOME: blocked. Use changed only after producing a non-empty bounded diff; use already-satisfied only when the contract is already met and no file changed; use blocked when implementation cannot proceed."
135
+ : undefined;
133
136
  return [
134
137
  `You are executing hybrid DAG node "${task.id}" (role=${role}, piStep=${step}, writePolicy=${writePolicy}).`,
135
138
  "The system prompt contains the full DAG envelope: objective, constraints, upstream context, and task.",
136
139
  "Use write tools only for the bounded implementation requested by this node.",
140
+ outcomeInstruction,
137
141
  `Allowed paths: ${(task.allowedPaths ?? []).join(", ") || "(none declared)"}.`,
138
142
  `Write set: ${(task.writeSet ?? []).join(", ") || "(none declared)"}.`,
139
143
  `Forbidden paths: ${(task.forbiddenPaths ?? []).join(", ") || "(none declared)"}.`,
140
144
  "Preserve unrelated files. Do not edit outside allowedPaths/writeSet. Never write forbiddenPaths, .harness/dag-runs/**, or undeclared root artifacts/**.",
141
145
  "Return concise Markdown with changed files, verification performed or still required, and residual risks.",
142
146
  "Do not wrap the output in code fences and do not add conversational preamble.",
143
- ].join(" ");
147
+ ]
148
+ .filter((value) => Boolean(value))
149
+ .join(" ");
144
150
  }
145
151
  // Decision Gate nodes must emit a mandatory DECISION_ENVELOPE_JSON fenced block;
146
152
  // do not inject the global no-fence instruction that would conflict with outputContract.
@@ -344,21 +350,39 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep) {
344
350
  ];
345
351
  }
346
352
  }
347
- if (writeGuardOk) {
348
- if (beforeStatus !== undefined && changeManifestChangedFiles !== undefined) {
349
- await persistWriterChangeManifest({
350
- runDir: meta.runDir,
351
- nodeId: input.task.id,
352
- writerNodeId: input.task.id,
353
- changedFiles: changeManifestChangedFiles,
354
- beforeStatus,
355
- afterStatus: changeManifestAfterStatus ?? "",
356
- });
353
+ let writerOutcomeViolation;
354
+ if (mapped.ok && input.task.writerOutcomePolicy) {
355
+ if (changeManifestChangedFiles === undefined) {
356
+ writerOutcomeViolation =
357
+ "writer outcome validation failed: actual diff is unavailable";
358
+ }
359
+ else {
360
+ const outcomeValidation = validateWriterImplementationOutcome(mapped.assistantText || mapped.stdout, changeManifestChangedFiles);
361
+ if (!outcomeValidation.ok) {
362
+ writerOutcomeViolation = outcomeValidation.reason;
363
+ }
357
364
  }
365
+ }
366
+ if (writeGuardOk && beforeStatus !== undefined && changeManifestChangedFiles !== undefined) {
367
+ await persistWriterChangeManifest({
368
+ runDir: meta.runDir,
369
+ nodeId: input.task.id,
370
+ writerNodeId: input.task.id,
371
+ changedFiles: changeManifestChangedFiles,
372
+ beforeStatus,
373
+ afterStatus: changeManifestAfterStatus ?? "",
374
+ });
375
+ }
376
+ if (writeGuardOk && !writerOutcomeViolation) {
358
377
  return mapped;
359
378
  }
360
379
  const stderrParts = [mapped.stderr];
361
- stderrParts.push(`write guard failed: ${writeGuardViolations.join(", ")}`);
380
+ if (!writeGuardOk) {
381
+ stderrParts.push(`write guard failed: ${writeGuardViolations.join(", ")}`);
382
+ }
383
+ if (writerOutcomeViolation) {
384
+ stderrParts.push(writerOutcomeViolation);
385
+ }
362
386
  if (meta.writeGuardAttribution === "best-effort") {
363
387
  stderrParts.push("write guard note: concurrent rank writers use best-effort per-node attribution; keep same-rank writeSet entries disjoint");
364
388
  }
@@ -366,10 +390,47 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep) {
366
390
  ...mapped,
367
391
  ok: false,
368
392
  stderr: stderrParts.filter(Boolean).join("\n\n"),
369
- failureCategory: mapped.ok ? "write-guard" : mapped.failureCategory,
393
+ failureCategory: mapped.ok
394
+ ? writeGuardOk
395
+ ? "invalid-output"
396
+ : "write-guard"
397
+ : mapped.failureCategory,
370
398
  durationMs: mapped.durationMs || Date.now() - started,
371
399
  };
372
400
  }
401
+ export function validateWriterImplementationOutcome(text, changedFiles) {
402
+ const firstLine = text
403
+ .split(/\r?\n/)
404
+ .map((line) => line.trim())
405
+ .find(Boolean);
406
+ const match = firstLine?.match(/^IMPLEMENTATION_OUTCOME: (changed|already-satisfied|blocked)$/);
407
+ if (!match) {
408
+ return {
409
+ ok: false,
410
+ reason: "writer outcome validation failed: missing or invalid first line; expected IMPLEMENTATION_OUTCOME: changed, IMPLEMENTATION_OUTCOME: already-satisfied, or IMPLEMENTATION_OUTCOME: blocked",
411
+ };
412
+ }
413
+ const outcome = match[1];
414
+ if (outcome === "blocked") {
415
+ return {
416
+ ok: false,
417
+ reason: "writer outcome validation failed: IMPLEMENTATION_OUTCOME: blocked cannot complete successfully",
418
+ };
419
+ }
420
+ if (outcome === "changed" && changedFiles.length === 0) {
421
+ return {
422
+ ok: false,
423
+ reason: "writer outcome validation failed: changed outcome has an empty diff",
424
+ };
425
+ }
426
+ if (outcome === "already-satisfied" && changedFiles.length > 0) {
427
+ return {
428
+ ok: false,
429
+ reason: "writer outcome validation failed: already-satisfied outcome has a non-empty diff",
430
+ };
431
+ }
432
+ return { ok: true, outcome };
433
+ }
373
434
  export function mapPiResultToDagNodeResult(result, firstProtocolLine) {
374
435
  const assistantText = canonicalizeProtocolFirstLine(result.assistantText, firstProtocolLine);
375
436
  return {
@@ -3,6 +3,8 @@ import { existsSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { PI_REUSE_RUNTIME_ENV, isPiReuseRuntimeEffective, resolvePiReuseRuntimeMode as resolveConfiguredPiReuseRuntimeMode, } from './pi-runtime-reuse.js';
6
+ import { cleanupPiPromptTransport, createPiPromptTransport, } from './pi-prompt-transport.js';
7
+ import { processTreeSpawnOptions, terminateProcessTree } from './process-tree.js';
6
8
  export function extractSubagentStats(stdout) {
7
9
  let totalCalls = 0;
8
10
  let failedCalls = 0;
@@ -507,12 +509,24 @@ function withCliBackend(result) {
507
509
  }
508
510
  /** CLI-only orchestration: primary attempt + optional fallback model retry. */
509
511
  async function runCliOrchestration(options, meta) {
512
+ const promptTransport = createPiPromptTransport(options.prompt);
513
+ try {
514
+ return await runCliOrchestrationWithPromptFile(options, promptTransport.filePath, meta);
515
+ }
516
+ finally {
517
+ const cleanup = cleanupPiPromptTransport(promptTransport);
518
+ for (const warning of cleanup.warnings) {
519
+ process.stderr.write(`${warning}\n`);
520
+ }
521
+ }
522
+ }
523
+ async function runCliOrchestrationWithPromptFile(options, promptFilePath, meta) {
510
524
  const primary = options.modelConfig ?? {};
511
525
  const attemptedModels = [];
512
526
  if (meta?.prependAttemptedModel) {
513
527
  attemptedModels.push(meta.prependAttemptedModel);
514
528
  }
515
- const primaryResult = await executeSingleCliAttempt(options, primary);
529
+ const primaryResult = await executeSingleCliAttempt(options, primary, promptFilePath);
516
530
  attemptedModels.push(primaryResult.modelDisplay);
517
531
  let primaryIssues = [];
518
532
  if (primaryResult.ok && options.validateOutput) {
@@ -525,7 +539,7 @@ async function runCliOrchestration(options, meta) {
525
539
  && shouldRetryWithFallback(primaryResult.stderr, primaryResult.stdout))
526
540
  || primaryInvalidOutput);
527
541
  if (shouldFallback) {
528
- const fallbackResult = await executeSingleCliAttempt(options, primary.fallback);
542
+ const fallbackResult = await executeSingleCliAttempt(options, primary.fallback, promptFilePath);
529
543
  attemptedModels.push(fallbackResult.modelDisplay);
530
544
  const combinedStderr = [primaryResult.stderr, fallbackResult.stderr].filter(Boolean).join('\n--- fallback ---\n');
531
545
  const combinedStdout = [primaryResult.stdout, fallbackResult.stdout].filter(Boolean).join('\n--- fallback ---\n');
@@ -659,7 +673,7 @@ export async function executePiStep(options) {
659
673
  });
660
674
  return { ...cliResult, reuseRuntimeActive };
661
675
  }
662
- async function executeSingleCliAttempt(options, modelConfig) {
676
+ async function executeSingleCliAttempt(options, modelConfig, promptFilePath) {
663
677
  const modelDisplay = modelConfig.provider && modelConfig.model
664
678
  ? `${modelConfig.provider}/${modelConfig.model}`
665
679
  : modelConfig.model ?? 'default';
@@ -687,13 +701,17 @@ async function executeSingleCliAttempt(options, modelConfig) {
687
701
  piArgs.push('--thinking', modelConfig.thinking);
688
702
  }
689
703
  piArgs.push('--tools', options.toolNames.join(','));
690
- piArgs.push('--append-system-prompt', options.prompt);
704
+ piArgs.push('--append-system-prompt', promptFilePath);
691
705
  piArgs.push(...options.attachedFiles.map((file) => `@${file}`));
692
706
  piArgs.push(options.userMessage);
707
+ const recordedPiArgs = piArgs.map((arg, index) => index > 0 && piArgs[index - 1] === '--append-system-prompt'
708
+ ? '<system-prompt-file>'
709
+ : arg);
693
710
  const startedAt = Date.now();
694
711
  return await new Promise((resolve, reject) => {
695
712
  const child = spawn(process.execPath, [getPiCliPath(), ...piArgs], {
696
713
  cwd: options.repoRoot,
714
+ ...processTreeSpawnOptions(),
697
715
  env: {
698
716
  ...process.env,
699
717
  // Explicitly propagate API keys that pi needs to recognise providers.
@@ -748,7 +766,7 @@ async function executeSingleCliAttempt(options, modelConfig) {
748
766
  });
749
767
  resolve({
750
768
  assistantText: collected.assistantText,
751
- command: ['pi', ...piArgs],
769
+ command: ['pi', ...recordedPiArgs],
752
770
  durationMs,
753
771
  exitCode: code,
754
772
  failureCategory,
@@ -771,7 +789,7 @@ async function executeSingleCliAttempt(options, modelConfig) {
771
789
  return;
772
790
  timedOut = true;
773
791
  supervisionStderr = reason;
774
- child.kill('SIGTERM');
792
+ terminateProcessTree(child, 'SIGTERM');
775
793
  sigkillHandle = setTimeout(() => {
776
794
  if (settled)
777
795
  return;
@@ -779,7 +797,7 @@ async function executeSingleCliAttempt(options, modelConfig) {
779
797
  finish(child.exitCode);
780
798
  return;
781
799
  }
782
- child.kill('SIGKILL');
800
+ terminateProcessTree(child, 'SIGKILL');
783
801
  exitConfirmationHandle = setTimeout(() => {
784
802
  if (settled)
785
803
  return;
@@ -0,0 +1,198 @@
1
+ import { closeSync, fstatSync, fsyncSync, ftruncateSync, lstatSync, mkdtempSync, openSync, realpathSync, rmdirSync, unlinkSync, writeFileSync, } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ const PROMPT_DIRECTORY_PREFIX = "loop-agent-pi-prompt-";
5
+ const PROMPT_FILE_NAME = "system-prompt.md";
6
+ function identityOf(stats) {
7
+ return { dev: stats.dev, ino: stats.ino };
8
+ }
9
+ function hasIdentity(stats, identity) {
10
+ return stats.dev === identity.dev && stats.ino === identity.ino;
11
+ }
12
+ function normalizedPath(value) {
13
+ const resolved = path.resolve(value);
14
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
15
+ }
16
+ function assertOwnedPromptPaths(transport) {
17
+ const tempRoot = normalizedPath(transport.tempRoot);
18
+ const directoryPath = normalizedPath(transport.directoryPath);
19
+ const filePath = normalizedPath(transport.filePath);
20
+ const tempRootStats = lstatSync(transport.tempRoot, { bigint: true });
21
+ if (!tempRootStats.isDirectory() ||
22
+ tempRootStats.isSymbolicLink() ||
23
+ !hasIdentity(tempRootStats, transport.tempRootIdentity)) {
24
+ throw new Error("Pi prompt cleanup refused: temporary root identity changed");
25
+ }
26
+ if (normalizedPath(path.dirname(transport.directoryPath)) !== tempRoot) {
27
+ throw new Error("Pi prompt cleanup refused: temporary directory escaped its recorded root");
28
+ }
29
+ if (!path.basename(directoryPath).startsWith(PROMPT_DIRECTORY_PREFIX)) {
30
+ throw new Error("Pi prompt cleanup refused: unexpected temporary directory name");
31
+ }
32
+ if (normalizedPath(path.dirname(transport.filePath)) !== directoryPath) {
33
+ throw new Error("Pi prompt cleanup refused: prompt file escaped its recorded directory");
34
+ }
35
+ if (path.basename(filePath) !== PROMPT_FILE_NAME) {
36
+ throw new Error("Pi prompt cleanup refused: unexpected prompt file name");
37
+ }
38
+ }
39
+ export function createPiPromptTransport(prompt) {
40
+ const tempRoot = realpathSync.native(tmpdir());
41
+ const tempRootStats = lstatSync(tempRoot, { bigint: true });
42
+ if (!tempRootStats.isDirectory() || tempRootStats.isSymbolicLink()) {
43
+ throw new Error("Pi prompt transport requires a real temporary directory");
44
+ }
45
+ const tempRootIdentity = identityOf(tempRootStats);
46
+ const directoryPath = mkdtempSync(path.join(tempRoot, PROMPT_DIRECTORY_PREFIX));
47
+ const currentTempRootStats = lstatSync(tempRoot, { bigint: true });
48
+ if (!hasIdentity(currentTempRootStats, tempRootIdentity)) {
49
+ throw new Error("Pi prompt transport temporary root changed during creation");
50
+ }
51
+ const directoryStats = lstatSync(directoryPath, { bigint: true });
52
+ if (!directoryStats.isDirectory() || directoryStats.isSymbolicLink()) {
53
+ throw new Error("Pi prompt transport did not create a private directory");
54
+ }
55
+ const filePath = path.join(directoryPath, PROMPT_FILE_NAME);
56
+ let fileDescriptor;
57
+ let fileIdentity;
58
+ try {
59
+ fileDescriptor = openSync(filePath, "wx", 0o600);
60
+ fileIdentity = identityOf(fstatSync(fileDescriptor, { bigint: true }));
61
+ writeFileSync(fileDescriptor, prompt, { encoding: "utf-8" });
62
+ fsyncSync(fileDescriptor);
63
+ return {
64
+ directoryPath,
65
+ filePath,
66
+ fileDescriptor,
67
+ tempRoot,
68
+ tempRootIdentity,
69
+ directoryIdentity: identityOf(directoryStats),
70
+ fileIdentity,
71
+ };
72
+ }
73
+ catch (error) {
74
+ if (fileDescriptor !== undefined && fileIdentity !== undefined) {
75
+ try {
76
+ cleanupPiPromptTransport({
77
+ directoryPath,
78
+ filePath,
79
+ fileDescriptor,
80
+ tempRoot,
81
+ tempRootIdentity,
82
+ directoryIdentity: identityOf(directoryStats),
83
+ fileIdentity,
84
+ });
85
+ }
86
+ catch {
87
+ // Creation failed; the original error remains authoritative.
88
+ }
89
+ }
90
+ else {
91
+ try {
92
+ if (fileDescriptor !== undefined)
93
+ closeSync(fileDescriptor);
94
+ }
95
+ catch {
96
+ // Best effort before rethrowing the creation error.
97
+ }
98
+ try {
99
+ const currentDirectory = lstatSync(directoryPath, { bigint: true });
100
+ if (hasIdentity(currentDirectory, identityOf(directoryStats))) {
101
+ rmdirSync(directoryPath);
102
+ }
103
+ }
104
+ catch {
105
+ // Never recursively remove a transport directory.
106
+ }
107
+ }
108
+ throw error;
109
+ }
110
+ }
111
+ /**
112
+ * Clear and remove only the exact prompt file created by this transport.
113
+ * The directory is removed only when it is still the same empty directory.
114
+ */
115
+ export function cleanupPiPromptTransport(transport) {
116
+ const warnings = [];
117
+ let contentCleared = false;
118
+ let fileRemoved = false;
119
+ let directoryRemoved = false;
120
+ let truncateError;
121
+ try {
122
+ ftruncateSync(transport.fileDescriptor, 0);
123
+ fsyncSync(transport.fileDescriptor);
124
+ contentCleared = true;
125
+ }
126
+ catch (error) {
127
+ truncateError = error;
128
+ }
129
+ finally {
130
+ try {
131
+ closeSync(transport.fileDescriptor);
132
+ }
133
+ catch {
134
+ // A closed descriptor is acceptable; identity checks still guard deletion.
135
+ }
136
+ }
137
+ let pathsOwned = false;
138
+ try {
139
+ assertOwnedPromptPaths(transport);
140
+ const directoryStats = lstatSync(transport.directoryPath, { bigint: true });
141
+ const fileStats = lstatSync(transport.filePath, { bigint: true });
142
+ pathsOwned =
143
+ directoryStats.isDirectory() &&
144
+ !directoryStats.isSymbolicLink() &&
145
+ hasIdentity(directoryStats, transport.directoryIdentity) &&
146
+ fileStats.isFile() &&
147
+ !fileStats.isSymbolicLink() &&
148
+ hasIdentity(fileStats, transport.fileIdentity);
149
+ if (!pathsOwned) {
150
+ warnings.push("Pi prompt cleanup refused deletion because path identity changed");
151
+ }
152
+ }
153
+ catch (error) {
154
+ const code = error.code;
155
+ if (code === "ENOENT") {
156
+ fileRemoved = true;
157
+ }
158
+ else {
159
+ warnings.push(`Pi prompt cleanup could not verify the exact prompt path: ${error instanceof Error ? error.message : String(error)}`);
160
+ }
161
+ }
162
+ if (pathsOwned) {
163
+ try {
164
+ unlinkSync(transport.filePath);
165
+ fileRemoved = true;
166
+ }
167
+ catch (error) {
168
+ warnings.push(`Pi prompt cleanup could not unlink the exact prompt file: ${error instanceof Error ? error.message : String(error)}`);
169
+ }
170
+ }
171
+ if (!contentCleared && !fileRemoved) {
172
+ throw new Error(`Pi prompt cleanup could not prove prompt removal: ${truncateError instanceof Error ? truncateError.message : String(truncateError ?? "unknown truncate failure")}`);
173
+ }
174
+ if (fileRemoved) {
175
+ try {
176
+ const directoryStats = lstatSync(transport.directoryPath, { bigint: true });
177
+ if (directoryStats.isDirectory() &&
178
+ !directoryStats.isSymbolicLink() &&
179
+ hasIdentity(directoryStats, transport.directoryIdentity)) {
180
+ rmdirSync(transport.directoryPath);
181
+ directoryRemoved = true;
182
+ }
183
+ else {
184
+ warnings.push("Pi prompt cleanup refused directory removal because identity changed");
185
+ }
186
+ }
187
+ catch (error) {
188
+ const code = error.code;
189
+ if (code === "ENOENT") {
190
+ directoryRemoved = true;
191
+ }
192
+ else {
193
+ warnings.push(`Pi prompt cleanup left an empty or non-empty transport directory: ${error instanceof Error ? error.message : String(error)}`);
194
+ }
195
+ }
196
+ }
197
+ return { contentCleared, fileRemoved, directoryRemoved, warnings };
198
+ }
@@ -296,7 +296,7 @@ export async function executeSingleSdkAttempt(options) {
296
296
  '--model', modelConfig.model ?? '(default)',
297
297
  ...(modelConfig.thinking ? ['--thinking', modelConfig.thinking] : []),
298
298
  '--tools', options.toolNames.join(','),
299
- '--append-system-prompt', options.prompt,
299
+ '--append-system-prompt', '<in-memory-system-prompt>',
300
300
  ...options.attachedFiles.map((file) => `@${file}`),
301
301
  options.userMessage,
302
302
  ];
@@ -0,0 +1,33 @@
1
+ import { spawnSync } from "node:child_process";
2
+ /** Terminate a command and all descendants that it started. */
3
+ export function terminateProcessTree(child, signal, dependencies = {}) {
4
+ if (child.exitCode !== null && child.exitCode !== undefined)
5
+ return true;
6
+ const pid = child.pid;
7
+ if (!pid)
8
+ return child.kill(signal);
9
+ const platform = dependencies.platform ?? process.platform;
10
+ if (platform === "win32") {
11
+ const runTaskkill = dependencies.runTaskkill ?? ((targetPid) => {
12
+ const result = spawnSync("taskkill.exe", ["/pid", String(targetPid), "/t", "/f"], { stdio: "ignore", windowsHide: true });
13
+ return !result.error && result.status === 0;
14
+ });
15
+ return runTaskkill(pid) || child.kill(signal);
16
+ }
17
+ try {
18
+ const killProcessGroup = dependencies.killProcessGroup ?? ((targetPid, targetSignal) => {
19
+ process.kill(-targetPid, targetSignal);
20
+ });
21
+ killProcessGroup(pid, signal);
22
+ return true;
23
+ }
24
+ catch {
25
+ return child.kill(signal);
26
+ }
27
+ }
28
+ export function processTreeSpawnOptions() {
29
+ return {
30
+ detached: process.platform !== "win32",
31
+ windowsHide: true,
32
+ };
33
+ }