pi-cohort 7.0.2 → 7.1.1

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [7.1.1] - 2026-09-24
4
+
5
+ ### Changed
6
+
7
+ - A dispatch that names no model now runs the child on the parent session's current model and thinking level instead of the child's saved `defaultModel`; `thinking: off` is emitted as `:off`, and `max` is a recognized level. Precedence: per-call `model:` > agent `model` / `thinking` (after `agentOverrides`) > parent session > none. Pin `subagents.agentOverrides.<agent>.model` to keep a child on a fixed model. Agents that pin `model` but not `thinking` now also take the parent's thinking level; set `thinking` to keep a fixed level. Children now bill on the parent's model, and an agent with `extensions:` whose parent model comes from an extension provider fails as an unknown model would; give it a pinned model or `fallbackModels`.
8
+
9
+ ## [7.1.0] - 2026-09-20
10
+
11
+ ### Changed
12
+
13
+ - `/handoff` no longer expands; `/skill:handoff [--out <path> | --key <stem>]` replaces it, writes to `<tmpdir>/pi-handoff/<primary>--<leaf>.md` by default and ends with `Handoff written: <path>`; `## Process state` is no longer produced here; argument-less `gauntlet-resume` lookup of that path lands on the pi-gauntlet side - until then resume takes the printed path. ([#18](https://github.com/jjuraszek/pi-cohort/issues/18), [pi-gauntlet#40](https://github.com/jjuraszek/pi-gauntlet/issues/40))
14
+
3
15
  ## [7.0.2] - 2026-09-20
4
16
 
5
17
  ### Changed
package/README.md CHANGED
@@ -117,7 +117,7 @@ Run parallel reviewers: one for correctness, one for tests, and one for unnecess
117
117
 
118
118
  That's the whole surface for day-to-day use. More phrasing patterns: [doc/commands.md](doc/commands.md#prompt-cookbook-appendix).
119
119
 
120
- Review and delivery workflows live in pi-gauntlet; pi-cohort ships delegation primitives plus `/investigate` and `/handoff`.
120
+ Review and delivery workflows live in pi-gauntlet; pi-cohort ships delegation primitives plus `/investigate` and `/skill:handoff [--out <path> | --key <stem>]` (the brief lands in `<tmpdir>/pi-handoff/<primary>--<leaf>.md` by default; contract in [doc/handoff-template.md](doc/handoff-template.md)).
121
121
 
122
122
  ## Architecture
123
123
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cohort",
3
- "version": "7.0.2",
3
+ "version": "7.1.1",
4
4
  "description": "Delegate Pi work to focused child agents: code review, scouting, implementation, parallel audits, saved chains, and background jobs.",
5
5
  "author": "Jacek Juraszek",
6
6
  "license": "MIT",
@@ -0,0 +1,83 @@
1
+ ---
2
+ name: handoff
3
+ description: Use when the context window is nearly full and the work must continue in a fresh session.
4
+ ---
5
+
6
+ # Handoff brief
7
+
8
+ Write a handoff brief for a fresh session. The brief is the contract in `doc/handoff-template.md` of pi-cohort: section names and order are fixed. This file carries every rule a producer needs; a caller that follows it reads nothing else. Nothing is written into the repository.
9
+
10
+ ## Argument
11
+
12
+ The argument text, if any, follows this block. Tokenize it shell-like (unquoted tokens end at whitespace; `".."`/`'..'` keep whitespace; `--opt=value` is one token). An option's value is the next token; a next token that is itself `--out`, `--key`, or an `--out=`/`--key=` form, or no next token, or an empty `--opt=` value, means the option has no value. `--out <path>` names the output file (relative resolves against cwd); `--key <stem>` names the file inside the shared mailbox directory; every other token is ignored.
13
+
14
+ Argument failures end the reply with the line and write nothing:
15
+
16
+ - an option with an empty or missing value -> `Handoff not written: <option> has no value`
17
+ - an option given twice -> `Handoff not written: <option> given twice`
18
+ - both options -> `Handoff not written: --out and --key are exclusive`
19
+
20
+ ## Rules
21
+
22
+ - Scratch dir, invocation-unique so overlapping invocations never read each other's snapshot: `SCRATCH=$(node -p "require('fs').mkdtempSync(require('path').join(require('os').tmpdir(), 'pi-handoff-'))")`. If `node` is missing -> `Handoff not written: cannot resolve tmpdir`.
23
+ - The snapshot dispatch: `async: false` and `context: "fresh"` at the top level; `reads: false`; absolute `output` under `$SCRATCH`. If it returns an async handle, a `forceTopLevelAsync` setting is active: report that in the reply, do not poll, relaunch, or continue the child, and write the brief with `## Repo state: unavailable (async handle returned)`.
24
+ - The child is read-only against the repository; its only write is its `output` file.
25
+ - You write `## Intent`, `## Decisions`, `## Open questions`, and `## Skills loaded` from your own transcript, before the call and after it returns. Do not fork a child for this: forking a near-full transcript is the cost this handoff avoids.
26
+
27
+ ## Run worktree
28
+
29
+ Before the snapshot dispatch, determine the run worktree from your own transcript and interpolate it into the scout task as `Run worktree: <abs path>` or `Run worktree: none`:
30
+
31
+ - A flow-level worktree the run created (any `git worktree add <path>` you ran, or a `Worktree ready at <path>` report from the skill that set up the worktree) or was told to work in (a resume brief's `worktree: yes <path>`, or a user instruction).
32
+ - Never inferred from the session cwd; a created or assigned worktree still qualifies when it happens to equal the cwd. Never a per-task worktree from `tasks[].worktree: true` or `worktree: true` dispatches - those are ephemeral.
33
+ - Two flow-level worktrees in the transcript: the candidate is the one most recently used by later work (a dispatch `cwd`, a `git -C <path>`, or a `cd <path>`); if that does not distinguish them, the most recent creation or assignment. The other goes into `## Open questions` as `Also seen: <path> - not recorded as the run worktree`.
34
+ - No flow-level worktree created or named: `Run worktree: none`.
35
+
36
+ ## Repo snapshot
37
+
38
+ ```
39
+ subagent({ async: false, context: "fresh", tasks: [
40
+ { agent: "scout", cwd: "<cwd>", reads: false, output: "<SCRATCH>/repo.md",
41
+ task: "Read-only; write only to your output path. Run worktree: <abs path|none>. Steps: (1) linked set: git worktree list --porcelain | awk '/^worktree /{print substr($0,10)}' - the first line is the primary checkout, the rest are the linked worktrees; a relative candidate resolves against the primary toplevel, not cwd. (2) candidate `none` or line absent -> target is cwd, go to (4). Otherwise if the candidate is relative, prefix it with the primary toplevel (first linked-set line) first; C=$(git -C <candidate> rev-parse --show-toplevel); valid when that succeeds and C equals one of the linked lines exactly (a line after the first; equality with the first, primary line is invalid); anything else (failure, not listed, the primary itself, prunable) is invalid -> target is cwd and emit the open-question line in (5). (3) valid -> target is $C and `worktree: yes $C`. (4) target cwd -> T=$(git rev-parse --show-toplevel); `worktree: yes $T` when $T equals one of the linked lines exactly (a line after the first; the first, primary line gives `worktree: no`), else `worktree: no`. (5) report each field on its own line, `unavailable` when a command fails, every git command as `git -C <target>`: toplevel (git -C <target> rev-parse --show-toplevel); the worktree line from (3) or (4); branch (git -C <target> branch --show-current, `detached` when empty); HEAD SHA; base (origin/HEAD short name, else origin/main or origin/master if present, else `base: unknown`); dirty (git -C <target> status --porcelain, or `clean`); diff-stat (git -C <target> diff --stat <base>...HEAD; `diff-stat: unavailable` when base is unknown); test command (<target>/package.json scripts or <target>/AGENTS.md). Invalid candidate: after the fields add the single line `open-question: producer named <candidate as submitted> as the run worktree; it is not a linked worktree of this repo`. cwd not a git repo -> the single line `not a git repo`, plus the same open-question line if a candidate was submitted." }
42
+ ]})
43
+ ```
44
+
45
+ Outcomes: the fields become `## Repo state`; any `open-question:` line goes into `## Open questions` without its prefix and never into `## Repo state`; `not a git repo` -> `## Repo state: not a git repo`; child failed, file missing or empty, or async handle -> `## Repo state: unavailable (<one-line reason>)`. The brief is written in every outcome - the transcript-derived sections are the point of a handoff.
46
+
47
+ ## Destination
48
+
49
+ Resolve after the snapshot (the default key needs its `worktree:` line). Precedence: `--out`, then `--key`, then the default key.
50
+
51
+ - `--out <path>`: that path, made absolute. Its parent directory must exist, else `Handoff not written: parent directory <dir> does not exist`; never fall back to the default when the caller named a path.
52
+ - `--key <stem>`: an exact caller-owned basename stem, written verbatim (no encoding) with `.md` appended - `--key foo.md` gives `foo.md.md`. Valid stem: non-empty after trimming, not `.` or `..`, none of `/`, `\`, NUL, control characters, or `<>:"|?*`, and not starting with `<primary>--` (the default-key prefix). Invalid -> `Handoff not written: --key is not a valid file name stem`; prefixed -> `Handoff not written: --key is reserved for the default key`. Destination `path.join(<tmpdir>, 'pi-handoff', <stem> + '.md')`.
53
+ - Default key `<primary>--<leaf>` at `<tmpdir>/pi-handoff/<primary>--<leaf>.md`, overwritten on each run:
54
+
55
+ ```bash
56
+ TMP=$(node -p "require('os').tmpdir()")
57
+ PRIMARY=$(basename "$(dirname "$(git rev-parse --path-format=absolute --git-common-dir)")")
58
+ # LEAF: basename of the run worktree when the snapshot says `worktree: yes <path>`;
59
+ # else `git branch --show-current`; else `detached`
60
+ enc() { printf '%s' "$1" | sed 's/[^A-Za-z0-9._-]/-/g'; }
61
+ OUT="$TMP/pi-handoff/$(enc "$PRIMARY")--$(enc "$LEAF").md"
62
+ ```
63
+
64
+ Outside a git repo the key is `no-repo--<encoded cwd basename>`. The encoder `[^A-Za-z0-9._-]` -> `-` applies to the two generated components only, never to a `--key` value. Examples: primary checkout on `main` with no run worktree -> `pi-cohort--main.md`; run worktree `.worktrees/gh-18-handoff-skill` -> `pi-cohort--gh-18-handoff-skill.md`; branch `feat/x` -> `pi-cohort--feat-x.md`.
65
+ - For `--key` and the default, `mkdir -p "$TMP/pi-handoff"` first. Paths are joined with `/`; `os.tmpdir()` covers Windows (`%LOCALAPPDATA%\Temp`).
66
+
67
+ ## Brief
68
+
69
+ ```markdown
70
+ # Handoff: <one line>
71
+ ## Intent
72
+ ## Repo state
73
+ ## Decisions
74
+ ## Open questions
75
+ ## Skills loaded
76
+ ```
77
+
78
+ - `## Repo state`: the snapshot fields, or one of the heading variants above.
79
+ - `## Decisions`: bullets; rejected alternatives marked `rejected:`.
80
+ - `## Open questions`: bullets, including any copied `open-question:` line and any `Also seen:` line.
81
+ - `## Skills loaded`: the frontmatter `name` of every skill whose SKILL.md body is in your context (a `<skill>` block or a tool result on a `*/SKILL.md` path); it always includes `handoff`.
82
+
83
+ Write nothing after `## Skills loaded`: a caller that follows this skill and continues in the same session may append its own `##` sections there. Then `rm -rf "$SCRATCH"`, echo the brief, and end the reply with the line `Handoff written: <abs path>`.
@@ -42,7 +42,7 @@ export function serializeAgent(config: AgentConfig): string {
42
42
  if (config.model) lines.push(`model: ${config.model}`);
43
43
  const fallbackModelsValue = joinComma(config.fallbackModels);
44
44
  if (fallbackModelsValue) lines.push(`fallbackModels: ${fallbackModelsValue}`);
45
- if (config.thinking && config.thinking !== "off") lines.push(`thinking: ${config.thinking}`);
45
+ if (config.thinking) lines.push(`thinking: ${config.thinking}`);
46
46
  lines.push(`systemPromptMode: ${config.systemPromptMode}`);
47
47
  lines.push(`inheritProjectContext: ${config.inheritProjectContext ? "true" : "false"}`);
48
48
  lines.push(`inheritSkills: ${config.inheritSkills ? "true" : "false"}`);
@@ -51,6 +51,10 @@ interface AsyncExecutionContext {
51
51
  cwd: string;
52
52
  currentSessionId: string;
53
53
  currentModelProvider?: string;
54
+ /** Parent session model as provider/id, captured at dispatch */
55
+ parentModel?: string;
56
+ /** Parent session thinking level, captured at dispatch */
57
+ parentThinking?: string;
54
58
  executionBackend?: string;
55
59
  }
56
60
 
@@ -355,8 +359,10 @@ export function executeAsyncChain(
355
359
  taskTemplate = taskTemplate.replace(/\{chain_dir\}/g, runnerCwd);
356
360
  const task = injectSingleOutputInstruction(`${readInstructions.prefix}${taskTemplate}${progressInstructions.suffix}`, outputPath);
357
361
 
358
- const primaryModel = resolveModelCandidate(behavior.model ?? a.model, availableModels, ctx.currentModelProvider);
359
- const model = applyThinkingSuffix(primaryModel, a.thinking);
362
+ const primaryRaw = behavior.model ?? a.model ?? ctx.parentModel;
363
+ const thinking = a.thinking ?? ctx.parentThinking;
364
+ const primaryModel = resolveModelCandidate(primaryRaw, availableModels, ctx.currentModelProvider);
365
+ const model = applyThinkingSuffix(primaryModel, thinking);
360
366
  return {
361
367
  agent: s.agent,
362
368
  task,
@@ -366,9 +372,9 @@ export function executeAsyncChain(
366
372
  structured: Boolean(s.outputSchema),
367
373
  cwd: stepCwd,
368
374
  model,
369
- thinking: resolveEffectiveThinking(model, a.thinking),
370
- modelCandidates: buildModelCandidates(behavior.model ?? a.model, a.fallbackModels, availableModels, ctx.currentModelProvider).map((candidate) =>
371
- applyThinkingSuffix(candidate, a.thinking),
375
+ thinking: resolveEffectiveThinking(model, thinking),
376
+ modelCandidates: buildModelCandidates(primaryRaw, a.fallbackModels, availableModels, ctx.currentModelProvider).map((candidate) =>
377
+ applyThinkingSuffix(candidate, thinking),
372
378
  ),
373
379
  tools: a.tools,
374
380
  extensions: a.extensions,
@@ -670,10 +676,9 @@ export function executeAsyncSingle(
670
676
  const validationError = validateFileOnlyOutputMode(outputMode, outputPath, `Async single run (${agent})`);
671
677
  if (validationError) return formatAsyncStartError("single", validationError);
672
678
  const taskWithOutputInstruction = injectSingleOutputInstruction(task, outputPath);
673
- const model = applyThinkingSuffix(
674
- resolveModelCandidate(params.modelOverride ?? agentConfig.model, availableModels, ctx.currentModelProvider),
675
- agentConfig.thinking,
676
- );
679
+ const primaryRaw = params.modelOverride ?? agentConfig.model ?? ctx.parentModel;
680
+ const thinking = agentConfig.thinking ?? ctx.parentThinking;
681
+ const model = applyThinkingSuffix(resolveModelCandidate(primaryRaw, availableModels, ctx.currentModelProvider), thinking);
677
682
  let spawnResult: { pid?: number; error?: string } = {};
678
683
  try {
679
684
  spawnResult = spawnRunner(
@@ -685,9 +690,9 @@ export function executeAsyncSingle(
685
690
  task: taskWithOutputInstruction,
686
691
  cwd: runnerCwd,
687
692
  model,
688
- thinking: resolveEffectiveThinking(model, agentConfig.thinking),
689
- modelCandidates: buildModelCandidates(params.modelOverride ?? agentConfig.model, agentConfig.fallbackModels, availableModels, ctx.currentModelProvider).map((candidate) =>
690
- applyThinkingSuffix(candidate, agentConfig.thinking),
693
+ thinking: resolveEffectiveThinking(model, thinking),
694
+ modelCandidates: buildModelCandidates(primaryRaw, agentConfig.fallbackModels, availableModels, ctx.currentModelProvider).map((candidate) =>
695
+ applyThinkingSuffix(candidate, thinking),
691
696
  ),
692
697
  tools: agentConfig.tools,
693
698
  extensions: agentConfig.extensions,
@@ -11,7 +11,7 @@ import { matchesKey, visibleWidth, truncateToWidth } from "@earendil-works/pi-tu
11
11
  import type { AgentConfig } from "../../agents/agents.ts";
12
12
  import type { ResolvedStepBehavior } from "../../shared/settings.ts";
13
13
  import { resolveModelCandidate, splitThinkingSuffix } from "../shared/model-fallback.ts";
14
- import { findModelInfo, getSupportedThinkingLevels, type ModelInfo, type ThinkingLevel } from "../../shared/model-info.ts";
14
+ import { findModelInfo, getSupportedThinkingLevels, splitKnownThinkingSuffix, type ModelInfo, type ThinkingLevel } from "../../shared/model-info.ts";
15
15
 
16
16
  type ClarifyMode = 'single' | 'parallel' | 'chain';
17
17
 
@@ -696,9 +696,8 @@ export class ChainClarifyComponent implements Component {
696
696
  const currentModel = this.getEffectiveBehavior(stepIndex).model;
697
697
  if (!currentModel) return;
698
698
 
699
- const { baseModel } = splitThinkingSuffix(currentModel);
700
- const newModel = level === "off" ? baseModel : `${baseModel}:${level}`;
701
- this.updateBehavior(stepIndex, "model", newModel);
699
+ const { baseModel } = splitKnownThinkingSuffix(currentModel);
700
+ this.updateBehavior(stepIndex, "model", `${baseModel}:${level}`);
702
701
  }
703
702
 
704
703
  private filterSkills(): void {
@@ -1009,7 +1008,8 @@ export class ChainClarifyComponent implements Component {
1009
1008
  "low": "Light reasoning",
1010
1009
  "medium": "Moderate reasoning",
1011
1010
  "high": "Deep reasoning",
1012
- "xhigh": "Maximum reasoning (ultrathink)",
1011
+ "xhigh": "Extra-deep reasoning (ultrathink)",
1012
+ "max": "Provider maximum reasoning",
1013
1013
  };
1014
1014
 
1015
1015
  const levels = this.getAvailableThinkingLevels(this.editingStep!);
@@ -8,7 +8,7 @@ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
8
8
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
9
9
  import type { AgentConfig } from "../../agents/agents.ts";
10
10
  import { ChainClarifyComponent, type ChainClarifyResult, type BehaviorOverride } from "./chain-clarify.ts";
11
- import { toModelInfo, type ModelInfo } from "../../shared/model-info.ts";
11
+ import { parentModelFullId, toModelInfo, type ModelInfo } from "../../shared/model-info.ts";
12
12
  import {
13
13
  resolveChainTemplates,
14
14
  createChainDir,
@@ -58,6 +58,7 @@ import {
58
58
  resolveChildMaxSubagentDepth,
59
59
  } from "../../shared/types.ts";
60
60
  import { resolveModelCandidate } from "../shared/model-fallback.ts";
61
+ import { applyThinkingSuffix } from "../shared/pi-args.ts";
61
62
  import { validateFileOnlyOutputMode } from "../shared/single-output.ts";
62
63
  import { buildWorkflowGraphSnapshot } from "../shared/workflow-graph.ts";
63
64
  import { ChainOutputValidationError, outputEntryFromResult, resolveOutputReferences, validateChainOutputBindings } from "../shared/chain-outputs.ts";
@@ -278,6 +279,8 @@ async function runParallelChainTasks(input: ParallelChainRunInput): Promise<Sing
278
279
  modelOverride: effectiveModel,
279
280
  availableModels: input.availableModels,
280
281
  preferredModelProvider: input.ctx.model?.provider,
282
+ parentModel: parentModelFullId(input.ctx.model),
283
+ parentThinking: input.ctx.thinkingLevel,
281
284
  skills: behavior.skills === false ? [] : behavior.skills,
282
285
  structuredOutput: structuredRuntime,
283
286
  acceptance: task.acceptance,
@@ -512,6 +515,14 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
512
515
  resolveStepBehavior(config, stepOverrides[i]!, chainSkills),
513
516
  );
514
517
  const flatTemplates = templates as string[];
518
+ // Seed what dispatch will resolve so the screen shows it and the thinking selector is enabled;
519
+ // untouched steps still reach runSync with no override and inherit there.
520
+ const parentModel = parentModelFullId(ctx.model);
521
+ const seededBehaviors = resolvedBehaviors.map((behavior, i) => {
522
+ const model = behavior.model ?? parentModel;
523
+ if (!model) return behavior;
524
+ return { ...behavior, model: applyThinkingSuffix(model, agentConfigs[i]!.thinking ?? ctx.thinkingLevel) };
525
+ });
515
526
 
516
527
  const result = await ctx.ui.custom<ChainClarifyResult>(
517
528
  (tui, theme, _kb, done) =>
@@ -522,7 +533,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
522
533
  flatTemplates,
523
534
  originalTask,
524
535
  chainDir,
525
- resolvedBehaviors,
536
+ seededBehaviors,
526
537
  availableModels,
527
538
  ctx.model?.provider,
528
539
  availableSkills,
@@ -1030,6 +1041,8 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
1030
1041
  modelOverride: effectiveModel,
1031
1042
  availableModels,
1032
1043
  preferredModelProvider: ctx.model?.provider,
1044
+ parentModel: parentModelFullId(ctx.model),
1045
+ parentThinking: ctx.thinkingLevel,
1033
1046
  skills: behavior.skills === false ? [] : behavior.skills,
1034
1047
  structuredOutput: structuredRuntime,
1035
1048
  acceptance: seqStep.acceptance,
@@ -684,11 +684,12 @@ export async function runSync(
684
684
  }
685
685
 
686
686
  const candidates = buildModelCandidates(
687
- options.modelOverride ?? agent.model,
687
+ options.modelOverride ?? agent.model ?? options.parentModel,
688
688
  agent.fallbackModels,
689
689
  options.availableModels,
690
690
  options.preferredModelProvider,
691
691
  );
692
+ const effectiveAgent: AgentConfig = { ...agent, thinking: agent.thinking ?? options.parentThinking };
692
693
  const attemptedModels: string[] = [];
693
694
  const modelAttempts: ModelAttempt[] = [];
694
695
  const aggregateUsage = emptyUsage();
@@ -751,7 +752,7 @@ export async function runSync(
751
752
  owner: externalOwner,
752
753
  attemptId: `${childId}-attempt-${i}`,
753
754
  runtimeCwd,
754
- agent,
755
+ agent: effectiveAgent,
755
756
  model: candidate,
756
757
  task: taskWithAcceptance,
757
758
  options,
@@ -762,7 +763,7 @@ export async function runSync(
762
763
  }
763
764
  } else {
764
765
  result = await (dependencies.runNativeAttempt ?? runSingleAttempt)(
765
- runtimeCwd, agent, taskWithAcceptance, candidate, options, shared,
766
+ runtimeCwd, effectiveAgent, taskWithAcceptance, candidate, options, shared,
766
767
  );
767
768
  }
768
769
  lastResult = result;
@@ -6,7 +6,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
6
6
  import type { AgentConfig, AgentScope } from "../../agents/agents.ts";
7
7
  import { getArtifactsDir } from "../../shared/artifacts.ts";
8
8
  import { ChainClarifyComponent, type ChainClarifyResult } from "./chain-clarify.ts";
9
- import { toModelInfo, type ModelInfo } from "../../shared/model-info.ts";
9
+ import { parentModelFullId, toModelInfo, type ModelInfo } from "../../shared/model-info.ts";
10
10
  import { executeChain } from "./chain-execution.ts";
11
11
  import { resolveExecutionAgentScope } from "../../agents/agent-scope.ts";
12
12
  import { handleManagementAction } from "../../agents/agent-management.ts";
@@ -210,6 +210,18 @@ function nestedResolutionScopeForExecutor(deps: ExecutorDeps): NestedRunResoluti
210
210
  };
211
211
  }
212
212
 
213
+ function buildAsyncContext(ctx: ExtensionContext, deps: ExecutorDeps, cwd: string) {
214
+ return {
215
+ pi: deps.pi,
216
+ cwd,
217
+ currentSessionId: deps.state.currentSessionId!,
218
+ currentModelProvider: ctx.model?.provider,
219
+ parentModel: parentModelFullId(ctx.model),
220
+ parentThinking: ctx.thinkingLevel,
221
+ executionBackend: deps.config.executionBackend,
222
+ };
223
+ }
224
+
213
225
  function foregroundStatusResult(control: SubagentState["foregroundControls"] extends Map<string, infer T> ? T : never): AgentToolResult<Details> {
214
226
  let nestedWarning: string | undefined;
215
227
  try {
@@ -591,13 +603,7 @@ async function resumeAsyncRun(input: {
591
603
  agent: target.agent,
592
604
  task: buildRevivedAsyncTask(target, followUp),
593
605
  agentConfig,
594
- ctx: {
595
- pi: input.deps.pi,
596
- cwd: input.requestCwd,
597
- currentSessionId: input.deps.state.currentSessionId,
598
- currentModelProvider: input.ctx.model?.provider,
599
- executionBackend: input.deps.config.executionBackend,
600
- },
606
+ ctx: buildAsyncContext(input.ctx, input.deps, input.requestCwd),
601
607
  cwd: effectiveCwd,
602
608
  maxOutput: input.params.maxOutput,
603
609
  artifactsDir: input.deps.tempArtifactsDir,
@@ -974,13 +980,7 @@ function runAsyncPath(data: ExecutionContextData, deps: ExecutorDeps): AgentTool
974
980
  };
975
981
  }
976
982
  const id = randomUUID();
977
- const asyncCtx = {
978
- pi: deps.pi,
979
- cwd: ctx.cwd,
980
- currentSessionId: deps.state.currentSessionId!,
981
- currentModelProvider: ctx.model?.provider,
982
- executionBackend: deps.config.executionBackend,
983
- };
983
+ const asyncCtx = buildAsyncContext(ctx, deps, ctx.cwd);
984
984
  const availableModels: ModelInfo[] = ctx.modelRegistry.getAvailable().map(toModelInfo);
985
985
  const currentMaxSubagentDepth = resolveCurrentMaxSubagentDepth(deps.config.maxSubagentDepth);
986
986
  const currentProvider = ctx.model?.provider;
@@ -1164,13 +1164,7 @@ async function runChainPath(data: ExecutionContextData, deps: ExecutorDeps): Pro
1164
1164
  };
1165
1165
  }
1166
1166
  const id = randomUUID();
1167
- const asyncCtx = {
1168
- pi: deps.pi,
1169
- cwd: ctx.cwd,
1170
- currentSessionId: deps.state.currentSessionId!,
1171
- currentModelProvider: ctx.model?.provider,
1172
- executionBackend: deps.config.executionBackend,
1173
- };
1167
+ const asyncCtx = buildAsyncContext(ctx, deps, ctx.cwd);
1174
1168
  const asyncChain = wrapChainTasksForFork(chainResult.requestedAsync.chain, params.context);
1175
1169
  return executeAsyncChain(id, {
1176
1170
  chain: asyncChain,
@@ -1432,6 +1426,8 @@ async function runForegroundParallelTasks(input: ForegroundParallelRunInput): Pr
1432
1426
  modelOverride: input.modelOverrides[index],
1433
1427
  availableModels: input.availableModels,
1434
1428
  preferredModelProvider: input.ctx.model?.provider,
1429
+ parentModel: parentModelFullId(input.ctx.model),
1430
+ parentThinking: input.ctx.thinkingLevel,
1435
1431
  skills: effectiveSkills === false ? [] : effectiveSkills,
1436
1432
  acceptance: task.acceptance,
1437
1433
  acceptanceContext: { mode: "parallel" },
@@ -1605,13 +1601,7 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
1605
1601
  };
1606
1602
  }
1607
1603
  const id = randomUUID();
1608
- const asyncCtx = {
1609
- pi: deps.pi,
1610
- cwd: ctx.cwd,
1611
- currentSessionId: deps.state.currentSessionId!,
1612
- currentModelProvider: ctx.model?.provider,
1613
- executionBackend: deps.config.executionBackend,
1614
- };
1604
+ const asyncCtx = buildAsyncContext(ctx, deps, ctx.cwd);
1615
1605
  const parallelTasks = tasks.map((t, i) => {
1616
1606
  const taskText = params.context === "fork" ? wrapForkTask(taskTexts[i]!) : taskTexts[i]!;
1617
1607
  const progress = taskDisallowsFileUpdates(taskText) ? false : behaviorOverrides[i]?.progress;
@@ -1883,13 +1873,7 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
1883
1873
  };
1884
1874
  }
1885
1875
  const id = randomUUID();
1886
- const asyncCtx = {
1887
- pi: deps.pi,
1888
- cwd: ctx.cwd,
1889
- currentSessionId: deps.state.currentSessionId!,
1890
- currentModelProvider: ctx.model?.provider,
1891
- executionBackend: deps.config.executionBackend,
1892
- };
1876
+ const asyncCtx = buildAsyncContext(ctx, deps, ctx.cwd);
1893
1877
  return executeAsyncSingle(id, {
1894
1878
  agent: params.agent!,
1895
1879
  task: params.context === "fork" ? wrapForkTask(task) : task,
@@ -1993,6 +1977,8 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
1993
1977
  modelOverride,
1994
1978
  availableModels,
1995
1979
  preferredModelProvider: currentProvider,
1980
+ parentModel: parentModelFullId(ctx.model),
1981
+ parentThinking: ctx.thinkingLevel,
1996
1982
  skills: effectiveSkills,
1997
1983
  acceptance: params.acceptance,
1998
1984
  acceptanceContext: { mode: "single" },
@@ -6,7 +6,7 @@ import { encodeNestedPathEnv, parseNestedPathEnv, type NestedPathEntry } from ".
6
6
  import { STRUCTURED_OUTPUT_CAPTURE_ENV, STRUCTURED_OUTPUT_SCHEMA_ENV, STRUCTURED_OUTPUT_TOOL_NAME } from "./structured-output.ts";
7
7
  import type { JsonSchemaObject } from "../../shared/types.ts";
8
8
 
9
- const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"];
9
+ const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
10
10
  const TASK_ARG_LIMIT = 8000;
11
11
  const PROMPT_RUNTIME_EXTENSION_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), "subagent-prompt-runtime.ts");
12
12
  const FANOUT_CHILD_EXTENSION_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "extension", "fanout-child.ts");
@@ -73,7 +73,7 @@ export function runDirEnv(asyncDir: string): Record<string, string> {
73
73
  }
74
74
 
75
75
  export function applyThinkingSuffix(model: string | undefined, thinking: string | undefined): string | undefined {
76
- if (!model || !thinking || thinking === "off") return model;
76
+ if (!model || !thinking) return model;
77
77
  const colonIdx = model.lastIndexOf(":");
78
78
  if (colonIdx !== -1 && THINKING_LEVELS.includes(model.substring(colonIdx + 1))) return model;
79
79
  return `${model}:${thinking}`;
@@ -1,4 +1,4 @@
1
- export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
1
+ export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
2
2
  export type ThinkingLevel = typeof THINKING_LEVELS[number];
3
3
  export type ThinkingLevelMap = Partial<Record<ThinkingLevel, string | null>>;
4
4
 
@@ -63,16 +63,21 @@ export function findModelInfo(model: string | undefined, availableModels: ModelI
63
63
  }
64
64
 
65
65
  export function getSupportedThinkingLevels(model: ModelInfo | undefined): ThinkingLevel[] {
66
- if (!model) return [...THINKING_LEVELS];
66
+ if (!model) return THINKING_LEVELS.filter((level) => level !== "max");
67
67
  if (model.reasoning === false) return ["off"];
68
68
 
69
- if (!model.thinkingLevelMap) return [...THINKING_LEVELS];
69
+ if (!model.thinkingLevelMap) return THINKING_LEVELS.filter((level) => level !== "max");
70
70
 
71
71
  const levels = THINKING_LEVELS.filter((level) => {
72
72
  const mapped = model.thinkingLevelMap?.[level];
73
73
  if (mapped === null) return false;
74
- if (level === "xhigh") return mapped !== undefined;
74
+ if (level === "xhigh" || level === "max") return mapped !== undefined;
75
75
  return true;
76
76
  });
77
77
  return levels;
78
78
  }
79
+
80
+ /** Provider-qualified id of the parent session model; undefined when either part is missing (SDK hosts, minimal test contexts). */
81
+ export function parentModelFullId(model: { provider?: string; id?: string } | undefined): string | undefined {
82
+ return model?.provider && model.id ? `${model.provider}/${model.id}` : undefined;
83
+ }
@@ -776,6 +776,10 @@ export interface RunSyncOptions {
776
776
  availableModels?: Array<{ provider: string; id: string; fullId: string }>;
777
777
  /** Current parent-session provider to prefer for ambiguous bare model ids */
778
778
  preferredModelProvider?: string;
779
+ /** Parent session model as provider/id; used when neither the call nor the agent names a model */
780
+ parentModel?: string;
781
+ /** Parent session thinking level; used when the agent sets no `thinking` */
782
+ parentThinking?: string;
779
783
  /** Skills to inject (overrides agent default if provided) */
780
784
  skills?: string[];
781
785
  structuredOutput?: {
@@ -1,54 +0,0 @@
1
- ---
2
- description: "Use when the context window is nearly full and the work must continue in a fresh session."
3
- ---
4
-
5
- Write a handoff brief for a fresh session. The template is the contract in `doc/handoff-template.md` of pi-cohort; section names are fixed.
6
-
7
- Optional `--out <path>` (relative resolves against cwd):
8
-
9
- $@
10
-
11
- ## Rules
12
-
13
- - `DIR=$(mktemp -d)`. The dispatch: `async: false` and `context: "fresh"` at the top level; `reads: false`; absolute `output` under `$DIR`. If it returns an async handle, a `forceTopLevelAsync` setting is active: stop and report it. Do not poll, relaunch, or continue.
14
- - The child is read-only against the repository; its only write is its `output` file.
15
- - You write `## Intent`, `## Decisions`, `## Open questions`, and `## Skills loaded` from your own transcript, before the call and after it returns. Do not fork a child for this: forking a near-full transcript is the cost this handoff avoids.
16
- - Write nothing into the repository; the brief goes to `--out` or `$DIR/handoff.md`.
17
-
18
- ## Run worktree
19
-
20
- Before the snapshot dispatch, determine the run worktree from your own transcript and interpolate it into the scout task as `Run worktree: <abs path>` or `Run worktree: none`:
21
-
22
- - A flow-level worktree the run created (any `git worktree add <path>` you ran, or a `Worktree ready at <path>` report from `/skill:using-git-worktrees`) or was told to work in (a resume brief's `worktree: yes <path>`, or a user instruction).
23
- - Never inferred from the session cwd; a created or assigned worktree still qualifies when it happens to equal the cwd. Never a per-task worktree from `tasks[].worktree: true` or `worktree: true` dispatches - those are ephemeral.
24
- - Two flow-level worktrees in the transcript: the candidate is the one most recently used by later work (a dispatch `cwd`, a `git -C <path>`, or a `cd <path>`); if that does not distinguish them, the most recent creation or assignment. The other goes into `## Open questions` as `Also seen: <path> - not recorded as the run worktree`.
25
- - No flow-level worktree created or named: `Run worktree: none`.
26
-
27
- After the scout returns, copy any `open-question:` line from `repo.md` into `## Open questions` without the `open-question:` prefix; it never appears in `## Repo state`.
28
-
29
- ## Repo snapshot
30
-
31
- ```
32
- subagent({ async: false, context: "fresh", tasks: [
33
- { agent: "scout", cwd: "<cwd>", reads: false, output: "<DIR>/repo.md",
34
- task: "Read-only; write only to your output path. Run worktree: <abs path|none>. Steps: (1) linked set: git worktree list --porcelain | awk '/^worktree /{print substr($0,10)}' - the first line is the primary checkout, the rest are the linked worktrees; a relative candidate resolves against the primary toplevel, not cwd. (2) candidate `none` or line absent -> target is cwd, go to (4). Otherwise if the candidate is relative, prefix it with the primary toplevel (first linked-set line) first; C=$(git -C <candidate> rev-parse --show-toplevel); valid when that succeeds and C equals one of the linked lines exactly (a line after the first; equality with the first, primary line is invalid); anything else (failure, not listed, the primary itself, prunable) is invalid -> target is cwd and emit the open-question line in (5). (3) valid -> target is $C and `worktree: yes $C`. (4) target cwd -> T=$(git rev-parse --show-toplevel); `worktree: yes $T` when $T equals one of the linked lines exactly (a line after the first; the first, primary line gives `worktree: no`), else `worktree: no`. (5) report each field on its own line, `unavailable` when a command fails, every git command as `git -C <target>`: toplevel (git -C <target> rev-parse --show-toplevel); the worktree line from (3) or (4); branch (git -C <target> branch --show-current, `detached` when empty); HEAD SHA; base (origin/HEAD short name, else origin/main or origin/master if present, else `base: unknown`); dirty (git -C <target> status --porcelain, or `clean`); diff-stat (git -C <target> diff --stat <base>...HEAD; `diff-stat: unavailable` when base is unknown); test command (<target>/package.json scripts or <target>/AGENTS.md). Invalid candidate: after the fields add the single line `open-question: producer named <candidate as submitted> as the run worktree; it is not a linked worktree of this repo`. cwd not a git repo -> the single line `not a git repo`, plus the same open-question line if a candidate was submitted." }
35
- ]})
36
- ```
37
-
38
- ## Process state
39
-
40
- Include `## Process state` only when both `phase_tracker` and `plan_tracker` tools exist AND `phase_tracker status` shows a phase `in_progress`. Then: `phase_tracker status` and `plan_tracker status` outputs verbatim; `Active task: <task name>` (the task you are working on; `none` when no plan is active or no task is active); the line `Gate history not restored - re-validate before advancing.`. Tools absent, all phases pending, or hotfix flow -> omit the section.
41
-
42
- ## Brief
43
-
44
- ```markdown
45
- # Handoff: <one line>
46
- ## Intent
47
- ## Repo state (the snapshot fields; `## Repo state: not a git repo` when so)
48
- ## Decisions (bullets; rejected alternatives marked `rejected:`)
49
- ## Open questions
50
- ## Skills loaded (frontmatter `name` of every skill whose SKILL.md body is in context - a `<skill>` block or a tool result on a `*/SKILL.md` path; `## Skills loaded: none` when there are none)
51
- ## Process state (only per the rule above)
52
- ```
53
-
54
- Write it, print the path and the brief.