pi-squire 0.20.0 → 0.20.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
@@ -6,6 +6,16 @@ follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.20.1] - 2026-11-02
10
+
11
+ ### Changed
12
+
13
+ - `delegation_fork` warns when its effective model differs from the source's
14
+ last model: copied context will be written to a new cache, with an approximate
15
+ token count when source usage is available. The model parameter and README
16
+ explain the cost; same-model forks retain their cache behavior. New fork tests
17
+ cover changed/same models, effective model locks and missing or invalid usage.
18
+
9
19
  ## [0.20.0] - 2026-11-02
10
20
 
11
21
  ### Added
@@ -1111,7 +1121,8 @@ author's dotfiles; the changes below are against that copy.
1111
1121
 
1112
1122
  - The published file list includes `child.ts`, which `index.ts` imports.
1113
1123
 
1114
- [Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.20.0...HEAD
1124
+ [Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.20.1...HEAD
1125
+ [0.20.1]: https://github.com/gvanderclay/pi-squire/compare/v0.20.0...v0.20.1
1115
1126
  [0.20.0]: https://github.com/gvanderclay/pi-squire/compare/v0.19.1...v0.20.0
1116
1127
  [0.19.1]: https://github.com/gvanderclay/pi-squire/compare/v0.19.0...v0.19.1
1117
1128
  [0.19.0]: https://github.com/gvanderclay/pi-squire/compare/v0.18.4...v0.19.0
package/README.md CHANGED
@@ -1670,7 +1670,10 @@ folder must exist, its agent must still be enabled, and current nesting grants,
1670
1670
  depth, capacity and trust checks apply. Copied history does not transfer
1671
1671
  ownership of the original child's descendants. Model, thinking and auto-exit
1672
1672
  default to the source's last settings with the same locks and fallbacks as a
1673
- resume; the current roster supplies the prompt and tools.
1673
+ resume; the current roster supplies the prompt and tools. A different effective
1674
+ model re-reads the copied context without the source's cache; the result warns
1675
+ about the new cache write, estimating copied tokens when source usage is
1676
+ available. Keep the source's model unless the task needs another role.
1674
1677
  `session:launch` is emitted with the `delegation_fork` tool call id.
1675
1678
 
1676
1679
  ## Hooks
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-squire",
3
- "version": "0.20.0",
3
+ "version": "0.20.1",
4
4
  "description": "Starts a delegate Pi session in a background tmux window, sends it the task over message:*, and shows the parent its result",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/fork.ts CHANGED
@@ -3,6 +3,7 @@ import { Type } from "typebox";
3
3
 
4
4
  import { disabledProblem, THINKING_LEVELS } from "./agents.ts";
5
5
  import { dirtyNotice } from "./render.ts";
6
+ import { finishedModel, sessionInputTokens } from "./result-sessions.ts";
6
7
  import { deriveState, isStarted, type StartedRecorded } from "./results.ts";
7
8
  import type { ResumeDeps, Resumed } from "./resume.ts";
8
9
  import { sessionFor, settleModel } from "./resume-checks.ts";
@@ -88,6 +89,14 @@ async function sharedFolderNotice(deps: ForkDeps, record: StartedRecorded, workt
88
89
  return [`shares a working folder with running delegation ${record.id}; pass worktree to isolate`];
89
90
  }
90
91
 
92
+ function modelNotice(record: StartedRecorded, sessionPath: string, model: string): string[] {
93
+ const sourceModel = finishedModel(sessionPath) ?? record.result?.finishedModel ?? record.model;
94
+ if (model === sourceModel) return [];
95
+ const tokens = sessionInputTokens(sessionPath);
96
+ const estimate = tokens === undefined ? "copied tokens" : `about ${Math.round(tokens / 1000)}k copied tokens`;
97
+ return [`${model} differs from the source's ${sourceModel}: ${estimate} will be written to a new cache.`];
98
+ }
99
+
91
100
  function launchText(id: string, details: Forked, chosen: Awaited<ReturnType<ResumeDeps["chooseModel"]>>): string {
92
101
  const workspace = details.workspace;
93
102
  const managed =
@@ -97,6 +106,13 @@ function launchText(id: string, details: Forked, chosen: Awaited<ReturnType<Resu
97
106
  return `Forked delegation ${id} as ${details.id} in window ${details.windowName} in ${details.cwd}.${managed} The original session is untouched. Its result arrives as a message that starts your next turn on its own; delegation_close ends it.${fallbackNote(chosen)}${details.notices.map((notice) => ` ${notice}`).join("")}`;
98
107
  }
99
108
 
109
+ const modelParameter = Type.Optional(
110
+ Type.String({
111
+ description:
112
+ "provider/id; defaults to the source's last model. A different model re-reads the copied context without its cache; keep the source's model unless the task needs another role.",
113
+ }),
114
+ );
115
+
100
116
  export function forkTool(deps: ForkDeps): ToolDef {
101
117
  return {
102
118
  name: "delegation_fork",
@@ -106,7 +122,7 @@ export function forkTool(deps: ForkDeps): ToolDef {
106
122
  parameters: Type.Object({
107
123
  id: Type.String({ description: "The direct child's delegation id whose context to copy." }),
108
124
  task: Type.String(),
109
- model: Type.Optional(Type.String({ description: "provider/id; defaults to the source's last model." })),
125
+ model: modelParameter,
110
126
  thinking: Type.Optional(
111
127
  Type.String({ description: `${THINKING_LEVELS.join(", ")}; defaults to the source's last level.` }),
112
128
  ),
@@ -141,6 +157,7 @@ export function forkTool(deps: ForkDeps): ToolDef {
141
157
  ctx,
142
158
  );
143
159
  const source = forkSource(record, session);
160
+ chosen.notices.push(...modelNotice(record, session.path, chosen.model));
144
161
  chosen.notices.push(...(await sharedFolderNotice(deps, record, given(params.worktree))));
145
162
  const details = await deps.launch(
146
163
  {
@@ -78,6 +78,32 @@ export function finishedModel(sessionPath: string | undefined): string | undefin
78
78
  return undefined;
79
79
  }
80
80
 
81
+ /** Approximate copied context from the last assistant request, excluding its output. */
82
+ export function sessionInputTokens(sessionPath: string): number | undefined {
83
+ let lines: string[];
84
+ try {
85
+ lines = readFileSync(sessionPath, "utf8").split("\n");
86
+ } catch {
87
+ return undefined;
88
+ }
89
+ for (let i = lines.length - 1; i >= 0; i--) {
90
+ let entry: { type?: unknown; message?: { role?: unknown; usage?: Record<string, unknown> } };
91
+ try {
92
+ entry = JSON.parse(lines[i]);
93
+ } catch {
94
+ continue;
95
+ }
96
+ if (entry?.type !== "message" || entry.message?.role !== "assistant") continue;
97
+ const usage = entry.message.usage;
98
+ const counts = [usage?.input, usage?.cacheRead, usage?.cacheWrite];
99
+ if (!counts.every((count): count is number => typeof count === "number" && Number.isFinite(count) && count >= 0))
100
+ return undefined;
101
+ const total = counts.reduce((sum, count) => sum + count, 0);
102
+ return Number.isFinite(total) && total > 0 ? total : undefined;
103
+ }
104
+ return undefined;
105
+ }
106
+
81
107
  /** One session-file line, as much of it as the branch walk reads. */
82
108
  type BranchEntry = { type?: unknown; id?: unknown; parentId?: unknown; thinkingLevel?: unknown };
83
109