@timqi/pier 0.1.0 → 0.1.2
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 +58 -125
- package/dist/agent/config.js +6 -15
- package/dist/agent/credentials.js +11 -23
- package/dist/agent/events.js +42 -64
- package/dist/agent/listing.js +39 -92
- package/dist/agent/pi.js +103 -252
- package/dist/boards/boards.js +19 -29
- package/dist/channels/attach.js +14 -42
- package/dist/channels/chains.js +33 -37
- package/dist/channels/chunk.js +8 -28
- package/dist/channels/commands.js +3 -14
- package/dist/channels/config.js +33 -52
- package/dist/channels/control.js +4 -13
- package/dist/channels/conversations.js +8 -25
- package/dist/channels/dedup.js +8 -17
- package/dist/channels/gatekeeper.js +13 -23
- package/dist/channels/lark-api.js +23 -63
- package/dist/channels/lark-outbound.js +12 -44
- package/dist/channels/lark-panel.js +7 -23
- package/dist/channels/lark-render.js +18 -62
- package/dist/channels/lark.js +52 -141
- package/dist/channels/lines.js +13 -15
- package/dist/channels/panel.js +16 -36
- package/dist/channels/receipts.js +29 -52
- package/dist/channels/routes.js +3 -9
- package/dist/channels/runtime.js +12 -23
- package/dist/channels/slack-api.js +34 -86
- package/dist/channels/slack-directory.js +7 -23
- package/dist/channels/slack-outbound.js +12 -56
- package/dist/channels/slack-panel.js +4 -13
- package/dist/channels/slack-render.js +23 -91
- package/dist/channels/slack-tool.js +48 -171
- package/dist/channels/slack.js +73 -239
- package/dist/channels/telegram-api.js +8 -20
- package/dist/channels/telegram-panel.js +5 -21
- package/dist/channels/telegram-render.js +13 -40
- package/dist/channels/telegram.js +54 -146
- package/dist/channels/types.js +5 -16
- package/dist/cli.js +17 -41
- package/dist/config-sync.js +87 -4
- package/dist/core/hub.js +7 -20
- package/dist/core/identity.js +20 -59
- package/dist/core/inbound-file.js +15 -49
- package/dist/core/inbox.js +12 -34
- package/dist/core/queue.js +3 -5
- package/dist/core/reply.js +41 -142
- package/dist/core/router.js +209 -264
- package/dist/core/types.js +4 -0
- package/dist/db.js +88 -272
- package/dist/drain.js +57 -50
- package/dist/extensions/index.js +3 -11
- package/dist/extensions/web/anthropic.js +3 -9
- package/dist/extensions/web/artifacts.js +2 -5
- package/dist/extensions/web/content.js +4 -12
- package/dist/extensions/web/http.js +2 -6
- package/dist/extensions/web/language.js +8 -18
- package/dist/extensions/web/openai.js +1 -1
- package/dist/extensions/web/provider.js +5 -18
- package/dist/extensions/web/tools.js +19 -63
- package/dist/lock.js +98 -0
- package/dist/log.js +9 -26
- package/dist/main.js +84 -183
- package/dist/paths.js +10 -26
- package/dist/secrets.js +18 -45
- package/dist/service.js +31 -73
- package/dist/settings.js +19 -63
- package/dist/tasks/agent.js +24 -45
- package/dist/tasks/callbacks.js +8 -16
- package/dist/tasks/command.js +2 -6
- package/dist/tasks/definitions.js +39 -62
- package/dist/tasks/execution.js +41 -39
- package/dist/tasks/groups.js +8 -11
- package/dist/tasks/messages.js +88 -155
- package/dist/tasks/outbox.js +33 -54
- package/dist/tasks/routes.js +4 -7
- package/dist/tasks/runs.js +4 -9
- package/dist/tasks/service.js +29 -42
- package/dist/tasks/store.js +36 -27
- package/dist/tasks/tool.js +59 -60
- package/dist/tools-task.js +20 -60
- package/dist/tools.js +98 -325
- package/dist/update.js +20 -43
- package/dist/web/auth.js +118 -179
- package/dist/web/config-sync.js +2 -2
- package/dist/web/config.js +3 -7
- package/dist/web/explorer.js +10 -21
- package/dist/web/fs.js +20 -42
- package/dist/web/instance.js +35 -82
- package/dist/web/providers.js +5 -11
- package/dist/web/public/assets/{activity-Bl3vZukb.js → activity-DOr8dWeX.js} +1 -1
- package/dist/web/public/assets/activity-DOr8dWeX.js.br +0 -0
- package/dist/web/public/assets/activity-DOr8dWeX.js.gz +0 -0
- package/dist/web/public/assets/{boards-DYuf4Mlj.js → boards-CneyR23E.js} +1 -1
- package/dist/web/public/assets/boards-CneyR23E.js.br +0 -0
- package/dist/web/public/assets/boards-CneyR23E.js.gz +0 -0
- package/dist/web/public/assets/explorer-D0srXT1c.js +4 -0
- package/dist/web/public/assets/explorer-D0srXT1c.js.br +0 -0
- package/dist/web/public/assets/explorer-D0srXT1c.js.gz +0 -0
- package/dist/web/public/assets/index-DbFu15NN.js +85 -0
- package/dist/web/public/assets/index-DbFu15NN.js.br +0 -0
- package/dist/web/public/assets/index-DbFu15NN.js.gz +0 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css +2 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css.br +0 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css.gz +0 -0
- package/dist/web/public/assets/{runs-BLJu7EXN.js → runs-Cv0A-e08.js} +1 -1
- package/dist/web/public/assets/runs-Cv0A-e08.js.br +0 -0
- package/dist/web/public/assets/runs-Cv0A-e08.js.gz +0 -0
- package/dist/web/public/assets/{settings-BrdVh-Zi.js → settings-VmCjhGBd.js} +1 -1
- package/dist/web/public/assets/settings-VmCjhGBd.js.br +0 -0
- package/dist/web/public/assets/settings-VmCjhGBd.js.gz +0 -0
- package/dist/web/public/assets/{task-runs-CeQS1rxa.js → task-runs-CqpTV644.js} +1 -1
- package/dist/web/public/assets/task-runs-CqpTV644.js.br +0 -0
- package/dist/web/public/assets/task-runs-CqpTV644.js.gz +0 -0
- package/dist/web/public/assets/tasks-BffPVgXg.js +4 -0
- package/dist/web/public/assets/tasks-BffPVgXg.js.br +0 -0
- package/dist/web/public/assets/tasks-BffPVgXg.js.gz +0 -0
- package/dist/web/public/index.html +30 -16
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/sw.js +14 -2
- package/dist/web/public/sw.js.br +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/push.js +55 -77
- package/dist/web/route.js +3 -7
- package/dist/web/server.js +109 -190
- package/dist/web/session-state.js +13 -53
- package/dist/web/types.js +2 -4
- package/dist/web/webpush.js +10 -25
- package/docs/deploy.md +115 -330
- package/package.json +1 -1
- package/skills/pier-boards/SKILL.md +81 -160
- package/skills/pier-help/SKILL.md +23 -20
- package/skills/pier-slack/SKILL.md +2 -2
- package/skills/pier-tasks/SKILL.md +23 -15
- package/dist/config-sync-fetch.js +0 -84
- package/dist/limits.js +0 -14
- package/dist/web/public/assets/activity-Bl3vZukb.js.br +0 -0
- package/dist/web/public/assets/activity-Bl3vZukb.js.gz +0 -0
- package/dist/web/public/assets/boards-DYuf4Mlj.js.br +0 -0
- package/dist/web/public/assets/boards-DYuf4Mlj.js.gz +0 -0
- package/dist/web/public/assets/explorer-qJH_9nTE.js +0 -4
- package/dist/web/public/assets/explorer-qJH_9nTE.js.br +0 -0
- package/dist/web/public/assets/explorer-qJH_9nTE.js.gz +0 -0
- package/dist/web/public/assets/index-Dqdb-Eqt.js +0 -85
- package/dist/web/public/assets/index-Dqdb-Eqt.js.br +0 -0
- package/dist/web/public/assets/index-Dqdb-Eqt.js.gz +0 -0
- package/dist/web/public/assets/index-DzmMzvi_.css +0 -2
- package/dist/web/public/assets/index-DzmMzvi_.css.br +0 -0
- package/dist/web/public/assets/index-DzmMzvi_.css.gz +0 -0
- package/dist/web/public/assets/runs-BLJu7EXN.js.br +0 -0
- package/dist/web/public/assets/runs-BLJu7EXN.js.gz +0 -0
- package/dist/web/public/assets/settings-BrdVh-Zi.js.br +0 -0
- package/dist/web/public/assets/settings-BrdVh-Zi.js.gz +0 -0
- package/dist/web/public/assets/task-runs-CeQS1rxa.js.br +0 -0
- package/dist/web/public/assets/task-runs-CeQS1rxa.js.gz +0 -0
- package/dist/web/public/assets/tasks-bcb3fYdK.js +0 -4
- package/dist/web/public/assets/tasks-bcb3fYdK.js.br +0 -0
- package/dist/web/public/assets/tasks-bcb3fYdK.js.gz +0 -0
package/dist/agent/pi.js
CHANGED
|
@@ -5,26 +5,16 @@ import { realpathSync } from "node:fs";
|
|
|
5
5
|
import { basename, dirname, join } from "node:path";
|
|
6
6
|
import { createAgentSession, CredentialSynchronizationError, DefaultResourceLoader, defineTool, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import { inlineExtensions } from "../extensions/index.js";
|
|
8
|
-
import { SESSION_TITLE_MAX } from "../
|
|
8
|
+
import { SESSION_TITLE_MAX } from "../core/types.js";
|
|
9
9
|
import { logger } from "../log.js";
|
|
10
10
|
import { textOf, toChatTurns, toSessionEvents, turnMetaAt, } from "./events.js";
|
|
11
11
|
import { defaultAgentDir, PiConfigStore } from "./config.js";
|
|
12
12
|
import { IndexedListing } from "./listing.js";
|
|
13
13
|
import { curateModels, pinFirst } from "./models.js";
|
|
14
14
|
const log = logger("agent");
|
|
15
|
-
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* session opened under `/home/qiqi` (a symlink to `/essd/qiqi`) never matched
|
|
19
|
-
* the worktrees recorded under the real path, and every branch checkout was
|
|
20
|
-
* offered as a project of its own. Resolved once per distinct path: a symlink
|
|
21
|
-
* that moves under a running instance is not a case we have.
|
|
22
|
-
*
|
|
23
|
-
* A directory that is gone — a worktree merged and removed — is still a
|
|
24
|
-
* session's cwd, and its spelling still decides whether the rail reads it as a
|
|
25
|
-
* branch of its repository. So the deepest ancestor that does resolve carries
|
|
26
|
-
* the rest of the path, and only a cwd with no resolvable ancestor at all is
|
|
27
|
-
* reported as recorded. */
|
|
15
|
+
/** Sessions are grouped by directory, so two names for one directory (a
|
|
16
|
+
* symlinked home) are two projects. A directory that is gone is still a cwd:
|
|
17
|
+
* the deepest resolvable ancestor carries the rest of the path. */
|
|
28
18
|
const realPaths = new Map();
|
|
29
19
|
function realPath(cwd) {
|
|
30
20
|
const known = realPaths.get(cwd);
|
|
@@ -50,9 +40,6 @@ function realPath(cwd) {
|
|
|
50
40
|
realPaths.set(cwd, real);
|
|
51
41
|
return real;
|
|
52
42
|
}
|
|
53
|
-
/** A listed record as the seam reports it. The one mapping, because `list` and
|
|
54
|
-
* `find` answer with the same shape and drifting would mean two answers about
|
|
55
|
-
* one session. */
|
|
56
43
|
const summaryOf = (s) => ({
|
|
57
44
|
id: s.id,
|
|
58
45
|
cwd: realPath(s.cwd),
|
|
@@ -60,49 +47,36 @@ const summaryOf = (s) => ({
|
|
|
60
47
|
modified: s.modified,
|
|
61
48
|
...(s.title ? { title: s.title } : {}),
|
|
62
49
|
});
|
|
63
|
-
/** Pi's bash tool has no default timeout,
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* a tool error the agent can retry with an explicit longer timeout, instead of
|
|
67
|
-
* killing the whole run. */
|
|
50
|
+
/** Pi's bash tool has no default timeout, and nobody is watching a scheduled
|
|
51
|
+
* task. Below the default task-run timeout, so a stuck command comes back as a
|
|
52
|
+
* tool error instead of killing the run. */
|
|
68
53
|
const BASH_DEFAULT_TIMEOUT_SECONDS = 600;
|
|
69
|
-
/**
|
|
70
|
-
*
|
|
71
|
-
* that a title no invalidation covers is never stale on screen. */
|
|
54
|
+
/** Long enough that one workspace event, answered by several surfaces, scans
|
|
55
|
+
* disk once; short enough that a title no invalidation covers is never stale. */
|
|
72
56
|
const LIST_TTL_MS = 3_000;
|
|
73
|
-
/** A probe nobody is watching is a hung page: the Console waits on this. */
|
|
74
57
|
const PROVIDER_CHECK_TIMEOUT_MS = 20_000;
|
|
75
|
-
/** An ordinary budget
|
|
76
|
-
* ever makes, and answers about it are answers about a different request. */
|
|
58
|
+
/** An ordinary budget: a 1-token cap is a request no real turn ever makes. */
|
|
77
59
|
const PROVIDER_CHECK_MAX_TOKENS = 8192;
|
|
78
|
-
/** Neither half of a probe is worth more than a screen. */
|
|
79
60
|
const clip = (text) => text.length > 4000 ? `${text.slice(0, 4000)}\n[… ${text.length - 4000} more characters]` : text;
|
|
80
|
-
/**
|
|
81
|
-
* first exchange. Each half of the exchange is cut to a few hundred
|
|
82
|
-
* characters — a title is about the subject, and the subject is in the
|
|
83
|
-
* opening lines — and the answer is a line, so the cap is a line's worth. */
|
|
61
|
+
/** The subject of an exchange is in its opening lines; the answer is one line. */
|
|
84
62
|
const TITLE_INPUT_CHARS = 600;
|
|
85
63
|
const TITLE_MAX_TOKENS = 40;
|
|
86
64
|
const TITLE_TIMEOUT_MS = 20_000;
|
|
87
65
|
const TITLE_PROMPT = "Name this conversation for a session list. Reply with the title only: at most 12 Chinese characters " +
|
|
88
66
|
"or 6 English words, in the language the user wrote in, no quotes, no trailing period. " +
|
|
89
67
|
"A leading `[name<id> time]` on the user's message is a speaker header, not content.";
|
|
90
|
-
/**
|
|
91
|
-
*
|
|
92
|
-
* not mistaken for one. */
|
|
68
|
+
/** The halves are labelled so a reply quoting an instruction back is not
|
|
69
|
+
* mistaken for one. */
|
|
93
70
|
const titleRequest = (first, reply) => `${TITLE_PROMPT}\n\n<user>\n${first.slice(0, TITLE_INPUT_CHARS)}\n</user>\n\n<assistant>\n${reply.slice(0, TITLE_INPUT_CHARS)}\n</assistant>`;
|
|
94
|
-
/**
|
|
95
|
-
*
|
|
96
|
-
* that from an answer. */
|
|
71
|
+
/** A provider can decline as a message rather than a throw; only the stop
|
|
72
|
+
* reason tells that from an answer. */
|
|
97
73
|
function textOfAnswer(answer) {
|
|
98
74
|
if (answer.stopReason === "error" || answer.stopReason === "aborted") {
|
|
99
75
|
throw new Error(answer.errorMessage ?? `the provider stopped: ${answer.stopReason}`);
|
|
100
76
|
}
|
|
101
77
|
return textOf(answer.content).trim();
|
|
102
78
|
}
|
|
103
|
-
/**
|
|
104
|
-
* off, capped where every other title is capped. Empty is a failure, not a
|
|
105
|
-
* cleared name — the caller reports it. */
|
|
79
|
+
/** Empty is a failure, not a cleared name — the caller reports it. */
|
|
106
80
|
export function titleFromAnswer(text) {
|
|
107
81
|
const line = text.trim().split("\n")[0]?.trim() ?? "";
|
|
108
82
|
return line.replace(/^["'“‘「]+|["'”’」.。]+$/g, "").trim().slice(0, SESSION_TITLE_MAX);
|
|
@@ -130,8 +104,7 @@ These rules govern conversational replies. A human reads them on a phone-sized s
|
|
|
130
104
|
- Destructive or irreversible actions on things you didn't create — deleting user files, force push, migrations, deploys, service restarts: ask first; unattended, don't do them and report what you would have done.
|
|
131
105
|
- Say plainly when something failed, was skipped, or is unverified. Never claim a test passed without running it.`;
|
|
132
106
|
export const pierSystemPrompt = (userPrompt) => userPrompt ? `${PIER_SYSTEM_PROMPT}\n\n${userPrompt}` : PIER_SYSTEM_PROMPT;
|
|
133
|
-
/** Patching the call
|
|
134
|
-
* shell settings, and the agent spends no tokens deciding a timeout. */
|
|
107
|
+
/** Patching the call keeps the built-in's shell settings. */
|
|
135
108
|
const bashTimeoutDefault = (pi) => {
|
|
136
109
|
pi.on("tool_call", (event) => {
|
|
137
110
|
if (event.toolName === "bash" && event.input.timeout === undefined) {
|
|
@@ -139,12 +112,8 @@ const bashTimeoutDefault = (pi) => {
|
|
|
139
112
|
}
|
|
140
113
|
});
|
|
141
114
|
};
|
|
142
|
-
/**
|
|
143
|
-
*
|
|
144
|
-
* its tools. Pi loads both and reports the clash as a diagnostic nobody reads,
|
|
145
|
-
* leaving two tools of the same name and no way to tell which one answered;
|
|
146
|
-
* the copy the user put there wins, and the journal says so (§5b).
|
|
147
|
-
*/
|
|
115
|
+
/** Pi loads a bundled extension and its on-disk twin both, leaving two tools
|
|
116
|
+
* of one name; the copy the user put there wins, and the journal says so (§5). */
|
|
148
117
|
export const standDownShadowed = (base) => {
|
|
149
118
|
const inline = (ext) => ext.path.startsWith("<inline:");
|
|
150
119
|
const onDisk = new Set(base.extensions.filter((ext) => !inline(ext)).flatMap((ext) => [...ext.tools.keys()]));
|
|
@@ -161,11 +130,8 @@ export const standDownShadowed = (base) => {
|
|
|
161
130
|
}),
|
|
162
131
|
};
|
|
163
132
|
};
|
|
164
|
-
/**
|
|
165
|
-
*
|
|
166
|
-
* description is resident in every prompt, so one pointing at a tool this
|
|
167
|
-
* session was not given is both a cost and a route the agent cannot take.
|
|
168
|
-
*/
|
|
133
|
+
/** A skill's description is resident in every prompt; one pointing at a tool
|
|
134
|
+
* this session was not given is a route the agent cannot take. */
|
|
169
135
|
export const standDownUndocumented = (tools, skills) => {
|
|
170
136
|
const gone = new Set(tools.filter((tool) => tool.skill && !(tool.available?.() ?? true)).map((tool) => tool.skill));
|
|
171
137
|
if (!gone.size)
|
|
@@ -179,17 +145,12 @@ export class PiSession {
|
|
|
179
145
|
retention;
|
|
180
146
|
suggestTitle;
|
|
181
147
|
constructor(pi,
|
|
182
|
-
/**
|
|
148
|
+
/** Read per call — the menu can change while we run. */
|
|
183
149
|
pinned = () => [],
|
|
184
|
-
/**
|
|
185
|
-
*
|
|
186
|
-
* every surface would re-read the old title and keep it until some
|
|
187
|
-
* unrelated event moved the list again. Same drop `create` does — a
|
|
188
|
-
* callback only because the session is what knows it happened. */
|
|
150
|
+
/** Drops the factory's retained listing: a rename lands in exactly the
|
|
151
|
+
* window it covers, and every surface would keep the old title. */
|
|
189
152
|
wrote = () => { }, retention = { value: "long" },
|
|
190
|
-
/**
|
|
191
|
-
* when auto-titling is off. Read per turn: switching it on names the next
|
|
192
|
-
* session that finishes a first turn, without a restart. */
|
|
153
|
+
/** Read per turn: switching auto-titling on takes effect without a restart. */
|
|
193
154
|
suggestTitle = () => undefined) {
|
|
194
155
|
this.pi = pi;
|
|
195
156
|
this.pinned = pinned;
|
|
@@ -197,11 +158,8 @@ export class PiSession {
|
|
|
197
158
|
this.retention = retention;
|
|
198
159
|
this.suggestTitle = suggestTitle;
|
|
199
160
|
}
|
|
200
|
-
/** Pi's dispose
|
|
201
|
-
*
|
|
202
|
-
* nowhere: no transcript entry, no event, and a promise that resolves as if
|
|
203
|
-
* it worked. Refusing is what turns that silence into the failure it is;
|
|
204
|
-
* callers already report or retry a rejection (§5b). */
|
|
161
|
+
/** A turn started after Pi's dispose runs for real and lands nowhere — no
|
|
162
|
+
* transcript, no event, a promise that resolves. Refusing makes it a failure (§5). */
|
|
205
163
|
disposed = false;
|
|
206
164
|
live() {
|
|
207
165
|
if (this.disposed)
|
|
@@ -227,7 +185,6 @@ export class PiSession {
|
|
|
227
185
|
async setModel(ref) {
|
|
228
186
|
const m = this.pi.modelRuntime.getModel(ref.provider, ref.id);
|
|
229
187
|
if (!m) {
|
|
230
|
-
// Lazy discovery: the failure itself documents what is selectable.
|
|
231
188
|
const available = (await this.availableModels())
|
|
232
189
|
.slice(0, 8).map((entry) => `${entry.provider}/${entry.id}`).join(", ");
|
|
233
190
|
throw new Error(`unknown model: ${ref.provider}/${ref.id}; available: ${available}`);
|
|
@@ -237,8 +194,7 @@ export class PiSession {
|
|
|
237
194
|
async availableModels() {
|
|
238
195
|
const available = await this.pi.modelRuntime.getAvailable();
|
|
239
196
|
const curated = pinFirst(curateModels(available.map((m) => ({ provider: m.provider, id: m.id, reasoning: m.reasoning }))), this.pinned());
|
|
240
|
-
// The
|
|
241
|
-
// (or an older catalog) would hide it.
|
|
197
|
+
// The active model must stay selectable even when curation would hide it.
|
|
242
198
|
const current = this.model;
|
|
243
199
|
if (current && !curated.some((m) => m.provider === current.provider && m.id === current.id)) {
|
|
244
200
|
curated.unshift(current);
|
|
@@ -261,22 +217,20 @@ export class PiSession {
|
|
|
261
217
|
};
|
|
262
218
|
}
|
|
263
219
|
/** A system input handed to a streaming session goes into Pi's *agent*
|
|
264
|
-
* queue,
|
|
265
|
-
*
|
|
266
|
-
* in front of a running turn is in neither the queue nor the transcript,
|
|
267
|
-
* and this list is the only thing that says it exists. */
|
|
220
|
+
* queue, which `pendingQueue` cannot see; this list is the only thing that
|
|
221
|
+
* says it exists. */
|
|
268
222
|
queuedInputs = [];
|
|
269
223
|
async pendingSystemInputs() {
|
|
270
|
-
// Pi drains its
|
|
271
|
-
//
|
|
272
|
-
//
|
|
224
|
+
// Pi drains its queue before the turn ends; anything still listed on an
|
|
225
|
+
// idle session was aborted, and calling it queued would leave its sender
|
|
226
|
+
// waiting forever (§5).
|
|
273
227
|
if (!this.pi.isStreaming)
|
|
274
228
|
this.queuedInputs.length = 0;
|
|
275
229
|
return [...this.queuedInputs];
|
|
276
230
|
}
|
|
277
231
|
async clearQueue() {
|
|
278
|
-
// Not
|
|
279
|
-
//
|
|
232
|
+
// Not returned (a system input is nobody's draft), but dropped, so its
|
|
233
|
+
// sender stops being told it is on its way.
|
|
280
234
|
this.queuedInputs.length = 0;
|
|
281
235
|
return this.pi.clearQueue();
|
|
282
236
|
}
|
|
@@ -286,8 +240,8 @@ export class PiSession {
|
|
|
286
240
|
}
|
|
287
241
|
async rewindToUserTurn(index) {
|
|
288
242
|
const total = (await this.history()).filter((t) => t.role === "user").length;
|
|
289
|
-
//
|
|
290
|
-
//
|
|
243
|
+
// Branch entries keep compacted-away history that history() no longer
|
|
244
|
+
// shows, so only end-relative indices line up.
|
|
291
245
|
const back = total - index;
|
|
292
246
|
const users = this.pi.sessionManager
|
|
293
247
|
.getBranch()
|
|
@@ -295,27 +249,20 @@ export class PiSession {
|
|
|
295
249
|
const target = back >= 1 ? users[users.length - back] : undefined;
|
|
296
250
|
if (!target)
|
|
297
251
|
throw new Error(`no user turn at index ${index}`);
|
|
298
|
-
//
|
|
299
|
-
// branch stays in the file but leaves the context.
|
|
252
|
+
// The old branch stays in the file but leaves the context.
|
|
300
253
|
const { cancelled } = await this.pi.navigateTree(target.id);
|
|
301
254
|
if (cancelled)
|
|
302
255
|
throw new Error("rewind cancelled");
|
|
303
256
|
}
|
|
304
|
-
/**
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
* pass the route's idle check, so the gate has to be here. */
|
|
257
|
+
/** Pi keeps no lock of its own: a second `compact()` summarizes a transcript
|
|
258
|
+
* being replaced under it, and two POSTs a millisecond apart both pass the
|
|
259
|
+
* route's idle check. */
|
|
308
260
|
compacting = null;
|
|
309
|
-
/** Pi's own compaction, minus its `CompactionResult`: the numbers reach
|
|
310
|
-
* surfaces as the `context-compacted` event the seam already emits for the
|
|
311
|
-
* automatic one, so a caller has nothing to do with them. Refused while one
|
|
312
|
-
* is running, rather than run twice over one context. */
|
|
313
261
|
async compact() {
|
|
314
262
|
this.live();
|
|
315
263
|
if (this.compacting)
|
|
316
264
|
throw new Error(`session ${this.pi.sessionId} is already compacting`);
|
|
317
|
-
//
|
|
318
|
-
// what makes the check above a gate and not a hint.
|
|
265
|
+
// Recorded in the same tick, no await between: that is what makes the check a gate.
|
|
319
266
|
const running = this.pi.compact().then(() => undefined);
|
|
320
267
|
this.compacting = running;
|
|
321
268
|
try {
|
|
@@ -325,48 +272,30 @@ export class PiSession {
|
|
|
325
272
|
this.compacting = null;
|
|
326
273
|
}
|
|
327
274
|
}
|
|
328
|
-
/**
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
* do with the turn running.
|
|
332
|
-
*
|
|
333
|
-
* Returns nothing, because the transcript is the answer: what the session is
|
|
334
|
-
* called after this — the name, or the title a cleared one falls back to —
|
|
335
|
-
* is what the next listing reads off the file, and deriving it here as well
|
|
336
|
-
* was a second copy of a rule agent/listing.ts already owns.
|
|
337
|
-
*
|
|
338
|
-
* TODO: renaming a cold session costs a whole resume, because the route
|
|
339
|
-
* reaches it through `ensure` and this method needs a live Pi session to
|
|
340
|
-
* append through. The work is one line in a file. Revisit when Pi offers a
|
|
341
|
-
* lightweight append to a session it has not loaded. */
|
|
275
|
+
/** An append: Pi's reader takes the latest `session_info`. Never refused for
|
|
276
|
+
* being busy. TODO: renaming a cold session costs a whole resume for one
|
|
277
|
+
* appended line; revisit when Pi offers a lightweight append. */
|
|
342
278
|
async rename(name) {
|
|
343
279
|
this.live();
|
|
344
280
|
this.pi.sessionManager.appendSessionInfo(name);
|
|
345
281
|
this.wrote();
|
|
346
282
|
}
|
|
347
|
-
/**
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
* follow-up queue, which is only drained by the *next* turn: a message
|
|
351
|
-
* parked there while nothing is running would sit unsent, and Pi's own
|
|
352
|
-
* `prompt()` guard would have thrown the user's message away (§5b). */
|
|
283
|
+
/** A dispatch landing mid-compaction waits for the summary instead of
|
|
284
|
+
* starting a turn over it. Not Pi's follow-up queue: that is drained only by
|
|
285
|
+
* the *next* turn, so a message parked there while idle would sit unsent. */
|
|
353
286
|
async whenCompacted() {
|
|
354
287
|
while (this.compacting)
|
|
355
288
|
await this.compacting.catch(() => undefined);
|
|
356
289
|
}
|
|
357
|
-
// Async, so a refusal is a rejected promise
|
|
358
|
-
// may only `.catch()` (core/types.ts), and dispatch does exactly that.
|
|
290
|
+
// Async, so a refusal is a rejected promise the seam lets callers `.catch()`.
|
|
359
291
|
async prompt(text) {
|
|
360
292
|
this.live();
|
|
361
293
|
await this.whenCompacted();
|
|
362
|
-
//
|
|
294
|
+
// The wait above is long enough for a dispose to land.
|
|
363
295
|
this.live();
|
|
364
|
-
// A turn may have started since the caller read the state
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
// processing" and the message is gone (§5b); queued, it is the same
|
|
368
|
-
// "delivered when idle" the core's own policy picks for an auto message
|
|
369
|
-
// that lands mid-turn (core/queue.ts).
|
|
296
|
+
// A turn may have started since the caller read the state. Bare, Pi throws
|
|
297
|
+
// "already processing" and the message is gone (§5); queued, it is the
|
|
298
|
+
// same "delivered when idle" core/queue.ts picks for a mid-turn message.
|
|
370
299
|
return this.pi.prompt(text, { streamingBehavior: "followUp" });
|
|
371
300
|
}
|
|
372
301
|
async steer(text) {
|
|
@@ -379,13 +308,10 @@ export class PiSession {
|
|
|
379
308
|
}
|
|
380
309
|
async systemInput(text, origin, mode) {
|
|
381
310
|
this.live();
|
|
382
|
-
// Same gate as prompt(): an idle session takes a system input as a turn
|
|
383
|
-
// whatever the mode says, so a callback landing mid-compaction would race
|
|
384
|
-
// the summary too.
|
|
311
|
+
// Same gate as prompt(): an idle session takes a system input as a turn.
|
|
385
312
|
await this.whenCompacted();
|
|
386
313
|
this.live();
|
|
387
|
-
// Read
|
|
388
|
-
// running, so the call below queues this input rather than starting one.
|
|
314
|
+
// Read after the wait: a running turn means the call below queues.
|
|
389
315
|
const queued = this.pi.isStreaming;
|
|
390
316
|
if (queued)
|
|
391
317
|
this.queuedInputs.push(origin);
|
|
@@ -393,8 +319,7 @@ export class PiSession {
|
|
|
393
319
|
return await this.pi.sendCustomMessage({ customType: "pier.system-input", content: text, display: true, details: origin }, { triggerTurn: true, deliverAs: mode === "prompt" ? undefined : mode });
|
|
394
320
|
}
|
|
395
321
|
catch (error) {
|
|
396
|
-
// Refused: nothing is in flight
|
|
397
|
-
// turn may have ended while we waited, so the entry may be gone already.
|
|
322
|
+
// Refused: nothing is in flight. The entry may be gone already.
|
|
398
323
|
const at = this.queuedInputs.indexOf(origin);
|
|
399
324
|
if (at >= 0)
|
|
400
325
|
this.queuedInputs.splice(at, 1);
|
|
@@ -411,9 +336,7 @@ export class PiSession {
|
|
|
411
336
|
if (piEvent.type === "agent_end")
|
|
412
337
|
retryPending = piEvent.willRetry === true;
|
|
413
338
|
if (piEvent.type === "agent_settled" && retryPending) {
|
|
414
|
-
// Aborting Pi during retry backoff produces no final agent_end
|
|
415
|
-
// turn has to be ended here. The idle is not: `agent_settled` carries
|
|
416
|
-
// it through the table below like any other settle.
|
|
339
|
+
// Aborting Pi during retry backoff produces no final agent_end.
|
|
417
340
|
retryPending = false;
|
|
418
341
|
fn({ type: "turn-end", text: "", meta: this.lastTurnMeta() });
|
|
419
342
|
}
|
|
@@ -424,23 +347,16 @@ export class PiSession {
|
|
|
424
347
|
}
|
|
425
348
|
});
|
|
426
349
|
}
|
|
427
|
-
/**
|
|
428
|
-
* never: a session that already has a name (a task's, a rename during the
|
|
429
|
-
* turn) or more than one exchange behind it keeps what it has. Several
|
|
430
|
-
* subscribers see every turn-end; this is what makes the request one. */
|
|
350
|
+
/** Several subscribers see every turn-end; this is what makes the request one. */
|
|
431
351
|
titleDecided = false;
|
|
432
|
-
/**
|
|
433
|
-
*
|
|
434
|
-
* first prompt that agent/listing.ts derives. The result is a rename like
|
|
435
|
-
* any other — an append the next listing reads — announced as `renamed` so
|
|
436
|
-
* every surface re-lists; a failure is announced too, because a title that
|
|
437
|
-
* silently stayed the prompt looks like the setting did nothing (§5b). */
|
|
352
|
+
/** A failure is announced too: a title that silently stayed the prompt looks
|
|
353
|
+
* like the setting did nothing (§5). */
|
|
438
354
|
autoTitle(reply, fn) {
|
|
439
355
|
if (this.titleDecided)
|
|
440
356
|
return;
|
|
441
357
|
const suggest = this.suggestTitle();
|
|
442
358
|
if (!suggest)
|
|
443
|
-
return; // off
|
|
359
|
+
return; // off is not decided: switched on later, the next turn still counts
|
|
444
360
|
this.titleDecided = true;
|
|
445
361
|
if (this.pi.sessionManager.getSessionName())
|
|
446
362
|
return;
|
|
@@ -449,7 +365,7 @@ export class PiSession {
|
|
|
449
365
|
if (!first.trim())
|
|
450
366
|
return;
|
|
451
367
|
void suggest(first, reply).then((title) => {
|
|
452
|
-
// Named while we waited
|
|
368
|
+
// Named while we waited, by a person: their word beats the model's.
|
|
453
369
|
if (this.disposed || this.pi.sessionManager.getSessionName())
|
|
454
370
|
return;
|
|
455
371
|
this.pi.sessionManager.appendSessionInfo(title);
|
|
@@ -460,7 +376,6 @@ export class PiSession {
|
|
|
460
376
|
fn({ type: "error", message: `session title: ${err instanceof Error ? err.message : String(err)} — the first message stays the title` });
|
|
461
377
|
});
|
|
462
378
|
}
|
|
463
|
-
/** Meta of the just-finished turn; live path, so "now" is the completion. */
|
|
464
379
|
lastTurnMeta() {
|
|
465
380
|
const messages = this.pi.messages;
|
|
466
381
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
@@ -485,31 +400,16 @@ export class PiAgentFactory {
|
|
|
485
400
|
titleModel;
|
|
486
401
|
listings;
|
|
487
402
|
constructor(extraTools = [],
|
|
488
|
-
/**
|
|
489
|
-
*
|
|
490
|
-
*
|
|
491
|
-
* ones. */
|
|
403
|
+
/** Getters are read per session open, so a Console change reaches the next
|
|
404
|
+
* session without a restart. Appended as a context file so the user's own
|
|
405
|
+
* instructions still win. */
|
|
492
406
|
instructions = () => "",
|
|
493
|
-
/**
|
|
494
|
-
* into the user's global or project skill directories. */
|
|
407
|
+
/** Loaded per session, never installed into the user's skill directories. */
|
|
495
408
|
skillPaths = [],
|
|
496
|
-
/**
|
|
497
|
-
*
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
credentials, providerConfig = new PiConfigStore(),
|
|
501
|
-
/** Operator-pinned models (Console → Settings → Models), surfaced first in
|
|
502
|
-
* every picker. A getter for the same reason `instructions` is one. */
|
|
503
|
-
pinned = () => [],
|
|
504
|
-
/** Which bundled extensions the Console has switched on. A getter for the
|
|
505
|
-
* same reason again: the toggle takes effect on the next session open. */
|
|
506
|
-
enabledExtensions = () => [],
|
|
507
|
-
/** The model that names a session after its first exchange (Console →
|
|
508
|
-
* Settings → Models); unset means the first prompt stays the title. A
|
|
509
|
-
* getter, read per turn end. */
|
|
510
|
-
titleModel = () => undefined,
|
|
511
|
-
/** What exists on disk. Injected so a test can hand this factory a listing
|
|
512
|
-
* instead of a session directory and a database. */
|
|
409
|
+
/** Optional only for bare test factories; an OAuth refresh persists here,
|
|
410
|
+
* never to a plaintext auth.json. */
|
|
411
|
+
credentials, providerConfig = new PiConfigStore(), pinned = () => [], enabledExtensions = () => [], titleModel = () => undefined,
|
|
412
|
+
/** Injected so a test needs no session directory or database. */
|
|
513
413
|
listings = new IndexedListing()) {
|
|
514
414
|
this.extraTools = extraTools;
|
|
515
415
|
this.instructions = instructions;
|
|
@@ -521,22 +421,16 @@ export class PiAgentFactory {
|
|
|
521
421
|
this.titleModel = titleModel;
|
|
522
422
|
this.listings = listings;
|
|
523
423
|
}
|
|
524
|
-
/**
|
|
424
|
+
/** Catalogs are global, not per session. */
|
|
525
425
|
catalog;
|
|
526
|
-
/**
|
|
527
|
-
*
|
|
528
|
-
* message, a task run. The sidebar's own listing keeps this warm; a miss
|
|
529
|
-
* still lists. */
|
|
426
|
+
/** A scan stats every session file, which `resume` would otherwise pay on
|
|
427
|
+
* every cold open. */
|
|
530
428
|
located = new Map();
|
|
531
|
-
/**
|
|
532
|
-
* surface — one workspace event has three (sidebar, Activity, task lookups).
|
|
533
|
-
* Dropped on create; ids appear for reasons this factory never sees, so
|
|
534
|
-
* a miss that decides something re-lists rather than trusts it (`resume`). */
|
|
429
|
+
/** Retained for LIST_TTL_MS; one workspace event has three asking surfaces. */
|
|
535
430
|
listing;
|
|
536
431
|
refreshQueue = Promise.resolve();
|
|
537
432
|
builtinProviderIds;
|
|
538
|
-
/**
|
|
539
|
-
* name, so the SDK accepts it without this file exporting any SDK type. */
|
|
433
|
+
/** CredentialStore mirrors pi-ai's interface structurally, so no SDK type is exported. */
|
|
540
434
|
createRuntime() {
|
|
541
435
|
return ModelRuntime.create(this.credentials ? { credentials: this.credentials } : {});
|
|
542
436
|
}
|
|
@@ -568,8 +462,8 @@ export class PiAgentFactory {
|
|
|
568
462
|
this.builtinIds(),
|
|
569
463
|
this.providerConfig.providerStructures(),
|
|
570
464
|
]);
|
|
571
|
-
//
|
|
572
|
-
// combine an older catalog with a newer models.json
|
|
465
|
+
// After queued config writes settle, so runtime and structure never
|
|
466
|
+
// combine an older catalog with a newer models.json.
|
|
573
467
|
const runtime = await this.refreshedRuntime();
|
|
574
468
|
const stored = new Map((await runtime.listCredentials()).map((c) => [c.providerId, c.type]));
|
|
575
469
|
return runtime.getProviders().map((provider) => {
|
|
@@ -601,17 +495,9 @@ export class PiAgentFactory {
|
|
|
601
495
|
};
|
|
602
496
|
});
|
|
603
497
|
}
|
|
604
|
-
/**
|
|
605
|
-
*
|
|
606
|
-
*
|
|
607
|
-
* gateway rewriting the request and a model this endpoint has never heard of
|
|
608
|
-
* all look identical until a turn fails hours later.
|
|
609
|
-
*
|
|
610
|
-
* The request goes out through a fetch of our own for one reason: what a
|
|
611
|
-
* provider (or a proxy in front of it) was actually sent, and what it
|
|
612
|
-
* actually said, is the answer here — a summary of either would be Pier's
|
|
613
|
-
* word for someone else's.
|
|
614
|
-
*/
|
|
498
|
+
/** A fetch of our own records what the provider (or a proxy in front of it)
|
|
499
|
+
* was actually sent and actually said; a summary would be Pier's word for
|
|
500
|
+
* someone else's. */
|
|
615
501
|
async check(providerId, modelId) {
|
|
616
502
|
const started = Date.now();
|
|
617
503
|
const signal = AbortSignal.timeout(PROVIDER_CHECK_TIMEOUT_MS);
|
|
@@ -620,7 +506,7 @@ export class PiAgentFactory {
|
|
|
620
506
|
const recorded = async (input, init) => {
|
|
621
507
|
request = typeof init?.body === "string" ? init.body : "";
|
|
622
508
|
const response = await globalThis.fetch(input, init);
|
|
623
|
-
// Cloned
|
|
509
|
+
// Cloned: the SDK still needs the real stream.
|
|
624
510
|
body = response.clone().text().then(clip, () => "");
|
|
625
511
|
return response;
|
|
626
512
|
};
|
|
@@ -638,7 +524,6 @@ export class PiAgentFactory {
|
|
|
638
524
|
throw new Error(`unknown model: ${providerId}/${modelId}`);
|
|
639
525
|
const answer = await runtime.completeSimple(model, { messages: [{ role: "user", content: "hi", timestamp: Date.now() }] }, { maxTokens: PROVIDER_CHECK_MAX_TOKENS, signal, fetch: recorded });
|
|
640
526
|
const text = textOfAnswer(answer);
|
|
641
|
-
// An empty answer is still an answer; say which kind of nothing it was.
|
|
642
527
|
return answered(clip(text) || `(no text; stop reason: ${answer.stopReason})`, true);
|
|
643
528
|
}
|
|
644
529
|
catch (err) {
|
|
@@ -650,17 +535,13 @@ export class PiAgentFactory {
|
|
|
650
535
|
: raw || error, false);
|
|
651
536
|
}
|
|
652
537
|
}
|
|
653
|
-
/** One bare completion — no session, no tools, no history — on the title
|
|
654
|
-
* model. Rejects on anything but a usable line: the session reports it. */
|
|
655
538
|
async suggestTitle(model, first, reply) {
|
|
656
539
|
const runtime = await this.refreshedRuntime();
|
|
657
540
|
const resolved = runtime.getModel(model.provider, model.id);
|
|
658
541
|
if (!resolved)
|
|
659
542
|
throw new Error(`title model ${model.provider}/${model.id} is not in the catalog`);
|
|
660
|
-
//
|
|
661
|
-
//
|
|
662
|
-
// medium) and spends the whole output cap thinking about a title. Anthropic
|
|
663
|
-
// is the exception — its default is thinking off, and any level turns it on.
|
|
543
|
+
// Unset means the provider's default, which on gpt-5 is medium and spends
|
|
544
|
+
// the whole output cap thinking. Anthropic's default is off, and any level turns it on.
|
|
664
545
|
const reasoning = resolved.reasoning && resolved.api !== "anthropic-messages" ? "minimal" : undefined;
|
|
665
546
|
const answer = await runtime.completeSimple(resolved, { messages: [{ role: "user", content: titleRequest(first, reply), timestamp: Date.now() }] }, { maxTokens: TITLE_MAX_TOKENS, reasoning, signal: AbortSignal.timeout(TITLE_TIMEOUT_MS) });
|
|
666
547
|
const title = titleFromAnswer(textOfAnswer(answer));
|
|
@@ -723,17 +604,11 @@ export class PiAgentFactory {
|
|
|
723
604
|
async logout(providerId) {
|
|
724
605
|
await (await this.authRuntime()).logout(providerId, { signal: AbortSignal.timeout(15_000) });
|
|
725
606
|
}
|
|
726
|
-
/**
|
|
727
|
-
* Pi discovers AGENTS.md itself; we append one more, in memory, telling the
|
|
728
|
-
* agent what the surface it is talking to can render. Layered as a context
|
|
729
|
-
* file (not a systemPromptOverride) so the user's own instructions still win.
|
|
730
|
-
*/
|
|
731
607
|
async resourceLoader(cwd) {
|
|
732
608
|
const loader = new DefaultResourceLoader({
|
|
733
609
|
cwd,
|
|
734
|
-
agentDir: defaultAgentDir(),
|
|
735
|
-
// SYSTEM.md
|
|
736
|
-
// Pi's generic default, preserving the user's later instruction layer.
|
|
610
|
+
agentDir: defaultAgentDir(),
|
|
611
|
+
// The user's SYSTEM.md is appended after Pier's baseline, so it still wins.
|
|
737
612
|
systemPromptOverride: pierSystemPrompt,
|
|
738
613
|
additionalSkillPaths: this.skillPaths,
|
|
739
614
|
skillsOverride: (base) => ({
|
|
@@ -762,11 +637,8 @@ export class PiAgentFactory {
|
|
|
762
637
|
}
|
|
763
638
|
async openSnapshot(cwd, sessionManager, opts) {
|
|
764
639
|
let live;
|
|
765
|
-
//
|
|
766
|
-
// configured yet would otherwise cost context on every turn of every
|
|
767
|
-
// session and be able to answer nothing.
|
|
640
|
+
// Per open: an unconfigured tool would cost context on every turn and answer nothing.
|
|
768
641
|
const active = this.extraTools.filter((tool) => tool.available?.() ?? true);
|
|
769
|
-
// Generic translation only — tool contracts are data owned by their feature.
|
|
770
642
|
const customTools = active.map((tool) => defineTool({
|
|
771
643
|
name: tool.name,
|
|
772
644
|
label: tool.label,
|
|
@@ -780,8 +652,7 @@ export class PiAgentFactory {
|
|
|
780
652
|
content: [
|
|
781
653
|
{
|
|
782
654
|
type: "text",
|
|
783
|
-
//
|
|
784
|
-
// costs ~20% more tokens and buys the model nothing.
|
|
655
|
+
// Pretty-printing costs ~20% more tokens and buys the model nothing.
|
|
785
656
|
text: JSON.stringify(await tool.execute(params, caller, signal)),
|
|
786
657
|
},
|
|
787
658
|
],
|
|
@@ -789,25 +660,20 @@ export class PiAgentFactory {
|
|
|
789
660
|
};
|
|
790
661
|
}
|
|
791
662
|
catch (err) {
|
|
792
|
-
// Pi turns this into tool-result text the
|
|
793
|
-
// right recovery and the wrong record: rethrown, but logged first.
|
|
663
|
+
// Pi turns this into tool-result text: the right recovery, the wrong record.
|
|
794
664
|
log.warn(`tool ${tool.name} failed for ${caller}`, err);
|
|
795
665
|
throw err;
|
|
796
666
|
}
|
|
797
667
|
},
|
|
798
668
|
}));
|
|
799
|
-
//
|
|
800
|
-
// configured"
|
|
801
|
-
// appendSessionInfo, so the refused open writes nothing to disk.
|
|
669
|
+
// A locked store is a refusal with a reason here, not "provider not
|
|
670
|
+
// configured" later. Before appendSessionInfo, so nothing is written.
|
|
802
671
|
this.credentials?.assertUnlocked();
|
|
803
672
|
if (opts.name)
|
|
804
673
|
sessionManager.appendSessionInfo(opts.name);
|
|
805
|
-
// This runtime serves
|
|
806
|
-
//
|
|
807
|
-
//
|
|
808
|
-
// tasks downgrade to "short" (5m) via setCacheRetention. The default sits
|
|
809
|
-
// before the spread so an explicit per-request value still wins —
|
|
810
|
-
// compaction passes cacheRetention: "none" and must keep it.
|
|
674
|
+
// This runtime serves one session, so shadowing streamSimple is the
|
|
675
|
+
// per-session seam for the cache TTL. Default before the spread: compaction
|
|
676
|
+
// passes cacheRetention: "none" and must keep it.
|
|
811
677
|
const runtime = await this.createRuntime();
|
|
812
678
|
const retention = { value: "long" };
|
|
813
679
|
const stream = runtime.streamSimple.bind(runtime);
|
|
@@ -820,12 +686,9 @@ export class PiAgentFactory {
|
|
|
820
686
|
resourceLoader: await this.resourceLoader(cwd),
|
|
821
687
|
});
|
|
822
688
|
live = created.session;
|
|
823
|
-
// Pi
|
|
824
|
-
//
|
|
825
|
-
//
|
|
826
|
-
// message 11 minutes late (2026-09-07). The agent's own setter only flips
|
|
827
|
-
// the in-memory queue; `session.setFollowUpMode` would persist it to Pi's
|
|
828
|
-
// settings.json on every open and make it global forever.
|
|
689
|
+
// Pi defaults to one follow-up per turn boundary, so N queued messages cost
|
|
690
|
+
// N turns. The agent's setter flips only the in-memory queue;
|
|
691
|
+
// `session.setFollowUpMode` would persist it to Pi's settings.json.
|
|
829
692
|
live.agent.followUpMode = "all";
|
|
830
693
|
const session = new PiSession(live, this.pinned, () => {
|
|
831
694
|
this.listing = undefined;
|
|
@@ -842,8 +705,7 @@ export class PiAgentFactory {
|
|
|
842
705
|
}
|
|
843
706
|
async create(opts) {
|
|
844
707
|
this.listing = undefined;
|
|
845
|
-
// Resolved before Pi records it, so
|
|
846
|
-
// and every later listing all name the directory the same way.
|
|
708
|
+
// Resolved before Pi records it, so every later listing names it the same way.
|
|
847
709
|
const cwd = realPath(opts.cwd);
|
|
848
710
|
return this.open(cwd, SessionManager.create(cwd), { ...opts, cwd });
|
|
849
711
|
}
|
|
@@ -854,8 +716,6 @@ export class PiAgentFactory {
|
|
|
854
716
|
return await this.open(known.cwd, SessionManager.open(known.path));
|
|
855
717
|
}
|
|
856
718
|
catch (err) {
|
|
857
|
-
// The file moved or went away under us: the cache was the only thing
|
|
858
|
-
// that claimed otherwise, so drop it and take the slow, true path.
|
|
859
719
|
log.warn(`cached path for session ${sessionId} did not open; re-listing`, err);
|
|
860
720
|
this.located.delete(sessionId);
|
|
861
721
|
}
|
|
@@ -865,14 +725,9 @@ export class PiAgentFactory {
|
|
|
865
725
|
throw new Error(`unknown session: ${sessionId}`);
|
|
866
726
|
return this.open(info.cwd || process.cwd(), SessionManager.open(info.path));
|
|
867
727
|
}
|
|
868
|
-
/** The
|
|
869
|
-
*
|
|
870
|
-
*
|
|
871
|
-
* session this factory opened. The miss is what earns a fresh scan, because
|
|
872
|
-
* callers read it as permission to start a replacement session
|
|
873
|
-
* (channels/conversations.ts), which costs a conversation its history, or as
|
|
874
|
-
* a session that no longer exists (tasks/, web/server.ts). `reused` is how we
|
|
875
|
-
* know a scan is owed: same entry back, same disk state. */
|
|
728
|
+
/** The one place "no such session" is decided. A retained listing is not
|
|
729
|
+
* evidence a session is gone, and callers read a miss as permission to start
|
|
730
|
+
* a replacement, so a miss earns a fresh scan. */
|
|
876
731
|
async locate(sessionId) {
|
|
877
732
|
const find = (infos) => infos.find((s) => s.id === sessionId);
|
|
878
733
|
const reused = this.listing;
|
|
@@ -883,11 +738,9 @@ export class PiAgentFactory {
|
|
|
883
738
|
const info = await this.locate(sessionId);
|
|
884
739
|
return info ? summaryOf(info) : undefined;
|
|
885
740
|
}
|
|
886
|
-
/**
|
|
887
|
-
*
|
|
888
|
-
* that format moves under it. */
|
|
741
|
+
/** agent/listing.ts parses Pi's transcripts itself; only a comparison
|
|
742
|
+
* notices when that format moves under it. */
|
|
889
743
|
audited = false;
|
|
890
|
-
/** Every listing goes through here, so it also refreshes `located`. */
|
|
891
744
|
listed(force = false) {
|
|
892
745
|
const now = Date.now();
|
|
893
746
|
if (!force && this.listing && now - this.listing.at < LIST_TTL_MS)
|
|
@@ -913,9 +766,7 @@ export class PiAgentFactory {
|
|
|
913
766
|
async list() {
|
|
914
767
|
return (await this.listed()).map(summaryOf);
|
|
915
768
|
}
|
|
916
|
-
/** After a listing, so a transcript that grew
|
|
917
|
-
* before it is asked about; the listing owns the index. One that cannot
|
|
918
|
-
* search — a test's — has nothing to say. */
|
|
769
|
+
/** After a listing, so a transcript that grew is indexed before it is asked about. */
|
|
919
770
|
async search(query) {
|
|
920
771
|
await this.listed();
|
|
921
772
|
return this.listings.search?.(query) ?? [];
|