pi-crew 0.10.1 → 0.10.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 (64) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/NOTICE.md +21 -0
  3. package/README.md +44 -2
  4. package/agents/executor.md +1 -1
  5. package/agents/test-engineer.md +1 -1
  6. package/dist/index.mjs +2781 -949
  7. package/package.json +1 -1
  8. package/schema.json +492 -94
  9. package/src/config/config-validation.ts +7 -1
  10. package/src/config/defaults.ts +8 -1
  11. package/src/config/role-tools.ts +4 -2
  12. package/src/config/types.ts +19 -1
  13. package/src/extension/crew-cleanup.ts +13 -0
  14. package/src/extension/crew-vibes/footer.ts +19 -0
  15. package/src/extension/crew-vibes/index.ts +11 -1
  16. package/src/extension/register.ts +8 -0
  17. package/src/extension/registration/command-registration.ts +1 -0
  18. package/src/extension/registration/commands/run.ts +15 -1
  19. package/src/extension/registration/commands/shared.ts +8 -0
  20. package/src/extension/registration/foreground-run-controller.ts +10 -2
  21. package/src/extension/registration/lifecycle-handlers.ts +52 -2
  22. package/src/extension/registration/runtime-cleanup.ts +23 -5
  23. package/src/extension/registration/team-tool.ts +5 -1
  24. package/src/extension/registration/ui.ts +5 -4
  25. package/src/extension/team-tool/run-deadline.ts +20 -3
  26. package/src/extension/team-tool/run.ts +26 -2
  27. package/src/runtime/child-pi/child-pi-constants.ts +8 -0
  28. package/src/runtime/child-pi/child-pi-streams.ts +21 -1
  29. package/src/runtime/detached-run-results.ts +90 -0
  30. package/src/runtime/goal-workflow/adaptive-plan.ts +30 -3
  31. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +5 -1
  32. package/src/runtime/merge-gate.ts +25 -9
  33. package/src/runtime/model/model-fallback.ts +7 -3
  34. package/src/runtime/process-status.ts +16 -1
  35. package/src/runtime/recovery/crash-recovery.ts +16 -0
  36. package/src/runtime/run-tracker.ts +77 -10
  37. package/src/runtime/team-runner.ts +3 -2
  38. package/src/schema/config-schema.ts +1 -1
  39. package/src/state/stores/plan-store.ts +1 -1
  40. package/src/state/stores/state-store.ts +73 -3
  41. package/src/state/types.ts +2 -0
  42. package/src/ui/dock-footer.ts +49 -0
  43. package/src/ui/inline-panel/agent-pane.ts +375 -0
  44. package/src/ui/inline-panel/agent-transcript.ts +338 -0
  45. package/src/ui/inline-panel/agent-view-overlay.ts +225 -0
  46. package/src/ui/inline-panel/crew-editor.ts +192 -0
  47. package/src/ui/inline-panel/index.ts +290 -0
  48. package/src/ui/inline-panel/panel-rows.ts +37 -0
  49. package/src/ui/inline-panel/panel-selection.ts +157 -0
  50. package/src/ui/inline-panel/panel-store.ts +111 -0
  51. package/src/ui/inline-panel/view-session-store.ts +36 -0
  52. package/src/ui/pi-ui-compat.ts +9 -0
  53. package/src/ui/widget/index.ts +176 -22
  54. package/src/ui/widget/task-list.ts +198 -0
  55. package/src/ui/widget/widget-formatters.ts +240 -4
  56. package/src/ui/widget/widget-renderer.ts +234 -37
  57. package/src/ui/widget/widget-types.ts +11 -0
  58. package/src/utils/child-process-shield.ts +106 -0
  59. package/src/utils/redaction.ts +7 -0
  60. package/src/utils/safe-abort.ts +45 -0
  61. package/src/workflows/discover-workflows.ts +1 -0
  62. package/src/workflows/workflow-config.ts +7 -0
  63. package/workflows/default.workflow.md +36 -26
  64. package/workflows/strict-fast-fix.workflow.md +26 -0
@@ -11,7 +11,13 @@
11
11
  import { logInternalError } from "../../utils/internal-error.ts";
12
12
  import { extractText } from "../output/pi-json-output.ts";
13
13
  import type { ChildPiRunInput } from "./child-pi.ts";
14
- import { MAX_ASSISTANT_TEXT_CHARS, MAX_LINE_BUFFER_BYTES, MAX_TOOL_INPUT_CHARS, MAX_TOOL_RESULT_CHARS } from "./child-pi-constants.ts";
14
+ import {
15
+ MAX_ASSISTANT_TEXT_CHARS,
16
+ MAX_LINE_BUFFER_BYTES,
17
+ MAX_THINKING_CHARS,
18
+ MAX_TOOL_INPUT_CHARS,
19
+ MAX_TOOL_RESULT_CHARS,
20
+ } from "./child-pi-constants.ts";
15
21
  import { appendTranscript, compactString, compactValue, flushPendingTranscriptWrites } from "./child-pi-transcript.ts";
16
22
 
17
23
  function asRecord(value: unknown): Record<string, unknown> | undefined {
@@ -38,6 +44,20 @@ function compactContentPart(part: unknown): unknown | undefined {
38
44
  name: record.name,
39
45
  input: compactValue(typeof record.input === "string" ? compactString(record.input, MAX_TOOL_INPUT_CHARS) : record.input),
40
46
  };
47
+ // Reasoning is display-only, but the agent view pane renders the FULL
48
+ // assistant message (AssistantMessageComponent) — without the thinking
49
+ // parts the pane would look sparse next to a real pi session. Keep them
50
+ // capped so a long-thinking model cannot balloon the event log.
51
+ if (record.type === "thinking")
52
+ return {
53
+ type: "thinking",
54
+ thinking:
55
+ typeof record.thinking === "string"
56
+ ? compactString(record.thinking, MAX_THINKING_CHARS, {
57
+ preserveImportant: false,
58
+ })
59
+ : "",
60
+ };
41
61
  if (record.type === "toolResult")
42
62
  return {
43
63
  type: "toolResult",
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Result delivery for runs detached by an agent-view switch.
3
+ *
4
+ * Opening an agent view mid-run releases the foreground waiter (see
5
+ * `detachRunPromise`), so the parent turn settles with a "detached" tool
6
+ * result and the run keeps executing. Without this module the run's FINAL
7
+ * result would never reach the conversation: the async notifier only toasts,
8
+ * and its toast is suppressed while a view session is active (different
9
+ * session id) or when the run finished before the operator returned to main.
10
+ *
11
+ * The registry is in-process; a pending result is parked while an agent view is
12
+ * open so the worker's own view session never receives the parent's report.
13
+ */
14
+ import { loadRunManifestById } from "../state/stores/state-store.ts";
15
+ import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
16
+ import { isFinishedRunStatus } from "./process-status.ts";
17
+
18
+ interface DetachedRun {
19
+ runId: string;
20
+ cwd: string;
21
+ }
22
+
23
+ const detachedRuns = new Map<string, DetachedRun>();
24
+
25
+ /** Record a run whose foreground waiter was released by a view switch. */
26
+ export function markRunDetached(runId: string, cwd: string): void {
27
+ if (!runId || !cwd) return;
28
+ detachedRuns.set(runId, { runId, cwd });
29
+ }
30
+
31
+ /** Cheap guard for hot paths (render tick): nothing to do when empty. */
32
+ export function hasDetachedRuns(): boolean {
33
+ return detachedRuns.size > 0;
34
+ }
35
+
36
+ export function forgetDetachedRun(runId: string): void {
37
+ detachedRuns.delete(runId);
38
+ }
39
+
40
+ export function clearDetachedRunsForTest(): void {
41
+ detachedRuns.clear();
42
+ }
43
+
44
+ /** Human-readable completion summary for a detached run. */
45
+ export function formatDetachedRunResult(manifest: TeamRunManifest, tasks: TeamTaskState[]): string {
46
+ const lines = [
47
+ `pi-crew run ${manifest.status}: ${manifest.runId} (${manifest.team}/${manifest.workflow ?? "none"})`,
48
+ `Goal: ${manifest.goal}`,
49
+ "",
50
+ "This run was detached when you opened an agent view; it finished in the background.",
51
+ ];
52
+ if (tasks.length > 0) {
53
+ lines.push("", "Tasks:");
54
+ for (const task of tasks) {
55
+ const detail = task.error ? ` — ${task.error}` : "";
56
+ lines.push(` • ${task.id} (${task.role ?? "?"}): ${task.status}${detail}`);
57
+ }
58
+ }
59
+ if (manifest.artifactsRoot) lines.push("", `Artifacts: ${manifest.artifactsRoot}`);
60
+ lines.push(`State: ${manifest.stateRoot}`);
61
+ return lines.join("\n");
62
+ }
63
+
64
+ /**
65
+ * Results of finished detached runs that may be shown in the CURRENT session.
66
+ * Entries are NOT removed: the caller drops each one with `forgetDetachedRun`
67
+ * only after the delivery actually succeeded, so a failed send retries on the
68
+ * next tick instead of losing the run's outcome.
69
+ *
70
+ * Gating is by "am I inside an agent view", not by session id: the report must
71
+ * never land inside a worker's view session, but matching the owner's id is too
72
+ * brittle (the main session can be replaced/renamed while the view is open, and
73
+ * the registry is per pi process anyway, so any non-view session here IS the
74
+ * operator's own).
75
+ */
76
+ export function peekFinishedDetachedRunResults(options: { inViewSession?: boolean } = {}): { runId: string; text: string }[] {
77
+ if (detachedRuns.size === 0 || options.inViewSession) return [];
78
+ const ready: { runId: string; text: string }[] = [];
79
+ for (const entry of [...detachedRuns.values()]) {
80
+ const loaded = loadRunManifestById(entry.cwd, entry.runId);
81
+ if (!loaded) {
82
+ // Run state vanished (pruned/deleted) — nothing left to report.
83
+ detachedRuns.delete(entry.runId);
84
+ continue;
85
+ }
86
+ if (!isFinishedRunStatus(loaded.manifest.status)) continue;
87
+ ready.push({ runId: entry.runId, text: formatDetachedRunResult(loaded.manifest, loaded.tasks) });
88
+ }
89
+ return ready;
90
+ }
@@ -368,6 +368,11 @@ export interface InjectAdaptivePlanInput {
368
368
  tasks: TeamTaskState[];
369
369
  workflow: WorkflowConfig;
370
370
  team: TeamConfig;
371
+ /** False for scaffold (dry-run) runs: the planner never executes, so there
372
+ * is no plan to inject — and no plan to be missing. Reports neutral
373
+ * (neither injected nor missing) so the preview completes instead of
374
+ * blocking on a plan a dry-run can never produce. */
375
+ executeWorkers?: boolean;
371
376
  }
372
377
 
373
378
  export interface InjectAdaptivePlanResult {
@@ -377,8 +382,18 @@ export interface InjectAdaptivePlanResult {
377
382
  missingPlan: boolean;
378
383
  }
379
384
 
385
+ /**
386
+ * True for workflows whose `assess` step emits a plan the runtime injects as
387
+ * concrete tasks. The `implementation` workflow is adaptive by name (historic
388
+ * hard-code, kept for backward compat); any other workflow opts in via
389
+ * frontmatter `adaptive: true`.
390
+ */
391
+ export function isAdaptiveWorkflow(workflow: WorkflowConfig): boolean {
392
+ return workflow.adaptive === true || workflow.name === "implementation";
393
+ }
394
+
380
395
  export async function injectAdaptivePlanIfReady(input: InjectAdaptivePlanInput): Promise<InjectAdaptivePlanResult> {
381
- if (input.workflow.name !== "implementation")
396
+ if (!isAdaptiveWorkflow(input.workflow) || input.executeWorkers === false)
382
397
  return {
383
398
  tasks: input.tasks,
384
399
  workflow: input.workflow,
@@ -504,11 +519,22 @@ export async function injectAdaptivePlanIfReady(input: InjectAdaptivePlanInput):
504
519
  const itemId = `adaptive-p${phaseIndex + 1}-t${taskIndex + 1}`;
505
520
  const stepId = `adaptive-${phaseIndex + 1}-${taskIndex + 1}-${slug(planned.role)}`;
506
521
  const taskId = `adaptive-${String(counter).padStart(2, "0")}-${slug(planned.role)}`;
522
+ // Same display contract as static workflow steps: the planner's
523
+ // title (or the task text's first line) names the task; the full
524
+ // task text is the description for detail surfaces. Never the bare
525
+ // stepId — the task list is the plan, not an id roster.
526
+ const firstLine =
527
+ planned.task
528
+ .split("\n")
529
+ .map((line) => line.trim())
530
+ .filter(Boolean)
531
+ .find((line) => !line.startsWith("#")) ?? "";
532
+ const title = (planned.title ?? firstLine ?? stepId).slice(0, 120);
507
533
  phaseItemIds.push(itemId);
508
534
  planItems.push({
509
535
  id: itemId,
510
536
  ref: stepId,
511
- title: planned.title ?? planned.task.slice(0, 80),
537
+ title,
512
538
  taskIds: [],
513
539
  specIds: [],
514
540
  acceptance: [],
@@ -527,7 +553,8 @@ export async function injectAdaptivePlanIfReady(input: InjectAdaptivePlanInput):
527
553
  stepId,
528
554
  role: planned.role,
529
555
  agent: input.team.roles.find((role) => role.name === planned.role)?.agent ?? planned.role,
530
- title: planned.title ?? stepId,
556
+ title,
557
+ description: planned.task !== title ? planned.task : undefined,
531
558
  status: "queued",
532
559
  dependsOn: previousStepIds,
533
560
  cwd: input.manifest.cwd,
@@ -26,6 +26,7 @@ import { writeArtifact } from "../../state/stores/artifact-store.ts";
26
26
  import type { TeamRunManifest, TeamTaskState } from "../../state/types.ts";
27
27
  import { logInternalError } from "../../utils/internal-error.ts";
28
28
  import { packageRoot, projectCrewRoot, userPiRoot } from "../../utils/paths.ts";
29
+ import { safeAbort } from "../../utils/safe-abort.ts";
29
30
  import { resolveRealContainedPath } from "../../utils/safe-paths.ts";
30
31
  import type { DynamicWorkflowConfig } from "../../workflows/workflow-config.ts";
31
32
  import { assertDeterministicScript, isDeterminismCheckEnabled } from "../deterministic-ast.ts";
@@ -231,7 +232,10 @@ export async function runDynamicWorkflow(input: RunDynamicWorkflowInput): Promis
231
232
  // ERR-1: abort the timeout controller so the abort propagates to
232
233
  // runChildPi (via the combined signal passed to makeWorkflowCtx),
233
234
  // which kills spawned children instead of letting them run.
234
- timeoutController.abort();
235
+ // safeAbort: the combined signal may feed a signal-spawned child
236
+ // (abortChildProcess AbortError would otherwise reach the
237
+ // uncaughtException handler from this timer callback).
238
+ safeAbort(timeoutController, "dwf-script-timeout");
235
239
  reject(
236
240
  new Error(
237
241
  `Dynamic workflow script timed out after ${SCRIPT_TIMEOUT_MS}ms. The script may have spawned a child process that did not exit. Check for spawn/exec calls without proper stdio handling.`,
@@ -181,15 +181,31 @@ export function mergeTaskUpdatesPreservingTerminal(base: TeamTaskState[], result
181
181
  const current = indexById.get(updated.id);
182
182
  if (!current) continue;
183
183
  if (!shouldMergeTaskUpdate(current, updated)) {
184
- // Log skipped merges for visibility into rejected parallel updates.
185
- // In distributed systems with parallel workers, rejected merges may
186
- // indicate bugs (wrong status, timestamp corruption) if they accumulate.
187
- console.debug("[merge-gate] Skipping stale merge for task", updated.id, {
188
- currentStatus: current.status,
189
- updatedStatus: updated.status,
190
- currentFinishedAt: current.finishedAt,
191
- updatedFinishedAt: updated.finishedAt,
192
- });
184
+ // Routine no-op skips (an unchanged queued/running snapshot
185
+ // arriving from a parallel worker) are NORMAL parallel-merge
186
+ // behaviour and must stay silent — pi renders extension
187
+ // console output inline in the session transcript, so logging
188
+ // every skip floods whatever session is open (main or an
189
+ // agent view). Only a REAL rejected change (status transition,
190
+ // completion time, or result) is surfaced, as a warning: in
191
+ // distributed systems, rejected transitions may indicate bugs
192
+ // (wrong status, timestamp corruption) if they accumulate.
193
+ const meaningfulDiff =
194
+ updated.status !== current.status ||
195
+ updated.finishedAt !== current.finishedAt ||
196
+ updated.startedAt !== current.startedAt ||
197
+ Boolean(updated.resultArtifact) !== Boolean(current.resultArtifact) ||
198
+ Boolean(updated.error) ||
199
+ Boolean(updated.modelAttempts?.length) ||
200
+ Boolean(updated.usage);
201
+ if (meaningfulDiff) {
202
+ console.warn("[merge-gate] Rejected update for task", updated.id, {
203
+ currentStatus: current.status,
204
+ updatedStatus: updated.status,
205
+ currentFinishedAt: current.finishedAt,
206
+ updatedFinishedAt: updated.finishedAt,
207
+ });
208
+ }
193
209
  skipped += 1;
194
210
  continue;
195
211
  }
@@ -282,9 +282,13 @@ function warnUnvalidatedPassthrough(model: string, reason: string): void {
282
282
  const key = `${model}|${reason}`;
283
283
  if (passthroughWarned.has(key)) return;
284
284
  passthroughWarned.add(key);
285
- console.warn(
286
- `[model-routing] unvalidated passthrough: '${model}' (${reason}) — not confirmed against the configured model catalog; spawn may fail at the provider.`,
287
- );
285
+ // Muted: per-model passthrough noise became too loud when many pi-crew
286
+ // child workers resolve their parent model + fallback chain against the
287
+ // full catalog (each worker printed one line). Dedup Set is preserved
288
+ // so re-enabling is a one-line revert.
289
+ // console.warn(
290
+ // `[model-routing] unvalidated passthrough: '${model}' (${reason}) — not confirmed against the configured model catalog; spawn may fail at the provider.`,
291
+ // );
288
292
  }
289
293
 
290
294
  /** Test seam: the dedup set is module-global (one warn per model+reason per
@@ -25,6 +25,12 @@ const ERROR_VISIBILITY_GRACE_MS = 10 * 60 * 1000;
25
25
  /** Maximum age (ms) for an active run before it's considered stale.
26
26
  * After this time, PID-only liveness is unreliable due to PID recycling. */
27
27
  const STALE_ACTIVE_RUN_MS = 30 * 60 * 1000;
28
+ /** Window (ms) during which an active run whose agents are all
29
+ * completed/queued-without-evidence still counts as display-active: the
30
+ * runner just wrote the manifest (task transition) and the next worker has
31
+ * not recorded progress yet. Without this the dock flickers off between
32
+ * phases of a healthy run. */
33
+ const SPAWN_GAP_VISIBILITY_MS = 60 * 1000;
28
34
 
29
35
  export function checkProcessLiveness(pid: number | undefined): ProcessLiveness {
30
36
  if (pid === undefined || !Number.isInteger(pid) || pid <= 0) {
@@ -150,5 +156,14 @@ export function isDisplayActiveRun(run: TeamRunManifest, agents: CrewAgentRecord
150
156
  // runs, or from cross-cwd registry history; showing them causes noisy 0/0 rows and
151
157
  // needless spinner redraws. The full dashboard can still list historical runs.
152
158
  if (agents.length === 0) return false;
153
- return agents.some(hasDurableActiveAgentEvidence);
159
+ if (agents.some(hasDurableActiveAgentEvidence)) return true;
160
+ // Spawn gap between phases: an active run whose manifest was JUST written
161
+ // and whose next agents are queued/waiting without evidence yet stays
162
+ // visible — the runner is alive and about to spawn them. Stale manifests
163
+ // (zombies) fall through and stay hidden.
164
+ if (agents.some((agent) => agent.status === "queued" || agent.status === "waiting")) {
165
+ const updatedAt = new Date(run.updatedAt).getTime();
166
+ if (Number.isFinite(updatedAt) && now - updatedAt < SPAWN_GAP_VISIBILITY_MS) return true;
167
+ }
168
+ return false;
154
169
  }
@@ -283,6 +283,22 @@ export function cancelOrphanedRuns(
283
283
  continue;
284
284
  }
285
285
 
286
+ // Foreground (in-process) runs have NO async worker PID, so the liveness
287
+ // check above cannot apply. They execute inside THIS pi process — a
288
+ // recently-updated manifest conclusively proves the run is alive; only a
289
+ // run that has gone quiet for the stale threshold can be an orphan (a
290
+ // crashed process stops updating). Without this, a YOUNG foreground run
291
+ // (first worker heartbeat not yet written) was orphan-cancelled by the
292
+ // session that opened its agent view seconds after the run started —
293
+ // killing the very run the user was looking at.
294
+ if (!manifest.async) {
295
+ const updatedMs = manifest.updatedAt ? new Date(manifest.updatedAt).getTime() : Number.NaN;
296
+ if (Number.isFinite(updatedMs) && now - updatedMs <= staleThresholdMs) {
297
+ skipped.push(manifest.runId);
298
+ continue;
299
+ }
300
+ }
301
+
286
302
  // Check for recent heartbeat activity
287
303
  const loaded = loadRunManifestById(cwd, manifest.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
288
304
  if (!loaded) continue;
@@ -5,21 +5,38 @@ import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
5
5
  import { projectCrewRoot } from "../utils/paths.ts";
6
6
  import { isFinishedRunStatus } from "./process-status.ts";
7
7
 
8
+ export interface RunWaitResult {
9
+ manifest: TeamRunManifest;
10
+ tasks: TeamTaskState[];
11
+ /** True when the waiter was released early by `detachRunPromise` while the
12
+ * run itself keeps executing (see that function). */
13
+ detached?: boolean;
14
+ }
15
+
8
16
  export interface ActiveRunPromise {
9
- promise: Promise<{ manifest: TeamRunManifest; tasks: TeamTaskState[] }>;
10
- resolve: (value: { manifest: TeamRunManifest; tasks: TeamTaskState[] }) => void;
17
+ promise: Promise<RunWaitResult>;
18
+ resolve: (value: RunWaitResult) => void;
11
19
  reject: (reason: unknown) => void;
12
20
  }
13
21
 
14
22
  const activeRunPromises = new Map<string, ActiveRunPromise>();
15
23
 
24
+ /**
25
+ * Runs whose waiter should be released on its next opportunity.
26
+ *
27
+ * A detach request can arrive BEFORE `executeTeamRun` registers its foreground
28
+ * promise (the tool starts the run and calls `waitForRun` immediately, so a
29
+ * slow-starting run puts the waiter on the polling path with no promise to
30
+ * resolve). The flag makes the request path-independent: the polling loop
31
+ * consumes it just like `detachRunPromise` resolves a registered promise.
32
+ */
33
+ const detachRequests = new Set<string>();
34
+
16
35
  export function registerRunPromise(runId: string): ActiveRunPromise {
17
- let resolve!: (value: { manifest: TeamRunManifest; tasks: TeamTaskState[] }) => void;
36
+ detachRequests.delete(runId);
37
+ let resolve!: (value: RunWaitResult) => void;
18
38
  let reject!: (reason: unknown) => void;
19
- const promise = new Promise<{
20
- manifest: TeamRunManifest;
21
- tasks: TeamTaskState[];
22
- }>((res, rej) => {
39
+ const promise = new Promise<RunWaitResult>((res, rej) => {
23
40
  resolve = res;
24
41
  reject = rej;
25
42
  });
@@ -28,7 +45,41 @@ export function registerRunPromise(runId: string): ActiveRunPromise {
28
45
  return entry;
29
46
  }
30
47
 
31
- export function resolveRunPromise(runId: string, result: { manifest: TeamRunManifest; tasks: TeamTaskState[] }): void {
48
+ /**
49
+ * Release a foreground waiter WITHOUT stopping the run.
50
+ *
51
+ * A foreground `team run` keeps the parent pi turn streaming for the whole run,
52
+ * and pi's `switchSession` tears the current session down via
53
+ * `session.abort()` → `waitForIdle()`. Opening an agent view therefore hung
54
+ * silently until the run finished. Detaching resolves the tool's `waitForRun`
55
+ * with the run's current (still-running) state so the tool call returns, the
56
+ * parent turn can settle, and the session switch lands — `executeTeamRun`
57
+ * itself keeps going in this process and the async notifier reports completion.
58
+ *
59
+ * Returns false only when the run's state cannot be read at all. When no
60
+ * foreground promise is registered yet (or the waiter is on the polling path),
61
+ * the request is recorded and consumed by `waitForRun` on its next poll.
62
+ */
63
+ export function detachRunPromise(runId: string, cwd: string): boolean {
64
+ const loaded = loadRunManifestById(cwd, runId);
65
+ if (!loaded) return false;
66
+ const entry = activeRunPromises.get(runId);
67
+ if (entry) {
68
+ activeRunPromises.delete(runId);
69
+ detachRequests.delete(runId);
70
+ entry.resolve({ ...loaded, detached: true });
71
+ return true;
72
+ }
73
+ detachRequests.add(runId);
74
+ return true;
75
+ }
76
+
77
+ /** True while a detach was requested but no waiter has consumed it yet. */
78
+ export function hasPendingRunDetach(runId: string): boolean {
79
+ return detachRequests.has(runId);
80
+ }
81
+
82
+ export function resolveRunPromise(runId: string, result: RunWaitResult): void {
32
83
  const entry = activeRunPromises.get(runId);
33
84
  if (entry) {
34
85
  entry.resolve(result);
@@ -54,15 +105,22 @@ export async function waitForRun(
54
105
  runId: string,
55
106
  cwd: string,
56
107
  options: { timeoutMs?: number; pollIntervalMs?: number } = {},
57
- ): Promise<{ manifest: TeamRunManifest; tasks: TeamTaskState[] }> {
108
+ ): Promise<RunWaitResult> {
58
109
  const { timeoutMs = 300_000, pollIntervalMs = 500 } = options;
59
110
  const deadline = Date.now() + timeoutMs;
60
111
 
112
+ // A detach requested before this waiter existed applies to it (and is
113
+ // consumed here, so it can never leak onto a later waiter for the same run).
114
+ const detachPending = detachRequests.delete(runId);
115
+
61
116
  // Fast path: already terminal on disk
62
117
  const loaded = loadRunManifestById(cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
63
118
  if (loaded && isFinishedRunStatus(loaded.manifest.status)) {
64
119
  return loaded;
65
120
  }
121
+ if (detachPending && loaded) {
122
+ return { ...loaded, detached: true };
123
+ }
66
124
 
67
125
  // Medium path: foreground promise registered in this process
68
126
  const entry = activeRunPromises.get(runId);
@@ -78,9 +136,17 @@ export async function waitForRun(
78
136
  }
79
137
  }
80
138
 
81
- // Slow path: background run — poll with exponential backoff capped at pollIntervalMs
139
+ // Slow path: background run — poll with exponential backoff capped at pollIntervalMs.
140
+ // This path is ALSO taken by a foreground run whose executeTeamRun has not
141
+ // registered its promise yet (the tool calls waitForRun immediately after
142
+ // starting the run), so it must honour a pending detach request too —
143
+ // otherwise opening an agent view can never release the parent turn.
82
144
  let attempt = 0;
83
145
  while (Date.now() < deadline) {
146
+ if (detachRequests.delete(runId)) {
147
+ const current = loadRunManifestById(cwd, runId);
148
+ if (current) return { ...current, detached: true };
149
+ }
84
150
  if (attempt === 0) {
85
151
  // Early exit: if the run directory doesn't exist, don't waste time polling.
86
152
  // Use projectCrewRoot() to honour the .pi/teams/ fallback for .pi-based
@@ -109,6 +175,7 @@ export function hasActiveRunPromise(runId: string): boolean {
109
175
  }
110
176
 
111
177
  export function clearRunPromisesForTest(): void {
178
+ detachRequests.clear();
112
179
  for (const entry of activeRunPromises.values()) {
113
180
  entry.reject(new Error("Cleared by test"));
114
181
  }
@@ -258,7 +258,7 @@ export {
258
258
  // Re-export the test-only helpers so existing test imports still resolve.
259
259
  export { __test__mergeTaskUpdates, __test__shouldMergeTaskUpdate } from "./merge-gate.ts";
260
260
 
261
- import { injectAdaptivePlanIfReady } from "./goal-workflow/adaptive-plan.ts";
261
+ import { injectAdaptivePlanIfReady, isAdaptiveWorkflow } from "./goal-workflow/adaptive-plan.ts";
262
262
 
263
263
  // formatTaskProgress / runEffectivenessLines / scratchpadSummaryLines /
264
264
  // lastProgressContentHash / writeProgress moved to ./finalize-run.ts (2026-08
@@ -769,7 +769,7 @@ async function executeTeamRunCore(
769
769
  }
770
770
  let tasks = refreshTaskGraphQueues(input.tasks);
771
771
  let queueIndex = buildTaskGraphIndex(tasks);
772
- const canInjectAdaptivePlan = workflow.name === "implementation";
772
+ const canInjectAdaptivePlan = isAdaptiveWorkflow(workflow);
773
773
  let adaptivePlanInjected = false;
774
774
  let adaptivePlanMissing = false;
775
775
  const attemptAdaptivePlan = async () => {
@@ -779,6 +779,7 @@ async function executeTeamRunCore(
779
779
  tasks,
780
780
  workflow,
781
781
  team: input.team,
782
+ executeWorkers: input.executeWorkers,
782
783
  });
783
784
  adaptivePlanInjected = adaptivePlanInjected || adaptivePlan.injected;
784
785
  adaptivePlanMissing = adaptivePlan.missingPlan;
@@ -282,7 +282,7 @@ export const PiTeamsOtlpConfigSchema = Type.Object(
282
282
 
283
283
  export const PiTeamsUiConfigSchema = Type.Object(
284
284
  {
285
- widgetPlacement: Type.Optional(Type.Union([Type.Literal("aboveEditor"), Type.Literal("belowEditor")])),
285
+ widgetPlacement: Type.Optional(Type.Union([Type.Literal("aboveEditor"), Type.Literal("belowEditor"), Type.Literal("bottom")])),
286
286
  widgetMaxLines: Type.Optional(Type.Integer({ minimum: 1, maximum: 50 })),
287
287
  powerbar: Type.Optional(Type.Boolean()),
288
288
  dashboardPlacement: Type.Optional(Type.Union([Type.Literal("center"), Type.Literal("right")])),
@@ -50,7 +50,7 @@ export function loadPlanRecords(manifest: TeamRunManifest): PlanRecord[] {
50
50
  const raw = fs.readFileSync(planFilePath(manifest), "utf-8");
51
51
  const parsed = JSON.parse(raw) as Partial<PlanFile>;
52
52
  if (!parsed || !Array.isArray(parsed.revisions)) return [];
53
- return parsed.revisions.filter((r): r is PlanRecord => Boolean(r && r.id && typeof r.version === "number"));
53
+ return parsed.revisions.filter((r): r is PlanRecord => Boolean(r?.id && typeof r.version === "number"));
54
54
  } catch (err) {
55
55
  if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
56
56
  logInternalError("plan-store.read-failed", err instanceof Error ? err : new Error(String(err)), `run=${manifest.runId}`);
@@ -260,7 +260,42 @@ export function createRunPaths(cwd: string, runId = createRunId()): RunPaths {
260
260
  };
261
261
  }
262
262
 
263
- export function createTasksFromWorkflow(runId: string, workflow: WorkflowConfig, team: TeamConfig, cwd: string): TeamTaskState[] {
263
+ /**
264
+ * Trailing phrases a step template leaves in front of `{goal}` — "Explore
265
+ * the codebase for the goal: {goal}" should name the task ("Explore the
266
+ * codebase"), not quote the whole run goal after every verb. The run goal is
267
+ * run-level context (statusline shows it once); a task list that repeats it
268
+ * per row reads like a roster of workers, not a list of work.
269
+ */
270
+ const GOAL_TAILS = ["for the goal", "for goal", "the goal", "goal", "cho mục tiêu", "của mục tiêu", "mục tiêu", "for", "of", "cho", "của"];
271
+
272
+ function stripGoalTail(prefix: string): string {
273
+ let name = prefix.replace(/[\s:—-]+$/, "").trim();
274
+ let changed = true;
275
+ while (changed) {
276
+ changed = false;
277
+ const lowered = name.toLowerCase();
278
+ for (const tail of GOAL_TAILS) {
279
+ if (lowered.endsWith(tail) && lowered.length > tail.length) {
280
+ name = name
281
+ .slice(0, name.length - tail.length)
282
+ .replace(/[\s:—-]+$/, "")
283
+ .trim();
284
+ changed = true;
285
+ break;
286
+ }
287
+ }
288
+ }
289
+ return name;
290
+ }
291
+
292
+ export function createTasksFromWorkflow(
293
+ runId: string,
294
+ workflow: WorkflowConfig,
295
+ team: TeamConfig,
296
+ cwd: string,
297
+ goal?: string,
298
+ ): TeamTaskState[] {
264
299
  const stepToTaskId = new Map(workflow.steps.map((step, index) => [step.id, createTaskId(step.id, index)]));
265
300
  return workflow.steps.map((step, index) => {
266
301
  const role = team.roles.find((candidate) => candidate.name === step.role);
@@ -270,13 +305,48 @@ export function createTasksFromWorkflow(runId: string, workflow: WorkflowConfig,
270
305
  .filter((candidate) => candidate.dependsOn?.includes(step.id))
271
306
  .map((candidate) => stepToTaskId.get(candidate.id))
272
307
  .filter((childId): childId is string => childId !== undefined);
308
+ // The plan's own words become the task's display identity: a heading
309
+ // from the step body if present, else its first content line; the
310
+ // remaining body is kept as `description` for detail surfaces (task
311
+ // list detail line, view). Falls back to the step id when the body
312
+ // carries no text of its own — `task` is optional on literal
313
+ // WorkflowStep objects.
314
+ //
315
+ // `{goal}` in the title line is the RUN's goal, context for every
316
+ // task, not this task's name. The template prefix before the
317
+ // placeholder names the task; a placeholder-only body ("{goal}",
318
+ // direct-run) adopts the goal itself as the title. `description`
319
+ // lines substitute the placeholder (the prompt path substitutes
320
+ // separately — unchanged).
321
+ const bodyLines =
322
+ typeof step.task === "string" && step.task.trim()
323
+ ? step.task
324
+ .split("\n")
325
+ .map((line) => line.trim())
326
+ .filter(Boolean)
327
+ : [];
328
+ const heading = bodyLines.find((line) => line.startsWith("#"));
329
+ const titleLine = heading ?? bodyLines[0];
330
+ const substitute = (text: string): string => (goal ? text.replaceAll("{goal}", goal) : text);
331
+ let title: string;
332
+ let restLines: string[];
333
+ const placeholderAt = titleLine?.indexOf("{goal}") ?? -1;
334
+ if (titleLine && placeholderAt >= 0) {
335
+ title = stripGoalTail(titleLine.slice(0, placeholderAt)) || goal || step.id;
336
+ restLines = bodyLines.filter((line) => line !== titleLine);
337
+ } else {
338
+ title = (titleLine ?? step.id).replace(/^#+\s*/, "");
339
+ restLines = heading ? bodyLines.filter((line) => line !== titleLine) : bodyLines.slice(1);
340
+ }
341
+ const description = restLines.map(substitute).join("\n");
273
342
  return {
274
343
  id,
275
344
  runId,
276
345
  stepId: step.id,
277
346
  role: step.role,
278
347
  agent: role?.agent ?? step.role,
279
- title: step.id,
348
+ title: title.slice(0, 120),
349
+ description: description && description !== title ? description : undefined,
280
350
  status: "queued",
281
351
  dependsOn: dependencies,
282
352
  cwd,
@@ -305,7 +375,7 @@ export function createRunManifest(params: {
305
375
  }): { manifest: TeamRunManifest; tasks: TeamTaskState[]; paths: RunPaths } {
306
376
  const paths = createRunPaths(params.cwd);
307
377
  const now = new Date().toISOString();
308
- const tasks = params.workflow ? createTasksFromWorkflow(paths.runId, params.workflow, params.team, params.cwd) : [];
378
+ const tasks = params.workflow ? createTasksFromWorkflow(paths.runId, params.workflow, params.team, params.cwd, params.goal) : [];
309
379
  const manifest: TeamRunManifest = {
310
380
  schemaVersion: CURRENT_SCHEMA_VERSION,
311
381
  runId: paths.runId,
@@ -554,6 +554,8 @@ export interface TeamTaskState {
554
554
  role: string;
555
555
  agent: string;
556
556
  title: string;
557
+ /** Full step body (the detailed plan text behind `title`). */
558
+ description?: string;
557
559
  displayName?: string;
558
560
  status: TeamTaskStatus;
559
561
  dependsOn: string[];