pi-sessions 0.9.0 → 0.10.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 +4 -0
- package/dist/session-messaging/broker/process.js +234 -0
- package/dist/shared/session-broker/framing.js +58 -0
- package/dist/shared/session-broker/protocol.js +163 -0
- package/dist/shared/session-broker/socket-path.js +19 -0
- package/dist/shared/typebox.js +36 -0
- package/extensions/session-handoff/board-view-model.ts +2 -4
- package/extensions/session-handoff/bootstrap.ts +14 -16
- package/extensions/session-handoff/extract.ts +34 -16
- package/extensions/session-handoff/install.ts +1 -1
- package/extensions/session-handoff/launch-target.ts +1 -1
- package/extensions/session-handoff/metadata.ts +72 -23
- package/extensions/session-messaging/broker/spawn.ts +12 -7
- package/extensions/shared/settings.ts +12 -7
- package/extensions/subagents/classify.ts +6 -0
- package/extensions/subagents/install.ts +22 -137
- package/extensions/subagents/settle.ts +182 -0
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -140,6 +140,8 @@ If you want to override the shortcut, put this in your `~/.pi/agent/settings.jso
|
|
|
140
140
|
"sessions": {
|
|
141
141
|
"handoff": {
|
|
142
142
|
"pickerShortcut": "alt+p",
|
|
143
|
+
"model": "openai-codex/gpt-5.6-terra",
|
|
144
|
+
"thinkingLevel": "low",
|
|
143
145
|
"deferred": {
|
|
144
146
|
"copyToClipboard": true
|
|
145
147
|
}
|
|
@@ -148,6 +150,8 @@ If you want to override the shortcut, put this in your `~/.pi/agent/settings.jso
|
|
|
148
150
|
}
|
|
149
151
|
```
|
|
150
152
|
|
|
153
|
+
`model` and `thinkingLevel` configure the agent that builds handoff prompts. They default to the new session's values when absent.
|
|
154
|
+
|
|
151
155
|
`deferred.copyToClipboard` (default `true`) controls whether deferred handoffs copy the resume command to the clipboard. When off, the resume command is only shown in the tool call.
|
|
152
156
|
|
|
153
157
|
Subagents require the handoff and messaging features. Limit recursive delegation depth with `sessions.subagents.maxDepth` (default `2`):
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { mkdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { createServer } from "node:net";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { readFrames, writeFrame } from "../../shared/session-broker/framing.js";
|
|
5
|
+
import { CLIENT_FRAME_SCHEMA, } from "../../shared/session-broker/protocol.js";
|
|
6
|
+
import { getSessionMessagingDir, getSessionMessagingSocketPath, } from "../../shared/session-broker/socket-path.js";
|
|
7
|
+
const SEND_TIMEOUT_MS = 10_000;
|
|
8
|
+
const EXIT_IDLE_MS = 5_000;
|
|
9
|
+
const KEEPALIVE_DELAY_MS = 5_000;
|
|
10
|
+
const messagingDir = getSessionMessagingDir();
|
|
11
|
+
const socketPath = getSessionMessagingSocketPath();
|
|
12
|
+
const pidPath = join(messagingDir, "broker.pid");
|
|
13
|
+
const sessions = new Map();
|
|
14
|
+
const pendingSends = new Map();
|
|
15
|
+
let idleTimer;
|
|
16
|
+
let server;
|
|
17
|
+
function closeWithError(socket, message) {
|
|
18
|
+
try {
|
|
19
|
+
writeFrame(socket, { type: "error", message });
|
|
20
|
+
}
|
|
21
|
+
catch { }
|
|
22
|
+
socket.destroy();
|
|
23
|
+
}
|
|
24
|
+
function scheduleIdleExit() {
|
|
25
|
+
if (idleTimer)
|
|
26
|
+
clearTimeout(idleTimer);
|
|
27
|
+
idleTimer = setTimeout(() => {
|
|
28
|
+
if (sessions.size === 0)
|
|
29
|
+
shutdown();
|
|
30
|
+
}, EXIT_IDLE_MS);
|
|
31
|
+
}
|
|
32
|
+
function clearIdleExit() {
|
|
33
|
+
if (!idleTimer)
|
|
34
|
+
return;
|
|
35
|
+
clearTimeout(idleTimer);
|
|
36
|
+
idleTimer = undefined;
|
|
37
|
+
}
|
|
38
|
+
function unregister(sessionId) {
|
|
39
|
+
if (!sessionId)
|
|
40
|
+
return;
|
|
41
|
+
sessions.delete(sessionId);
|
|
42
|
+
failPendingSendsTo(sessionId);
|
|
43
|
+
if (sessions.size === 0)
|
|
44
|
+
scheduleIdleExit();
|
|
45
|
+
}
|
|
46
|
+
function resolveTarget(target) {
|
|
47
|
+
const socket = sessions.get(target);
|
|
48
|
+
if (socket)
|
|
49
|
+
return { sessionId: target, socket };
|
|
50
|
+
return { error: `No live session found for id: ${target}` };
|
|
51
|
+
}
|
|
52
|
+
function handleFrame(socket, message, getSessionId, setSessionId) {
|
|
53
|
+
const currentSessionId = getSessionId();
|
|
54
|
+
if (!currentSessionId && message.type !== "register") {
|
|
55
|
+
closeWithError(socket, `Received ${message.type} before register.`);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
switch (message.type) {
|
|
59
|
+
case "register": {
|
|
60
|
+
const existing = sessions.get(message.sessionId);
|
|
61
|
+
if (existing && existing !== socket && !existing.destroyed) {
|
|
62
|
+
writeFrame(socket, {
|
|
63
|
+
type: "register_failed",
|
|
64
|
+
reason: "Session messaging unavailable: this session is already registered by another live Pi process.",
|
|
65
|
+
});
|
|
66
|
+
socket.end();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
clearIdleExit();
|
|
70
|
+
setSessionId(message.sessionId);
|
|
71
|
+
sessions.set(message.sessionId, socket);
|
|
72
|
+
socket.setKeepAlive(true, KEEPALIVE_DELAY_MS);
|
|
73
|
+
writeFrame(socket, { type: "registered", sessionId: message.sessionId });
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
case "unregister":
|
|
77
|
+
unregister(currentSessionId);
|
|
78
|
+
setSessionId(undefined);
|
|
79
|
+
socket.end();
|
|
80
|
+
break;
|
|
81
|
+
case "list":
|
|
82
|
+
writeFrame(socket, {
|
|
83
|
+
type: "sessions",
|
|
84
|
+
requestId: message.requestId,
|
|
85
|
+
sessionIds: [...sessions.keys()],
|
|
86
|
+
});
|
|
87
|
+
break;
|
|
88
|
+
case "send":
|
|
89
|
+
handleSend(socket, currentSessionId, message);
|
|
90
|
+
break;
|
|
91
|
+
case "incoming_ack":
|
|
92
|
+
handleIncomingAck(socket, message);
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function handleSend(socket, currentSessionId, message) {
|
|
97
|
+
const source = currentSessionId ? sessions.get(currentSessionId) : undefined;
|
|
98
|
+
if (!currentSessionId || !source) {
|
|
99
|
+
writeFrame(socket, {
|
|
100
|
+
type: "send_result",
|
|
101
|
+
requestId: message.requestId,
|
|
102
|
+
delivered: false,
|
|
103
|
+
error: "Sender session is not registered.",
|
|
104
|
+
});
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const resolved = resolveTarget(message.target);
|
|
108
|
+
if ("error" in resolved) {
|
|
109
|
+
writeFrame(socket, {
|
|
110
|
+
type: "send_result",
|
|
111
|
+
requestId: message.requestId,
|
|
112
|
+
delivered: false,
|
|
113
|
+
reason: "no_session",
|
|
114
|
+
error: resolved.error,
|
|
115
|
+
});
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (resolved.sessionId === currentSessionId) {
|
|
119
|
+
writeFrame(socket, {
|
|
120
|
+
type: "send_result",
|
|
121
|
+
requestId: message.requestId,
|
|
122
|
+
delivered: false,
|
|
123
|
+
error: "Cannot send a session envelope to the current session.",
|
|
124
|
+
});
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const timeout = setTimeout(() => {
|
|
128
|
+
pendingSends.delete(message.requestId);
|
|
129
|
+
writeFrame(socket, {
|
|
130
|
+
type: "send_result",
|
|
131
|
+
requestId: message.requestId,
|
|
132
|
+
delivered: false,
|
|
133
|
+
error: "Timed out waiting for target session to accept the envelope.",
|
|
134
|
+
});
|
|
135
|
+
}, SEND_TIMEOUT_MS);
|
|
136
|
+
pendingSends.set(message.requestId, {
|
|
137
|
+
source: socket,
|
|
138
|
+
target: resolved.socket,
|
|
139
|
+
timeout,
|
|
140
|
+
targetSessionId: resolved.sessionId,
|
|
141
|
+
});
|
|
142
|
+
writeFrame(resolved.socket, {
|
|
143
|
+
type: "incoming",
|
|
144
|
+
requestId: message.requestId,
|
|
145
|
+
envelope: stampEnvelope(message.envelope, currentSessionId, resolved.sessionId),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
function failPendingSendsTo(targetSessionId) {
|
|
149
|
+
for (const [requestId, pending] of pendingSends) {
|
|
150
|
+
if (pending.targetSessionId !== targetSessionId) {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
clearTimeout(pending.timeout);
|
|
154
|
+
pendingSends.delete(requestId);
|
|
155
|
+
writeFrame(pending.source, {
|
|
156
|
+
type: "send_result",
|
|
157
|
+
requestId,
|
|
158
|
+
delivered: false,
|
|
159
|
+
reason: "disconnected",
|
|
160
|
+
error: "Target disconnected before accepting the envelope.",
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function handleIncomingAck(socket, message) {
|
|
165
|
+
const pending = pendingSends.get(message.requestId);
|
|
166
|
+
if (!pending || pending.target !== socket)
|
|
167
|
+
return;
|
|
168
|
+
clearTimeout(pending.timeout);
|
|
169
|
+
pendingSends.delete(message.requestId);
|
|
170
|
+
writeFrame(pending.source, {
|
|
171
|
+
type: "send_result",
|
|
172
|
+
requestId: message.requestId,
|
|
173
|
+
delivered: message.delivered,
|
|
174
|
+
...(message.error === undefined ? {} : { error: message.error }),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
function stampEnvelope(envelope, source, target) {
|
|
178
|
+
return { ...envelope, source, target };
|
|
179
|
+
}
|
|
180
|
+
function handleConnection(socket) {
|
|
181
|
+
let sessionId;
|
|
182
|
+
socket.setKeepAlive(true, KEEPALIVE_DELAY_MS);
|
|
183
|
+
void readConnection(socket, () => sessionId, (nextSessionId) => {
|
|
184
|
+
sessionId = nextSessionId;
|
|
185
|
+
});
|
|
186
|
+
socket.on("close", () => {
|
|
187
|
+
unregister(sessionId);
|
|
188
|
+
});
|
|
189
|
+
socket.on("error", () => { });
|
|
190
|
+
}
|
|
191
|
+
async function readConnection(socket, getSessionId, setSessionId) {
|
|
192
|
+
try {
|
|
193
|
+
for await (const frame of readFrames(socket, CLIENT_FRAME_SCHEMA, "Invalid session messaging client frame")) {
|
|
194
|
+
handleFrame(socket, frame, getSessionId, setSessionId);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
closeWithError(socket, error instanceof Error ? error.message : String(error));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function shutdown() {
|
|
202
|
+
for (const socket of sessions.values())
|
|
203
|
+
socket.end();
|
|
204
|
+
sessions.clear();
|
|
205
|
+
for (const pending of pendingSends.values())
|
|
206
|
+
clearTimeout(pending.timeout);
|
|
207
|
+
pendingSends.clear();
|
|
208
|
+
if (server)
|
|
209
|
+
server.close();
|
|
210
|
+
if (process.platform !== "win32") {
|
|
211
|
+
try {
|
|
212
|
+
unlinkSync(socketPath);
|
|
213
|
+
}
|
|
214
|
+
catch { }
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
unlinkSync(pidPath);
|
|
218
|
+
}
|
|
219
|
+
catch { }
|
|
220
|
+
process.exit(0);
|
|
221
|
+
}
|
|
222
|
+
mkdirSync(messagingDir, { recursive: true, mode: 0o700 });
|
|
223
|
+
if (process.platform !== "win32") {
|
|
224
|
+
try {
|
|
225
|
+
unlinkSync(socketPath);
|
|
226
|
+
}
|
|
227
|
+
catch { }
|
|
228
|
+
}
|
|
229
|
+
server = createServer(handleConnection);
|
|
230
|
+
server.listen(socketPath, () => {
|
|
231
|
+
writeFileSync(pidPath, String(process.pid));
|
|
232
|
+
});
|
|
233
|
+
process.on("SIGTERM", shutdown);
|
|
234
|
+
process.on("SIGINT", shutdown);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createInterface } from "node:readline";
|
|
2
|
+
import { parseTypeBoxValue } from "../typebox.js";
|
|
3
|
+
const MAX_FRAME_BYTES = 256 * 1024;
|
|
4
|
+
export function writeFrame(socket, frame) {
|
|
5
|
+
socket.write(`${JSON.stringify(frame)}\n`);
|
|
6
|
+
}
|
|
7
|
+
export async function* readFrames(socket, schema, context) {
|
|
8
|
+
const reader = createInterface({ input: socket, crlfDelay: Number.POSITIVE_INFINITY });
|
|
9
|
+
const lines = [];
|
|
10
|
+
const waiters = [];
|
|
11
|
+
let error;
|
|
12
|
+
let closed = false;
|
|
13
|
+
const wake = () => {
|
|
14
|
+
const waiter = waiters.shift();
|
|
15
|
+
waiter?.();
|
|
16
|
+
};
|
|
17
|
+
reader.on("line", (line) => {
|
|
18
|
+
lines.push(line);
|
|
19
|
+
wake();
|
|
20
|
+
});
|
|
21
|
+
reader.on("error", (nextError) => {
|
|
22
|
+
error = nextError instanceof Error ? nextError : new Error(String(nextError));
|
|
23
|
+
closed = true;
|
|
24
|
+
wake();
|
|
25
|
+
});
|
|
26
|
+
reader.on("close", () => {
|
|
27
|
+
closed = true;
|
|
28
|
+
wake();
|
|
29
|
+
});
|
|
30
|
+
try {
|
|
31
|
+
while (true) {
|
|
32
|
+
while (lines.length === 0 && !closed) {
|
|
33
|
+
await new Promise((resolve) => waiters.push(resolve));
|
|
34
|
+
}
|
|
35
|
+
const line = lines.shift();
|
|
36
|
+
if (line === undefined) {
|
|
37
|
+
if (error) {
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (Buffer.byteLength(line, "utf8") > MAX_FRAME_BYTES) {
|
|
43
|
+
throw new Error("Session messaging frame exceeds maximum size.");
|
|
44
|
+
}
|
|
45
|
+
let parsed;
|
|
46
|
+
try {
|
|
47
|
+
parsed = JSON.parse(line);
|
|
48
|
+
}
|
|
49
|
+
catch (parseError) {
|
|
50
|
+
throw parseError instanceof Error ? parseError : new Error(String(parseError));
|
|
51
|
+
}
|
|
52
|
+
yield parseTypeBoxValue(schema, parsed, context);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
reader.close();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
export const TASK_REPORT_REFERENCE_SCHEMA = Type.Object({
|
|
3
|
+
reference: Type.String({
|
|
4
|
+
description: "A precise reference such as a file path and line range, symbol name, command, URL, session id, or external artifact.",
|
|
5
|
+
}),
|
|
6
|
+
description: Type.Optional(Type.String({
|
|
7
|
+
description: "Why this reference matters or what evidence the parent should take from it.",
|
|
8
|
+
})),
|
|
9
|
+
});
|
|
10
|
+
export const TASK_REPORT_SCHEMA = Type.Object({
|
|
11
|
+
status: Type.Union([
|
|
12
|
+
Type.Literal("done", {
|
|
13
|
+
description: "The delegated task or requested follow-up is complete.",
|
|
14
|
+
}),
|
|
15
|
+
Type.Literal("blocked", {
|
|
16
|
+
description: "The task cannot continue without a specific decision, clarification, permission, dependency, or other action outside the scope of the task.",
|
|
17
|
+
}),
|
|
18
|
+
Type.Literal("incomplete", {
|
|
19
|
+
description: "Useful work was performed, but the requested result could not be completed and it's uncertain what should be done to reach completion.",
|
|
20
|
+
}),
|
|
21
|
+
], { description: "The subagent's assessment of the task at the end of this turn." }),
|
|
22
|
+
summary: Type.String({
|
|
23
|
+
description: "A concise, 3-4 sentence self-contained account of the outcome and its most important evidence.",
|
|
24
|
+
}),
|
|
25
|
+
details: Type.Optional(Type.String({
|
|
26
|
+
description: "Optional supporting context such as reasoning, implementation notes, validation performed, limitations, unexpected behavior or roadblocks, or failed approaches.",
|
|
27
|
+
})),
|
|
28
|
+
references: Type.Optional(Type.Array(TASK_REPORT_REFERENCE_SCHEMA, {
|
|
29
|
+
description: "Supporting material for the report.",
|
|
30
|
+
})),
|
|
31
|
+
nextSteps: Type.Optional(Type.Array(Type.String(), {
|
|
32
|
+
description: "Optional concrete actions that would extend or build on the state of the work. If blocked or incomplete, call out specifically what is causing the early return.",
|
|
33
|
+
})),
|
|
34
|
+
});
|
|
35
|
+
const OUTBOUND_MESSAGE_ENVELOPE_SCHEMA = Type.Object({
|
|
36
|
+
kind: Type.Literal("message"),
|
|
37
|
+
messageId: Type.String(),
|
|
38
|
+
body: Type.String(),
|
|
39
|
+
requestResponse: Type.Optional(Type.Boolean()),
|
|
40
|
+
sentAt: Type.String(),
|
|
41
|
+
sourceToolCallId: Type.Optional(Type.String()),
|
|
42
|
+
});
|
|
43
|
+
const OUTBOUND_CANCEL_ENVELOPE_SCHEMA = Type.Object({
|
|
44
|
+
kind: Type.Literal("cancel"),
|
|
45
|
+
cancelId: Type.String(),
|
|
46
|
+
sentAt: Type.String(),
|
|
47
|
+
});
|
|
48
|
+
const OUTBOUND_SUBAGENT_REPORT_ENVELOPE_SCHEMA = Type.Intersect([
|
|
49
|
+
Type.Object({
|
|
50
|
+
kind: Type.Literal("subagent_report"),
|
|
51
|
+
reportId: Type.String(),
|
|
52
|
+
sentAt: Type.String(),
|
|
53
|
+
}),
|
|
54
|
+
TASK_REPORT_SCHEMA,
|
|
55
|
+
]);
|
|
56
|
+
export const OUTBOUND_SESSION_ENVELOPE_SCHEMA = Type.Union([
|
|
57
|
+
OUTBOUND_MESSAGE_ENVELOPE_SCHEMA,
|
|
58
|
+
OUTBOUND_CANCEL_ENVELOPE_SCHEMA,
|
|
59
|
+
OUTBOUND_SUBAGENT_REPORT_ENVELOPE_SCHEMA,
|
|
60
|
+
]);
|
|
61
|
+
export const SESSION_MESSAGE_ENVELOPE_SCHEMA = Type.Object({
|
|
62
|
+
kind: Type.Literal("message"),
|
|
63
|
+
messageId: Type.String(),
|
|
64
|
+
source: Type.String(),
|
|
65
|
+
target: Type.String(),
|
|
66
|
+
body: Type.String(),
|
|
67
|
+
requestResponse: Type.Optional(Type.Boolean()),
|
|
68
|
+
sentAt: Type.String(),
|
|
69
|
+
sourceToolCallId: Type.Optional(Type.String()),
|
|
70
|
+
});
|
|
71
|
+
export const SESSION_CANCEL_ENVELOPE_SCHEMA = Type.Object({
|
|
72
|
+
kind: Type.Literal("cancel"),
|
|
73
|
+
cancelId: Type.String(),
|
|
74
|
+
source: Type.String(),
|
|
75
|
+
target: Type.String(),
|
|
76
|
+
sentAt: Type.String(),
|
|
77
|
+
});
|
|
78
|
+
export const SESSION_SUBAGENT_REPORT_ENVELOPE_SCHEMA = Type.Intersect([
|
|
79
|
+
Type.Object({
|
|
80
|
+
kind: Type.Literal("subagent_report"),
|
|
81
|
+
reportId: Type.String(),
|
|
82
|
+
source: Type.String(),
|
|
83
|
+
target: Type.String(),
|
|
84
|
+
sentAt: Type.String(),
|
|
85
|
+
}),
|
|
86
|
+
TASK_REPORT_SCHEMA,
|
|
87
|
+
]);
|
|
88
|
+
export const SESSION_ENVELOPE_SCHEMA = Type.Union([
|
|
89
|
+
SESSION_MESSAGE_ENVELOPE_SCHEMA,
|
|
90
|
+
SESSION_CANCEL_ENVELOPE_SCHEMA,
|
|
91
|
+
SESSION_SUBAGENT_REPORT_ENVELOPE_SCHEMA,
|
|
92
|
+
]);
|
|
93
|
+
const REGISTER_CLIENT_FRAME_SCHEMA = Type.Object({
|
|
94
|
+
type: Type.Literal("register"),
|
|
95
|
+
sessionId: Type.String(),
|
|
96
|
+
});
|
|
97
|
+
const UNREGISTER_CLIENT_FRAME_SCHEMA = Type.Object({
|
|
98
|
+
type: Type.Literal("unregister"),
|
|
99
|
+
});
|
|
100
|
+
const LIST_CLIENT_FRAME_SCHEMA = Type.Object({
|
|
101
|
+
type: Type.Literal("list"),
|
|
102
|
+
requestId: Type.String(),
|
|
103
|
+
});
|
|
104
|
+
const SEND_CLIENT_FRAME_SCHEMA = Type.Object({
|
|
105
|
+
type: Type.Literal("send"),
|
|
106
|
+
requestId: Type.String(),
|
|
107
|
+
target: Type.String(),
|
|
108
|
+
envelope: OUTBOUND_SESSION_ENVELOPE_SCHEMA,
|
|
109
|
+
});
|
|
110
|
+
const INCOMING_ACK_CLIENT_FRAME_SCHEMA = Type.Object({
|
|
111
|
+
type: Type.Literal("incoming_ack"),
|
|
112
|
+
requestId: Type.String(),
|
|
113
|
+
delivered: Type.Boolean(),
|
|
114
|
+
error: Type.Optional(Type.String()),
|
|
115
|
+
});
|
|
116
|
+
const REGISTERED_BROKER_FRAME_SCHEMA = Type.Object({
|
|
117
|
+
type: Type.Literal("registered"),
|
|
118
|
+
sessionId: Type.String(),
|
|
119
|
+
});
|
|
120
|
+
const REGISTER_FAILED_BROKER_FRAME_SCHEMA = Type.Object({
|
|
121
|
+
type: Type.Literal("register_failed"),
|
|
122
|
+
reason: Type.String(),
|
|
123
|
+
});
|
|
124
|
+
const SESSIONS_BROKER_FRAME_SCHEMA = Type.Object({
|
|
125
|
+
type: Type.Literal("sessions"),
|
|
126
|
+
requestId: Type.String(),
|
|
127
|
+
sessionIds: Type.Array(Type.String()),
|
|
128
|
+
});
|
|
129
|
+
const INCOMING_BROKER_FRAME_SCHEMA = Type.Object({
|
|
130
|
+
type: Type.Literal("incoming"),
|
|
131
|
+
requestId: Type.String(),
|
|
132
|
+
envelope: SESSION_ENVELOPE_SCHEMA,
|
|
133
|
+
});
|
|
134
|
+
export const SESSION_ENVELOPE_SEND_FAILURE_REASON_SCHEMA = Type.Union([
|
|
135
|
+
Type.Literal("no_session"),
|
|
136
|
+
Type.Literal("disconnected"),
|
|
137
|
+
]);
|
|
138
|
+
const SEND_RESULT_BROKER_FRAME_SCHEMA = Type.Object({
|
|
139
|
+
type: Type.Literal("send_result"),
|
|
140
|
+
requestId: Type.String(),
|
|
141
|
+
delivered: Type.Boolean(),
|
|
142
|
+
reason: Type.Optional(SESSION_ENVELOPE_SEND_FAILURE_REASON_SCHEMA),
|
|
143
|
+
error: Type.Optional(Type.String()),
|
|
144
|
+
});
|
|
145
|
+
const ERROR_BROKER_FRAME_SCHEMA = Type.Object({
|
|
146
|
+
type: Type.Literal("error"),
|
|
147
|
+
message: Type.String(),
|
|
148
|
+
});
|
|
149
|
+
export const CLIENT_FRAME_SCHEMA = Type.Union([
|
|
150
|
+
REGISTER_CLIENT_FRAME_SCHEMA,
|
|
151
|
+
UNREGISTER_CLIENT_FRAME_SCHEMA,
|
|
152
|
+
LIST_CLIENT_FRAME_SCHEMA,
|
|
153
|
+
SEND_CLIENT_FRAME_SCHEMA,
|
|
154
|
+
INCOMING_ACK_CLIENT_FRAME_SCHEMA,
|
|
155
|
+
]);
|
|
156
|
+
export const BROKER_FRAME_SCHEMA = Type.Union([
|
|
157
|
+
REGISTERED_BROKER_FRAME_SCHEMA,
|
|
158
|
+
REGISTER_FAILED_BROKER_FRAME_SCHEMA,
|
|
159
|
+
SESSIONS_BROKER_FRAME_SCHEMA,
|
|
160
|
+
INCOMING_BROKER_FRAME_SCHEMA,
|
|
161
|
+
SEND_RESULT_BROKER_FRAME_SCHEMA,
|
|
162
|
+
ERROR_BROKER_FRAME_SCHEMA,
|
|
163
|
+
]);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
function sanitizePipeSegment(value) {
|
|
4
|
+
return (value
|
|
5
|
+
.replace(/[^a-zA-Z0-9]+/g, "-")
|
|
6
|
+
.replace(/^-+|-+$/g, "")
|
|
7
|
+
.toLowerCase() || "default");
|
|
8
|
+
}
|
|
9
|
+
export function getSessionMessagingDir(homeDir = homedir()) {
|
|
10
|
+
return (process.env.PI_SESSIONS_MESSAGING_DIR ??
|
|
11
|
+
join(homeDir, ".pi", "agent", "pi-sessions", "messaging"));
|
|
12
|
+
}
|
|
13
|
+
export function getSessionMessagingSocketPath(platform = process.platform, homeDir = homedir()) {
|
|
14
|
+
const messagingDir = getSessionMessagingDir(homeDir);
|
|
15
|
+
if (platform === "win32") {
|
|
16
|
+
return `\\\\.\\pipe\\pi-sessions-messaging-${sanitizePipeSegment(messagingDir)}`;
|
|
17
|
+
}
|
|
18
|
+
return join(messagingDir, "broker.sock");
|
|
19
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Value } from "typebox/value";
|
|
2
|
+
export function isTypeBoxValue(schema, value) {
|
|
3
|
+
return Value.Check(schema, value);
|
|
4
|
+
}
|
|
5
|
+
export function parseTypeBoxValue(schema, value, context) {
|
|
6
|
+
if (Value.Check(schema, value)) {
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
throw new Error(formatTypeBoxError(schema, value, context));
|
|
10
|
+
}
|
|
11
|
+
export function safeParseTypeBoxValue(schema, value) {
|
|
12
|
+
return Value.Check(schema, value) ? value : undefined;
|
|
13
|
+
}
|
|
14
|
+
export function parseTypeBoxRows(schema, value, context) {
|
|
15
|
+
if (!Array.isArray(value)) {
|
|
16
|
+
throw new Error(`${context}: expected an array of rows.`);
|
|
17
|
+
}
|
|
18
|
+
return value.map((row, index) => parseTypeBoxValue(schema, row, `${context} at row ${index + 1}`));
|
|
19
|
+
}
|
|
20
|
+
export function safeParseTypeBoxJson(schema, raw) {
|
|
21
|
+
try {
|
|
22
|
+
const parsed = JSON.parse(raw);
|
|
23
|
+
return safeParseTypeBoxValue(schema, parsed);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function formatTypeBoxError(schema, value, context) {
|
|
30
|
+
const firstError = Value.Errors(schema, value)[0];
|
|
31
|
+
if (!firstError) {
|
|
32
|
+
return `${context}: invalid value.`;
|
|
33
|
+
}
|
|
34
|
+
const path = firstError.instancePath.length > 0 ? firstError.instancePath : "/";
|
|
35
|
+
return `${context}: ${path} ${firstError.message}`;
|
|
36
|
+
}
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
import { tmuxSessionName } from "../shared/tmux.ts";
|
|
2
|
-
import type
|
|
2
|
+
import { isRunningSubagentState, type SubagentState } from "../subagents/classify.ts";
|
|
3
3
|
import type { SubagentRosterEntry } from "../subagents/roster.ts";
|
|
4
4
|
import { shellQuote } from "./launch/shell.ts";
|
|
5
5
|
import type { HandoffLaunchReceipt } from "./receipt.ts";
|
|
6
6
|
|
|
7
|
-
const RUNNING_SUBAGENT_STATES: ReadonlySet<SubagentState> = new Set(["starting", "busy", "active"]);
|
|
8
|
-
|
|
9
7
|
export type HandoffBoardTab = "subagents" | "user-sessions";
|
|
10
8
|
export type UserSessionStatus = "live" | "ready" | "starting" | "closed" | "unknown";
|
|
11
9
|
|
|
@@ -169,7 +167,7 @@ function buildUserSessionAction(
|
|
|
169
167
|
function buildSubagentAction(entry: SubagentRosterEntry, insideTmux: boolean): HandoffBoardAction {
|
|
170
168
|
return {
|
|
171
169
|
subagent: entry,
|
|
172
|
-
canStop:
|
|
170
|
+
canStop: isRunningSubagentState(entry.state),
|
|
173
171
|
...(entry.tmuxWindowId ? { observeCommand: buildObserveCommand(entry, insideTmux) } : {}),
|
|
174
172
|
...(!entry.managedLive ? { resumeCommand: entry.resumeCommand } : {}),
|
|
175
173
|
};
|
|
@@ -2,9 +2,9 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
|
2
2
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import type { ModelRuntimeProvider } from "../shared/model-runtime.ts";
|
|
5
|
+
import type { HandoffSettings } from "../shared/settings.ts";
|
|
5
6
|
import { generateHandoffDraftFromSessionManager } from "./extract.ts";
|
|
6
7
|
import { buildHandoffKickoffMessage, buildHandoffKickoffSource } from "./kickoff.ts";
|
|
7
|
-
import { SUBAGENT_LAUNCH } from "./launch-target.ts";
|
|
8
8
|
import {
|
|
9
9
|
type ChildGeneratedHandoffBootstrap,
|
|
10
10
|
createHandoffSessionMetadata,
|
|
@@ -23,8 +23,8 @@ export async function consumePendingHandoffBootstrap(
|
|
|
23
23
|
pi: ExtensionAPI,
|
|
24
24
|
ctx: ExtensionContext,
|
|
25
25
|
getModelRuntime: ModelRuntimeProvider,
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
destinationThinkingLevel: ThinkingLevel | undefined,
|
|
27
|
+
handoffSettings: HandoffSettings,
|
|
28
28
|
): Promise<void> {
|
|
29
29
|
const scan = findPendingHandoffBootstrap(ctx.sessionManager.getBranch());
|
|
30
30
|
if (!scan) {
|
|
@@ -56,8 +56,8 @@ export async function consumePendingHandoffBootstrap(
|
|
|
56
56
|
scan.entryId,
|
|
57
57
|
consumeBootstrap,
|
|
58
58
|
getModelRuntime,
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
destinationThinkingLevel,
|
|
60
|
+
handoffSettings,
|
|
61
61
|
);
|
|
62
62
|
}
|
|
63
63
|
|
|
@@ -68,8 +68,8 @@ async function startChildGeneratedHandoff(
|
|
|
68
68
|
bootstrapEntryId: string,
|
|
69
69
|
consumeBootstrap: (reason: HandoffBootstrapConsumedReason) => void,
|
|
70
70
|
getModelRuntime: ModelRuntimeProvider,
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
destinationThinkingLevel: ThinkingLevel | undefined,
|
|
72
|
+
handoffSettings: HandoffSettings,
|
|
73
73
|
): Promise<void> {
|
|
74
74
|
const entries = ctx.sessionManager.getEntries();
|
|
75
75
|
if (hasStartedConversation(entries)) {
|
|
@@ -96,17 +96,17 @@ async function startChildGeneratedHandoff(
|
|
|
96
96
|
ctx,
|
|
97
97
|
"Generating handoff draft...",
|
|
98
98
|
async (signal: AbortSignal) =>
|
|
99
|
-
generateHandoffDraftFromSessionManager(
|
|
99
|
+
generateHandoffDraftFromSessionManager({
|
|
100
100
|
ctx,
|
|
101
101
|
modelRuntime,
|
|
102
102
|
sourceSessionManager,
|
|
103
|
-
bootstrap.sourceLeafId,
|
|
104
|
-
bootstrap.goal,
|
|
105
|
-
|
|
106
|
-
|
|
103
|
+
sourceLeafId: bootstrap.sourceLeafId,
|
|
104
|
+
goal: bootstrap.goal,
|
|
105
|
+
settings: handoffSettings,
|
|
106
|
+
destinationThinkingLevel,
|
|
107
107
|
signal,
|
|
108
|
-
bootstrap.requestResponse,
|
|
109
|
-
),
|
|
108
|
+
requestResponse: bootstrap.requestResponse,
|
|
109
|
+
}),
|
|
110
110
|
);
|
|
111
111
|
if (!generatedDraft) {
|
|
112
112
|
consumeBootstrap("cancelled");
|
|
@@ -140,13 +140,11 @@ async function startChildGeneratedHandoff(
|
|
|
140
140
|
|
|
141
141
|
// The tool-provided bootstrap title is authoritative; extraction does not
|
|
142
142
|
// replace it with a second generated title.
|
|
143
|
-
const subagent = bootstrap.launch === SUBAGENT_LAUNCH ? bootstrap.subagent : undefined;
|
|
144
143
|
const metadata = createHandoffSessionMetadata(
|
|
145
144
|
bootstrap.goal,
|
|
146
145
|
prompt,
|
|
147
146
|
bootstrap.title,
|
|
148
147
|
bootstrap.launch,
|
|
149
|
-
subagent,
|
|
150
148
|
);
|
|
151
149
|
if (!getHandoffMetadataFromEntries(ctx.sessionManager.getEntries())) {
|
|
152
150
|
pi.appendEntry(HANDOFF_METADATA_CUSTOM_TYPE, metadata);
|