pi-sessions 0.10.0 → 0.11.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.
- package/README.md +25 -20
- package/dist/session-messaging/broker/client-frame.js +62 -0
- package/dist/session-messaging/broker/process.js +2 -2
- package/dist/shared/session-broker/framing.js +4 -3
- package/extensions/pi-sessions.ts +14 -1
- package/extensions/session-auto-title/generate.ts +56 -66
- package/extensions/session-auto-title/install.ts +2 -0
- package/extensions/session-auto-title/runs.ts +50 -0
- package/extensions/session-handoff/kickoff.ts +4 -3
- package/extensions/session-handoff/metadata.ts +1 -1
- package/extensions/session-messaging/broker/client-frame.ts +113 -0
- package/extensions/session-messaging/broker/process.ts +12 -21
- package/extensions/session-messaging/pi/reachable-contract.ts +59 -0
- package/extensions/session-messaging/pi/reachable-presenter.ts +49 -0
- package/extensions/session-messaging/pi/reachable-tool.ts +190 -0
- package/extensions/session-messaging/pi/reachable-view-model.ts +59 -0
- package/extensions/session-messaging/pi/renderer.ts +1 -1
- package/extensions/session-messaging/pi/tools.ts +8 -8
- package/extensions/session-search/hooks.ts +45 -52
- package/extensions/session-search/install.ts +3 -158
- package/extensions/session-search/reindex.ts +3 -3
- package/extensions/session-search/renderer.ts +1 -32
- package/extensions/session-search/tool-contract.ts +1 -21
- package/extensions/shared/model-runtime.ts +8 -1
- package/extensions/shared/session-broker/client.ts +3 -4
- package/extensions/shared/session-broker/framing.ts +6 -7
- package/extensions/shared/session-index/lineage.ts +6 -2
- package/extensions/shared/session-index/sqlite.ts +44 -17
- package/extensions/shared/settings.ts +13 -2
- package/extensions/shared/tmux.ts +22 -0
- package/extensions/subagents/report-message-renderer.ts +2 -2
- package/package.json +17 -29
- package/dist/shared/session-broker/protocol.js +0 -163
- package/dist/shared/typebox.js +0 -36
package/README.md
CHANGED
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
|
|
35
35
|
## Install
|
|
36
36
|
|
|
37
|
-
Requires Pi `0.
|
|
37
|
+
Requires Pi `0.82.1` or newer and Node `>=24 <26`.
|
|
38
38
|
|
|
39
39
|
**From npm** (recommended):
|
|
40
40
|
|
|
@@ -65,15 +65,15 @@ Open the frontend implementation task in a session to the right.
|
|
|
65
65
|
|
|
66
66
|
## Features
|
|
67
67
|
|
|
68
|
-
| Extension | Surface
|
|
69
|
-
| ------------------ |
|
|
70
|
-
| Session Search | `session_search` pi tool
|
|
71
|
-
| Session Ask | `session_ask` pi tool
|
|
72
|
-
| Session Handoff | `session_handoff` pi tool, `/handoff` board
|
|
73
|
-
| Session Messaging | `session_send_message`, `session_cancel` pi tools | Coordinate between live Pi sessions and own subagents |
|
|
74
|
-
| Session Picker | `Alt+O`
|
|
75
|
-
| Session Index | `/session-index` slash command
|
|
76
|
-
| Session Auto Title | in background, `/title` slash command
|
|
68
|
+
| Extension | Surface | What it does |
|
|
69
|
+
| ------------------ | ---------------------------------------------------------------------- | ------------------------------------------------------- |
|
|
70
|
+
| Session Search | `session_search` pi tool | Search through old sessions |
|
|
71
|
+
| Session Ask | `session_ask` pi tool | Ask questions about old sessions |
|
|
72
|
+
| Session Handoff | `session_handoff` pi tool, `/handoff` board | Start and manage focused child sessions |
|
|
73
|
+
| Session Messaging | `session_reachable`, `session_send_message`, `session_cancel` pi tools | Coordinate between live Pi sessions and own subagents |
|
|
74
|
+
| Session Picker | `Alt+O` | Reference old sessions in your prompt |
|
|
75
|
+
| Session Index | `/session-index` slash command | Shows index status and rebuilds the local session index |
|
|
76
|
+
| Session Auto Title | in background, `/title` slash command | Give sessions titles |
|
|
77
77
|
|
|
78
78
|
Every feature is on by default. Turn one off with `enable: false` under its own settings namespace, which unregisters its tools and hooks.
|
|
79
79
|
|
|
@@ -93,16 +93,16 @@ Every feature is on by default. Turn one off with `enable: false` under its own
|
|
|
93
93
|
|
|
94
94
|
## Session Search
|
|
95
95
|
|
|
96
|
-
`session_search` searches the local session index by text, repo, cwd, time range, file evidence
|
|
96
|
+
`session_search` searches the local session index by text, repo, cwd, time range, and file evidence.
|
|
97
97
|
|
|
98
|
-
Queries support regular text for normal usage, quoted phrases, `AND` / `OR` / `NOT`, parentheses, and `-term` negation when matching needs to be stricter. Unquoted terms use prefix matching, quoted terms are exact. A search with no query returns matching sessions chronologically, newest first.
|
|
98
|
+
Queries support regular text for normal usage, quoted phrases, `AND` / `OR` / `NOT`, parentheses, and `-term` negation when matching needs to be stricter. Unquoted terms use prefix matching, quoted terms are exact. A search with no query returns matching sessions chronologically, newest first.
|
|
99
99
|
|
|
100
100
|
File filters distinguish read-or-write evidence from write-only evidence:
|
|
101
101
|
|
|
102
102
|
- `files.touched`: sessions that read or changed a path
|
|
103
103
|
- `files.changed`: sessions that changed a path
|
|
104
104
|
|
|
105
|
-
Use `kind: "user"` or `kind: "subagent"` to filter by session type across the whole index.
|
|
105
|
+
Use `kind: "user"` or `kind: "subagent"` to filter by session type across the whole index. Searching for sessions that can be addressed right now is a different question, answered by `session_reachable`.
|
|
106
106
|
|
|
107
107
|
## Session Handoff
|
|
108
108
|
|
|
@@ -116,7 +116,7 @@ Run `/handoff` to open the **Handoffs** board. The Subagents and User sessions t
|
|
|
116
116
|
|
|
117
117
|
Agents can coordinate with live Pi sessions and their own subagents:
|
|
118
118
|
|
|
119
|
-
- `
|
|
119
|
+
- `session_reachable` lists the sessions this session can send messages to
|
|
120
120
|
- `session_send_message` sends a message to a live session or own subagent
|
|
121
121
|
- `session_cancel` aborts another live session's current turn
|
|
122
122
|
|
|
@@ -214,9 +214,10 @@ Note that generating titles for all sessions can take some time, and will hit yo
|
|
|
214
214
|
- if you manually rename a session with `/name`, automatic retitling pauses for that session
|
|
215
215
|
- Regenerate the title for the current session to resume automatic retitling
|
|
216
216
|
- if unconfigured, it will attempt to use these models in order, first one that is available:
|
|
217
|
-
- `
|
|
217
|
+
- `openai-codex/gpt-5.6-luna`
|
|
218
|
+
- `openai/gpt-5.6-luna`
|
|
218
219
|
- `anthropic/claude-haiku-4-5`
|
|
219
|
-
- `
|
|
220
|
+
- `google/gemini-flash-lite-latest`
|
|
220
221
|
- your currently configured model
|
|
221
222
|
|
|
222
223
|
To change auto-titling settings, edit `~/.pi/agent/settings.json`:
|
|
@@ -227,20 +228,24 @@ To change auto-titling settings, edit `~/.pi/agent/settings.json`:
|
|
|
227
228
|
"autoTitle": {
|
|
228
229
|
"refreshTurns": 4,
|
|
229
230
|
"timeoutSecs": 15,
|
|
231
|
+
"tokenBudget": 64,
|
|
230
232
|
"model": "anthropic/claude-haiku-4-5",
|
|
231
233
|
"thinkingLevel": "off",
|
|
232
|
-
"prompt": "Custom prompt that overrides the default."
|
|
234
|
+
"prompt": "Custom prompt that overrides the default.",
|
|
235
|
+
"persistRuns": false
|
|
233
236
|
}
|
|
234
237
|
}
|
|
235
238
|
}
|
|
236
239
|
```
|
|
237
240
|
|
|
241
|
+
`persistRuns` records each title request as its own session file under `~/.pi/agent/pi-sessions/session-auto-title/`, holding the exact prompt and response. Open one with `pi --session <file>` to see what the titling model was sent.
|
|
242
|
+
|
|
238
243
|
## Development
|
|
239
244
|
|
|
240
245
|
```bash
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
246
|
+
mise trust && mise bootstrap
|
|
247
|
+
mise run check
|
|
248
|
+
mise run test
|
|
244
249
|
```
|
|
245
250
|
|
|
246
251
|
For an end-to-end manual flow, see [SMOKE.md](./SMOKE.md).
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// The broker runs as a detached raw-Node process with nothing installed next to
|
|
2
|
+
// it, so client frames are validated by hand rather than against the TypeBox
|
|
3
|
+
// schemas in shared/session-broker/protocol.ts. The broker only routes
|
|
4
|
+
// envelopes, so it validates the routing fields and treats `envelope` as an
|
|
5
|
+
// opaque object; the receiving client validates the envelope body in full.
|
|
6
|
+
const CONTEXT = "Invalid session messaging client frame";
|
|
7
|
+
export function parseClientFrame(value) {
|
|
8
|
+
const frame = requireObject(value, "/");
|
|
9
|
+
switch (frame.type) {
|
|
10
|
+
case "register":
|
|
11
|
+
return { type: "register", sessionId: requireString(frame.sessionId, "/sessionId") };
|
|
12
|
+
case "unregister":
|
|
13
|
+
return { type: "unregister" };
|
|
14
|
+
case "list":
|
|
15
|
+
return { type: "list", requestId: requireString(frame.requestId, "/requestId") };
|
|
16
|
+
case "send":
|
|
17
|
+
return {
|
|
18
|
+
type: "send",
|
|
19
|
+
requestId: requireString(frame.requestId, "/requestId"),
|
|
20
|
+
target: requireString(frame.target, "/target"),
|
|
21
|
+
envelope: requireObject(frame.envelope, "/envelope"),
|
|
22
|
+
};
|
|
23
|
+
case "incoming_ack": {
|
|
24
|
+
const error = optionalString(frame.error, "/error");
|
|
25
|
+
return {
|
|
26
|
+
type: "incoming_ack",
|
|
27
|
+
requestId: requireString(frame.requestId, "/requestId"),
|
|
28
|
+
delivered: requireBoolean(frame.delivered, "/delivered"),
|
|
29
|
+
...(error === undefined ? {} : { error }),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
default:
|
|
33
|
+
throw invalid("/type", "expected a known client frame type");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function requireObject(value, path) {
|
|
37
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
38
|
+
throw invalid(path, "expected an object");
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
function requireString(value, path) {
|
|
43
|
+
if (typeof value !== "string") {
|
|
44
|
+
throw invalid(path, "expected a string");
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
function requireBoolean(value, path) {
|
|
49
|
+
if (typeof value !== "boolean") {
|
|
50
|
+
throw invalid(path, "expected a boolean");
|
|
51
|
+
}
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
function optionalString(value, path) {
|
|
55
|
+
if (value === undefined) {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
return requireString(value, path);
|
|
59
|
+
}
|
|
60
|
+
function invalid(path, message) {
|
|
61
|
+
return new Error(`${CONTEXT}: ${path} ${message}`);
|
|
62
|
+
}
|
|
@@ -2,8 +2,8 @@ import { mkdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { createServer } from "node:net";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { readFrames, writeFrame } from "../../shared/session-broker/framing.js";
|
|
5
|
-
import { CLIENT_FRAME_SCHEMA, } from "../../shared/session-broker/protocol.js";
|
|
6
5
|
import { getSessionMessagingDir, getSessionMessagingSocketPath, } from "../../shared/session-broker/socket-path.js";
|
|
6
|
+
import { parseClientFrame, } from "./client-frame.js";
|
|
7
7
|
const SEND_TIMEOUT_MS = 10_000;
|
|
8
8
|
const EXIT_IDLE_MS = 5_000;
|
|
9
9
|
const KEEPALIVE_DELAY_MS = 5_000;
|
|
@@ -190,7 +190,7 @@ function handleConnection(socket) {
|
|
|
190
190
|
}
|
|
191
191
|
async function readConnection(socket, getSessionId, setSessionId) {
|
|
192
192
|
try {
|
|
193
|
-
for await (const frame of readFrames(socket,
|
|
193
|
+
for await (const frame of readFrames(socket, parseClientFrame)) {
|
|
194
194
|
handleFrame(socket, frame, getSessionId, setSessionId);
|
|
195
195
|
}
|
|
196
196
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { createInterface } from "node:readline";
|
|
2
|
-
import { parseTypeBoxValue } from "../typebox.js";
|
|
3
2
|
const MAX_FRAME_BYTES = 256 * 1024;
|
|
4
3
|
export function writeFrame(socket, frame) {
|
|
5
4
|
socket.write(`${JSON.stringify(frame)}\n`);
|
|
6
5
|
}
|
|
7
|
-
|
|
6
|
+
// The parser is injected so the detached broker can run with no dependencies
|
|
7
|
+
// installed alongside it, while Pi-side callers keep using TypeBox schemas.
|
|
8
|
+
export async function* readFrames(socket, parseFrame) {
|
|
8
9
|
const reader = createInterface({ input: socket, crlfDelay: Number.POSITIVE_INFINITY });
|
|
9
10
|
const lines = [];
|
|
10
11
|
const waiters = [];
|
|
@@ -49,7 +50,7 @@ export async function* readFrames(socket, schema, context) {
|
|
|
49
50
|
catch (parseError) {
|
|
50
51
|
throw parseError instanceof Error ? parseError : new Error(String(parseError));
|
|
51
52
|
}
|
|
52
|
-
yield
|
|
53
|
+
yield parseFrame(parsed);
|
|
53
54
|
}
|
|
54
55
|
}
|
|
55
56
|
finally {
|
|
@@ -9,6 +9,7 @@ import { installHandoff } from "./session-handoff/install.ts";
|
|
|
9
9
|
import { installHooks } from "./session-hooks/install.ts";
|
|
10
10
|
import { installIndex } from "./session-index/install.ts";
|
|
11
11
|
import { installMessaging } from "./session-messaging/install.ts";
|
|
12
|
+
import { createSessionReachableTool } from "./session-messaging/pi/reachable-tool.ts";
|
|
12
13
|
import {
|
|
13
14
|
createSessionCancelTool,
|
|
14
15
|
createSessionSendMessageTool,
|
|
@@ -67,6 +68,18 @@ export default function piSessions(pi: ExtensionAPI): void {
|
|
|
67
68
|
}),
|
|
68
69
|
);
|
|
69
70
|
pi.registerTool(createSessionCancelTool(subagents ?? messaging));
|
|
71
|
+
pi.registerTool(
|
|
72
|
+
createSessionReachableTool({
|
|
73
|
+
indexPath: index.path,
|
|
74
|
+
listSessions: () => messaging.listSessions(),
|
|
75
|
+
getRelationTo: messaging.getCachedRelationTo,
|
|
76
|
+
...(subagents
|
|
77
|
+
? {
|
|
78
|
+
listSubagents: async (scope) => (await subagents.roster.resolve(scope)).entries,
|
|
79
|
+
}
|
|
80
|
+
: {}),
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
70
83
|
}
|
|
71
84
|
if (settings.features.handoff) {
|
|
72
85
|
const board = {
|
|
@@ -88,7 +101,7 @@ export default function piSessions(pi: ExtensionAPI): void {
|
|
|
88
101
|
);
|
|
89
102
|
}
|
|
90
103
|
if (settings.features.search) {
|
|
91
|
-
installSearch(pi, { settings, index
|
|
104
|
+
installSearch(pi, { settings, index });
|
|
92
105
|
}
|
|
93
106
|
if (settings.features.ask) {
|
|
94
107
|
installAsk(pi, { settings, index, getModelRuntime });
|
|
@@ -1,13 +1,10 @@
|
|
|
1
|
-
import { appendFileSync, mkdirSync } from "node:fs";
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
1
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
5
2
|
import type { Api, Model, TextContent, UserMessage } from "@earendil-works/pi-ai";
|
|
6
3
|
import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
|
7
4
|
import type { AutoTitleContext } from "./context.ts";
|
|
5
|
+
import { type AutoTitleRun, startAutoTitleRun } from "./runs.ts";
|
|
8
6
|
import type { AutoTitleTrigger } from "./state.ts";
|
|
9
7
|
|
|
10
|
-
const AUTO_TITLE_MAX_TOKENS = 64;
|
|
11
8
|
const AUTO_TITLE_CHAR_MAX = 80;
|
|
12
9
|
|
|
13
10
|
export interface AutoTitleFailure {
|
|
@@ -20,7 +17,9 @@ export interface AutoTitleFailure {
|
|
|
20
17
|
export interface AutoTitleGeneration {
|
|
21
18
|
systemPrompt: string;
|
|
22
19
|
timeoutMs: number;
|
|
20
|
+
tokenBudget: number;
|
|
23
21
|
thinkingLevel: ThinkingLevel | undefined;
|
|
22
|
+
persistRuns: boolean;
|
|
24
23
|
}
|
|
25
24
|
|
|
26
25
|
export type AutoTitleGenerationResult =
|
|
@@ -66,35 +65,47 @@ export async function generateAutoTitle(
|
|
|
66
65
|
const abortController = new AbortController();
|
|
67
66
|
const timeoutId = setTimeout(() => abortController.abort(), generation.timeoutMs);
|
|
68
67
|
const thinkingLevel = generation.thinkingLevel;
|
|
68
|
+
const run = generation.persistRuns
|
|
69
|
+
? startAutoTitleRun({
|
|
70
|
+
cwd: context.cwd ?? process.cwd(),
|
|
71
|
+
model,
|
|
72
|
+
trigger,
|
|
73
|
+
systemPrompt: resolvedSystemPrompt,
|
|
74
|
+
tokenBudget: generation.tokenBudget,
|
|
75
|
+
thinkingLevel,
|
|
76
|
+
message,
|
|
77
|
+
})
|
|
78
|
+
: undefined;
|
|
69
79
|
|
|
70
80
|
try {
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
81
|
+
const response = await modelRuntime.completeSimple(
|
|
82
|
+
model,
|
|
83
|
+
{
|
|
84
|
+
systemPrompt: resolvedSystemPrompt,
|
|
85
|
+
messages: [message],
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
maxTokens: generation.tokenBudget,
|
|
89
|
+
...(thinkingLevel && thinkingLevel !== "off" ? { reasoning: thinkingLevel } : {}),
|
|
90
|
+
signal: abortController.signal,
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
run?.recordResponse(response);
|
|
82
94
|
|
|
83
95
|
if (response.stopReason === "error" || response.stopReason === "aborted") {
|
|
84
96
|
const fallbackMessage =
|
|
85
97
|
response.stopReason === "aborted" ? "Request was aborted." : "Provider returned an error.";
|
|
86
|
-
return
|
|
87
|
-
ok: false,
|
|
88
|
-
failure: createAutoTitleFailure(trigger, model, response.errorMessage || fallbackMessage),
|
|
89
|
-
};
|
|
98
|
+
return failGeneration(run, trigger, model, response.errorMessage || fallbackMessage);
|
|
90
99
|
}
|
|
91
100
|
|
|
92
101
|
const normalizedTitle = normalizeGeneratedAutoTitle(extractResponseText(response.content));
|
|
93
102
|
if (!normalizedTitle) {
|
|
94
|
-
return
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
103
|
+
return failGeneration(
|
|
104
|
+
run,
|
|
105
|
+
trigger,
|
|
106
|
+
model,
|
|
107
|
+
describeEmptyTitle(response.stopReason === "length", generation.tokenBudget),
|
|
108
|
+
);
|
|
98
109
|
}
|
|
99
110
|
|
|
100
111
|
return {
|
|
@@ -102,10 +113,7 @@ export async function generateAutoTitle(
|
|
|
102
113
|
title: normalizedTitle,
|
|
103
114
|
};
|
|
104
115
|
} catch (error) {
|
|
105
|
-
return
|
|
106
|
-
ok: false,
|
|
107
|
-
failure: createAutoTitleFailure(trigger, model, extractErrorMessage(error)),
|
|
108
|
-
};
|
|
116
|
+
return failGeneration(run, trigger, model, extractErrorMessage(error));
|
|
109
117
|
} finally {
|
|
110
118
|
clearTimeout(timeoutId);
|
|
111
119
|
}
|
|
@@ -124,6 +132,27 @@ export function createAutoTitleFailure(
|
|
|
124
132
|
};
|
|
125
133
|
}
|
|
126
134
|
|
|
135
|
+
function failGeneration(
|
|
136
|
+
run: AutoTitleRun | undefined,
|
|
137
|
+
trigger: AutoTitleTrigger,
|
|
138
|
+
model: Model<Api>,
|
|
139
|
+
message: string,
|
|
140
|
+
): AutoTitleGenerationResult {
|
|
141
|
+
run?.recordFailure(message);
|
|
142
|
+
return {
|
|
143
|
+
ok: false,
|
|
144
|
+
failure: createAutoTitleFailure(trigger, model, message),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function describeEmptyTitle(hitTokenBudget: boolean, tokenBudget: number): string {
|
|
149
|
+
if (!hitTokenBudget) {
|
|
150
|
+
return "Model returned an empty title.";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return `Model spent its ${tokenBudget}-token budget without producing a title. Raise sessions.autoTitle.tokenBudget if the model reasons before answering.`;
|
|
154
|
+
}
|
|
155
|
+
|
|
127
156
|
function buildAutoTitleSystemPrompt(systemPrompt: string, shouldPreserveTitle: boolean): string {
|
|
128
157
|
if (!shouldPreserveTitle) {
|
|
129
158
|
return systemPrompt;
|
|
@@ -170,45 +199,6 @@ function normalizeGeneratedAutoTitle(value: string): string | undefined {
|
|
|
170
199
|
return truncated || undefined;
|
|
171
200
|
}
|
|
172
201
|
|
|
173
|
-
function writeAutoTitleDebugRequest(
|
|
174
|
-
model: Model<Api>,
|
|
175
|
-
trigger: AutoTitleTrigger,
|
|
176
|
-
request: unknown,
|
|
177
|
-
): void {
|
|
178
|
-
writeAutoTitleDebugEntry({
|
|
179
|
-
phase: "request",
|
|
180
|
-
trigger,
|
|
181
|
-
model: formatModelLabel(model),
|
|
182
|
-
request,
|
|
183
|
-
});
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function writeAutoTitleDebugResponse(
|
|
187
|
-
model: Model<Api>,
|
|
188
|
-
trigger: AutoTitleTrigger,
|
|
189
|
-
response: unknown,
|
|
190
|
-
): void {
|
|
191
|
-
writeAutoTitleDebugEntry({
|
|
192
|
-
phase: "response",
|
|
193
|
-
trigger,
|
|
194
|
-
model: formatModelLabel(model),
|
|
195
|
-
response,
|
|
196
|
-
});
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
function writeAutoTitleDebugEntry(entry: Record<string, unknown>): void {
|
|
200
|
-
try {
|
|
201
|
-
const dir = join(homedir(), ".pi", "agent", "pi-sessions");
|
|
202
|
-
mkdirSync(dir, { recursive: true });
|
|
203
|
-
appendFileSync(
|
|
204
|
-
join(dir, "auto-title-debug.jsonl"),
|
|
205
|
-
`${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`,
|
|
206
|
-
);
|
|
207
|
-
} catch {
|
|
208
|
-
// Debug logging must not affect title generation.
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
202
|
function extractResponseText(content: unknown[]): string {
|
|
213
203
|
return content
|
|
214
204
|
.filter(
|
|
@@ -53,7 +53,9 @@ export function installAutoTitle(
|
|
|
53
53
|
): AutoTitleGeneration => ({
|
|
54
54
|
systemPrompt: settings.autoTitle.prompt,
|
|
55
55
|
timeoutMs: settings.autoTitle.timeoutMs,
|
|
56
|
+
tokenBudget: settings.autoTitle.tokenBudget,
|
|
56
57
|
thinkingLevel: settings.autoTitle.thinkingLevel ?? resolution?.thinkingLevel,
|
|
58
|
+
persistRuns: settings.autoTitle.persistRuns,
|
|
57
59
|
});
|
|
58
60
|
|
|
59
61
|
pi.registerCommand("title", {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import type { Api, AssistantMessage, Model, UserMessage } from "@earendil-works/pi-ai";
|
|
3
|
+
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { getDefaultAutoTitleRunsDir } from "../shared/settings.ts";
|
|
5
|
+
import type { AutoTitleTrigger } from "./state.ts";
|
|
6
|
+
|
|
7
|
+
export const AUTO_TITLE_RUN_REQUEST_CUSTOM_TYPE = "pi-sessions.auto-title-request";
|
|
8
|
+
export const AUTO_TITLE_RUN_FAILURE_CUSTOM_TYPE = "pi-sessions.auto-title-failure";
|
|
9
|
+
|
|
10
|
+
export interface AutoTitleRunRequest {
|
|
11
|
+
cwd: string;
|
|
12
|
+
model: Model<Api>;
|
|
13
|
+
trigger: AutoTitleTrigger;
|
|
14
|
+
systemPrompt: string;
|
|
15
|
+
tokenBudget: number;
|
|
16
|
+
thinkingLevel: ThinkingLevel | undefined;
|
|
17
|
+
message: UserMessage;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface AutoTitleRun {
|
|
21
|
+
sessionPath: string | undefined;
|
|
22
|
+
recordResponse(response: AssistantMessage): void;
|
|
23
|
+
recordFailure(message: string): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Records one auto-title request as a standalone Pi session so the exact prompt can be replayed. */
|
|
27
|
+
export function startAutoTitleRun(request: AutoTitleRunRequest): AutoTitleRun {
|
|
28
|
+
const sessionManager = SessionManager.create(request.cwd, getDefaultAutoTitleRunsDir());
|
|
29
|
+
sessionManager.appendSessionInfo(`auto-title (${request.trigger})`);
|
|
30
|
+
sessionManager.appendModelChange(request.model.provider, request.model.id);
|
|
31
|
+
if (request.thinkingLevel) {
|
|
32
|
+
sessionManager.appendThinkingLevelChange(request.thinkingLevel);
|
|
33
|
+
}
|
|
34
|
+
sessionManager.appendCustomEntry(AUTO_TITLE_RUN_REQUEST_CUSTOM_TYPE, {
|
|
35
|
+
trigger: request.trigger,
|
|
36
|
+
systemPrompt: request.systemPrompt,
|
|
37
|
+
tokenBudget: request.tokenBudget,
|
|
38
|
+
});
|
|
39
|
+
sessionManager.appendMessage(request.message);
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
sessionPath: sessionManager.getSessionFile(),
|
|
43
|
+
recordResponse(response) {
|
|
44
|
+
sessionManager.appendMessage(response);
|
|
45
|
+
},
|
|
46
|
+
recordFailure(message) {
|
|
47
|
+
sessionManager.appendCustomEntry(AUTO_TITLE_RUN_FAILURE_CUSTOM_TYPE, { message });
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -55,22 +55,23 @@ export function buildHandoffKickoffSource(source: HandoffKickoffSource): Handoff
|
|
|
55
55
|
};
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
export const renderHandoffKickoffMessage: MessageRenderer = (message,
|
|
58
|
+
export const renderHandoffKickoffMessage: MessageRenderer = (message, options, theme) => {
|
|
59
59
|
const details = safeParseTypeBoxValue(HANDOFF_KICKOFF_DETAILS_SCHEMA, message.details);
|
|
60
60
|
if (!details) {
|
|
61
61
|
return undefined;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
const prompt = typeof message.content === "string" ? message.content : "";
|
|
65
|
-
return createHandoffKickoffComponent(details, prompt, theme);
|
|
65
|
+
return createHandoffKickoffComponent(details, prompt, options.outputPad, theme);
|
|
66
66
|
};
|
|
67
67
|
|
|
68
68
|
export function createHandoffKickoffComponent(
|
|
69
69
|
details: HandoffKickoffDetails,
|
|
70
70
|
prompt: string,
|
|
71
|
+
outputPad: number,
|
|
71
72
|
theme: RenderTheme,
|
|
72
73
|
): Component {
|
|
73
|
-
const box = new Box(
|
|
74
|
+
const box = new Box(outputPad, 1, (text: string) => theme.bg("customMessageBg", text));
|
|
74
75
|
box.addChild(new Text(renderHandoffKickoffView(details, prompt, theme), 0, 0));
|
|
75
76
|
return box;
|
|
76
77
|
}
|
|
@@ -15,7 +15,7 @@ export const HANDOFF_BOOTSTRAP_CONSUMED_CUSTOM_TYPE = "pi-sessions.handoff-boots
|
|
|
15
15
|
export const HANDOFF_STALE_SESSION_MESSAGE =
|
|
16
16
|
"Session handoff failed: target session already has user input.";
|
|
17
17
|
export const SESSION_STARTING_MESSAGE =
|
|
18
|
-
"Target session is still starting. wait for
|
|
18
|
+
"Target session is still starting. wait for session_reachable to report it as live, then resend.";
|
|
19
19
|
|
|
20
20
|
// A subagent's self-knowledge, stamped into its child-local bootstrap so the
|
|
21
21
|
// child never opens the parent transcript to learn who it is.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// The broker runs as a detached raw-Node process with nothing installed next to
|
|
2
|
+
// it, so client frames are validated by hand rather than against the TypeBox
|
|
3
|
+
// schemas in shared/session-broker/protocol.ts. The broker only routes
|
|
4
|
+
// envelopes, so it validates the routing fields and treats `envelope` as an
|
|
5
|
+
// opaque object; the receiving client validates the envelope body in full.
|
|
6
|
+
|
|
7
|
+
export interface RoutedEnvelope {
|
|
8
|
+
[key: string]: unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface RegisterClientFrame {
|
|
12
|
+
type: "register";
|
|
13
|
+
sessionId: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface UnregisterClientFrame {
|
|
17
|
+
type: "unregister";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ListClientFrame {
|
|
21
|
+
type: "list";
|
|
22
|
+
requestId: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SendClientFrame {
|
|
26
|
+
type: "send";
|
|
27
|
+
requestId: string;
|
|
28
|
+
target: string;
|
|
29
|
+
envelope: RoutedEnvelope;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface IncomingAckClientFrame {
|
|
33
|
+
type: "incoming_ack";
|
|
34
|
+
requestId: string;
|
|
35
|
+
delivered: boolean;
|
|
36
|
+
error?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type BrokerClientFrame =
|
|
40
|
+
| RegisterClientFrame
|
|
41
|
+
| UnregisterClientFrame
|
|
42
|
+
| ListClientFrame
|
|
43
|
+
| SendClientFrame
|
|
44
|
+
| IncomingAckClientFrame;
|
|
45
|
+
|
|
46
|
+
const CONTEXT = "Invalid session messaging client frame";
|
|
47
|
+
|
|
48
|
+
export function parseClientFrame(value: unknown): BrokerClientFrame {
|
|
49
|
+
const frame = requireObject(value, "/");
|
|
50
|
+
|
|
51
|
+
switch (frame.type) {
|
|
52
|
+
case "register":
|
|
53
|
+
return { type: "register", sessionId: requireString(frame.sessionId, "/sessionId") };
|
|
54
|
+
case "unregister":
|
|
55
|
+
return { type: "unregister" };
|
|
56
|
+
case "list":
|
|
57
|
+
return { type: "list", requestId: requireString(frame.requestId, "/requestId") };
|
|
58
|
+
case "send":
|
|
59
|
+
return {
|
|
60
|
+
type: "send",
|
|
61
|
+
requestId: requireString(frame.requestId, "/requestId"),
|
|
62
|
+
target: requireString(frame.target, "/target"),
|
|
63
|
+
envelope: requireObject(frame.envelope, "/envelope"),
|
|
64
|
+
};
|
|
65
|
+
case "incoming_ack": {
|
|
66
|
+
const error = optionalString(frame.error, "/error");
|
|
67
|
+
return {
|
|
68
|
+
type: "incoming_ack",
|
|
69
|
+
requestId: requireString(frame.requestId, "/requestId"),
|
|
70
|
+
delivered: requireBoolean(frame.delivered, "/delivered"),
|
|
71
|
+
...(error === undefined ? {} : { error }),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
default:
|
|
75
|
+
throw invalid("/type", "expected a known client frame type");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function requireObject(value: unknown, path: string): Record<string, unknown> {
|
|
80
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
81
|
+
throw invalid(path, "expected an object");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return value as Record<string, unknown>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function requireString(value: unknown, path: string): string {
|
|
88
|
+
if (typeof value !== "string") {
|
|
89
|
+
throw invalid(path, "expected a string");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return value;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function requireBoolean(value: unknown, path: string): boolean {
|
|
96
|
+
if (typeof value !== "boolean") {
|
|
97
|
+
throw invalid(path, "expected a boolean");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return value;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function optionalString(value: unknown, path: string): string | undefined {
|
|
104
|
+
if (value === undefined) {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return requireString(value, path);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function invalid(path: string, message: string): Error {
|
|
112
|
+
return new Error(`${CONTEXT}: ${path} ${message}`);
|
|
113
|
+
}
|