pi-sessions 0.14.3 → 0.16.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.
Files changed (31) hide show
  1. package/README.md +63 -9
  2. package/extensions/hosts/contract.ts +51 -0
  3. package/extensions/hosts/registry.ts +129 -0
  4. package/extensions/pi-sessions.ts +36 -10
  5. package/extensions/session-handoff/install.ts +4 -1
  6. package/extensions/session-handoff/launch/backend.ts +3 -10
  7. package/extensions/session-handoff/launch-options.ts +17 -2
  8. package/extensions/session-handoff/launch-target.ts +28 -16
  9. package/extensions/session-handoff/metadata.ts +2 -0
  10. package/extensions/session-handoff/query.ts +5 -4
  11. package/extensions/session-handoff/spawn.ts +2 -1
  12. package/extensions/session-handoff/tool-schema.ts +10 -2
  13. package/extensions/session-handoff/tool.ts +1 -0
  14. package/extensions/session-hooks/install.ts +14 -5
  15. package/extensions/session-index/install.ts +25 -4
  16. package/extensions/session-index/panel.ts +9 -0
  17. package/extensions/session-messaging/message-router.ts +91 -0
  18. package/extensions/session-messaging/pi/reachable-contract.ts +2 -1
  19. package/extensions/session-messaging/pi/reachable-tool.ts +24 -4
  20. package/extensions/session-messaging/pi/tools.ts +9 -1
  21. package/extensions/shared/session-index/access.ts +42 -5
  22. package/extensions/shared/session-index/common.ts +1 -0
  23. package/extensions/shared/session-index/recovery.ts +270 -0
  24. package/extensions/shared/session-index/schema.ts +9 -1
  25. package/extensions/shared/session-index/sqlite.ts +20 -10
  26. package/extensions/{session-search/hooks.ts → shared/session-index/sync.ts} +40 -16
  27. package/extensions/subagents/install.ts +13 -13
  28. package/extensions/subagents/settle.ts +1 -1
  29. package/extensions/subagents/wake.ts +32 -116
  30. package/package.json +5 -5
  31. package/extensions/session-search/reindex.ts +0 -97
@@ -1,6 +1,6 @@
1
1
  import type { Api, Model } from "@earendil-works/pi-ai";
2
2
  import { Type } from "typebox";
3
- import { type HandoffLaunchTarget, SUBAGENT_LAUNCH } from "./launch-target.ts";
3
+ import { type HandoffLaunchTarget, LAUNCH_DIRECTIONS, SUBAGENT_LAUNCH } from "./launch-target.ts";
4
4
  import { formatRosterEntry, type HandoffRoster } from "./roster.ts";
5
5
 
6
6
  export function buildHandoffLaunchSchema(targets: readonly HandoffLaunchTarget[]) {
@@ -46,11 +46,19 @@ export function buildHandoffPromptGuidelines(
46
46
  roster: HandoffRoster | undefined,
47
47
  ): string[] {
48
48
  const hasSubagent = targets.some((target) => target.value === SUBAGENT_LAUNCH);
49
+ const hasHost = targets.some(
50
+ (target) =>
51
+ target.value !== "subagent" &&
52
+ target.value !== "deferred" &&
53
+ !LAUNCH_DIRECTIONS.some((direction) => direction === target.value),
54
+ );
49
55
  const selectable =
50
56
  roster?.map(formatRosterEntry) ?? models.map((model) => `${model.provider}/${model.id}`);
51
57
  return [
52
58
  ...(hasSubagent ? SUBAGENT_GUIDELINES : []),
53
- LAUNCH_TARGET_GUIDELINE,
59
+ hasHost
60
+ ? "Use session_handoff host or deferred launches only when the user requests one."
61
+ : LAUNCH_TARGET_GUIDELINE,
54
62
  ...MODEL_INHERITANCE_GUIDELINES,
55
63
  hasSubagent ? SUBAGENT_MODEL_OVERRIDE_GUIDELINE : DEFAULT_MODEL_OVERRIDE_GUIDELINE,
56
64
  ...(selectable.length > 0
@@ -108,6 +108,7 @@ export async function executeSessionHandoffTool(
108
108
  title,
109
109
  model,
110
110
  approveProjectTrust: target.approveProjectTrust,
111
+ useDefaultSessionDir: target.useDefaultSessionDir ?? false,
111
112
  buildBootstrap: (sessionId) =>
112
113
  createChildGeneratedHandoffBootstrap({
113
114
  sessionId,
@@ -3,28 +3,37 @@ import {
3
3
  findPendingHandoffBootstrap,
4
4
  getHandoffMetadataFromEntries,
5
5
  } from "../session-handoff/metadata.ts";
6
- import { createSessionHookController } from "../session-search/hooks.ts";
7
6
  import type { IndexHandle, SessionLifecycle } from "../shared/composition.ts";
7
+ import { requestIndexRecovery } from "../shared/session-index/recovery.ts";
8
+ import { createSessionHookController } from "../shared/session-index/sync.ts";
8
9
 
9
- export function installHooks(pi: ExtensionAPI, deps: { index: IndexHandle }): SessionLifecycle {
10
+ export function installHooks(
11
+ pi: ExtensionAPI,
12
+ deps: { index: IndexHandle; turnSync: boolean },
13
+ ): SessionLifecycle {
10
14
  const controller = createSessionHookController({ indexPath: deps.index.path });
11
15
 
12
16
  pi.on("turn_end", async (_event, ctx) => {
13
- await controller.handleTurnEnd(ctx.sessionManager.getSessionFile());
17
+ if (deps.turnSync) await controller.handleTurnEnd(ctx.sessionManager.getSessionFile());
14
18
  });
15
19
 
16
20
  pi.on("session_tree", async (_event, ctx) => {
17
- await controller.handleSessionTree(ctx.sessionManager.getSessionFile());
21
+ if (deps.turnSync) await controller.handleSessionTree(ctx.sessionManager.getSessionFile());
18
22
  });
19
23
 
20
24
  pi.on("session_compact", async (_event, ctx) => {
21
- await controller.handleSessionCompact(ctx.sessionManager.getSessionFile());
25
+ if (deps.turnSync) await controller.handleSessionCompact(ctx.sessionManager.getSessionFile());
22
26
  });
23
27
 
24
28
  return {
25
29
  async onSessionStart(event, ctx) {
26
30
  const { reason, previousSessionFile } = event;
27
31
  const sessionFile = ctx.sessionManager.getSessionFile();
32
+ void requestIndexRecovery(deps.index.path, true)
33
+ .then(() => controller.handleSessionStart(ctx.sessionManager.getSessionFile()))
34
+ .catch((error: unknown) =>
35
+ ctx.ui.notify(`Session index sync failed: ${String(error)}`, "error"),
36
+ );
28
37
 
29
38
  switch (reason) {
30
39
  case "new":
@@ -1,15 +1,24 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
- import { type ReindexResult, rebuildSessionIndex } from "../session-search/reindex.ts";
3
- import type { IndexHandle } from "../shared/composition.ts";
2
+ import type { IndexHandle, SessionLifecycle } from "../shared/composition.ts";
4
3
  import { isTuiMode } from "../shared/pi-mode.ts";
5
4
  import { getIndexStatus } from "../shared/session-index/index.ts";
5
+ import {
6
+ getIndexRecoveryMessage,
7
+ observeIndexRecovery,
8
+ type ReindexResult,
9
+ rebuildSessionIndex,
10
+ } from "../shared/session-index/recovery.ts";
6
11
  import type { SessionSettings } from "../shared/settings.ts";
7
12
 
8
13
  import { ReindexLoader } from "./loader.ts";
9
14
  import { type SessionIndexAction, SessionIndexPanel } from "./panel.ts";
10
15
 
11
- export function installIndex(pi: ExtensionAPI, deps: { settings: SessionSettings }): IndexHandle {
16
+ export function installIndex(
17
+ pi: ExtensionAPI,
18
+ deps: { settings: SessionSettings },
19
+ ): IndexHandle & SessionLifecycle {
12
20
  const indexPath = deps.settings.index.path;
21
+ let lastNotice: string | undefined;
13
22
 
14
23
  pi.registerCommand("session-index", {
15
24
  description: "Open the session index control panel",
@@ -20,6 +29,7 @@ export function installIndex(pi: ExtensionAPI, deps: { settings: SessionSettings
20
29
  }
21
30
 
22
31
  const status = getIndexStatus(indexPath);
32
+ status.recoveryMessage = getIndexRecoveryMessage(indexPath);
23
33
  const action = await ctx.ui.custom<SessionIndexAction>(
24
34
  (tui, theme, _keybindings, done) =>
25
35
  new SessionIndexPanel(theme, status, done, () => tui.requestRender()),
@@ -59,7 +69,18 @@ export function installIndex(pi: ExtensionAPI, deps: { settings: SessionSettings
59
69
  },
60
70
  });
61
71
 
62
- return { path: indexPath };
72
+ return {
73
+ path: indexPath,
74
+ onSessionStart(_event, ctx) {
75
+ observeIndexRecovery(indexPath, (message) => {
76
+ ctx.ui.setStatus("session-index", message);
77
+ if (message && message !== lastNotice && !message.includes("in progress")) {
78
+ ctx.ui.notify(message, "error");
79
+ }
80
+ lastNotice = message;
81
+ });
82
+ },
83
+ };
63
84
  }
64
85
 
65
86
  async function runReindexWithLoader(
@@ -6,6 +6,7 @@ import {
6
6
  type TuiMouseEventResult,
7
7
  truncateToWidth,
8
8
  visibleWidth,
9
+ wrapTextWithAnsi,
9
10
  } from "@earendil-works/pi-tui";
10
11
  import { type LegendHit, LegendPointer, layoutLegend, legendHitAt } from "../shared/legend.ts";
11
12
  import type { SessionIndexStatus } from "../shared/session-index/index.ts";
@@ -73,6 +74,14 @@ export class SessionIndexPanel implements Focusable {
73
74
  lines.push(
74
75
  this.renderRow(innerWidth, ` Last full reindex: ${this.status.lastFullReindexAt ?? "n/a"}`),
75
76
  );
77
+ if (this.status.recoveryMessage) {
78
+ for (const line of wrapTextWithAnsi(
79
+ this.status.recoveryMessage,
80
+ Math.max(1, innerWidth - 2),
81
+ )) {
82
+ lines.push(this.renderRow(innerWidth, ` ${this.theme.fg("warning", line)}`));
83
+ }
84
+ }
76
85
  lines.push(this.renderRow(innerWidth, ""));
77
86
  this.renderAction(lines, innerWidth, "R", "rebuild from disk", () => this.done("reindex"));
78
87
  this.renderAction(lines, innerWidth, "Enter / Esc", "close", () => this.done(undefined));
@@ -0,0 +1,91 @@
1
+ import type { SendMessageRequest, SendMessageResult } from "./install.ts";
2
+
3
+ export interface SessionWaker {
4
+ owns(target: string): boolean | Promise<boolean>;
5
+ wake(target: string): Promise<void>;
6
+ restart?(target: string): Promise<void>;
7
+ afterSend?(): Promise<void> | void;
8
+ }
9
+
10
+ export interface WakeMessaging {
11
+ sendMessage(request: SendMessageRequest): Promise<SendMessageResult>;
12
+ listSessions(): Promise<string[]>;
13
+ waitForSession(sessionId: string, timeoutMs: number): Promise<boolean>;
14
+ }
15
+
16
+ export class MessageRouter {
17
+ private readonly waking = new Map<string, Promise<void>>();
18
+
19
+ constructor(
20
+ private readonly messaging: WakeMessaging,
21
+ private readonly wakers: readonly SessionWaker[],
22
+ private readonly getEpoch: () => number,
23
+ private readonly readyTimeoutMs = 30_000,
24
+ ) {}
25
+
26
+ async sendMessage(request: SendMessageRequest): Promise<SendMessageResult> {
27
+ const epoch = this.getEpoch();
28
+ let failedLiveSend: SendMessageResult | undefined;
29
+ if (await this.isLive(request.target)) {
30
+ this.requireCurrent(epoch);
31
+ const result = await this.messaging.sendMessage(request);
32
+ if (!isTargetDeparture(result)) return result;
33
+ failedLiveSend = result;
34
+ }
35
+ let owner: SessionWaker | undefined;
36
+ for (const waker of this.wakers) {
37
+ if (await waker.owns(request.target)) {
38
+ owner = waker;
39
+ break;
40
+ }
41
+ }
42
+ this.requireCurrent(epoch);
43
+ if (!owner) return failedLiveSend ?? this.messaging.sendMessage(request);
44
+
45
+ try {
46
+ for (let attempt = 0; ; attempt += 1) {
47
+ await this.wake(request.target, owner, epoch);
48
+ this.requireCurrent(epoch);
49
+ const result = await this.messaging.sendMessage(request);
50
+ if (attempt === 1 || !isTargetDeparture(result)) return result;
51
+ }
52
+ } finally {
53
+ if (epoch === this.getEpoch()) await owner.afterSend?.();
54
+ }
55
+ }
56
+
57
+ private wake(target: string, waker: SessionWaker, epoch: number): Promise<void> {
58
+ const key = `${epoch}:${target}`;
59
+ const existing = this.waking.get(key);
60
+ if (existing) return existing;
61
+ const wake = this.ensureReady(target, waker, epoch).finally(() => this.waking.delete(key));
62
+ this.waking.set(key, wake);
63
+ return wake;
64
+ }
65
+
66
+ private async ensureReady(target: string, waker: SessionWaker, epoch: number): Promise<void> {
67
+ if (await this.isLive(target)) return;
68
+ this.requireCurrent(epoch);
69
+ await waker.wake(target);
70
+ if (await this.messaging.waitForSession(target, this.readyTimeoutMs)) return;
71
+ this.requireCurrent(epoch);
72
+ if (waker.restart) {
73
+ await waker.restart(target);
74
+ if (await this.messaging.waitForSession(target, this.readyTimeoutMs)) return;
75
+ }
76
+ throw new Error(`Session ${target} did not register for messaging.`);
77
+ }
78
+
79
+ private async isLive(target: string): Promise<boolean> {
80
+ return (await this.messaging.listSessions()).includes(target);
81
+ }
82
+
83
+ private requireCurrent(epoch: number): void {
84
+ if (epoch !== this.getEpoch())
85
+ throw new Error("The current session changed while waking a session.");
86
+ }
87
+ }
88
+
89
+ function isTargetDeparture(result: SendMessageResult): boolean {
90
+ return !result.delivered && (result.reason === "no_session" || result.reason === "disconnected");
91
+ }
@@ -26,7 +26,8 @@ const REACHABLE_USER_SESSION_SCHEMA = Type.Object({
26
26
  cwd: Type.Optional(Type.String()),
27
27
  modifiedAt: Type.Optional(Type.String()),
28
28
  relation: Type.Optional(Type.String()),
29
- state: Type.Union([Type.Literal("live"), Type.Literal("starting")]),
29
+ host: Type.Optional(Type.String()),
30
+ state: Type.Union([Type.Literal("live"), Type.Literal("starting"), Type.Literal("dormant")]),
30
31
  });
31
32
 
32
33
  const REACHABLE_SUBAGENT_SCHEMA = Type.Object({
@@ -5,6 +5,7 @@ import {
5
5
  type ToolDefinition,
6
6
  } from "@earendil-works/pi-coding-agent";
7
7
  import { Text } from "@earendil-works/pi-tui";
8
+ import type { HostedSession } from "../../hosts/contract.ts";
8
9
  import { isSessionStarting } from "../../session-handoff/metadata.ts";
9
10
  import { formatError } from "../../shared/errors.ts";
10
11
  import { ExpandableContentLayout } from "../../shared/rendering/expandable-content-layout.ts";
@@ -46,7 +47,9 @@ export interface ReachableSubagentEntry {
46
47
 
47
48
  export interface SessionReachableDeps {
48
49
  indexPath: string;
50
+ hostedSessions?: boolean;
49
51
  listSessions(): Promise<string[]>;
52
+ listHostedSessions?: () => Promise<HostedSession[]>;
50
53
  getRelationTo(sessionId: string): string | undefined;
51
54
  listSubagents?:
52
55
  | ((
@@ -75,6 +78,11 @@ export function createSessionReachableTool(deps: SessionReachableDeps): ToolDefi
75
78
  : "List addressable live sessions",
76
79
  promptGuidelines: [
77
80
  "Before session_send_message, use session_reachable to find the target session id.",
81
+ ...(deps.hostedSessions
82
+ ? [
83
+ "User sessions include dormant hosted sessions. session_send_message wakes them automatically; the host field identifies their host.",
84
+ ]
85
+ : []),
78
86
  ],
79
87
  annotations: { readOnlyHint: true, openWorldHint: false },
80
88
  parameters: subagentsAvailable ? SESSION_REACHABLE_PARAMS : SESSION_REACHABLE_USER_PARAMS,
@@ -129,7 +137,11 @@ async function listReachableUserSessions(
129
137
  }
130
138
 
131
139
  const parentSessionId = deps.getParentSessionId?.();
132
- const targetIds = liveSessionIds.filter(
140
+ const hostedSessions = new Map(
141
+ ((await deps.listHostedSessions?.()) ?? []).map((session) => [session.sessionId, session]),
142
+ );
143
+ const liveIds = new Set(liveSessionIds);
144
+ const targetIds = [...new Set([...liveSessionIds, ...hostedSessions.keys()])].filter(
133
145
  (sessionId) => sessionId !== currentSessionId && sessionId !== parentSessionId,
134
146
  );
135
147
  return withSessionIndex(deps.indexPath, { mode: "read", required: true }, ({ db }) => {
@@ -140,12 +152,20 @@ async function listReachableUserSessions(
140
152
  continue;
141
153
  }
142
154
  const relation = deps.getRelationTo(sessionId);
155
+ const hosted = hostedSessions.get(sessionId);
156
+ const title = hosted?.title ?? row?.sessionName;
157
+ const cwd = hosted?.cwd ?? row?.cwd;
143
158
  sessions.push({
144
159
  kind: "user",
145
160
  sessionId,
146
- state: row && isStartingSession(row) ? "starting" : "live",
147
- ...(row?.sessionName ? { title: row.sessionName } : {}),
148
- ...(row?.cwd ? { cwd: row.cwd } : {}),
161
+ state: !liveIds.has(sessionId)
162
+ ? "dormant"
163
+ : row && isStartingSession(row)
164
+ ? "starting"
165
+ : "live",
166
+ ...(hosted ? { host: hosted.host } : {}),
167
+ ...(title ? { title } : {}),
168
+ ...(cwd ? { cwd } : {}),
149
169
  ...(row?.modifiedAt ? { modifiedAt: row.modifiedAt } : {}),
150
170
  ...(relation === undefined ? {} : { relation }),
151
171
  });
@@ -53,6 +53,7 @@ export type SessionSendMessageRole =
53
53
 
54
54
  export interface SessionSendMessageToolOptions {
55
55
  role: SessionSendMessageRole;
56
+ hostedSessions?: boolean;
56
57
  getCachedRelationTo(sessionId: string | undefined): string | undefined;
57
58
  /**
58
59
  * Resolved on every call: subagent identity depends on the active branch, so a rewind can
@@ -119,7 +120,14 @@ export function createSessionSendMessageTool(
119
120
  label: "Send Message to Session",
120
121
  description,
121
122
  promptSnippet,
122
- promptGuidelines,
123
+ promptGuidelines: [
124
+ ...promptGuidelines,
125
+ ...(options.hostedSessions
126
+ ? [
127
+ "session_reachable also lists dormant hosted sessions; sending a message wakes them automatically.",
128
+ ]
129
+ : []),
130
+ ],
123
131
  parameters: SEND_MESSAGE_PARAMS,
124
132
  renderCall(args, theme, context) {
125
133
  const state = context.state as SendMessageRendererState;
@@ -6,6 +6,13 @@ import {
6
6
  type SessionIndexDatabase,
7
7
  type SessionIndexStatus,
8
8
  } from "./common.ts";
9
+ import {
10
+ acquireIndexLock,
11
+ disableOlderIndexClient,
12
+ getIndexRecoveryMessage,
13
+ isIndexClientDisabled,
14
+ requestIndexRecovery,
15
+ } from "./recovery.ts";
9
16
  import { getMetadata, openIndexDatabase } from "./schema.ts";
10
17
 
11
18
  export type SessionIndexOpenMode = "read" | "write";
@@ -67,28 +74,57 @@ function openValidatedSessionIndexInternal(
67
74
  indexPath: string,
68
75
  options: WithSessionIndexOptions,
69
76
  ): ValidSessionIndex | undefined {
77
+ if (isIndexClientDisabled(indexPath)) return handleUnavailableIndex(indexPath, options);
70
78
  if (!existsSync(indexPath)) {
71
79
  return handleUnavailableIndex(indexPath, options);
72
80
  }
73
81
 
74
82
  let db: SessionIndexDatabase | undefined;
83
+ let release: (() => void) | undefined;
75
84
  try {
85
+ if (options.mode === "write") {
86
+ release = acquireIndexLock(indexPath);
87
+ if (!release) return handleUnavailableIndex(indexPath, options);
88
+ }
76
89
  db = openIndexDatabase(indexPath, {
77
90
  create: false,
78
- mode: options.mode,
91
+ mode: "read",
79
92
  ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
80
93
  });
81
- const status = getIndexStatusFromDb(indexPath, db);
82
- if (status.schemaVersion !== INDEX_SCHEMA_VERSION) {
94
+ const version = Number(getMetadata(db, "schema_version"));
95
+ if (version !== INDEX_SCHEMA_VERSION) {
83
96
  const invalidDb = db;
84
97
  db = undefined;
85
98
  invalidDb.close();
99
+ release?.();
100
+ release = undefined;
101
+ if (version > INDEX_SCHEMA_VERSION) disableOlderIndexClient(indexPath);
86
102
  return handleUnavailableIndex(indexPath, options);
87
103
  }
88
-
104
+ const status = getIndexStatusFromDb(indexPath, db);
105
+ if (options.mode === "write") {
106
+ db.close();
107
+ db = undefined;
108
+ db = openIndexDatabase(indexPath, {
109
+ create: false,
110
+ mode: "write",
111
+ timeoutMs: options.timeoutMs,
112
+ });
113
+ const close = db.close;
114
+ const unlock = release;
115
+ db.close = () => {
116
+ try {
117
+ close();
118
+ } finally {
119
+ unlock?.();
120
+ }
121
+ };
122
+ release = undefined;
123
+ }
89
124
  return { db, status };
90
125
  } catch (error) {
91
126
  db?.close();
127
+ release?.();
92
128
  return handleUnavailableIndex(indexPath, options, error);
93
129
  }
94
130
  }
@@ -112,7 +148,7 @@ export function getIndexStatusFromDb(dbPath: string, db: SessionIndexDatabase):
112
148
  }
113
149
 
114
150
  export function formatRequiredSessionIndexError(indexPath: string): string {
115
- return `Session index missing or incompatible at ${indexPath}. Run /session-index and press r to rebuild it.`;
151
+ return getIndexRecoveryMessage(indexPath) ?? "Session indexing in progress; try again shortly.";
116
152
  }
117
153
 
118
154
  function handleUnavailableIndex(
@@ -120,6 +156,7 @@ function handleUnavailableIndex(
120
156
  options: WithSessionIndexOptions,
121
157
  cause?: unknown,
122
158
  ): undefined {
159
+ if (!isIndexClientDisabled(indexPath)) void requestIndexRecovery(indexPath);
123
160
  if (!options.required) {
124
161
  return undefined;
125
162
  }
@@ -148,6 +148,7 @@ export interface SessionIndexStatus {
148
148
  schemaVersion?: number | undefined;
149
149
  sessionCount?: number | undefined;
150
150
  lastFullReindexAt?: string | undefined;
151
+ recoveryMessage?: string | undefined;
151
152
  }
152
153
 
153
154
  export type SessionIndexDatabase = SqliteDatabase;