pi-squire 0.6.0 โ†’ 0.8.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,40 @@ follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.8.0] - 2026-10-20
10
+
11
+ ### Added
12
+
13
+ - A delegate's footer shows that it is a delegate and whether auto-exit is
14
+ on: `๐Ÿ‘ฆ delegate ยท auto-exit on`, or
15
+ `๐Ÿ‘ฆ delegate ยท auto-exit off`. It follows the
16
+ setting as typing turns it off and `/auto-exit` turns it back on.
17
+
18
+ ### Changed
19
+
20
+ - The parent's footer reads `๐Ÿ‘จ N delegates running` instead of
21
+ `โ‡„ N running`. Each side picks its icon at random per session: ๐Ÿ‘จ or ๐Ÿ‘ฉ
22
+ for the parent, ๐Ÿ‘ฆ or ๐Ÿ‘ง for a delegate. The footer tests in
23
+ `test/delegate.test.ts` change to match.
24
+
25
+ ## [0.7.0] - 2026-10-19
26
+
27
+ ### Added
28
+
29
+ - `delegate`, `delegation_status` and `delegation_close` declare an
30
+ `outputSchema` and return matching `structuredContent`, so a codemode
31
+ script gets objects instead of text. `delegation_status` gives each
32
+ delegation its derived `state` (`running`, `done`, `gone`, `closed`,
33
+ `window-closed` or `unknown`), also added to its details.
34
+ `delegation_close` returns one shape on every path:
35
+ `{ id, windowId, windowName, closed, alreadyClosed, killed }`; its details
36
+ gain the fields each path lacked. The model-facing text is unchanged.
37
+ - The three tools declare MCP-style `annotations`: `delegation_status` is
38
+ read-only and idempotent; `delegation_close` is destructive and idempotent;
39
+ `delegate` is neither, and is the only one that reaches an open world,
40
+ since the delegate it starts calls a remote model and runs tools
41
+ unattended.
42
+
9
43
  ## [0.6.0] - 2026-10-15
10
44
 
11
45
  ### Added
@@ -239,7 +273,9 @@ author's dotfiles; the changes below are against that copy.
239
273
 
240
274
  - The published file list includes `child.ts`, which `index.ts` imports.
241
275
 
242
- [Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.6.0...HEAD
276
+ [Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.8.0...HEAD
277
+ [0.8.0]: https://github.com/gvanderclay/pi-squire/compare/v0.7.0...v0.8.0
278
+ [0.7.0]: https://github.com/gvanderclay/pi-squire/compare/v0.6.0...v0.7.0
243
279
  [0.6.0]: https://github.com/gvanderclay/pi-squire/compare/v0.5.1...v0.6.0
244
280
  [0.5.1]: https://github.com/gvanderclay/pi-squire/compare/v0.5.0...v0.5.1
245
281
  [0.5.0]: https://github.com/gvanderclay/pi-squire/compare/v0.4.0...v0.5.0
package/README.md CHANGED
@@ -393,6 +393,10 @@ The parent resolves each delegation's auto-exit from the call
393
393
  notice: typed or RPC input, or a run the user stopped (Esc).
394
394
  - `/auto-exit` inside the delegate turns it on again for the next normal
395
395
  completion, whatever the launch said.
396
+ - The footer shows that this is a delegate and whether auto-exit is on:
397
+ `๐Ÿ‘ฆ delegate ยท auto-exit on`, or
398
+ `๐Ÿ‘ฆ delegate ยท auto-exit off`, with ๐Ÿ‘ฆ or ๐Ÿ‘ง
399
+ picked at random per session.
396
400
 
397
401
  There is no `subagent_done` tool: auto-exit off only means the window stays.
398
402
 
@@ -439,8 +443,9 @@ Delegate session: <session file or id>
439
443
  current tool calls, before the next model call. A follow-up would wait
440
444
  until the parent had answered, by which time it had often read the
441
445
  envelope itself, and the late result cost a second, redundant turn.
442
- - The footer counts the running delegations (`โ‡„ N running`), clears one entry
443
- per result or close and hides itself at zero.
446
+ - The footer counts the running delegations (`๐Ÿ‘จ N delegates running`, with ๐Ÿ‘จ or ๐Ÿ‘ฉ
447
+ picked at random per session), clears one entry per result or close and
448
+ hides itself at zero.
444
449
  - Each result and each close is recorded in the parent session, and the
445
450
  records are rebuilt synchronously at session start. A resume still
446
451
  recognises replies, including one that arrived while the parent was
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-squire",
3
- "version": "0.6.0",
3
+ "version": "0.8.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/child.ts CHANGED
@@ -21,6 +21,7 @@
21
21
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
22
22
 
23
23
  import { activeMark, classify, type Response, recordLimit } from "./limits.ts";
24
+ import { STATUS_KEY } from "./results.ts";
24
25
 
25
26
  /** The launch variable that carries the parent's resolved setting. */
26
27
  export const AUTO_EXIT_ENV = "PI_DELEGATE_AUTO_EXIT";
@@ -104,6 +105,19 @@ function registerFallback(pi: ExtensionAPI): (model: string | undefined) => void
104
105
  };
105
106
  }
106
107
 
108
+ /** This delegate's footer icon, picked once per load. */
109
+ const ICON = Math.random() < 0.5 ? "๐Ÿ‘ฆ" : "๐Ÿ‘ง";
110
+
111
+ function showStatus(ctx: ExtensionContext, armed: boolean): void {
112
+ const { theme } = ctx.ui;
113
+ ctx.ui.setStatus(
114
+ STATUS_KEY,
115
+ armed
116
+ ? `${ICON} delegate ยท auto-exit ${theme.fg("success", "on")}`
117
+ : `${ICON} delegate ยท auto-exit ${theme.fg("dim", "off")}`,
118
+ );
119
+ }
120
+
107
121
  export function registerChild(pi: ExtensionAPI, parent: string): void {
108
122
  registerTakeover(pi, parent);
109
123
  const setLimited = registerFallback(pi);
@@ -112,10 +126,14 @@ export function registerChild(pi: ExtensionAPI, parent: string): void {
112
126
  /** Whether the run that just ended finished without the user stopping it. */
113
127
  let completed = false;
114
128
 
129
+ // Reload and session replacement clear extension statuses, then start again.
130
+ pi.on("session_start", async (_event, ctx) => showStatus(ctx, armed));
131
+
115
132
  function disarm(ctx: ExtensionContext): void {
116
133
  if (!armed) return;
117
134
  armed = false;
118
135
  ctx.ui.notify(OFF_NOTICE, "info");
136
+ showStatus(ctx, armed);
119
137
  }
120
138
 
121
139
  // Typed text and RPC input are the user's; a prompt an extension sends is not.
@@ -166,6 +184,7 @@ export function registerChild(pi: ExtensionAPI, parent: string): void {
166
184
  handler: async (_args, ctx) => {
167
185
  armed = true;
168
186
  ctx.ui.notify(ON_NOTICE, "info");
187
+ showStatus(ctx, armed);
169
188
  },
170
189
  });
171
190
  }
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 =
@@ -551,6 +567,9 @@ export default function delegate(
551
567
  }),
552
568
  ),
553
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 },
554
573
  async execute(
555
574
  _toolCallId: string,
556
575
  params: {
package/src/results.ts CHANGED
@@ -237,6 +237,7 @@ export function createResults(pi: ExtensionAPI): Results {
237
237
  let ctx: ExtensionContext | undefined;
238
238
  /** Whether the footer currently shows the entry, so a clear is sent only when needed. */
239
239
  let footerShown = false;
240
+ const icon = Math.random() < 0.5 ? "๐Ÿ‘จ" : "๐Ÿ‘ฉ";
240
241
 
241
242
  /** A delegation still going: no result and no recorded close. */
242
243
  const running = (): Recorded[] =>
@@ -251,7 +252,7 @@ export function createResults(pi: ExtensionAPI): Results {
251
252
  const visible = count > 0;
252
253
  if (!visible && !footerShown) return;
253
254
  footerShown = visible;
254
- ctx.ui.setStatus(STATUS_KEY, visible ? `โ‡„ ${count} running` : undefined);
255
+ ctx.ui.setStatus(STATUS_KEY, visible ? `${icon} ${count} delegate${count === 1 ? "" : "s"} running` : undefined);
255
256
  }
256
257
 
257
258
  /** Rebuild the records from the session's entries, so a resume still recognises replies. */
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
  }