pi-sessions 0.12.1 → 0.13.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.
Files changed (35) hide show
  1. package/README.md +6 -3
  2. package/extensions/pi-sessions.ts +20 -6
  3. package/extensions/session-ask/install.ts +24 -10
  4. package/extensions/session-auto-title/generate.ts +17 -14
  5. package/extensions/session-auto-title/install.ts +6 -29
  6. package/extensions/session-auto-title/retitle.ts +6 -19
  7. package/extensions/session-auto-title/wizard.ts +97 -31
  8. package/extensions/session-handoff/board-loading.ts +93 -0
  9. package/extensions/session-handoff/board.ts +239 -127
  10. package/extensions/session-handoff/install.ts +5 -5
  11. package/extensions/session-handoff/picker.ts +167 -19
  12. package/extensions/session-handoff/review.ts +56 -7
  13. package/extensions/session-handoff/ui.ts +32 -7
  14. package/extensions/session-hooks/install.ts +7 -20
  15. package/extensions/session-index/install.ts +8 -86
  16. package/extensions/session-index/loader.ts +80 -0
  17. package/extensions/session-index/panel.ts +103 -0
  18. package/extensions/session-messaging/broker/spawn.ts +35 -12
  19. package/extensions/session-messaging/pi/reachable-tool.ts +6 -1
  20. package/extensions/session-messaging/pi/tools.ts +79 -12
  21. package/extensions/session-search/extract.ts +120 -91
  22. package/extensions/session-search/hooks.ts +25 -203
  23. package/extensions/session-search/reindex.ts +13 -8
  24. package/extensions/shared/legend.ts +116 -0
  25. package/extensions/shared/session-index/scoring.ts +0 -1
  26. package/extensions/shared/session-index/search.ts +76 -49
  27. package/extensions/shared/settings.ts +22 -0
  28. package/extensions/subagents/context-limit.ts +58 -0
  29. package/extensions/subagents/install.ts +110 -30
  30. package/extensions/subagents/reconcile.ts +58 -19
  31. package/extensions/subagents/report.ts +0 -2
  32. package/extensions/subagents/settle.ts +31 -0
  33. package/extensions/subagents/should-message.ts +59 -0
  34. package/package.json +13 -13
  35. package/extensions/session-handoff/refs.ts +0 -145
@@ -0,0 +1,80 @@
1
+ import {
2
+ DynamicBorder,
3
+ type KeybindingsManager,
4
+ type Theme,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import {
7
+ type Component,
8
+ Container,
9
+ Loader,
10
+ Spacer,
11
+ type TUI,
12
+ type TuiMouseEvent,
13
+ type TuiMouseEventResult,
14
+ } from "@earendil-works/pi-tui";
15
+ import { type LegendHit, LegendPointer, layoutLegend, legendHitAt } from "../shared/legend.ts";
16
+
17
+ export class ReindexLoader implements Component {
18
+ private readonly body = new Container();
19
+ private readonly loader: Loader;
20
+ private cancelRow = 0;
21
+ private cancelHits: LegendHit[] = [];
22
+ private readonly pointer: LegendPointer;
23
+
24
+ constructor(
25
+ tui: TUI,
26
+ private readonly theme: Theme,
27
+ private readonly keybindings: KeybindingsManager,
28
+ private readonly cancel: () => void,
29
+ ) {
30
+ this.pointer = new LegendPointer(() => tui.requestRender());
31
+ this.loader = new Loader(
32
+ tui,
33
+ (text) => theme.fg("accent", text),
34
+ (text) => theme.fg("muted", text),
35
+ "Rebuilding session index...",
36
+ );
37
+ this.body.addChild(new DynamicBorder((text) => theme.fg("border", text)));
38
+ this.body.addChild(this.loader);
39
+ this.body.addChild(new Spacer(1));
40
+ }
41
+
42
+ render(width: number): string[] {
43
+ this.cancelHits = [];
44
+ const lines = this.body.render(width);
45
+ const legend = layoutLegend(
46
+ this.theme,
47
+ [
48
+ {
49
+ key: this.keybindings.getKeys("tui.select.cancel").join("/"),
50
+ description: "cancel",
51
+ run: this.cancel,
52
+ },
53
+ ],
54
+ { width: Math.max(0, width - 2), pressedId: this.pointer.pressedId },
55
+ );
56
+ this.cancelRow = lines.length;
57
+ this.cancelHits = legend.hits;
58
+ lines.push(` ${legend.text}`, "", this.theme.fg("border", "─".repeat(Math.max(0, width))));
59
+ return lines;
60
+ }
61
+
62
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
63
+ return this.pointer.handleMouse(
64
+ event,
65
+ event.y === this.cancelRow ? legendHitAt(this.cancelHits, event.x - 1) : undefined,
66
+ );
67
+ }
68
+
69
+ handleInput(data: string): void {
70
+ if (this.keybindings.matches(data, "tui.select.cancel")) this.cancel();
71
+ }
72
+
73
+ invalidate(): void {
74
+ this.body.invalidate();
75
+ }
76
+
77
+ dispose(): void {
78
+ this.loader.stop();
79
+ }
80
+ }
@@ -0,0 +1,103 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ type Focusable,
4
+ matchesKey,
5
+ type TuiMouseEvent,
6
+ type TuiMouseEventResult,
7
+ truncateToWidth,
8
+ visibleWidth,
9
+ } from "@earendil-works/pi-tui";
10
+ import { type LegendHit, LegendPointer, layoutLegend, legendHitAt } from "../shared/legend.ts";
11
+ import type { SessionIndexStatus } from "../shared/session-index/index.ts";
12
+
13
+ export type SessionIndexAction = "reindex" | undefined;
14
+
15
+ export class SessionIndexPanel implements Focusable {
16
+ focused = false;
17
+ private actionRows = new Map<number, LegendHit[]>();
18
+ private readonly pointer: LegendPointer;
19
+
20
+ constructor(
21
+ private readonly theme: Theme,
22
+ private readonly status: SessionIndexStatus,
23
+ private readonly done: (result: SessionIndexAction) => void,
24
+ requestRender: () => void,
25
+ ) {
26
+ this.pointer = new LegendPointer(requestRender);
27
+ }
28
+
29
+ invalidate(): void {}
30
+
31
+ handleInput(data: string): void {
32
+ if (matchesKey(data, "escape") || matchesKey(data, "enter")) {
33
+ this.done(undefined);
34
+ } else if (data === "r" || data === "R" || matchesKey(data, "r")) {
35
+ this.done("reindex");
36
+ }
37
+ }
38
+
39
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
40
+ const result = this.pointer.handleMouse(
41
+ event,
42
+ legendHitAt(this.actionRows.get(event.y) ?? [], event.x - 2),
43
+ );
44
+ return event.type === "press" && result?.handled ? { ...result, focus: true } : result;
45
+ }
46
+
47
+ render(width: number): string[] {
48
+ this.actionRows.clear();
49
+ const innerWidth = Math.max(0, Math.min(72, width) - 2);
50
+ const lines: string[] = [this.theme.fg("border", `╭${"─".repeat(innerWidth)}╮`)];
51
+ lines.push(
52
+ this.renderRow(innerWidth, ` ${this.theme.bold(this.theme.fg("accent", "Session Index"))}`),
53
+ );
54
+ lines.push(this.renderRow(innerWidth, ""));
55
+ lines.push(
56
+ this.renderRow(
57
+ innerWidth,
58
+ ` Path: ${this.status.exists ? this.status.dbPath : "<no index found>"}`,
59
+ ),
60
+ );
61
+ lines.push(
62
+ this.renderRow(
63
+ innerWidth,
64
+ ` Schema version: ${this.status.schemaVersion !== undefined ? String(this.status.schemaVersion) : "n/a"}`,
65
+ ),
66
+ );
67
+ lines.push(
68
+ this.renderRow(
69
+ innerWidth,
70
+ ` Session count: ${this.status.sessionCount !== undefined ? String(this.status.sessionCount) : "n/a"}`,
71
+ ),
72
+ );
73
+ lines.push(
74
+ this.renderRow(innerWidth, ` Last full reindex: ${this.status.lastFullReindexAt ?? "n/a"}`),
75
+ );
76
+ lines.push(this.renderRow(innerWidth, ""));
77
+ this.renderAction(lines, innerWidth, "R", "rebuild from disk", () => this.done("reindex"));
78
+ this.renderAction(lines, innerWidth, "Enter / Esc", "close", () => this.done(undefined));
79
+ lines.push(this.theme.fg("border", `╰${"─".repeat(innerWidth)}╯`));
80
+ return lines;
81
+ }
82
+
83
+ private renderAction(
84
+ lines: string[],
85
+ innerWidth: number,
86
+ key: string,
87
+ description: string,
88
+ run: () => void,
89
+ ): void {
90
+ const legend = layoutLegend(this.theme, [{ key, description, run }], {
91
+ width: Math.max(0, innerWidth - 1),
92
+ pressedId: this.pointer.pressedId,
93
+ });
94
+ this.actionRows.set(lines.length, legend.hits);
95
+ lines.push(this.renderRow(innerWidth, ` ${legend.text}`));
96
+ }
97
+
98
+ private renderRow(innerWidth: number, content: string): string {
99
+ const text = truncateToWidth(content, innerWidth, "…");
100
+ const pad = Math.max(0, innerWidth - visibleWidth(text));
101
+ return `${this.theme.fg("border", "│")}${text}${" ".repeat(pad)}${this.theme.fg("border", "│")}`;
102
+ }
103
+ }
@@ -1,5 +1,13 @@
1
- import { spawn } from "node:child_process";
2
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
1
+ import { type ChildProcess, spawn } from "node:child_process";
2
+ import {
3
+ closeSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ openSync,
7
+ readFileSync,
8
+ unlinkSync,
9
+ writeFileSync,
10
+ } from "node:fs";
3
11
  import net from "node:net";
4
12
  import { dirname, join } from "node:path";
5
13
  import { fileURLToPath } from "node:url";
@@ -16,6 +24,7 @@ const brokerDir = getSessionMessagingDir();
16
24
  const brokerSocketPath = getSessionMessagingSocketPath();
17
25
  const brokerPidPath = join(brokerDir, "broker.pid");
18
26
  const brokerSpawnLockPath = join(brokerDir, "broker.spawn.lock");
27
+ const brokerLogPath = join(brokerDir, "broker.log");
19
28
 
20
29
  export async function spawnSessionMessagingBrokerIfNeeded(): Promise<void> {
21
30
  mkdirSync(brokerDir, { recursive: true, mode: 0o700 });
@@ -43,14 +52,23 @@ export async function spawnSessionMessagingBrokerIfNeeded(): Promise<void> {
43
52
  `Session messaging broker build not found: ${brokerPath}. Run npm install in the pi-sessions package directory.`,
44
53
  );
45
54
  }
46
- const child = spawn(process.execPath, [brokerPath], {
47
- detached: true,
48
- stdio: "ignore",
49
- windowsHide: process.platform === "win32",
50
- env: brokerSpawnEnv(),
51
- });
52
- child.unref();
53
- await waitForBroker();
55
+ const log = openSync(brokerLogPath, "w", 0o600);
56
+ try {
57
+ const child = spawn(process.execPath, [brokerPath], {
58
+ detached: true,
59
+ stdio: ["ignore", log, log],
60
+ windowsHide: process.platform === "win32",
61
+ env: brokerSpawnEnv(),
62
+ });
63
+ await new Promise<void>((resolve, reject) => {
64
+ child.once("spawn", resolve);
65
+ child.once("error", reject);
66
+ });
67
+ child.unref();
68
+ await waitForBroker(child);
69
+ } finally {
70
+ closeSync(log);
71
+ }
54
72
  } finally {
55
73
  releaseSpawnLock();
56
74
  }
@@ -103,16 +121,21 @@ function canConnectToBroker(): Promise<boolean> {
103
121
  });
104
122
  }
105
123
 
106
- async function waitForBroker(): Promise<void> {
124
+ async function waitForBroker(child?: ChildProcess): Promise<void> {
107
125
  const start = Date.now();
108
126
  while (Date.now() - start < BROKER_START_TIMEOUT_MS) {
109
127
  if (await canConnectToBroker()) {
110
128
  return;
111
129
  }
130
+ if (child && (child.exitCode !== null || child.signalCode !== null)) {
131
+ throw new Error(
132
+ `Session messaging broker exited (${child.signalCode ?? child.exitCode}). See ${brokerLogPath}.`,
133
+ );
134
+ }
112
135
  await new Promise((resolve) => setTimeout(resolve, 100));
113
136
  }
114
137
 
115
- throw new Error("Session messaging broker failed to start.");
138
+ throw new Error(`Session messaging broker failed to start. See ${brokerLogPath}.`);
116
139
  }
117
140
 
118
141
  function acquireSpawnLock(): boolean {
@@ -53,6 +53,8 @@ export interface SessionReachableDeps {
53
53
  scope: Exclude<SessionReachableScope, "user">,
54
54
  ) => Promise<readonly ReachableSubagentEntry[]>)
55
55
  | undefined;
56
+ /** Resolved per call; the parent is unreachable from a subagent, so it stays off the menu. */
57
+ getParentSessionId?: (() => string | undefined) | undefined;
56
58
  }
57
59
 
58
60
  interface SessionReachableRendererState {
@@ -123,7 +125,10 @@ async function listReachableUserSessions(
123
125
  throw new Error(`Session messaging is unavailable: ${formatError(error)}`);
124
126
  }
125
127
 
126
- const targetIds = liveSessionIds.filter((sessionId) => sessionId !== currentSessionId);
128
+ const parentSessionId = deps.getParentSessionId?.();
129
+ const targetIds = liveSessionIds.filter(
130
+ (sessionId) => sessionId !== currentSessionId && sessionId !== parentSessionId,
131
+ );
127
132
  return withSessionIndex(deps.indexPath, { mode: "read", required: true }, ({ db }) => {
128
133
  const sessions: ReachableUserSession[] = [];
129
134
  for (const sessionId of targetIds) {
@@ -46,9 +46,62 @@ interface CancelSessionService {
46
46
  cancelSession(sessionId: string): Promise<CancelSessionResult | ManagedCancelSessionResult>;
47
47
  }
48
48
 
49
+ export type SessionSendMessageRole =
50
+ | { kind: "plain" }
51
+ | { kind: "wakeCapable" }
52
+ | { kind: "subagent" };
53
+
49
54
  export interface SessionSendMessageToolOptions {
50
- wakeCapable: boolean;
55
+ role: SessionSendMessageRole;
51
56
  getCachedRelationTo(sessionId: string | undefined): string | undefined;
57
+ /**
58
+ * Resolved on every call: subagent identity depends on the active branch, so a rewind can
59
+ * turn a plain session into a subagent without re-registering this tool.
60
+ */
61
+ getParentSessionId?: (() => string | undefined) | undefined;
62
+ }
63
+
64
+ export type SessionCancelRole = { kind: "plain" } | { kind: "subagent" };
65
+
66
+ export interface SessionCancelToolOptions {
67
+ role: SessionCancelRole;
68
+ /**
69
+ * Resolved on every call: subagent identity depends on the active branch, so a rewind can
70
+ * turn a plain session into a subagent without re-registering this tool.
71
+ */
72
+ getParentSessionId?: (() => string | undefined) | undefined;
73
+ }
74
+
75
+ interface SendMessageToolWording {
76
+ description: string;
77
+ promptSnippet: string;
78
+ promptGuidelines: string[];
79
+ }
80
+
81
+ function getSendMessageWording(role: SessionSendMessageRole): SendMessageToolWording {
82
+ switch (role.kind) {
83
+ case "plain":
84
+ return {
85
+ description: "Send a message to another live pi session.",
86
+ promptSnippet: "Send a message to another live pi session",
87
+ promptGuidelines: ["Use session_reachable to list live sessions and find the target."],
88
+ };
89
+ case "wakeCapable":
90
+ return {
91
+ description: "Send a message to another live pi session or a subagent.",
92
+ promptSnippet: "Send a message to another pi session or a subagent",
93
+ promptGuidelines: ["Use session_reachable to discover live sessions and owned subagents."],
94
+ };
95
+ case "subagent":
96
+ return {
97
+ description: "Send a message to another subagent.",
98
+ promptSnippet: "Send a message to another subagent",
99
+ promptGuidelines: [
100
+ "Use session_reachable to discover reachable subagents.",
101
+ "Report to your parent with submit_task_report; it cannot be reached with session_send_message.",
102
+ ],
103
+ };
104
+ }
52
105
  }
53
106
 
54
107
  interface SendMessageRendererState {
@@ -59,15 +112,7 @@ export function createSessionSendMessageTool(
59
112
  service: SendMessageService,
60
113
  options: SessionSendMessageToolOptions,
61
114
  ): ToolDefinition {
62
- const description = options.wakeCapable
63
- ? "Send a message to another live pi session or a subagent."
64
- : "Send a message to another live pi session.";
65
- const promptSnippet = options.wakeCapable
66
- ? "Send a message to another pi session or a subagent"
67
- : "Send a message to another live pi session";
68
- const promptGuidelines = options.wakeCapable
69
- ? ["Use session_reachable to discover live sessions and owned subagents."]
70
- : ["Use session_reachable to list live sessions and find the target."];
115
+ const { description, promptSnippet, promptGuidelines } = getSendMessageWording(options.role);
71
116
 
72
117
  return defineTool({
73
118
  name: "session_send_message",
@@ -109,6 +154,9 @@ export function createSessionSendMessageTool(
109
154
  },
110
155
  async execute(toolCallId, params: SendMessageParams, _signal, _onUpdate, _ctx) {
111
156
  const target = parseSessionTarget(params.session, "session_send_message");
157
+ if (target === options.getParentSessionId?.()) {
158
+ throw new Error("The parent session cannot be messaged. Use submit_task_report.");
159
+ }
112
160
 
113
161
  const body = params.message.trim();
114
162
  if (!body) {
@@ -147,13 +195,28 @@ export function createSessionSendMessageTool(
147
195
  });
148
196
  }
149
197
 
150
- export function createSessionCancelTool(service: CancelSessionService): ToolDefinition {
198
+ function getCancelPromptGuidelines(role: SessionCancelRole): string[] {
199
+ switch (role.kind) {
200
+ case "plain":
201
+ return ["Only cancel a user session when the user directs it."];
202
+ case "subagent":
203
+ return [
204
+ "Only cancel a user session when the user directs it.",
205
+ "Never cancel your parent session; it is waiting on your report.",
206
+ ];
207
+ }
208
+ }
209
+
210
+ export function createSessionCancelTool(
211
+ service: CancelSessionService,
212
+ options: SessionCancelToolOptions,
213
+ ): ToolDefinition {
151
214
  return defineTool<typeof CANCEL_SESSION_PARAMS, CancelSessionToolDetails>({
152
215
  name: "session_cancel",
153
216
  label: "Cancel a running session",
154
217
  description: "Cancel another running pi session",
155
218
  promptSnippet: "Cancel another running pi session",
156
- promptGuidelines: ["Only cancel a user session when the user directs it."],
219
+ promptGuidelines: getCancelPromptGuidelines(options.role),
157
220
  parameters: CANCEL_SESSION_PARAMS,
158
221
  renderResult(result, options, theme, context) {
159
222
  const output = getFirstText(result);
@@ -175,6 +238,10 @@ export function createSessionCancelTool(service: CancelSessionService): ToolDefi
175
238
  },
176
239
  async execute(_toolCallId, params: CancelSessionParams) {
177
240
  const target = parseSessionTarget(params.session, "session_cancel");
241
+ if (target === options.getParentSessionId?.()) {
242
+ throw new Error("The parent session cannot be cancelled. It is waiting on your report.");
243
+ }
244
+
178
245
  const result = await service.cancelSession(target);
179
246
  if (result.kind === "managed") {
180
247
  const details: CancelSessionToolDetails = {