pi-squire 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,35 @@ follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.7.0] - 2026-10-19
10
+
11
+ ### Added
12
+
13
+ - `delegate`, `delegation_status` and `delegation_close` declare an
14
+ `outputSchema` and return matching `structuredContent`, so a codemode
15
+ script gets objects instead of text. `delegation_status` gives each
16
+ delegation its derived `state` (`running`, `done`, `gone`, `closed`,
17
+ `window-closed` or `unknown`), also added to its details.
18
+ `delegation_close` returns one shape on every path:
19
+ `{ id, windowId, windowName, closed, alreadyClosed, killed }`; its details
20
+ gain the fields each path lacked. The model-facing text is unchanged.
21
+ - The three tools declare MCP-style `annotations`: `delegation_status` is
22
+ read-only and idempotent; `delegation_close` is destructive and idempotent;
23
+ `delegate` is neither, and is the only one that reaches an open world,
24
+ since the delegate it starts calls a remote model and runs tools
25
+ unattended.
26
+
27
+ ## [0.6.0] - 2026-10-15
28
+
29
+ ### Added
30
+
31
+ - Every delegate now starts with `PI_UNATTENDED=1` in its environment, a
32
+ general signal that no human is watching the session. Delegates run as a
33
+ full TUI, so extensions that open a dialog, such as a handoff gate or an
34
+ approval prompt, waited on nobody and stalled the delegate; they can read
35
+ this variable to skip the dialog instead. The test that pins the child's
36
+ exact environment now includes it.
37
+
9
38
  ## [0.5.1] - 2026-10-14
10
39
 
11
40
  ### Changed
@@ -228,7 +257,9 @@ author's dotfiles; the changes below are against that copy.
228
257
 
229
258
  - The published file list includes `child.ts`, which `index.ts` imports.
230
259
 
231
- [Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.5.1...HEAD
260
+ [Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.7.0...HEAD
261
+ [0.7.0]: https://github.com/gvanderclay/pi-squire/compare/v0.6.0...v0.7.0
262
+ [0.6.0]: https://github.com/gvanderclay/pi-squire/compare/v0.5.1...v0.6.0
232
263
  [0.5.1]: https://github.com/gvanderclay/pi-squire/compare/v0.5.0...v0.5.1
233
264
  [0.5.0]: https://github.com/gvanderclay/pi-squire/compare/v0.4.0...v0.5.0
234
265
  [0.4.0]: https://github.com/gvanderclay/pi-squire/compare/v0.3.2...v0.4.0
package/README.md CHANGED
@@ -178,7 +178,11 @@ variable of its own. `PI_DELEGATE_PARENT` and `PI_DELEGATE_AUTO_EXIT` are set
178
178
  for the child, never read as configuration by the parent. The window starts
179
179
  with the tmux server's environment plus only these from the parent:
180
180
  `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR` (when the parent has it
181
- set), `PI_DELEGATE_PARENT` and `PI_DELEGATE_AUTO_EXIT`. Anything else the
181
+ set), `PI_DELEGATE_PARENT` and `PI_DELEGATE_AUTO_EXIT`, and it always gets `PI_UNATTENDED=1`.
182
+ `PI_UNATTENDED=1` is a general signal, not specific to pi-squire: no human is
183
+ watching this session, even though it runs in a TUI. Other extensions may read
184
+ it to skip a dialog or confirmation instead of waiting on one, and any other
185
+ runner that starts Pi unattended can set it too. Anything else the
182
186
  parent process has, such as a `CLAUDE_CONFIG_DIR` set by a shell alias,
183
187
  reaches the child only through a `session:launch` contributor. `--model` and
184
188
  `--thinking` override the agent's defaults for one call, and an unknown value
@@ -557,8 +561,8 @@ Other extensions may rely on:
557
561
  - the `[delegate]` result text and the closed-without-a-result text;
558
562
  - `AGENT.md`'s frontmatter keys and its location,
559
563
  `<agent dir>/agents/<name>/AGENT.md`;
560
- - the child environment variables `PI_DELEGATE_PARENT` and
561
- `PI_DELEGATE_AUTO_EXIT`.
564
+ - the child environment variables `PI_DELEGATE_PARENT`,
565
+ `PI_DELEGATE_AUTO_EXIT` and `PI_UNATTENDED=1`.
562
566
 
563
567
  A `message:*` provider must:
564
568
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-squire",
3
- "version": "0.5.1",
3
+ "version": "0.7.0",
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/index.ts CHANGED
@@ -99,6 +99,22 @@ function parentTrust(parent: string): string {
99
99
  /** The flags `/delegate` takes before the task. */
100
100
  const FLAGS = ["--model", "--thinking", "--label", "--auto-exit", "--no-auto-exit"] as const;
101
101
 
102
+ /** The `delegate` tool's structured result, which is also its details. */
103
+ export const DelegateSchema = Type.Object({
104
+ outcome: Type.Literal("started"),
105
+ id: Type.String({ description: "The delegation id that delegation_status and delegation_close take." }),
106
+ agent: Type.String(),
107
+ model: Type.String({ description: "The provider/id it started on, after any fallback." }),
108
+ skipped: Type.Array(Type.Object({ model: Type.String(), reason: Type.String() }), {
109
+ description: "Earlier models in the fallback list that were passed over, and why.",
110
+ }),
111
+ notices: Type.Array(Type.String()),
112
+ thinking: Type.String(),
113
+ autoExit: Type.Boolean(),
114
+ windowId: Type.String(),
115
+ windowName: Type.String(),
116
+ });
117
+
102
118
  const NOT_IN_TMUX = "this session is not inside tmux; start it in a tmux pane to open a delegate window";
103
119
 
104
120
  const USAGE =
@@ -469,6 +485,8 @@ export default function delegate(
469
485
  PI_CODING_AGENT_DIR: getAgentDir(),
470
486
  [PARENT_ENV]: parent,
471
487
  [AUTO_EXIT_ENV]: start.autoExit ? "1" : "0",
488
+ // A general signal for any extension: no human watches this session, so don't wait on a dialog.
489
+ PI_UNATTENDED: "1",
472
490
  };
473
491
  if (start.rest.length > 0) env[FALLBACK_ENV] = start.rest.join(",");
474
492
  const sessionDir = process.env[SESSION_DIR_ENV];
@@ -549,6 +567,9 @@ export default function delegate(
549
567
  }),
550
568
  ),
551
569
  }),
570
+ outputSchema: DelegateSchema,
571
+ // Opens a new window each call and removes nothing. Open world: the delegate calls a remote model and runs its agent's tools unattended.
572
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
552
573
  async execute(
553
574
  _toolCallId: string,
554
575
  params: {
package/src/tracking.ts CHANGED
@@ -37,20 +37,76 @@ export type Tracking = {
37
37
  stop(): void;
38
38
  };
39
39
 
40
- /** The one result every tool returns. */
40
+ /** The one result every tool returns. `details` doubles as `structuredContent`, so it must match the tool's `outputSchema`; Pi does not check it. */
41
41
  export function toolResult(
42
42
  payload: string,
43
- details: unknown = {},
44
- ): { content: { type: "text"; text: string }[]; details: unknown } {
45
- return { content: [{ type: "text", text: payload }], details };
43
+ details: unknown,
44
+ ): { content: { type: "text"; text: string }[]; details: unknown; structuredContent: unknown } {
45
+ return { content: [{ type: "text", text: payload }], details, structuredContent: details };
46
46
  }
47
47
 
48
+ /** Every tool here touches only local state: tmux, the session's entries and files under the agent directory. */
49
+ const CLOSED_WORLD = { openWorldHint: false } as const;
50
+
51
+ const DELEGATION_STATES: readonly DelegationState[] = ["closed", "done", "gone", "running", "window-closed", "unknown"];
52
+
53
+ /** One delegation in `delegation_status`'s structured result; `viewDetails` builds it. */
54
+ const DelegationSchema = Type.Object({
55
+ id: Type.String(),
56
+ name: Type.String(),
57
+ agent: Type.String(),
58
+ model: Type.String(),
59
+ thinking: Type.String(),
60
+ autoExit: Type.Boolean(),
61
+ windowId: Type.String(),
62
+ windowName: Type.String(),
63
+ state: Type.Union(
64
+ DELEGATION_STATES.map((state) => Type.Literal(state)),
65
+ {
66
+ description:
67
+ "running: window open, no result. done: a result arrived (see status, envelopePath). gone: the window went with no result; a late reply makes it done. closed: delegation_close recorded it. window-closed: the window is gone. unknown: tmux could not be asked. done and closed are final; gone and window-closed turn done if a late reply arrives.",
68
+ },
69
+ ),
70
+ closed: Type.Boolean(),
71
+ closedWithoutResult: Type.Boolean(),
72
+ status: Type.Optional(Type.String({ description: "The result envelope's status: done, failed or stopped." })),
73
+ envelopePath: Type.Optional(Type.String()),
74
+ sessionPath: Type.Optional(Type.String()),
75
+ lastActiveAt: Type.Optional(
76
+ Type.String({ description: "ISO time the session file was last written; running only." }),
77
+ ),
78
+ });
79
+
80
+ export const StatusSchema = Type.Object({
81
+ delegations: Type.Array(DelegationSchema),
82
+ marks: Type.Array(
83
+ Type.Object({
84
+ scope: Type.String(),
85
+ source: Type.Union([Type.Literal("reactive"), Type.Literal("proactive")]),
86
+ reason: Type.String(),
87
+ clearsAt: Type.String(),
88
+ }),
89
+ ),
90
+ readingErrors: Type.Array(Type.Object({ provider: Type.String(), reason: Type.String(), at: Type.String() })),
91
+ });
92
+
93
+ export const CloseSchema = Type.Object({
94
+ id: Type.String(),
95
+ windowId: Type.String(),
96
+ windowName: Type.String(),
97
+ closed: Type.Literal(true),
98
+ alreadyClosed: Type.Boolean({ description: "An earlier delegation_close had recorded it; nothing was done." }),
99
+ killed: Type.Boolean({ description: "This call killed the delegate's window." }),
100
+ });
101
+
48
102
  /** A tool as registered; `execute` takes the tool's own parameters, which the registration call does not check. */
49
103
  type ToolDef = {
50
104
  name: string;
51
105
  label: string;
52
106
  description: string;
53
107
  parameters: unknown;
108
+ outputSchema: unknown;
109
+ annotations: { readOnlyHint: boolean; destructiveHint: boolean; idempotentHint: boolean; openWorldHint: boolean };
54
110
  execute: (...args: never[]) => Promise<ReturnType<typeof toolResult>>;
55
111
  };
56
112
 
@@ -149,7 +205,7 @@ function viewText({ record, state, name, sessionPath, lastActive }: View): strin
149
205
  }
150
206
 
151
207
  /** The structured details for one delegation. */
152
- function viewDetails({ record, name, sessionPath, lastActive }: View): Record<string, unknown> {
208
+ function viewDetails({ record, state, name, sessionPath, lastActive }: View): Record<string, unknown> {
153
209
  return {
154
210
  id: record.id,
155
211
  name,
@@ -159,6 +215,7 @@ function viewDetails({ record, name, sessionPath, lastActive }: View): Record<st
159
215
  autoExit: record.autoExit === true,
160
216
  windowId: record.windowId,
161
217
  windowName: record.windowName,
218
+ state,
162
219
  closed: record.closed === true,
163
220
  closedWithoutResult: record.gone === true && record.result === undefined && record.closed !== true,
164
221
  status: record.result?.status,
@@ -267,6 +324,8 @@ function statusTool(results: Results, tmux: TmuxClient, readingErrors: () => Rea
267
324
  parameters: Type.Object({
268
325
  id: Type.Optional(Type.String({ description: "One delegation id; omit to report every delegation." })),
269
326
  }),
327
+ outputSchema: StatusSchema,
328
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, ...CLOSED_WORLD },
270
329
  async execute(
271
330
  _toolCallId: string,
272
331
  params: { id?: string },
@@ -310,6 +369,9 @@ function closeTool(results: Results, tmux: TmuxClient): ToolDef {
310
369
  parameters: Type.Object({
311
370
  id: Type.String({ description: "The delegation id to close." }),
312
371
  }),
372
+ outputSchema: CloseSchema,
373
+ // Kills a window; a second close finds it recorded and does nothing.
374
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, ...CLOSED_WORLD },
313
375
  async execute(
314
376
  _toolCallId: string,
315
377
  params: { id: string },
@@ -318,8 +380,15 @@ function closeTool(results: Results, tmux: TmuxClient): ToolDef {
318
380
  _ctx: ExtensionContext,
319
381
  ): Promise<ReturnType<typeof toolResult>> {
320
382
  const [record] = selectRecords(results, params.id);
321
- if (record.closed === true)
322
- return toolResult(`Delegation ${record.id} is already closed.`, { id: record.id, closed: true });
383
+ const closed = (alreadyClosed: boolean, killed: boolean) => ({
384
+ id: record.id,
385
+ windowId: record.windowId,
386
+ windowName: record.windowName,
387
+ closed: true,
388
+ alreadyClosed,
389
+ killed,
390
+ });
391
+ if (record.closed === true) return toolResult(`Delegation ${record.id} is already closed.`, closed(true, false));
323
392
  let windows: TmuxWindow[];
324
393
  try {
325
394
  windows = await tmux.listWindows();
@@ -339,11 +408,7 @@ function closeTool(results: Results, tmux: TmuxClient): ToolDef {
339
408
  const how = alive
340
409
  ? `killed window ${record.windowName} (${record.windowId})`
341
410
  : `its window ${record.windowName} was already gone`;
342
- return toolResult(`Closed delegation ${record.id}: ${how}.`, {
343
- id: record.id,
344
- windowId: record.windowId,
345
- killed: alive,
346
- });
411
+ return toolResult(`Closed delegation ${record.id}: ${how}.`, closed(false, alive));
347
412
  },
348
413
  };
349
414
  }