pi-sessions 0.18.1 → 0.20.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/README.md +64 -39
- package/extensions/hosts/contract.ts +10 -46
- package/extensions/hosts/registry.ts +4 -78
- package/extensions/pi-sessions.ts +27 -53
- package/extensions/session-handoff/agent-profiles.ts +110 -0
- package/extensions/session-handoff/board-loading.ts +4 -1
- package/extensions/session-handoff/board-view-model.ts +1 -0
- package/extensions/session-handoff/board.ts +4 -1
- package/extensions/session-handoff/install.ts +78 -34
- package/extensions/session-handoff/launch/backend.ts +16 -4
- package/extensions/session-handoff/launch-options.ts +2 -17
- package/extensions/session-handoff/launch-target.ts +8 -10
- package/extensions/session-handoff/metadata.ts +10 -0
- package/extensions/session-handoff/model.ts +22 -0
- package/extensions/session-handoff/receipt.ts +1 -1
- package/extensions/session-handoff/spawn.ts +6 -0
- package/extensions/session-handoff/tool-presenter.ts +1 -1
- package/extensions/session-handoff/tool-schema.ts +14 -9
- package/extensions/session-handoff/tool-view-model.ts +6 -2
- package/extensions/session-handoff/tool.ts +20 -6
- package/extensions/session-messaging/directory-list.ts +115 -0
- package/extensions/session-messaging/directory.ts +114 -0
- package/extensions/session-messaging/message-router.ts +3 -8
- package/extensions/session-messaging/pi/directory-tools.ts +41 -0
- package/extensions/session-messaging/pi/install.ts +102 -0
- package/extensions/session-messaging/pi/message-contracts.ts +4 -6
- package/extensions/session-messaging/pi/reachable-contract.ts +3 -4
- package/extensions/session-messaging/pi/reachable-tool.ts +25 -175
- package/extensions/session-messaging/pi/send-message-presenter.ts +2 -1
- package/extensions/session-messaging/pi/tools.ts +49 -91
- package/extensions/session-search/extract.ts +12 -2
- package/extensions/shared/lineage.ts +8 -0
- package/extensions/shared/session-index/sync.ts +3 -0
- package/extensions/shared/settings.ts +21 -5
- package/extensions/subagents/install.ts +11 -31
- package/extensions/subagents/launch-target.ts +2 -1
- package/extensions/subagents/reconcile.ts +10 -2
- package/extensions/subagents/settle.ts +2 -0
- package/package.json +5 -5
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { type ExtensionContext, SessionManager } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { isSessionStarting } from "../session-handoff/metadata.ts";
|
|
3
|
+
import { formatError } from "../shared/errors.ts";
|
|
4
|
+
import {
|
|
5
|
+
getSessionById,
|
|
6
|
+
type SessionLineageRow,
|
|
7
|
+
withSessionIndex,
|
|
8
|
+
} from "../shared/session-index/index.ts";
|
|
9
|
+
import type {
|
|
10
|
+
ReachableSubagent,
|
|
11
|
+
ReachableUserSession,
|
|
12
|
+
SessionReachableScope,
|
|
13
|
+
} from "./pi/reachable-contract.ts";
|
|
14
|
+
|
|
15
|
+
/** The subagent facts the directory needs; the roster satisfies it structurally. */
|
|
16
|
+
export interface ReachableSubagentEntry {
|
|
17
|
+
sessionId: string;
|
|
18
|
+
title: string;
|
|
19
|
+
goal: string;
|
|
20
|
+
cwd: string;
|
|
21
|
+
state: string;
|
|
22
|
+
depth: number;
|
|
23
|
+
onActiveBranch: boolean;
|
|
24
|
+
launchedAt: string;
|
|
25
|
+
model?: string | undefined;
|
|
26
|
+
ownerSessionId: string;
|
|
27
|
+
ownerTitle: string;
|
|
28
|
+
ownerIsCurrentSession: boolean;
|
|
29
|
+
resumeCommand: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SessionReachableDeps {
|
|
33
|
+
indexPath: string;
|
|
34
|
+
listSessions(): Promise<string[]>;
|
|
35
|
+
getRelationTo(sessionId: string): string | undefined;
|
|
36
|
+
listSubagents?:
|
|
37
|
+
| ((
|
|
38
|
+
scope: Exclude<SessionReachableScope, "user">,
|
|
39
|
+
) => Promise<readonly ReachableSubagentEntry[]>)
|
|
40
|
+
| undefined;
|
|
41
|
+
getParentSessionId?: (() => string | undefined) | undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function listReachableUserSessions(
|
|
45
|
+
deps: SessionReachableDeps,
|
|
46
|
+
ctx: ExtensionContext,
|
|
47
|
+
): Promise<ReachableUserSession[]> {
|
|
48
|
+
const currentSessionId = ctx.sessionManager.getSessionId();
|
|
49
|
+
let liveSessionIds: string[];
|
|
50
|
+
try {
|
|
51
|
+
liveSessionIds = await deps.listSessions();
|
|
52
|
+
} catch (error) {
|
|
53
|
+
throw new Error(`Session messaging is unavailable: ${formatError(error)}`);
|
|
54
|
+
}
|
|
55
|
+
const parentSessionId = deps.getParentSessionId?.();
|
|
56
|
+
const targetIds = [...new Set(liveSessionIds)].filter(
|
|
57
|
+
(sessionId) => sessionId !== currentSessionId && sessionId !== parentSessionId,
|
|
58
|
+
);
|
|
59
|
+
return withSessionIndex(deps.indexPath, { mode: "read", required: true }, ({ db }) => {
|
|
60
|
+
const sessions: ReachableUserSession[] = [];
|
|
61
|
+
for (const sessionId of targetIds) {
|
|
62
|
+
const row = getSessionById(db, sessionId);
|
|
63
|
+
if (row?.sessionOrigin === "subagent") continue;
|
|
64
|
+
const relation = deps.getRelationTo(sessionId);
|
|
65
|
+
sessions.push({
|
|
66
|
+
kind: "user",
|
|
67
|
+
sessionId,
|
|
68
|
+
state: row && isStartingSession(row) ? "starting" : "live",
|
|
69
|
+
...(row?.sessionName ? { title: row.sessionName } : {}),
|
|
70
|
+
...(row?.cwd ? { cwd: row.cwd } : {}),
|
|
71
|
+
...(row?.modifiedAt ? { modifiedAt: row.modifiedAt } : {}),
|
|
72
|
+
...(relation === undefined ? {} : { relation }),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return sessions.sort((left, right) =>
|
|
76
|
+
(right.modifiedAt ?? "").localeCompare(left.modifiedAt ?? ""),
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function listReachableSubagents(
|
|
82
|
+
deps: SessionReachableDeps,
|
|
83
|
+
scope: Exclude<SessionReachableScope, "user">,
|
|
84
|
+
): Promise<ReachableSubagent[]> {
|
|
85
|
+
if (!deps.listSubagents) throw new Error("Subagent roster is unavailable.");
|
|
86
|
+
try {
|
|
87
|
+
const entries = await deps.listSubagents(scope);
|
|
88
|
+
return entries.map((entry) => ({
|
|
89
|
+
kind: "subagent",
|
|
90
|
+
sessionId: entry.sessionId,
|
|
91
|
+
title: entry.title,
|
|
92
|
+
goal: entry.goal,
|
|
93
|
+
cwd: entry.cwd,
|
|
94
|
+
state: entry.state,
|
|
95
|
+
depth: entry.depth,
|
|
96
|
+
onActiveBranch: entry.onActiveBranch,
|
|
97
|
+
launchedAt: entry.launchedAt,
|
|
98
|
+
...(entry.model === undefined ? {} : { model: entry.model }),
|
|
99
|
+
ownerSessionId: entry.ownerSessionId,
|
|
100
|
+
ownerTitle: entry.ownerTitle,
|
|
101
|
+
ownerIsCurrentSession: entry.ownerIsCurrentSession,
|
|
102
|
+
resumeCommand: entry.resumeCommand,
|
|
103
|
+
}));
|
|
104
|
+
} catch (error) {
|
|
105
|
+
throw new Error(`Subagent roster is unavailable: ${formatError(error)}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isStartingSession(row: SessionLineageRow): boolean {
|
|
110
|
+
try {
|
|
111
|
+
return isSessionStarting(SessionManager.open(row.sessionPath).getBranch());
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { getSessionById, withSessionIndex } from "../shared/session-index/index.ts";
|
|
3
|
+
import type { SubagentsHandle } from "../subagents/install.ts";
|
|
4
|
+
import {
|
|
5
|
+
listReachableSubagents,
|
|
6
|
+
listReachableUserSessions,
|
|
7
|
+
type SessionReachableDeps,
|
|
8
|
+
} from "./directory-list.ts";
|
|
9
|
+
import type { MessagingHandle } from "./install.ts";
|
|
10
|
+
import type { MessageRouter } from "./message-router.ts";
|
|
11
|
+
import type { ReachableSession, SessionReachableScope } from "./pi/reachable-contract.ts";
|
|
12
|
+
import type { CancelSessionResult, SendMessageRequest, SendMessageResult } from "./pi/service.ts";
|
|
13
|
+
import type { ManagedCancelSessionResult } from "./pi/tools.ts";
|
|
14
|
+
|
|
15
|
+
/** The addressable set, not a transport. Resolution is enforced before every mutation. */
|
|
16
|
+
export interface SessionDirectory {
|
|
17
|
+
list(scope: SessionReachableScope, ctx: ExtensionContext): Promise<ReachableSession[]>;
|
|
18
|
+
resolve(sessionId: string, ctx: ExtensionContext): Promise<{ sessionId: string }>;
|
|
19
|
+
send(request: SendMessageRequest, ctx: ExtensionContext): Promise<SendMessageResult>;
|
|
20
|
+
cancel(
|
|
21
|
+
sessionId: string,
|
|
22
|
+
ctx: ExtensionContext,
|
|
23
|
+
): Promise<CancelSessionResult | ManagedCancelSessionResult>;
|
|
24
|
+
wake(sessionId: string): Promise<void>;
|
|
25
|
+
getRelationTo(sessionId: string | undefined): string | undefined;
|
|
26
|
+
getParentSessionId(): string | undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createPeerDirectory(
|
|
30
|
+
messaging: MessagingHandle,
|
|
31
|
+
deps: SessionReachableDeps,
|
|
32
|
+
): SessionDirectory {
|
|
33
|
+
const directory: SessionDirectory = {
|
|
34
|
+
list: (scope, ctx) => {
|
|
35
|
+
if (scope !== "user") throw new Error("Peer discovery has no subagent scope.");
|
|
36
|
+
return listReachableUserSessions(deps, ctx);
|
|
37
|
+
},
|
|
38
|
+
async resolve(sessionId, ctx) {
|
|
39
|
+
const target = (await directory.list("user", ctx)).find(
|
|
40
|
+
(entry) => entry.sessionId === sessionId,
|
|
41
|
+
);
|
|
42
|
+
if (!target) throw new Error(`No reachable user session: ${sessionId}`);
|
|
43
|
+
return target;
|
|
44
|
+
},
|
|
45
|
+
async send(request, ctx) {
|
|
46
|
+
await directory.resolve(request.target, ctx);
|
|
47
|
+
return messaging.sendMessage(request);
|
|
48
|
+
},
|
|
49
|
+
async cancel(sessionId, ctx) {
|
|
50
|
+
await directory.resolve(sessionId, ctx);
|
|
51
|
+
return messaging.cancelSession(sessionId);
|
|
52
|
+
},
|
|
53
|
+
async wake() {
|
|
54
|
+
throw new Error("Peer sessions must already be live.");
|
|
55
|
+
},
|
|
56
|
+
getRelationTo: messaging.getCachedRelationTo,
|
|
57
|
+
getParentSessionId: () => deps.getParentSessionId?.(),
|
|
58
|
+
};
|
|
59
|
+
return directory;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createSubagentDirectory(
|
|
63
|
+
subagents: SubagentsHandle,
|
|
64
|
+
router: MessageRouter,
|
|
65
|
+
messaging: MessagingHandle,
|
|
66
|
+
indexPath: string,
|
|
67
|
+
): SessionDirectory {
|
|
68
|
+
const deps: SessionReachableDeps = {
|
|
69
|
+
indexPath,
|
|
70
|
+
listSessions: () => messaging.listSessions(),
|
|
71
|
+
getRelationTo: messaging.getCachedRelationTo,
|
|
72
|
+
listSubagents: async (scope) => (await subagents.roster.resolve(scope)).entries,
|
|
73
|
+
};
|
|
74
|
+
const directory: SessionDirectory = {
|
|
75
|
+
list: (scope) => {
|
|
76
|
+
if (scope === "user") throw new Error("Subagent discovery requires branch or tree scope.");
|
|
77
|
+
return listReachableSubagents(deps, scope);
|
|
78
|
+
},
|
|
79
|
+
async resolve(sessionId, ctx) {
|
|
80
|
+
if (sessionId === subagents.getParentSessionId())
|
|
81
|
+
throw new Error("The parent session is not addressable. Use submit_task_report.");
|
|
82
|
+
if (sessionId === ctx.sessionManager.getSessionId())
|
|
83
|
+
throw new Error("The current session is not a subagent target.");
|
|
84
|
+
const target = (await directory.list("tree", ctx)).find(
|
|
85
|
+
(entry) => entry.sessionId === sessionId,
|
|
86
|
+
);
|
|
87
|
+
if (target) return target;
|
|
88
|
+
// Known live workers (including siblings) remain addressable without transferring
|
|
89
|
+
// ownership. Only the caller's own ledger can authorize dormant wake or teardown.
|
|
90
|
+
const live = (await messaging.listSessions()).includes(sessionId);
|
|
91
|
+
const worker =
|
|
92
|
+
live &&
|
|
93
|
+
withSessionIndex(
|
|
94
|
+
indexPath,
|
|
95
|
+
{ mode: "read", required: true },
|
|
96
|
+
({ db }) => getSessionById(db, sessionId)?.sessionOrigin === "subagent",
|
|
97
|
+
);
|
|
98
|
+
if (worker) return { sessionId };
|
|
99
|
+
throw new Error(`No reachable subagent: ${sessionId}`);
|
|
100
|
+
},
|
|
101
|
+
async send(request, ctx) {
|
|
102
|
+
await directory.resolve(request.target, ctx);
|
|
103
|
+
return router.sendMessage(request);
|
|
104
|
+
},
|
|
105
|
+
async cancel(sessionId, ctx) {
|
|
106
|
+
await directory.resolve(sessionId, ctx);
|
|
107
|
+
return subagents.cancelSession(sessionId);
|
|
108
|
+
},
|
|
109
|
+
wake: (sessionId) => subagents.waker.wake(sessionId),
|
|
110
|
+
getRelationTo: messaging.getCachedRelationTo,
|
|
111
|
+
getParentSessionId: () => subagents.getParentSessionId(),
|
|
112
|
+
};
|
|
113
|
+
return directory;
|
|
114
|
+
}
|
|
@@ -18,7 +18,7 @@ export class MessageRouter {
|
|
|
18
18
|
|
|
19
19
|
constructor(
|
|
20
20
|
private readonly messaging: WakeMessaging,
|
|
21
|
-
private readonly
|
|
21
|
+
private readonly getWaker: () => SessionWaker | undefined,
|
|
22
22
|
private readonly getEpoch: () => number,
|
|
23
23
|
private readonly readyTimeoutMs = 30_000,
|
|
24
24
|
) {}
|
|
@@ -32,13 +32,8 @@ export class MessageRouter {
|
|
|
32
32
|
if (!isTargetDeparture(result)) return result;
|
|
33
33
|
failedLiveSend = result;
|
|
34
34
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (await waker.owns(request.target)) {
|
|
38
|
-
owner = waker;
|
|
39
|
-
break;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
35
|
+
const waker = this.getWaker();
|
|
36
|
+
const owner = waker && (await waker.owns(request.target)) ? waker : undefined;
|
|
42
37
|
this.requireCurrent(epoch);
|
|
43
38
|
if (!owner) return failedLiveSend ?? this.messaging.sendMessage(request);
|
|
44
39
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { SessionDirectory } from "../directory.ts";
|
|
3
|
+
import { createSessionReachableTool, type ReachableToolOptions } from "./reachable-tool.ts";
|
|
4
|
+
import {
|
|
5
|
+
createSessionCancelTool,
|
|
6
|
+
createSessionSendMessageTool,
|
|
7
|
+
type SendMessageToolWording,
|
|
8
|
+
} from "./tools.ts";
|
|
9
|
+
|
|
10
|
+
export interface DirectoryToolWording {
|
|
11
|
+
list: ReachableToolOptions;
|
|
12
|
+
send: SendMessageToolWording & { name: string };
|
|
13
|
+
cancel: SendMessageToolWording & { name: string };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Both public tool sets share execution, validation and native rendering. */
|
|
17
|
+
export function createDirectoryTools(
|
|
18
|
+
directory: SessionDirectory,
|
|
19
|
+
wording: DirectoryToolWording,
|
|
20
|
+
): ToolDefinition[] {
|
|
21
|
+
return [
|
|
22
|
+
createSessionReachableTool(directory, wording.list),
|
|
23
|
+
createSessionSendMessageTool(
|
|
24
|
+
{ sendMessage: directory.send },
|
|
25
|
+
{
|
|
26
|
+
name: wording.send.name,
|
|
27
|
+
wording: wording.send,
|
|
28
|
+
getCachedRelationTo: directory.getRelationTo,
|
|
29
|
+
getParentSessionId: directory.getParentSessionId,
|
|
30
|
+
},
|
|
31
|
+
),
|
|
32
|
+
createSessionCancelTool(
|
|
33
|
+
{ cancelSession: directory.cancel },
|
|
34
|
+
{
|
|
35
|
+
name: wording.cancel.name,
|
|
36
|
+
wording: wording.cancel,
|
|
37
|
+
getParentSessionId: directory.getParentSessionId,
|
|
38
|
+
},
|
|
39
|
+
),
|
|
40
|
+
];
|
|
41
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { SessionDirectory } from "../directory.ts";
|
|
3
|
+
import { createDirectoryTools } from "./directory-tools.ts";
|
|
4
|
+
import { SESSION_REACHABLE_PARAMS, SESSION_REACHABLE_USER_PARAMS } from "./reachable-contract.ts";
|
|
5
|
+
|
|
6
|
+
export function installDirectoryTools(
|
|
7
|
+
pi: ExtensionAPI,
|
|
8
|
+
deps: {
|
|
9
|
+
peers: SessionDirectory;
|
|
10
|
+
subagents?: SessionDirectory;
|
|
11
|
+
claimsPeers(): boolean;
|
|
12
|
+
},
|
|
13
|
+
): { refresh(): void } {
|
|
14
|
+
const ownedPeerNames = new Set<string>();
|
|
15
|
+
const refresh = (): void => {
|
|
16
|
+
const parentSessionId = deps.peers.getParentSessionId();
|
|
17
|
+
if (deps.claimsPeers()) {
|
|
18
|
+
// Pi has no unregisterTool. Remove only definitions we installed from the model's
|
|
19
|
+
// loadout, not a host's load-time tools. Retained definitions are execution-guarded.
|
|
20
|
+
pi.setActiveTools(pi.getActiveTools().filter((name) => !ownedPeerNames.has(name)));
|
|
21
|
+
} else {
|
|
22
|
+
const tools = createDirectoryTools(deps.peers, {
|
|
23
|
+
list: {
|
|
24
|
+
name: "session_reachable",
|
|
25
|
+
description: "List live user-facing Pi sessions.",
|
|
26
|
+
promptSnippet: "Find live peer sessions",
|
|
27
|
+
promptGuidelines: [],
|
|
28
|
+
parameters: SESSION_REACHABLE_USER_PARAMS,
|
|
29
|
+
defaultScope: "user",
|
|
30
|
+
},
|
|
31
|
+
send: {
|
|
32
|
+
name: "session_send_message",
|
|
33
|
+
description: "Send a message to another live Pi session.",
|
|
34
|
+
promptSnippet: "Message a live peer",
|
|
35
|
+
promptGuidelines: [
|
|
36
|
+
"Use session_reachable to find the target.",
|
|
37
|
+
...(parentSessionId
|
|
38
|
+
? ["Report to your parent with submit_task_report, never peer messaging."]
|
|
39
|
+
: []),
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
cancel: {
|
|
43
|
+
name: "session_cancel",
|
|
44
|
+
description: "Cancel another live Pi session's turn.",
|
|
45
|
+
promptSnippet: "Cancel a peer turn",
|
|
46
|
+
promptGuidelines: [
|
|
47
|
+
"Only cancel a user session when the user directs it.",
|
|
48
|
+
...(parentSessionId ? ["Never cancel your parent session."] : []),
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
for (const tool of tools) {
|
|
53
|
+
ownedPeerNames.add(tool.name);
|
|
54
|
+
pi.registerTool({
|
|
55
|
+
...tool,
|
|
56
|
+
execute: async (...args) => {
|
|
57
|
+
if (deps.claimsPeers()) throw new Error("Peer tools belong to the current host.");
|
|
58
|
+
return tool.execute(...args);
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
pi.setActiveTools([...new Set([...pi.getActiveTools(), ...ownedPeerNames])]);
|
|
63
|
+
}
|
|
64
|
+
if (deps.subagents) {
|
|
65
|
+
const tools = createDirectoryTools(deps.subagents, {
|
|
66
|
+
list: {
|
|
67
|
+
name: "subagent_list",
|
|
68
|
+
description: "List owned subagents in the branch or tree.",
|
|
69
|
+
promptSnippet: "Find delegated subagents",
|
|
70
|
+
promptGuidelines: [],
|
|
71
|
+
parameters: SESSION_REACHABLE_PARAMS,
|
|
72
|
+
defaultScope: "branch",
|
|
73
|
+
},
|
|
74
|
+
send: {
|
|
75
|
+
name: "subagent_send",
|
|
76
|
+
description: parentSessionId
|
|
77
|
+
? "Send a message to another subagent; dormant owned workers wake automatically."
|
|
78
|
+
: "Send a message to a subagent; dormant owned workers wake automatically.",
|
|
79
|
+
promptSnippet: "Steer or follow up with a subagent",
|
|
80
|
+
promptGuidelines: [
|
|
81
|
+
"Use subagent_list to find the target.",
|
|
82
|
+
...(parentSessionId
|
|
83
|
+
? ["Report to your parent with submit_task_report, not subagent_send."]
|
|
84
|
+
: []),
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
cancel: {
|
|
88
|
+
name: "subagent_cancel",
|
|
89
|
+
description: "Stop a subagent.",
|
|
90
|
+
promptSnippet: "Cancel delegated work",
|
|
91
|
+
promptGuidelines: parentSessionId
|
|
92
|
+
? ["Never cancel your parent session; it is waiting on your report."]
|
|
93
|
+
: [],
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
for (const tool of tools) pi.registerTool(tool);
|
|
97
|
+
pi.setActiveTools([...new Set([...pi.getActiveTools(), ...tools.map((tool) => tool.name)])]);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
pi.on("session_tree", refresh);
|
|
101
|
+
return { refresh };
|
|
102
|
+
}
|
|
@@ -11,12 +11,10 @@ export const SEND_MESSAGE_PARAMS = Type.Object({
|
|
|
11
11
|
message: Type.String({
|
|
12
12
|
description: "Message to send to the target session.",
|
|
13
13
|
}),
|
|
14
|
-
requestResponse: Type.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}),
|
|
19
|
-
),
|
|
14
|
+
requestResponse: Type.Boolean({
|
|
15
|
+
description:
|
|
16
|
+
"Whether the target session should respond with completion/results back to this session.",
|
|
17
|
+
}),
|
|
20
18
|
});
|
|
21
19
|
|
|
22
20
|
export const SEND_MESSAGE_TOOL_DETAILS_SCHEMA = Type.Object({
|
|
@@ -9,9 +9,9 @@ const SCOPE_SCHEMA = Type.Union([
|
|
|
9
9
|
/** Registered when subagents are active; the roster is what makes branch/tree answerable. */
|
|
10
10
|
export const SESSION_REACHABLE_PARAMS = Type.Object({
|
|
11
11
|
scope: Type.Optional(
|
|
12
|
-
Type.Union([Type.Literal("
|
|
12
|
+
Type.Union([Type.Literal("branch"), Type.Literal("tree")], {
|
|
13
13
|
description:
|
|
14
|
-
'
|
|
14
|
+
'"branch" lists subagents from the active conversation branch; "tree" lists subagents anywhere in this session\'s conversation tree. Defaults to "branch".',
|
|
15
15
|
}),
|
|
16
16
|
),
|
|
17
17
|
});
|
|
@@ -26,8 +26,7 @@ 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
|
-
|
|
30
|
-
state: Type.Union([Type.Literal("live"), Type.Literal("starting"), Type.Literal("dormant")]),
|
|
29
|
+
state: Type.Union([Type.Literal("live"), Type.Literal("starting")]),
|
|
31
30
|
});
|
|
32
31
|
|
|
33
32
|
const REACHABLE_SUBAGENT_SCHEMA = Type.Object({
|
|
@@ -1,26 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
defineTool,
|
|
3
|
-
type ExtensionContext,
|
|
4
|
-
SessionManager,
|
|
5
|
-
type ToolDefinition,
|
|
6
|
-
} from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import { defineTool, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
7
2
|
import { Text } from "@earendil-works/pi-tui";
|
|
8
|
-
import type { HostedSession } from "../../hosts/contract.ts";
|
|
9
|
-
import { isSessionStarting } from "../../session-handoff/metadata.ts";
|
|
10
|
-
import { formatError } from "../../shared/errors.ts";
|
|
11
3
|
import { ExpandableContentLayout } from "../../shared/rendering/expandable-content-layout.ts";
|
|
12
|
-
import {
|
|
13
|
-
getSessionById,
|
|
14
|
-
type SessionLineageRow,
|
|
15
|
-
withSessionIndex,
|
|
16
|
-
} from "../../shared/session-index/index.ts";
|
|
17
4
|
import { safeParseTypeBoxValue } from "../../shared/typebox.ts";
|
|
5
|
+
import type { SessionDirectory } from "../directory.ts";
|
|
18
6
|
import {
|
|
19
|
-
type
|
|
20
|
-
type ReachableUserSession,
|
|
21
|
-
SESSION_REACHABLE_PARAMS,
|
|
7
|
+
type SESSION_REACHABLE_PARAMS,
|
|
22
8
|
SESSION_REACHABLE_TOOL_DETAILS_SCHEMA,
|
|
23
|
-
SESSION_REACHABLE_USER_PARAMS,
|
|
9
|
+
type SESSION_REACHABLE_USER_PARAMS,
|
|
24
10
|
type SessionReachableParams,
|
|
25
11
|
type SessionReachableScope,
|
|
26
12
|
type SessionReachableToolDetails,
|
|
@@ -28,76 +14,37 @@ import {
|
|
|
28
14
|
import { buildSessionReachablePresentation } from "./reachable-presenter.ts";
|
|
29
15
|
import { buildSessionReachableView } from "./reachable-view-model.ts";
|
|
30
16
|
|
|
31
|
-
/** The subagent facts this tool needs; the roster satisfies it structurally. */
|
|
32
|
-
export interface ReachableSubagentEntry {
|
|
33
|
-
sessionId: string;
|
|
34
|
-
title: string;
|
|
35
|
-
goal: string;
|
|
36
|
-
cwd: string;
|
|
37
|
-
state: string;
|
|
38
|
-
depth: number;
|
|
39
|
-
onActiveBranch: boolean;
|
|
40
|
-
launchedAt: string;
|
|
41
|
-
model?: string | undefined;
|
|
42
|
-
ownerSessionId: string;
|
|
43
|
-
ownerTitle: string;
|
|
44
|
-
ownerIsCurrentSession: boolean;
|
|
45
|
-
resumeCommand: string;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export interface SessionReachableDeps {
|
|
49
|
-
indexPath: string;
|
|
50
|
-
hostedSessions?: boolean;
|
|
51
|
-
listSessions(): Promise<string[]>;
|
|
52
|
-
listHostedSessions?: () => Promise<HostedSession[]>;
|
|
53
|
-
getRelationTo(sessionId: string): string | undefined;
|
|
54
|
-
listSubagents?:
|
|
55
|
-
| ((
|
|
56
|
-
scope: Exclude<SessionReachableScope, "user">,
|
|
57
|
-
) => Promise<readonly ReachableSubagentEntry[]>)
|
|
58
|
-
| undefined;
|
|
59
|
-
/** Resolved per call; the parent is unreachable from a subagent, so it stays off the menu. */
|
|
60
|
-
getParentSessionId?: (() => string | undefined) | undefined;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
17
|
interface SessionReachableRendererState {
|
|
64
18
|
layout?: ExpandableContentLayout | undefined;
|
|
65
19
|
}
|
|
66
20
|
|
|
67
|
-
export
|
|
68
|
-
|
|
21
|
+
export interface ReachableToolOptions {
|
|
22
|
+
name: string;
|
|
23
|
+
description: string;
|
|
24
|
+
promptSnippet: string;
|
|
25
|
+
promptGuidelines: string[];
|
|
26
|
+
parameters: typeof SESSION_REACHABLE_PARAMS | typeof SESSION_REACHABLE_USER_PARAMS;
|
|
27
|
+
defaultScope: SessionReachableScope;
|
|
28
|
+
}
|
|
69
29
|
|
|
30
|
+
export function createSessionReachableTool(
|
|
31
|
+
directory: SessionDirectory,
|
|
32
|
+
options: ReachableToolOptions,
|
|
33
|
+
): ToolDefinition {
|
|
70
34
|
return defineTool({
|
|
71
|
-
name:
|
|
35
|
+
name: options.name,
|
|
72
36
|
label: "Reachable Sessions",
|
|
73
|
-
description:
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
promptSnippet: subagentsAvailable
|
|
77
|
-
? "List addressable live sessions and owned subagents"
|
|
78
|
-
: "List addressable live sessions",
|
|
79
|
-
promptGuidelines: [
|
|
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
|
-
: []),
|
|
86
|
-
],
|
|
37
|
+
description: options.description,
|
|
38
|
+
promptSnippet: options.promptSnippet,
|
|
39
|
+
promptGuidelines: options.promptGuidelines,
|
|
87
40
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
88
|
-
parameters:
|
|
41
|
+
parameters: options.parameters,
|
|
89
42
|
outputSchema: SESSION_REACHABLE_TOOL_DETAILS_SCHEMA,
|
|
90
43
|
renderResult(result, options, theme, context) {
|
|
91
44
|
const output = result.content.find((item) => item.type === "text")?.text ?? "";
|
|
92
|
-
if (context.isError)
|
|
93
|
-
return new Text(output ? theme.fg("error", output) : "", 0, 0);
|
|
94
|
-
}
|
|
95
|
-
|
|
45
|
+
if (context.isError) return new Text(output ? theme.fg("error", output) : "", 0, 0);
|
|
96
46
|
const details = safeParseTypeBoxValue(SESSION_REACHABLE_TOOL_DETAILS_SCHEMA, result.details);
|
|
97
|
-
if (!details)
|
|
98
|
-
return new Text(output, 0, 0);
|
|
99
|
-
}
|
|
100
|
-
|
|
47
|
+
if (!details) return new Text(output, 0, 0);
|
|
101
48
|
const state = context.state as SessionReachableRendererState;
|
|
102
49
|
const layout = state.layout ?? new ExpandableContentLayout(theme);
|
|
103
50
|
state.layout = layout;
|
|
@@ -108,12 +55,8 @@ export function createSessionReachableTool(deps: SessionReachableDeps): ToolDefi
|
|
|
108
55
|
return layout;
|
|
109
56
|
},
|
|
110
57
|
async execute(_toolCallId, params: SessionReachableParams, _signal, _onUpdate, ctx) {
|
|
111
|
-
const scope = params.scope ??
|
|
112
|
-
const sessions =
|
|
113
|
-
scope === "user"
|
|
114
|
-
? await listReachableUserSessions(deps, ctx)
|
|
115
|
-
: await listReachableSubagents(deps, scope);
|
|
116
|
-
|
|
58
|
+
const scope = params.scope ?? options.defaultScope;
|
|
59
|
+
const sessions = await directory.list(scope, ctx);
|
|
117
60
|
const details: SessionReachableToolDetails = { scope, sessions };
|
|
118
61
|
return {
|
|
119
62
|
content: [{ type: "text" as const, text: JSON.stringify(details, null, 2) }],
|
|
@@ -123,96 +66,3 @@ export function createSessionReachableTool(deps: SessionReachableDeps): ToolDefi
|
|
|
123
66
|
},
|
|
124
67
|
});
|
|
125
68
|
}
|
|
126
|
-
|
|
127
|
-
async function listReachableUserSessions(
|
|
128
|
-
deps: SessionReachableDeps,
|
|
129
|
-
ctx: ExtensionContext,
|
|
130
|
-
): Promise<ReachableUserSession[]> {
|
|
131
|
-
const currentSessionId = ctx.sessionManager.getSessionId();
|
|
132
|
-
let liveSessionIds: string[];
|
|
133
|
-
try {
|
|
134
|
-
liveSessionIds = await deps.listSessions();
|
|
135
|
-
} catch (error) {
|
|
136
|
-
throw new Error(`Session messaging is unavailable: ${formatError(error)}`);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const parentSessionId = deps.getParentSessionId?.();
|
|
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(
|
|
145
|
-
(sessionId) => sessionId !== currentSessionId && sessionId !== parentSessionId,
|
|
146
|
-
);
|
|
147
|
-
return withSessionIndex(deps.indexPath, { mode: "read", required: true }, ({ db }) => {
|
|
148
|
-
const sessions: ReachableUserSession[] = [];
|
|
149
|
-
for (const sessionId of targetIds) {
|
|
150
|
-
const row = getSessionById(db, sessionId);
|
|
151
|
-
if (row?.sessionOrigin === "subagent") {
|
|
152
|
-
continue;
|
|
153
|
-
}
|
|
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;
|
|
158
|
-
sessions.push({
|
|
159
|
-
kind: "user",
|
|
160
|
-
sessionId,
|
|
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 } : {}),
|
|
169
|
-
...(row?.modifiedAt ? { modifiedAt: row.modifiedAt } : {}),
|
|
170
|
-
...(relation === undefined ? {} : { relation }),
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
return sessions.sort(compareByModifiedDesc);
|
|
174
|
-
});
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
async function listReachableSubagents(
|
|
178
|
-
deps: SessionReachableDeps,
|
|
179
|
-
scope: Exclude<SessionReachableScope, "user">,
|
|
180
|
-
): Promise<ReachableSubagent[]> {
|
|
181
|
-
if (!deps.listSubagents) {
|
|
182
|
-
throw new Error(`session_reachable scope "${scope}" requires subagents to be active.`);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
try {
|
|
186
|
-
const entries = await deps.listSubagents(scope);
|
|
187
|
-
return entries.map((entry) => ({
|
|
188
|
-
kind: "subagent",
|
|
189
|
-
sessionId: entry.sessionId,
|
|
190
|
-
title: entry.title,
|
|
191
|
-
goal: entry.goal,
|
|
192
|
-
cwd: entry.cwd,
|
|
193
|
-
state: entry.state,
|
|
194
|
-
depth: entry.depth,
|
|
195
|
-
onActiveBranch: entry.onActiveBranch,
|
|
196
|
-
launchedAt: entry.launchedAt,
|
|
197
|
-
...(entry.model === undefined ? {} : { model: entry.model }),
|
|
198
|
-
ownerSessionId: entry.ownerSessionId,
|
|
199
|
-
ownerTitle: entry.ownerTitle,
|
|
200
|
-
ownerIsCurrentSession: entry.ownerIsCurrentSession,
|
|
201
|
-
resumeCommand: entry.resumeCommand,
|
|
202
|
-
}));
|
|
203
|
-
} catch (error) {
|
|
204
|
-
throw new Error(`Subagent roster is unavailable: ${formatError(error)}`);
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function isStartingSession(row: SessionLineageRow): boolean {
|
|
209
|
-
try {
|
|
210
|
-
return isSessionStarting(SessionManager.open(row.sessionPath).getBranch());
|
|
211
|
-
} catch {
|
|
212
|
-
return false;
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function compareByModifiedDesc(left: ReachableUserSession, right: ReachableUserSession): number {
|
|
217
|
-
return (right.modifiedAt ?? "").localeCompare(left.modifiedAt ?? "");
|
|
218
|
-
}
|