pi-sessions 0.11.1 → 0.11.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.
package/README.md CHANGED
@@ -108,7 +108,7 @@ Use `kind: "user"` or `kind: "subagent"` to filter by session type across the wh
108
108
 
109
109
  The `session_handoff` tool lets the agent create a child session with a self-contained task. Ask for a direction when you want a visible split, ask for a deferred handoff when you only want the prepared session, or let the agent delegate suitable independent work to a background subagent.
110
110
 
111
- Directional launches use tmux when the current terminal is inside tmux, or Ghostty on macOS. Deferred launches create the child without starting it and copy its resume command to the clipboard. A background subagent runs in a detached tmux window and reports back when finished.
111
+ Directional launches use tmux when the current terminal is inside tmux, or Ghostty on macOS. Deferred launches create the child without starting it and copy its resume command to the clipboard. A background subagent runs in a detached tmux window and reports back when finished; note that subagents are started with `--approve`, meaning pi will always start in the directory as trusted.
112
112
 
113
113
  Run `/handoff` to open the **Handoffs** board. The Subagents and User sessions tabs show status, age, launch details, and the actions currently available: stop, copy an observation command, or copy a resume command.
114
114
 
@@ -0,0 +1,28 @@
1
+ import type { EntryRenderer } from "@earendil-works/pi-coding-agent";
2
+ import { Box, Text } from "@earendil-works/pi-tui";
3
+ import type { RenderTheme } from "../shared/rendering/theme.ts";
4
+ import { safeParseTypeBoxValue } from "../shared/typebox.ts";
5
+ import { HANDOFF_BOOTSTRAP_FAILED_SCHEMA, type HandoffBootstrapFailed } from "./metadata.ts";
6
+
7
+ export const renderHandoffBootstrapFailedEntry: EntryRenderer = (entry, _options, theme) => {
8
+ const failure = safeParseTypeBoxValue(HANDOFF_BOOTSTRAP_FAILED_SCHEMA, entry.data);
9
+ if (!failure) {
10
+ return undefined;
11
+ }
12
+
13
+ const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
14
+ box.addChild(new Text(presentHandoffBootstrapFailure(failure, theme), 0, 0));
15
+ return box;
16
+ };
17
+
18
+ export function presentHandoffBootstrapFailure(
19
+ failure: HandoffBootstrapFailed,
20
+ theme: RenderTheme,
21
+ ): string {
22
+ const header = `${theme.fg("error", theme.bold("handoff failed"))} ${theme.fg("muted", "draft generation did not complete")}`;
23
+ const retry = theme.fg(
24
+ "dim",
25
+ "The handoff is retried every time this session starts, until a message is sent.",
26
+ );
27
+ return [header, "", theme.fg("toolOutput", failure.error), "", retry].join("\n");
28
+ }
@@ -11,6 +11,7 @@ import {
11
11
  findPendingHandoffBootstrap,
12
12
  getHandoffMetadataFromEntries,
13
13
  HANDOFF_BOOTSTRAP_CONSUMED_CUSTOM_TYPE,
14
+ HANDOFF_BOOTSTRAP_FAILED_CUSTOM_TYPE,
14
15
  HANDOFF_METADATA_CUSTOM_TYPE,
15
16
  HANDOFF_STALE_SESSION_MESSAGE,
16
17
  type HandoffBootstrapConsumedReason,
@@ -54,6 +55,7 @@ export async function consumePendingHandoffBootstrap(
54
55
  ctx,
55
56
  bootstrap,
56
57
  scan.entryId,
58
+ scan.failure !== undefined,
57
59
  consumeBootstrap,
58
60
  getModelRuntime,
59
61
  destinationThinkingLevel,
@@ -66,6 +68,7 @@ async function startChildGeneratedHandoff(
66
68
  ctx: ExtensionContext,
67
69
  bootstrap: ChildGeneratedHandoffBootstrap,
68
70
  bootstrapEntryId: string,
71
+ hasEarlierFailure: boolean,
69
72
  consumeBootstrap: (reason: HandoffBootstrapConsumedReason) => void,
70
73
  getModelRuntime: ModelRuntimeProvider,
71
74
  destinationThinkingLevel: ThinkingLevel | undefined,
@@ -80,8 +83,13 @@ async function startChildGeneratedHandoff(
80
83
  return;
81
84
  }
82
85
 
86
+ // An automatic bootstrap exits on its first failure because only an
87
+ // unattended worker ever reaches that attempt. Every later start is someone
88
+ // opening the session deliberately, so it latches open for them instead.
89
+ const exitOnFailure = bootstrap.bootstrapMode === "automatic" && !hasEarlierFailure;
90
+
83
91
  if (!ctx.hasUI) {
84
- if (bootstrap.bootstrapMode === "automatic") {
92
+ if (exitOnFailure) {
85
93
  ctx.shutdown();
86
94
  }
87
95
  return;
@@ -164,8 +172,15 @@ async function startChildGeneratedHandoff(
164
172
  { triggerTurn: true },
165
173
  );
166
174
  } catch (error) {
167
- ctx.ui.notify(formatHandoffError(error), "error");
168
- if (bootstrap.bootstrapMode === "automatic") {
175
+ // An exiting bootstrap takes the notification down with it, so the reason
176
+ // has to live in the transcript instead.
177
+ const message = formatHandoffError(error);
178
+ pi.appendEntry(HANDOFF_BOOTSTRAP_FAILED_CUSTOM_TYPE, {
179
+ bootstrapEntryId,
180
+ error: message,
181
+ });
182
+ ctx.ui.notify(message, "error");
183
+ if (exitOnFailure) {
169
184
  ctx.shutdown();
170
185
  }
171
186
  }
@@ -1,4 +1,4 @@
1
- import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
1
+ import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core";
2
2
  import type {
3
3
  Api,
4
4
  AssistantMessage,
@@ -247,6 +247,13 @@ async function runHandoffExtractionAgent(
247
247
  if (capturedArguments) {
248
248
  break;
249
249
  }
250
+ // A failed turn carries no refusal to re-prompt: retrying only burns the
251
+ // remaining attempts and buries the provider's reason behind the generic
252
+ // "did not return structured context" error.
253
+ const providerError = findProviderError(session.messages);
254
+ if (providerError) {
255
+ throw new Error(`Handoff extraction failed: ${providerError}`);
256
+ }
250
257
  }
251
258
  } finally {
252
259
  signal?.removeEventListener("abort", abortHandler);
@@ -272,6 +279,14 @@ async function runHandoffExtractionAgent(
272
279
  };
273
280
  }
274
281
 
282
+ function findProviderError(messages: readonly AgentMessage[]): string | undefined {
283
+ const lastAssistant = messages.findLast((message) => message.role === "assistant");
284
+ if (lastAssistant?.role !== "assistant" || lastAssistant.stopReason !== "error") {
285
+ return undefined;
286
+ }
287
+ return lastAssistant.errorMessage || "the provider returned an error.";
288
+ }
289
+
275
290
  export function assembleHandoffDraft(
276
291
  sessionId: string,
277
292
  sessionPath: string | undefined,
@@ -10,11 +10,13 @@ import { THINKING_LEVELS } from "../shared/thinking-levels.ts";
10
10
  import { safeParseTypeBoxValue } from "../shared/typebox.ts";
11
11
  import { type HandoffBoardServices, openHandoffBoard } from "./board.ts";
12
12
  import { consumePendingHandoffBootstrap } from "./bootstrap.ts";
13
+ import { renderHandoffBootstrapFailedEntry } from "./bootstrap-failed-renderer.ts";
13
14
  import { HANDOFF_KICKOFF_CUSTOM_TYPE, renderHandoffKickoffMessage } from "./kickoff.ts";
14
15
  import type { ClipboardStatus } from "./launch/backend.ts";
15
16
  import { resolveSplitLaunchBackend } from "./launch/resolution.ts";
16
17
  import { createHandoffLaunchTargets } from "./launch-options.ts";
17
18
  import type { HandoffLaunchTarget } from "./launch-target.ts";
19
+ import { HANDOFF_BOOTSTRAP_FAILED_CUSTOM_TYPE } from "./metadata.ts";
18
20
  import { openSessionReferencePicker } from "./picker.ts";
19
21
  import { SESSION_TOKEN_PREFIX } from "./query.ts";
20
22
  import {
@@ -51,6 +53,7 @@ export function installHandoff(
51
53
  });
52
54
 
53
55
  pi.registerMessageRenderer(HANDOFF_KICKOFF_CUSTOM_TYPE, renderHandoffKickoffMessage);
56
+ pi.registerEntryRenderer(HANDOFF_BOOTSTRAP_FAILED_CUSTOM_TYPE, renderHandoffBootstrapFailedEntry);
54
57
  function registerHandoffTool(
55
58
  models: readonly Model<Api>[],
56
59
  launchTargets: readonly HandoffLaunchTarget[],
@@ -59,6 +59,8 @@ export interface HandoffLaunchTarget {
59
59
  description?: string | undefined;
60
60
  requestResponseDefault: boolean;
61
61
  bootstrapMode: "review" | "automatic";
62
+ /** Unattended children skip the startup trust prompt; watched ones let the user answer it. */
63
+ approveProjectTrust: boolean;
62
64
  /** Subagent launches stamp their child's identity into the child bootstrap. */
63
65
  describeSubagentChild?(input: {
64
66
  childSessionId: string;
@@ -79,6 +81,7 @@ export function createBackendLaunchTarget(
79
81
  ...(description ? { description } : {}),
80
82
  requestResponseDefault: false,
81
83
  bootstrapMode: "review",
84
+ approveProjectTrust: false,
82
85
  prepareChild() {},
83
86
  launch(input) {
84
87
  return backend.launch({
@@ -12,6 +12,7 @@ import {
12
12
  export const HANDOFF_METADATA_CUSTOM_TYPE = "pi-sessions.handoff";
13
13
  export const HANDOFF_BOOTSTRAP_PENDING_CUSTOM_TYPE = "pi-sessions.handoff-bootstrap";
14
14
  export const HANDOFF_BOOTSTRAP_CONSUMED_CUSTOM_TYPE = "pi-sessions.handoff-bootstrap-consumed";
15
+ export const HANDOFF_BOOTSTRAP_FAILED_CUSTOM_TYPE = "pi-sessions.handoff-bootstrap-failed";
15
16
  export const HANDOFF_STALE_SESSION_MESSAGE =
16
17
  "Session handoff failed: target session already has user input.";
17
18
  export const SESSION_STARTING_MESSAGE =
@@ -62,6 +63,14 @@ export const HANDOFF_BOOTSTRAP_SCHEMA = Type.Union([
62
63
  }),
63
64
  ]);
64
65
 
66
+ // A bootstrap whose generation failed stays pending so a resume can retry it,
67
+ // but the failure is recorded so the session stops reading as one that is
68
+ // still starting.
69
+ export const HANDOFF_BOOTSTRAP_FAILED_SCHEMA = Type.Object({
70
+ bootstrapEntryId: Type.String(),
71
+ error: Type.String(),
72
+ });
73
+
65
74
  export const HANDOFF_BOOTSTRAP_CONSUMED_SCHEMA = Type.Object({
66
75
  bootstrapEntryId: Type.String(),
67
76
  reason: Type.Union([
@@ -77,6 +86,7 @@ export type HandoffSessionMetadata = Static<typeof HANDOFF_SESSION_METADATA_SCHE
77
86
  export type HandoffBootstrap = Static<typeof HANDOFF_BOOTSTRAP_SCHEMA>;
78
87
  export type ChildGeneratedHandoffBootstrap = HandoffBootstrap;
79
88
  export type HandoffBootstrapConsumed = Static<typeof HANDOFF_BOOTSTRAP_CONSUMED_SCHEMA>;
89
+ export type HandoffBootstrapFailed = Static<typeof HANDOFF_BOOTSTRAP_FAILED_SCHEMA>;
80
90
  export type HandoffBootstrapConsumedReason = HandoffBootstrapConsumed["reason"];
81
91
 
82
92
  export function createHandoffSessionMetadata(
@@ -125,7 +135,7 @@ export function createChildGeneratedHandoffBootstrap(options: {
125
135
  }
126
136
 
127
137
  export type PendingHandoffBootstrapScan =
128
- | { kind: "pending"; entryId: string; bootstrap: HandoffBootstrap }
138
+ | { kind: "pending"; entryId: string; bootstrap: HandoffBootstrap; failure?: string | undefined }
129
139
  | { kind: "invalid"; entryId: string };
130
140
 
131
141
  /**
@@ -137,6 +147,7 @@ export function findPendingHandoffBootstrap(
137
147
  branch: readonly SessionEntry[],
138
148
  ): PendingHandoffBootstrapScan | undefined {
139
149
  const consumedEntryIds = new Set<string>();
150
+ const failuresByEntryId = new Map<string, string>();
140
151
  for (const entry of branch) {
141
152
  if (entry.type === "custom_message" && entry.customType === HANDOFF_KICKOFF_CUSTOM_TYPE) {
142
153
  const details = safeParseTypeBoxValue(HANDOFF_KICKOFF_DETAILS_SCHEMA, entry.details);
@@ -150,6 +161,12 @@ export function findPendingHandoffBootstrap(
150
161
  consumedEntryIds.add(consumed.bootstrapEntryId);
151
162
  }
152
163
  }
164
+ if (entry.type === "custom" && entry.customType === HANDOFF_BOOTSTRAP_FAILED_CUSTOM_TYPE) {
165
+ const failed = safeParseTypeBoxValue(HANDOFF_BOOTSTRAP_FAILED_SCHEMA, entry.data);
166
+ if (failed) {
167
+ failuresByEntryId.set(failed.bootstrapEntryId, failed.error);
168
+ }
169
+ }
153
170
  }
154
171
 
155
172
  for (let i = branch.length - 1; i >= 0; i -= 1) {
@@ -163,9 +180,11 @@ export function findPendingHandoffBootstrap(
163
180
  }
164
181
 
165
182
  const bootstrap = safeParseTypeBoxValue(HANDOFF_BOOTSTRAP_SCHEMA, entry.data);
166
- return bootstrap
167
- ? { kind: "pending", entryId: entry.id, bootstrap }
168
- : { kind: "invalid", entryId: entry.id };
183
+ if (!bootstrap) {
184
+ return { kind: "invalid", entryId: entry.id };
185
+ }
186
+ const failure = failuresByEntryId.get(entry.id);
187
+ return { kind: "pending", entryId: entry.id, bootstrap, ...(failure ? { failure } : {}) };
169
188
  }
170
189
 
171
190
  return undefined;
@@ -175,10 +194,13 @@ export function findPendingHandoffBootstrap(
175
194
  * A session is *starting* when its active branch still carries an unconsumed,
176
195
  * well-formed handoff bootstrap: it has been prepared but has not received its
177
196
  * kickoff. An invalid bootstrap does not count — the child consumes it and
178
- * proceeds as an ordinary session rather than kicking off a handoff.
197
+ * proceeds as an ordinary session rather than kicking off a handoff. Neither
198
+ * does one whose generation already failed: it stays retryable on resume, but
199
+ * nothing is starting right now.
179
200
  */
180
201
  export function isSessionStarting(branch: readonly SessionEntry[]): boolean {
181
- return findPendingHandoffBootstrap(branch)?.kind === "pending";
202
+ const scan = findPendingHandoffBootstrap(branch);
203
+ return scan?.kind === "pending" && scan.failure === undefined;
182
204
  }
183
205
 
184
206
  /**
@@ -21,6 +21,7 @@ export function prepareHandoffLaunch(options: {
21
21
  parentSessionFile: string;
22
22
  title: string;
23
23
  model: string | undefined;
24
+ approveProjectTrust: boolean;
24
25
  buildBootstrap: (sessionId: string) => HandoffBootstrap;
25
26
  prepareChild?: ((manager: SessionManager, sessionId: string) => void) | undefined;
26
27
  }): PreparedHandoff {
@@ -52,6 +53,7 @@ export function prepareHandoffLaunch(options: {
52
53
  sessionId: manager.getSessionId(),
53
54
  sessionDir: manager.usesDefaultSessionDir() ? undefined : manager.getSessionDir(),
54
55
  model: options.model,
56
+ approveProjectTrust: options.approveProjectTrust,
55
57
  });
56
58
 
57
59
  return { sessionId: manager.getSessionId(), sessionFile, resumeCommand };
@@ -79,6 +81,11 @@ function flushPreparedSession(manager: SessionManager): void {
79
81
  * The canonical recovery artifact consumed by launch backends, failure
80
82
  * messages, clipboard delivery, and renderers. Self-locating: when the target
81
83
  * cwd differs from the parent's, the command starts with `cd <target> &&`.
84
+ *
85
+ * Unattended children carry `--approve`, which resolves project trust for that
86
+ * process without prompting: nobody is watching a detached worker's window, so
87
+ * a blocked trust selector there stalls the launch indefinitely. The override
88
+ * is process-scoped and never written to the trust store.
82
89
  */
83
90
  export function buildPiResumeCommand(options: {
84
91
  targetCwd: string;
@@ -86,12 +93,16 @@ export function buildPiResumeCommand(options: {
86
93
  sessionId: string;
87
94
  sessionDir?: string | undefined;
88
95
  model?: string | undefined;
96
+ approveProjectTrust: boolean;
89
97
  }): string {
90
98
  const args = ["pi"];
91
99
  if (options.sessionDir) {
92
100
  args.push("--session-dir", shellQuote(options.sessionDir));
93
101
  }
94
102
  args.push("--session-id", shellQuote(options.sessionId));
103
+ if (options.approveProjectTrust) {
104
+ args.push("--approve");
105
+ }
95
106
  if (options.model) {
96
107
  args.push("--model", shellQuote(options.model));
97
108
  }
@@ -40,14 +40,19 @@ export class HandoffToolComponent implements Component {
40
40
  return container.render(width);
41
41
  }
42
42
 
43
- const commandLabel =
44
- this.model.result.launch === "deferred"
45
- ? formatDeferredCommandLabel(this.clipboardStatus)
46
- : "recovery command";
47
- container.addChild(new Spacer(1));
48
- container.addChild(
49
- createLaunchCommandComponent(this.model.result.resumeCommand, commandLabel, this.theme),
50
- );
43
+ // Only a deferred handoff needs its command in the transcript, because the
44
+ // user is the one who has to run it. A launched child is already running;
45
+ // the board is where its command belongs.
46
+ if (this.model.result.launch === "deferred") {
47
+ container.addChild(new Spacer(1));
48
+ container.addChild(
49
+ createLaunchCommandComponent(
50
+ this.model.result.resumeCommand,
51
+ formatDeferredCommandLabel(this.clipboardStatus),
52
+ this.theme,
53
+ ),
54
+ );
55
+ }
51
56
  if (this.model.result.degradedFrom) {
52
57
  container.addChild(new Spacer(1));
53
58
  container.addChild(
@@ -105,6 +105,7 @@ export async function executeSessionHandoffTool(
105
105
  parentSessionFile,
106
106
  title,
107
107
  model,
108
+ approveProjectTrust: target.approveProjectTrust,
108
109
  buildBootstrap: (sessionId) =>
109
110
  createChildGeneratedHandoffBootstrap({
110
111
  sessionId,
@@ -21,6 +21,7 @@ export function createSubagentLaunchTarget(
21
21
  "'subagent' delegates one task to a detached tmux worker and requests a report by default.",
22
22
  requestResponseDefault: true,
23
23
  bootstrapMode: "automatic",
24
+ approveProjectTrust: true,
24
25
  describeSubagentChild(input) {
25
26
  return {
26
27
  childSessionId: input.childSessionId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-sessions",
3
- "version": "0.11.1",
3
+ "version": "0.11.2",
4
4
  "type": "module",
5
5
  "description": "Pi session search, ask, handoff, messaging, auto-titling, and indexing tools",
6
6
  "license": "MIT",
@@ -38,20 +38,20 @@
38
38
  },
39
39
  "devDependencies": {
40
40
  "@biomejs/biome": "^2.4.14",
41
- "@earendil-works/pi-agent-core": "^0.82.1",
42
- "@earendil-works/pi-ai": "^0.82.1",
43
- "@earendil-works/pi-coding-agent": "^0.82.1",
44
- "@earendil-works/pi-tui": "^0.82.1",
41
+ "@earendil-works/pi-agent-core": "^0.83.0",
42
+ "@earendil-works/pi-ai": "^0.83.0",
43
+ "@earendil-works/pi-coding-agent": "^0.83.0",
44
+ "@earendil-works/pi-tui": "^0.83.0",
45
45
  "@types/node": "^25.6.2",
46
46
  "strip-ansi": "^7.2.0",
47
47
  "typebox": "^1.1.38",
48
- "typescript": "^6.0.3",
48
+ "typescript": "^7.0.0",
49
49
  "vitest": "^4.1.5"
50
50
  },
51
51
  "peerDependencies": {
52
- "@earendil-works/pi-agent-core": ">=0.82.1",
53
- "@earendil-works/pi-ai": ">=0.82.1",
54
- "@earendil-works/pi-coding-agent": ">=0.82.1",
55
- "@earendil-works/pi-tui": ">=0.82.1"
52
+ "@earendil-works/pi-agent-core": ">=0.83.0",
53
+ "@earendil-works/pi-ai": ">=0.83.0",
54
+ "@earendil-works/pi-coding-agent": ">=0.83.0",
55
+ "@earendil-works/pi-tui": ">=0.83.0"
56
56
  }
57
57
  }