pi-orchestrator-config 4.2.0 → 4.3.2

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 (100) hide show
  1. package/README.md +76 -67
  2. package/agents/code-reviewer-docs.md +11 -1
  3. package/agents/code-reviewer-guidelines.md +11 -1
  4. package/agents/code-reviewer-quality.md +11 -1
  5. package/agents/code-reviewer-security.md +11 -1
  6. package/agents/code-reviewer-spec.md +11 -1
  7. package/agents/git-expert.md +42 -3
  8. package/agents/github-expert.md +11 -0
  9. package/extensions/acpx-provider/runtime-models.ts +18 -6
  10. package/extensions/cli-provider/agents/claude.ts +3 -2
  11. package/extensions/cli-provider/agents/cursor.ts +3 -5
  12. package/extensions/cli-provider/agents/gemini.ts +3 -2
  13. package/extensions/cli-provider/discover.ts +7 -7
  14. package/extensions/cli-provider/index.ts +5 -0
  15. package/extensions/cli-provider/runtime-models.ts +18 -6
  16. package/extensions/cli-provider/session-reaper.ts +5 -7
  17. package/extensions/cli-provider/sessions.ts +4 -0
  18. package/extensions/cli-provider/shared/discover-cache.ts +17 -176
  19. package/extensions/coms/coms-p2p.ts +1719 -423
  20. package/extensions/coms/coms-shared.ts +261 -39
  21. package/extensions/coms/coms-wrapper.ts +100 -62
  22. package/extensions/coms/index.ts +8 -4
  23. package/extensions/coms/mixed-turn.ts +26 -0
  24. package/extensions/coms/probe-socket.ts +42 -0
  25. package/extensions/coms/themeMap.ts +0 -1
  26. package/extensions/image-gen/image-gen.ts +19 -1
  27. package/extensions/orchestrator/async-agents.ts +563 -102
  28. package/extensions/orchestrator/async-capability.ts +8 -8
  29. package/extensions/orchestrator/cron.ts +15 -9
  30. package/extensions/orchestrator/dreaming.ts +32 -25
  31. package/extensions/orchestrator/enforcement-helpers.ts +459 -17
  32. package/extensions/orchestrator/enforcement-rules.ts +5 -4
  33. package/extensions/orchestrator/enforcement.ts +80 -27
  34. package/extensions/orchestrator/extended-autocomplete.ts +7 -0
  35. package/extensions/orchestrator/git-helpers.ts +2 -2
  36. package/extensions/orchestrator/index.ts +78 -10
  37. package/extensions/orchestrator/markdown-transformer.ts +313 -0
  38. package/extensions/orchestrator/memory-embeddings.ts +36 -34
  39. package/extensions/orchestrator/memory-provenance.ts +29 -25
  40. package/extensions/orchestrator/memory-scoring.ts +23 -18
  41. package/extensions/orchestrator/memory-tools.ts +10 -3
  42. package/extensions/orchestrator/overlay-dashboard.ts +17 -5
  43. package/extensions/orchestrator/parse-model-override.ts +29 -0
  44. package/extensions/orchestrator/pi-config-review-state.ts +57 -33
  45. package/extensions/orchestrator/project-settings.ts +333 -127
  46. package/extensions/orchestrator/resolve-agent-model.ts +24 -13
  47. package/extensions/orchestrator/review-porcelain.ts +4 -0
  48. package/extensions/orchestrator/review-ui.ts +63 -15
  49. package/extensions/orchestrator/rule-placeholders.ts +411 -2
  50. package/extensions/orchestrator/rules.ts +80 -132
  51. package/extensions/orchestrator/settings-tui-helpers.ts +380 -0
  52. package/extensions/orchestrator/settings-tui-submenus.ts +647 -0
  53. package/extensions/orchestrator/settings-tui.ts +636 -0
  54. package/extensions/orchestrator/state-jsonl.ts +431 -0
  55. package/extensions/orchestrator/subagent-tool.ts +157 -59
  56. package/extensions/orchestrator/sync-limit.ts +23 -0
  57. package/extensions/orchestrator/task-lifecycle.ts +77 -67
  58. package/extensions/orchestrator/utils.ts +8 -5
  59. package/extensions/pidash/pidash.ts +193 -156
  60. package/extensions/pidiff/pidiff.ts +96 -38
  61. package/extensions/pidiff/spawn-lock.ts +62 -0
  62. package/extensions/pitasks/index.ts +430 -0
  63. package/extensions/pitasks/reminders.ts +37 -0
  64. package/extensions/pitasks/task-store.ts +467 -0
  65. package/extensions/pitasks/task-tools.ts +375 -0
  66. package/extensions/pitasks/task-widget.ts +181 -0
  67. package/extensions/pitasks/types.ts +38 -0
  68. package/extensions/providers/acpx-driver.ts +12 -2
  69. package/extensions/providers/built-in-drivers.ts +6 -6
  70. package/extensions/providers/claude-driver.ts +1 -1
  71. package/extensions/providers/cursor-cli-driver.ts +1 -1
  72. package/extensions/providers/gemini-driver.ts +1 -1
  73. package/extensions/providers/index.ts +144 -26
  74. package/extensions/providers/initialized-guard.ts +27 -0
  75. package/extensions/providers/restore-default-model.ts +596 -0
  76. package/extensions/providers/session-shutdown.ts +39 -0
  77. package/extensions/shared/coms-active.ts +23 -0
  78. package/extensions/shared/create-runtime-provider.ts +2 -0
  79. package/extensions/shared/daemon-manager.ts +0 -8
  80. package/extensions/shared/file-logger.ts +132 -25
  81. package/extensions/shared/logger.ts +50 -0
  82. package/extensions/shared/models-dev.ts +421 -0
  83. package/extensions/shared/oneshot.ts +116 -0
  84. package/extensions/shared/resolve-binary.ts +191 -0
  85. package/extensions/shared/types.ts +3 -0
  86. package/package.json +9 -3
  87. package/prompts/coderabbit-rate-limit.md +2 -0
  88. package/rules/00-orchestrator-core.md +3 -0
  89. package/rules/10-agent-routing.md +12 -1
  90. package/rules/20-code-review-loop.md +52 -25
  91. package/rules/30-prompt-templates.md +4 -0
  92. package/rules/35-memory.md +3 -3
  93. package/rules/40-critical-rules.md +3 -0
  94. package/rules/55-coms-protocol.md +84 -18
  95. package/rules/60-task-tracking.md +2 -0
  96. package/settings-keys.json +563 -0
  97. package/templates/coms-feature-manager-prompt.md +4 -6
  98. package/extensions/coms/coms-net-server.ts +0 -1642
  99. package/extensions/coms/coms-net-wrapper.ts +0 -521
  100. package/extensions/coms/coms-net.ts +0 -1589
@@ -6,7 +6,7 @@ import { spawn } from "node:child_process";
6
6
  import * as fs from "node:fs";
7
7
  import * as os from "node:os";
8
8
  import * as path from "node:path";
9
- import { pathToFileURL } from "node:url";
9
+ import { fileURLToPath, pathToFileURL } from "node:url";
10
10
 
11
11
  import { createRequire } from "node:module";
12
12
 
@@ -16,6 +16,7 @@ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
16
16
  import type { Message } from "@earendil-works/pi-ai";
17
17
  import { StringEnum } from "@earendil-works/pi-ai";
18
18
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
+ import { createLogger } from "../shared/logger.js";
19
20
  import {
20
21
  getMarkdownTheme,
21
22
  withFileMutationQueue,
@@ -29,29 +30,33 @@ import {
29
30
  import { Type } from "typebox";
30
31
  import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.js";
31
32
 
32
- // Import TaskStore for taskId validation (bypasses raw file reads)
33
- let TaskStoreClass: any = null;
34
- (async () => {
35
- const candidates = [
36
- "@tintinweb/pi-tasks/dist/task-store.js",
37
- pathToFileURL(path.join(os.homedir(), ".pi/agent/npm/node_modules/@tintinweb/pi-tasks/dist/task-store.js")).href,
38
- ];
39
- for (const c of candidates) {
40
- try { const mod = await import(c); if (mod.TaskStore) { TaskStoreClass = mod.TaskStore; break; } } catch { continue; }
41
- }
42
- if (!TaskStoreClass) {
43
- throw new Error("[subagent] FATAL: TaskStore not found — @tintinweb/pi-tasks is required but failed to load");
44
- }
45
- })();
33
+ // Task store access via shared instance from extensions/pitasks
34
+ let _taskStore: any = null;
35
+ async function getTaskStore(): Promise<any> {
36
+ if (_taskStore) return _taskStore;
37
+ try { const mod = await import("../pitasks/index.js"); _taskStore = mod.taskStore; } catch { /* ignore */ }
38
+ return _taskStore;
39
+ }
46
40
  import {
47
41
  decideAsyncLlmDispatch,
48
42
  supportsAsyncLlm,
49
43
  } from "./async-capability.js";
50
44
  import { autoMarkInProgress, autoCompleteTask } from "./async-agents.js";
45
+ import { getSetting } from "./project-settings.js";
46
+ import { checkSyncLimit } from "./sync-limit.js";
47
+ import { substituteSettingsPlaceholders } from "./rule-placeholders.js";
51
48
  import { resolveAgentModelProvider } from "./resolve-agent-model.js";
49
+ import { parseModelOverride, mergeModelOverride } from "./parse-model-override.js";
52
50
  import { clockHHMM, getPiInvocation, getProjectTmpDir, djb2Hash } from "./utils.js";
53
51
 
54
- export { resolveAgentModelProvider };
52
+ const SETTINGS_KEYS_FOR_SUBAGENT = JSON.parse(
53
+ fs.readFileSync(
54
+ path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "settings-keys.json"),
55
+ "utf-8",
56
+ ),
57
+ );
58
+
59
+ export { resolveAgentModelProvider, parseModelOverride, checkSyncLimit };
55
60
 
56
61
  // ── Constants ────────────────────────────────────────────────────────────
57
62
 
@@ -59,11 +64,19 @@ const MAX_PARALLEL_TASKS = 8;
59
64
  const MAX_CONCURRENCY = 4;
60
65
  const COLLAPSED_ITEM_COUNT = 10;
61
66
  const MISSING_CWD_ERROR = "Missing required parameter: cwd. Always specify the working directory for subagent tasks.";
62
- const MAX_SYNC_SECONDS = 30;
63
- const SYNC_TIME_EXCEEDED_ERROR = (seconds: number) =>
64
- `Estimated time ${seconds}s meets or exceeds ${MAX_SYNC_SECONDS}s sync limit. Use async: true instead.`;
67
+ // Resolved per-call from settings
68
+ const log = createLogger("subagent");
69
+ const SYNC_TIME_EXCEEDED_ERROR = (seconds: number, limit: number, cwd: string) => {
70
+ log.info("sync_time_exceeded", "seconds", seconds, "limit", limit, "cwd", cwd);
71
+ return `Estimated time ${seconds}s meets or exceeds ${limit}s sync limit. Use async: true instead.`;
72
+ };
65
73
  const MISSING_ESTIMATE_ERROR = "Missing required parameter: estimatedSeconds. Provide an estimated duration in seconds for sync agent tasks.";
66
74
 
75
+ let syncPenalty = 0;
76
+ let lastSyncAgent = "";
77
+ let lastSyncEstimate = 0;
78
+ let lastSyncActual = 0;
79
+
67
80
  /** Agents that MUST be dispatched with async: true. Sync calls are rejected.
68
81
  * Keep in sync with rules/20-code-review-loop.md when changing this list. */
69
82
  const ASYNC_ONLY_AGENTS = new Set([
@@ -82,6 +95,7 @@ const TaskItem = Type.Object({
82
95
  name: Type.Optional(Type.String({ description: "Display name for async status" })),
83
96
  estimatedSeconds: Type.Optional(Type.Number({ description: "Estimated task duration in seconds. Required for sync parallel tasks." })),
84
97
  taskId: Type.Optional(Type.String({ description: "Task ID to auto-complete when this async agent finishes" })),
98
+ model: Type.Optional(Type.String({ description: "Override the agent's model. Format: 'provider/model-id' or 'model-id'." })),
85
99
  });
86
100
  const ChainItem = Type.Object({
87
101
  agent: Type.String({ description: "Agent name" }),
@@ -141,6 +155,9 @@ const SubagentParams = Type.Object({
141
155
  persistSession: Type.Optional(
142
156
  Type.Boolean({ description: "Persist agent session to disk for reuse across calls. Default: false (ephemeral). When true, the same agent in the same project reuses its session — keeps context, saves tokens." }),
143
157
  ),
158
+ model: Type.Optional(
159
+ Type.String({ description: "Override the agent's model. Format: 'provider/model-id' (e.g., 'litellm/claude-opus-4-6-1m') or just 'model-id' (inherits provider). Overrides all other model resolution." }),
160
+ ),
144
161
  });
145
162
 
146
163
  // ── Interfaces ───────────────────────────────────────────────────────────
@@ -370,6 +387,7 @@ export async function runSingleAgent(
370
387
  parentModelId?: string,
371
388
  parentProvider?: string,
372
389
  persistSession?: boolean,
390
+ explicit?: { model?: string; provider?: string },
373
391
  ): Promise<SingleResult> {
374
392
  const agent = agents.find((a) => a.name === agentName);
375
393
  if (!agent) {
@@ -401,7 +419,7 @@ export async function runSingleAgent(
401
419
  // Deterministic session ID based on agent + cwd for session reuse
402
420
  args.push("--session-id", `sync-${agentName}-${djb2Hash(agentName + ':' + cwd).toString(36)}`);
403
421
  }
404
- const { model: effectiveModel, provider: effectiveProvider } = resolveAgentModelProvider(agentName, agent, parentModelId, parentProvider, cwd);
422
+ const { model: effectiveModel, provider: effectiveProvider } = resolveAgentModelProvider(agentName, agent, parentModelId, parentProvider, cwd, explicit);
405
423
  if (effectiveModel) args.push("--model", effectiveModel);
406
424
  if (effectiveProvider) args.push("--provider", effectiveProvider);
407
425
  if (agent.tools && agent.tools.length > 0)
@@ -446,7 +464,12 @@ export async function runSingleAgent(
446
464
 
447
465
  try {
448
466
  if (agent.systemPrompt.trim()) {
449
- const tmp = await writePromptFile(agent.name, agent.systemPrompt, cwd);
467
+ const resolvedPrompt = substituteSettingsPlaceholders(
468
+ agent.systemPrompt,
469
+ (key) => getSetting(cwd, key as any),
470
+ Object.keys(SETTINGS_KEYS_FOR_SUBAGENT),
471
+ );
472
+ const tmp = await writePromptFile(agent.name, resolvedPrompt, cwd);
450
473
  tmpDir = tmp.dir;
451
474
  tmpFile = tmp.filePath;
452
475
  args.push("--append-system-prompt", tmpFile);
@@ -454,13 +477,14 @@ export async function runSingleAgent(
454
477
  args.push(`Task: ${task}`);
455
478
  let aborted = false;
456
479
 
480
+ log.info("sync_spawn_start", agentName, "cwd", cwd);
457
481
  const exitCode = await new Promise<number>((resolve) => {
458
482
  const inv = getPiInvocation(args);
459
483
  const proc = spawn(inv.command, inv.args, {
460
484
  cwd: cwd,
461
485
  shell: false,
462
486
  stdio: ["ignore", "pipe", "pipe"],
463
- env: { ...process.env, PI_SUBAGENT_CHILD: "1", PI_AGENT_NAME: agentName },
487
+ env: { ...process.env, PI_SUBAGENT_CHILD: "1", PI_AGENT_NAME: agentName, PI_PRIMARY_MODEL: process.env.PI_PRIMARY_MODEL || process.env.PI_MODEL || "", __PI_CONFIG_SESSION_ID: (globalThis as any).__piConfigSessionId || "", __PI_PARENT_SESSION_ID: (globalThis as any).__piConfigSessionId || "" },
464
488
  });
465
489
  let buf = "";
466
490
 
@@ -508,10 +532,14 @@ export async function runSingleAgent(
508
532
  cur.stderr += d.toString();
509
533
  });
510
534
  proc.on("close", (c) => {
535
+ log.info("sync_proc_close", agentName, "code", c);
511
536
  if (buf.trim()) processLine(buf);
512
537
  resolve(c ?? 0);
513
538
  });
514
- proc.on("error", () => resolve(1));
539
+ proc.on("error", (err) => {
540
+ log.error("sync_proc_error", agentName, err?.message);
541
+ resolve(1);
542
+ });
515
543
 
516
544
  if (signal) {
517
545
  const kill = () => {
@@ -526,8 +554,10 @@ export async function runSingleAgent(
526
554
  }
527
555
  });
528
556
 
557
+ log.info("sync_proc_resolved", agentName, "exitCode", exitCode, "elapsed", Date.now() - startTime);
529
558
  cur.exitCode = exitCode;
530
559
  cur.durationMs = Date.now() - startTime;
560
+
531
561
  if (aborted) throw new Error("Subagent was aborted");
532
562
  return cur;
533
563
  } finally {
@@ -542,32 +572,21 @@ export async function runSingleAgent(
542
572
  }
543
573
  }
544
574
 
545
- /** Validate that a taskId references an existing task in the pi-tasks store. */
546
- function validateTaskId(taskId: string, cwd: string, sessionId?: string): string | null {
575
+ /** Validate that a taskId references an existing task in the pitasks store. */
576
+ function validateTaskId(taskId: string, _cwd: string, _sessionId?: string): string | null {
547
577
  if (taskId === "-1") return null;
548
-
549
- const tasksDir = path.join(cwd, ".pi", "tasks");
550
- const paths: string[] = [];
551
- if (sessionId) paths.push(path.join(tasksDir, `tasks-${sessionId}.json`));
552
- paths.push(path.join(tasksDir, "tasks.json"));
553
-
554
- for (const p of paths) {
555
- try {
556
- if (TaskStoreClass) {
557
- const store = new TaskStoreClass(p);
558
- if (store.get(taskId)) return null;
559
- } else {
560
- // Fallback: raw file read when TaskStore hasn't loaded yet (async init race)
561
- const data = JSON.parse(fs.readFileSync(p, "utf-8"));
562
- if ((data.tasks || []).some((t: any) => t.id === taskId)) return null;
563
- }
564
- } catch (e: any) {
565
- // Only continue silently for missing files; report other errors
566
- if (e?.code === "ENOENT" || e?.message?.includes("ENOENT")) continue;
567
- return `Task store error for ${path.basename(p)}: ${e?.message || e}. Fix the task store or pass taskId: "-1".`;
578
+ try {
579
+ // Ensure store is loaded (async init may not have completed)
580
+ if (!_taskStore) {
581
+ getTaskStore().catch(() => {});
568
582
  }
569
- }
570
-
583
+ const store = _taskStore;
584
+ if (!store) {
585
+ // Store not loaded yet (async init) — fail open, skip validation
586
+ return null;
587
+ }
588
+ if (store.get(taskId)) return null;
589
+ } catch { return null; }
571
590
  return `Task #${taskId} not found. Verify the task ID exists (use TaskList to check), or pass taskId: "-1" if not linked to a task.`;
572
591
  }
573
592
 
@@ -575,7 +594,7 @@ function validateTaskId(taskId: string, cwd: string, sessionId?: string): string
575
594
 
576
595
  export function registerSubagentTool(
577
596
  pi: ExtensionAPI,
578
- spawnAsyncAgent: (agentName: string, task: string, cwd: string, agents: AgentConfig[], options?: { fireAndForget?: boolean; name?: string; parentModelId?: string; parentProvider?: string; groupId?: string; taskId?: string }) => { id: string; error?: string; model?: string },
597
+ spawnAsyncAgent: (agentName: string, task: string, cwd: string, agents: AgentConfig[], options?: { fireAndForget?: boolean; name?: string; parentModelId?: string; parentProvider?: string; groupId?: string; taskId?: string; persistSession?: boolean; explicit?: { model?: string; provider?: string } }) => { id: string; error?: string; model?: string },
579
598
  killAsyncAgent: (target: string) => { killed: string[]; errors: string[] },
580
599
  ): void {
581
600
  // Only the orchestrator (top-level pi) can spawn subagents.
@@ -606,6 +625,7 @@ export function registerSubagentTool(
606
625
  parentModelId: string | undefined,
607
626
  parentProvider: string | undefined,
608
627
  ctx: any,
628
+ explicit?: { model?: string; provider?: string },
609
629
  ) {
610
630
  if (params.chain) {
611
631
  return {
@@ -664,6 +684,7 @@ export function registerSubagentTool(
664
684
  ? `group-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
665
685
  : undefined;
666
686
  for (const t of params.tasks) {
687
+ const taskExplicit = mergeModelOverride(t.model, explicit);
667
688
  const r = spawnAsyncAgent(t.agent, t.task, t.cwd, agents, {
668
689
  fireAndForget: params.fireAndForget,
669
690
  name: (t as any).name,
@@ -672,6 +693,7 @@ export function registerSubagentTool(
672
693
  groupId,
673
694
  taskId: (t as any).taskId,
674
695
  persistSession: params.persistSession,
696
+ explicit: taskExplicit,
675
697
  });
676
698
  if (r.error) {
677
699
  errors.push(`${t.agent}: ${r.error}`);
@@ -742,7 +764,19 @@ export function registerSubagentTool(
742
764
  };
743
765
  }
744
766
  }
745
- const result = spawnAsyncAgent(params.agent, params.task, params.cwd, agents, { fireAndForget: params.fireAndForget, name: params.name, parentModelId, parentProvider, taskId: params.taskId, persistSession: params.persistSession });
767
+ if (syncPenalty > 0) {
768
+ log.info("sync_penalty_reset", "async used");
769
+ syncPenalty = 0;
770
+ }
771
+ const result = spawnAsyncAgent(params.agent, params.task, params.cwd, agents, {
772
+ fireAndForget: params.fireAndForget,
773
+ name: params.name,
774
+ parentModelId,
775
+ parentProvider,
776
+ taskId: params.taskId,
777
+ persistSession: params.persistSession,
778
+ explicit: explicit,
779
+ });
746
780
  if (result.error) {
747
781
  return {
748
782
  content: [{ type: "text" as const, text: result.error }],
@@ -770,6 +804,7 @@ export function registerSubagentTool(
770
804
  parentProvider: string | undefined,
771
805
  asyncOk: boolean,
772
806
  sessionId?: string,
807
+ explicit?: { model?: string; provider?: string },
773
808
  ) {
774
809
  const results: SingleResult[] = [];
775
810
  let prev = "";
@@ -807,9 +842,25 @@ export function registerSubagentTool(
807
842
  }
808
843
  // chain runs sequentially — sum all steps
809
844
  const totalChainSeconds = params.chain.reduce((sum: number, s: any) => sum + s.estimatedSeconds, 0);
810
- if (totalChainSeconds >= MAX_SYNC_SECONDS && asyncOk) {
845
+ if (!params.chain || params.chain.length === 0) {
846
+ return { content: [{ type: "text" as const, text: "Error: chain array is empty — at least one step is required." }] };
847
+ }
848
+ const chainCwd = params.chain[0].cwd;
849
+ const { limit: chainLimit } = checkSyncLimit(0, chainCwd);
850
+ if (syncPenalty > 0) {
851
+ const adjustedTotal = totalChainSeconds + syncPenalty;
852
+ if (adjustedTotal >= chainLimit) {
853
+ log.info("sync_penalty_rejected_chain", "penalty", syncPenalty, "adjusted", adjustedTotal);
854
+ return {
855
+ content: [{ type: "text" as const, text: `Rejected: your last sync agent '${lastSyncAgent}' estimated ${lastSyncEstimate}s but took ${lastSyncActual}s. Penalty: ${syncPenalty}s. Chain total ${totalChainSeconds}s + penalty ${syncPenalty}s = ${adjustedTotal}s — exceeds ${chainLimit}s limit. Use async: true.` }],
856
+ details: mkd("chain")([]),
857
+ isError: true,
858
+ };
859
+ }
860
+ }
861
+ if (totalChainSeconds >= chainLimit && asyncOk) {
811
862
  return {
812
- content: [{ type: "text" as const, text: SYNC_TIME_EXCEEDED_ERROR(totalChainSeconds) }],
863
+ content: [{ type: "text" as const, text: SYNC_TIME_EXCEEDED_ERROR(totalChainSeconds, chainLimit, chainCwd) }],
813
864
  details: mkd("chain")([]),
814
865
  isError: true,
815
866
  };
@@ -848,6 +899,7 @@ export function registerSubagentTool(
848
899
  parentModelId,
849
900
  parentProvider,
850
901
  params.persistSession,
902
+ explicit,
851
903
  );
852
904
  activeAgents.delete(s.agent);
853
905
  updateWorking();
@@ -899,6 +951,7 @@ export function registerSubagentTool(
899
951
  parentProvider: string | undefined,
900
952
  asyncOk: boolean,
901
953
  sessionId?: string,
954
+ explicit?: { model?: string; provider?: string },
902
955
  ) {
903
956
  if (params.tasks.length > MAX_PARALLEL_TASKS)
904
957
  return {
@@ -930,9 +983,22 @@ export function registerSubagentTool(
930
983
  }
931
984
  // parallel runs concurrently — use longest task
932
985
  const maxParallelSeconds = Math.max(...params.tasks.map((t: any) => t.estimatedSeconds!));
933
- if (maxParallelSeconds >= MAX_SYNC_SECONDS && asyncOk) {
986
+ const parallelCwd = params.tasks[0]?.cwd ?? process.cwd();
987
+ const { limit: parallelLimit } = checkSyncLimit(0, parallelCwd);
988
+ if (syncPenalty > 0) {
989
+ const adjustedMax = maxParallelSeconds + syncPenalty;
990
+ if (adjustedMax >= parallelLimit) {
991
+ log.info("sync_penalty_rejected_parallel", "penalty", syncPenalty, "adjusted", adjustedMax);
992
+ return {
993
+ content: [{ type: "text" as const, text: `Rejected: your last sync agent '${lastSyncAgent}' estimated ${lastSyncEstimate}s but took ${lastSyncActual}s. Penalty: ${syncPenalty}s. Max parallel estimate ${maxParallelSeconds}s + penalty ${syncPenalty}s = ${adjustedMax}s — exceeds ${parallelLimit}s limit. Use async: true.` }],
994
+ details: mkd("parallel")([]),
995
+ isError: true,
996
+ };
997
+ }
998
+ }
999
+ if (maxParallelSeconds >= parallelLimit && asyncOk) {
934
1000
  return {
935
- content: [{ type: "text" as const, text: SYNC_TIME_EXCEEDED_ERROR(maxParallelSeconds) }],
1001
+ content: [{ type: "text" as const, text: SYNC_TIME_EXCEEDED_ERROR(maxParallelSeconds, parallelLimit, parallelCwd) }],
936
1002
  details: mkd("parallel")([]),
937
1003
  isError: true,
938
1004
  };
@@ -997,6 +1063,7 @@ export function registerSubagentTool(
997
1063
  const label = t.name || t.agent;
998
1064
  activeAgents.add(label);
999
1065
  updateWorking();
1066
+ const taskExplicit = mergeModelOverride(t.model, explicit);
1000
1067
  const r = await runSingleAgent(
1001
1068
  agents,
1002
1069
  t.agent,
@@ -1014,6 +1081,7 @@ export function registerSubagentTool(
1014
1081
  parentModelId,
1015
1082
  parentProvider,
1016
1083
  params.persistSession,
1084
+ taskExplicit,
1017
1085
  );
1018
1086
  all[i] = r;
1019
1087
  activeAgents.delete(t.name || t.agent);
@@ -1062,6 +1130,7 @@ export function registerSubagentTool(
1062
1130
  parentProvider: string | undefined,
1063
1131
  asyncOk: boolean,
1064
1132
  sessionId?: string,
1133
+ explicit?: { model?: string; provider?: string },
1065
1134
  ) {
1066
1135
  if (!params.cwd) {
1067
1136
  return {
@@ -1089,9 +1158,21 @@ export function registerSubagentTool(
1089
1158
  isError: true,
1090
1159
  };
1091
1160
  }
1092
- if (params.estimatedSeconds >= MAX_SYNC_SECONDS && asyncOk) {
1161
+ const { limit: singleLimit } = checkSyncLimit(0, params.cwd);
1162
+ if (syncPenalty > 0) {
1163
+ const adjustedEstimate = (params.estimatedSeconds ?? 0) + syncPenalty;
1164
+ if (adjustedEstimate >= singleLimit) {
1165
+ log.info("sync_penalty_rejected", params.agent, "estimate", params.estimatedSeconds, "penalty", syncPenalty, "adjusted", adjustedEstimate);
1166
+ return {
1167
+ content: [{ type: "text" as const, text: `Rejected: your last sync agent '${lastSyncAgent}' estimated ${lastSyncEstimate}s but took ${lastSyncActual}s. Penalty: ${syncPenalty}s. Your estimate ${params.estimatedSeconds}s + penalty ${syncPenalty}s = ${adjustedEstimate}s — exceeds ${singleLimit}s limit. Use async: true.` }],
1168
+ details: mkd("single")([]),
1169
+ isError: true,
1170
+ };
1171
+ }
1172
+ }
1173
+ if (params.estimatedSeconds >= singleLimit && asyncOk) {
1093
1174
  return {
1094
- content: [{ type: "text" as const, text: SYNC_TIME_EXCEEDED_ERROR(params.estimatedSeconds) }],
1175
+ content: [{ type: "text" as const, text: SYNC_TIME_EXCEEDED_ERROR(params.estimatedSeconds, singleLimit, params.cwd) }],
1095
1176
  details: mkd("single")([]),
1096
1177
  isError: true,
1097
1178
  };
@@ -1104,6 +1185,8 @@ export function registerSubagentTool(
1104
1185
  if (syncTaskId && syncTaskId !== "-1") {
1105
1186
  await autoMarkInProgress(syncTaskId, params.cwd, sessionId).catch(() => {});
1106
1187
  }
1188
+ log.info("sync_execute_start", params.agent, "taskId", syncTaskId ?? "none");
1189
+ const syncStartTime = Date.now();
1107
1190
  const r = await runSingleAgent(
1108
1191
  agents,
1109
1192
  params.agent,
@@ -1116,7 +1199,17 @@ export function registerSubagentTool(
1116
1199
  parentModelId,
1117
1200
  parentProvider,
1118
1201
  params.persistSession,
1202
+ explicit,
1119
1203
  );
1204
+ const actualSeconds = Math.round((Date.now() - syncStartTime) / 1000);
1205
+ if (actualSeconds > checkSyncLimit(0, params.cwd).limit && actualSeconds - (params.estimatedSeconds ?? 0) > 30) {
1206
+ syncPenalty = actualSeconds - (params.estimatedSeconds ?? 0);
1207
+ lastSyncAgent = params.agent;
1208
+ lastSyncEstimate = params.estimatedSeconds ?? 0;
1209
+ lastSyncActual = actualSeconds;
1210
+ log.info("sync_penalty_set", params.agent, "estimate", params.estimatedSeconds, "actual", actualSeconds, "penalty", syncPenalty);
1211
+ }
1212
+ log.info("sync_execute_done", params.agent, "exitCode", r.exitCode, "stopReason", r.stopReason, "elapsed", Date.now() - syncStartTime);
1120
1213
  activeAgents.delete(label);
1121
1214
  updateWorking();
1122
1215
  const err =
@@ -1138,6 +1231,7 @@ export function registerSubagentTool(
1138
1231
  if (syncTaskId && syncTaskId !== "-1") {
1139
1232
  await autoCompleteTask(syncTaskId, params.cwd, sessionId).catch(() => {});
1140
1233
  }
1234
+ log.info("sync_execute_returning", params.agent);
1141
1235
  return {
1142
1236
  content: [
1143
1237
  { type: "text" as const, text: getFinalOutput(r.messages) || "(no output)" },
@@ -1451,6 +1545,8 @@ export function registerSubagentTool(
1451
1545
  const confirm = params.confirmProjectAgents ?? true;
1452
1546
  let parentModelId = ctx.model?.id as string | undefined;
1453
1547
  let parentProvider = ctx.model?.provider as string | undefined;
1548
+ // Parse explicit model override from params (applies to all execution modes)
1549
+ const explicitOverride = parseModelOverride(params.model);
1454
1550
  const asyncOk = supportsAsyncLlm(parentProvider, ctx.cwd);
1455
1551
  let capabilityNote = "";
1456
1552
 
@@ -1577,9 +1673,11 @@ export function registerSubagentTool(
1577
1673
  }
1578
1674
  }
1579
1675
 
1676
+ const explicit = explicitOverride;
1677
+
1580
1678
  // Async mode — spawn in background and return immediately
1581
1679
  if (params.async === true) {
1582
- const result = executeAsync(params, agents, mkd, parentModelId, parentProvider, ctx);
1680
+ const result = executeAsync(params, agents, mkd, parentModelId, parentProvider, ctx, explicit);
1583
1681
  if (capabilityNote && result.content?.[0]?.type === "text") {
1584
1682
  result.content[0].text = `${capabilityNote}\n\n${result.content[0].text}`;
1585
1683
  }
@@ -1600,21 +1698,21 @@ export function registerSubagentTool(
1600
1698
  // Chain mode
1601
1699
  if (params.chain && params.chain.length > 0) {
1602
1700
  return withCapabilityNote(
1603
- await executeChain(params, agents, mkd, signal, onUpdate, activeAgents, updateWorking, parentModelId, parentProvider, asyncOk, sessionId),
1701
+ await executeChain(params, agents, mkd, signal, onUpdate, activeAgents, updateWorking, parentModelId, parentProvider, asyncOk, sessionId, explicit),
1604
1702
  );
1605
1703
  }
1606
1704
 
1607
1705
  // Parallel mode
1608
1706
  if (params.tasks && params.tasks.length > 0) {
1609
1707
  return withCapabilityNote(
1610
- await executeParallel(params, agents, mkd, signal, onUpdate, activeAgents, updateWorking, parentModelId, parentProvider, asyncOk, sessionId),
1708
+ await executeParallel(params, agents, mkd, signal, onUpdate, activeAgents, updateWorking, parentModelId, parentProvider, asyncOk, sessionId, explicit),
1611
1709
  );
1612
1710
  }
1613
1711
 
1614
1712
  // Single mode
1615
1713
  if (params.agent && params.task) {
1616
1714
  return withCapabilityNote(
1617
- await executeSingle(params, agents, mkd, signal, onUpdate, activeAgents, updateWorking, parentModelId, parentProvider, asyncOk, sessionId),
1715
+ await executeSingle(params, agents, mkd, signal, onUpdate, activeAgents, updateWorking, parentModelId, parentProvider, asyncOk, sessionId, explicit),
1618
1716
  );
1619
1717
  }
1620
1718
 
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Sync agent time-limit helpers.
3
+ * Kept separate from subagent-tool so unit tests can import without heavy deps.
4
+ */
5
+
6
+ import { getSetting } from "./project-settings.js";
7
+ import { createLogger } from "../shared/logger.js";
8
+
9
+ const log = createLogger("subagent");
10
+
11
+ /**
12
+ * Check if estimated seconds exceed the sync limit for a given cwd.
13
+ * Exported for testing.
14
+ */
15
+ export function checkSyncLimit(
16
+ estimatedSeconds: number,
17
+ cwd: string,
18
+ ): { exceeded: boolean; limit: number } {
19
+ const raw = getSetting(cwd, "sync_agent_max_seconds");
20
+ const limit = typeof raw === "number" && raw > 0 ? raw : 60; // fallback to default
21
+ log.debug("checkSyncLimit", "estimated", estimatedSeconds, "limit", limit, "exceeded", estimatedSeconds >= limit);
22
+ return { exceeded: estimatedSeconds >= limit, limit };
23
+ }
@@ -1,88 +1,98 @@
1
1
  /**
2
- * Task lifecycle helpers — auto-complete / mark in_progress via pi-tasks TaskStore.
3
- * Kept separate from async-agents.ts so unit tests can import without loading
4
- * @earendil-works/pi-coding-agent (unavailable in the tsx test harness).
2
+ * Task lifecycle helpers — auto-complete / mark in_progress via owned taskStore.
3
+ * Uses the exported store instance from extensions/pitasks when available.
4
+ * Falls back to _storeFactory for unit testing.
5
5
  */
6
6
 
7
- import * as os from "node:os";
8
7
  import * as path from "node:path";
9
- import { pathToFileURL } from "node:url";
8
+ import { createLogger } from "../shared/logger.js";
10
9
 
11
- let TaskStoreClass: any = null;
12
- const taskStoreReady: Promise<void> = (async () => {
13
- const candidates = [
14
- "@tintinweb/pi-tasks/dist/task-store.js",
15
- pathToFileURL(path.join(os.homedir(), ".pi/agent/npm/node_modules/@tintinweb/pi-tasks/dist/task-store.js")).href,
16
- ];
17
- for (const candidate of candidates) {
18
- try {
19
- const mod = await import(candidate);
20
- if (mod.TaskStore) { TaskStoreClass = mod.TaskStore; break; }
21
- } catch { continue; }
22
- }
23
- if (!TaskStoreClass) {
24
- throw new Error("[task-lifecycle] FATAL: TaskStore not found — @tintinweb/pi-tasks is required but failed to load");
25
- }
26
- })();
10
+ const log = createLogger("task-lifecycle");
27
11
 
28
- /** Auto-complete a task via pi-tasks TaskStore (in-process, no AI involvement). */
12
+ /** Get the shared task store instance (lazy import to avoid circular deps). */
13
+ async function getStore(): Promise<any> {
14
+ // Don't cache — pitasks reassigns taskStore during session upgrade/switch.
15
+ // Always read the latest export.
16
+ try {
17
+ const mod = await import("../pitasks/index.js");
18
+ return mod.taskStore ?? null;
19
+ } catch { return null; }
20
+ }
21
+
22
+ /** Auto-complete a task (in-process, no AI involvement). */
29
23
  export async function autoCompleteTask(taskId: string, cwd: string, sessionId?: string, _storeFactory?: (path: string) => any): Promise<boolean> {
30
24
  if (!taskId || taskId === "-1") return false;
31
- await taskStoreReady;
32
-
33
- const tasksDir = path.join(cwd, ".pi", "tasks");
34
- const candidates: string[] = [];
35
- if (sessionId) candidates.push(path.join(tasksDir, `tasks-${sessionId}.json`));
36
- candidates.push(path.join(tasksDir, "tasks.json"));
37
25
 
38
- for (const storePath of candidates) {
39
- try {
40
- const factory = _storeFactory ?? ((p: string) => new TaskStoreClass(p));
41
- const store = factory(storePath);
42
- const task = store.get(taskId);
43
- if (task) {
44
- // Task found in this store — do not fall through to later candidates
45
- if (task.status !== "completed") {
46
- store.update(taskId, { status: "completed" });
47
- return true;
26
+ // TEST-ONLY: _storeFactory constructs file paths for unit test isolation.
27
+ // Production code NEVER passes this — it falls through to getStore() below.
28
+ if (_storeFactory) {
29
+ const tasksDir = path.join(cwd, ".pi", "tasks");
30
+ const candidates: string[] = [];
31
+ if (sessionId) candidates.push(path.join(tasksDir, `tasks-${sessionId}.json`));
32
+ candidates.push(path.join(tasksDir, "tasks.json"));
33
+ for (const storePath of candidates) {
34
+ let store: any;
35
+ try {
36
+ store = _storeFactory(storePath);
37
+ const task = store.get(taskId);
38
+ if (task) {
39
+ if (task.status !== "completed") { store.update(taskId, { status: "completed" }); return true; }
40
+ return false;
48
41
  }
49
- return false;
50
- }
51
- } catch (e: any) {
52
- console.debug(`[task-lifecycle] autoCompleteTask failed for task ${taskId}: ${e?.message?.slice(0, 100)}`);
53
- continue;
42
+ } catch { continue; }
43
+ finally { if (store && typeof store.close === "function") store.close(); }
54
44
  }
45
+ return false;
46
+ }
47
+
48
+ const store = await getStore();
49
+ if (!store) return false;
50
+ try {
51
+ const task = store.get(taskId);
52
+ if (!task) return false;
53
+ if (task.status !== "completed") { store.update(taskId, { status: "completed" }); return true; }
54
+ return false;
55
+ } catch (e: any) {
56
+ log.debug("autoCompleteTask failed for task", taskId, e?.message?.slice(0, 100));
57
+ return false;
55
58
  }
56
- return false;
57
59
  }
58
60
 
59
- /** Auto-mark a task in_progress via pi-tasks TaskStore (in-process, no AI involvement). */
61
+ /** Auto-mark a task in_progress (in-process, no AI involvement). */
60
62
  export async function autoMarkInProgress(taskId: string, cwd: string, sessionId?: string, _storeFactory?: (path: string) => any): Promise<boolean> {
61
63
  if (!taskId || taskId === "-1") return false;
62
- await taskStoreReady;
63
-
64
- const tasksDir = path.join(cwd, ".pi", "tasks");
65
- const candidates: string[] = [];
66
- if (sessionId) candidates.push(path.join(tasksDir, `tasks-${sessionId}.json`));
67
- candidates.push(path.join(tasksDir, "tasks.json"));
68
64
 
69
- for (const storePath of candidates) {
70
- try {
71
- const factory = _storeFactory ?? ((p: string) => new TaskStoreClass(p));
72
- const store = factory(storePath);
73
- const task = store.get(taskId);
74
- if (task) {
75
- // Task found in this store — do not fall through to later candidates
76
- if (task.status === "pending") {
77
- store.update(taskId, { status: "in_progress" });
78
- return true;
65
+ // TEST-ONLY: _storeFactory constructs file paths for unit test isolation.
66
+ // Production code NEVER passes this — it falls through to getStore() below.
67
+ if (_storeFactory) {
68
+ const tasksDir = path.join(cwd, ".pi", "tasks");
69
+ const candidates: string[] = [];
70
+ if (sessionId) candidates.push(path.join(tasksDir, `tasks-${sessionId}.json`));
71
+ candidates.push(path.join(tasksDir, "tasks.json"));
72
+ for (const storePath of candidates) {
73
+ let store: any;
74
+ try {
75
+ store = _storeFactory(storePath);
76
+ const task = store.get(taskId);
77
+ if (task) {
78
+ if (task.status === "pending") { store.update(taskId, { status: "in_progress" }); return true; }
79
+ return false;
79
80
  }
80
- return false;
81
- }
82
- } catch (e: any) {
83
- console.debug(`[task-lifecycle] autoMarkInProgress failed for task ${taskId}: ${e?.message?.slice(0, 100)}`);
84
- continue;
81
+ } catch { continue; }
82
+ finally { if (store && typeof store.close === "function") store.close(); }
85
83
  }
84
+ return false;
85
+ }
86
+
87
+ const store = await getStore();
88
+ if (!store) return false;
89
+ try {
90
+ const task = store.get(taskId);
91
+ if (!task) return false;
92
+ if (task.status === "pending") { store.update(taskId, { status: "in_progress" }); return true; }
93
+ return false;
94
+ } catch (e: any) {
95
+ log.debug("autoMarkInProgress failed for task", taskId, e?.message?.slice(0, 100));
96
+ return false;
86
97
  }
87
- return false;
88
98
  }