@timqi/pier 0.0.1 → 0.0.3
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 +87 -12
- package/dist/agent/config.js +273 -27
- package/dist/agent/credentials.js +18 -12
- package/dist/agent/events.js +5 -41
- package/dist/agent/models.js +12 -0
- package/dist/agent/pi.js +182 -27
- package/dist/boards/boards.js +20 -10
- package/dist/channels/routes.js +1 -1
- package/dist/channels/runtime.js +36 -5
- package/dist/channels/slack-api.js +2 -4
- package/dist/channels/slack-outbound.js +4 -8
- package/dist/channels/slack-render.js +1 -4
- package/dist/channels/slack-tool.js +28 -3
- package/dist/channels/slack.js +20 -9
- package/dist/channels/telegram-api.js +3 -4
- package/dist/channels/telegram.js +37 -28
- package/dist/cli.js +177 -29
- package/dist/core/hub.js +36 -5
- package/dist/core/identity.js +5 -0
- package/dist/core/inbound-file.js +70 -0
- package/dist/core/inbox.js +32 -0
- package/dist/core/queue.js +9 -3
- package/dist/core/reply.js +20 -5
- package/dist/core/router.js +200 -14
- package/dist/core/types.js +53 -0
- package/dist/db.js +54 -8
- package/dist/drain.js +145 -0
- package/dist/main.js +180 -18
- package/dist/secrets.js +10 -6
- package/dist/service.js +192 -18
- package/dist/settings.js +77 -8
- package/dist/tasks/agent.js +41 -5
- package/dist/tasks/callbacks.js +29 -89
- package/dist/tasks/definitions.js +2 -6
- package/dist/tasks/execution.js +10 -1
- package/dist/tasks/groups.js +20 -49
- package/dist/tasks/messages.js +106 -21
- package/dist/tasks/outbox.js +157 -0
- package/dist/tasks/routes.js +6 -4
- package/dist/tasks/service.js +92 -22
- package/dist/tasks/store.js +48 -55
- package/dist/tasks/tool.js +19 -4
- package/dist/tasks/types.js +7 -0
- package/dist/update.js +146 -0
- package/dist/web/auth.js +89 -26
- package/dist/web/explorer.js +147 -0
- package/dist/web/files.js +28 -12
- package/dist/web/instance.js +165 -0
- package/dist/web/provider-flows.js +249 -0
- package/dist/web/providers.js +141 -0
- package/dist/web/public/assets/index-cCIuQnDr.css +2 -0
- package/dist/web/public/assets/index-fASxMPr6.js +90 -0
- package/dist/web/public/icon-192.png +0 -0
- package/dist/web/public/icon-32.png +0 -0
- package/dist/web/public/icon-512.png +0 -0
- package/dist/web/public/icon-maskable-512.png +0 -0
- package/dist/web/public/icon-touch-192.png +0 -0
- package/dist/web/public/icon.svg +29 -11
- package/dist/web/public/index.html +50 -32
- package/dist/web/server.js +110 -120
- package/docs/deploy.md +142 -64
- package/package.json +1 -1
- package/skills/pier-boards/SKILL.md +16 -7
- package/skills/pier-help/SKILL.md +110 -0
- package/skills/pier-slack/SKILL.md +20 -3
- package/skills/pier-tasks/SKILL.md +19 -12
- package/dist/web/public/assets/index-8CinH1uR.css +0 -2
- package/dist/web/public/assets/index-DAgP1Gq8.js +0 -78
- package/dist/web/public/sw.js +0 -21
package/dist/service.js
CHANGED
|
@@ -11,16 +11,33 @@
|
|
|
11
11
|
// installed by fnm/nvm/asdf is not on it) and the absolute path of the
|
|
12
12
|
// installed entry point.
|
|
13
13
|
import { execFileSync } from "node:child_process";
|
|
14
|
-
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
|
|
15
15
|
import { homedir, userInfo } from "node:os";
|
|
16
16
|
import { dirname, join } from "node:path";
|
|
17
17
|
export const UNIT_NAME = "pier.service";
|
|
18
|
+
export const UPDATE_UNIT_NAME = "pier-update.service";
|
|
18
19
|
/** `~/.config/systemd/user/pier.service` — where a user unit belongs. */
|
|
19
20
|
export const unitPath = (home = homedir()) => join(home, ".config", "systemd", "user", UNIT_NAME);
|
|
20
21
|
/** The drop-in Pier writes once and never touches again: what the unit may
|
|
21
22
|
* consume is the operator's call, not ours. */
|
|
22
23
|
export const limitsPath = (home = homedir()) => join(dirname(unitPath(home)), `${UNIT_NAME}.d`, "limits.conf");
|
|
23
|
-
|
|
24
|
+
/** The oneshot that installs a new version, beside the unit it restarts. */
|
|
25
|
+
export const updateUnitPath = (home = homedir()) => join(dirname(unitPath(home)), UPDATE_UNIT_NAME);
|
|
26
|
+
/** Runtime-only effective state for the updater, regenerated before each run. */
|
|
27
|
+
export const updateRuntimePath = (home = homedir()) => join(dirname(unitPath(home)), `${UPDATE_UNIT_NAME}.d`, "runtime.conf");
|
|
28
|
+
/** Quote one systemd word. Percent is doubled because specifier expansion runs
|
|
29
|
+
* after parsing; dollar is doubled only for command lines. */
|
|
30
|
+
function quote(value, command = false) {
|
|
31
|
+
if (/[\0\r\n]/.test(value))
|
|
32
|
+
throw new Error("systemd values cannot contain control characters");
|
|
33
|
+
let escaped = value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("%", "%%");
|
|
34
|
+
if (command)
|
|
35
|
+
escaped = escaped.replaceAll("$", () => "$$");
|
|
36
|
+
return `"${escaped}"`;
|
|
37
|
+
}
|
|
38
|
+
const environment = (key, value) => `Environment=${quote(`${key}=${value}`)}`;
|
|
39
|
+
export function renderUnit(options) {
|
|
40
|
+
const { execPath, entry, host, port, pierHome } = options;
|
|
24
41
|
return `[Unit]
|
|
25
42
|
Description=Pier — agent workspace
|
|
26
43
|
Documentation=https://github.com/timqi/pier
|
|
@@ -32,13 +49,13 @@ Type=simple
|
|
|
32
49
|
WorkingDirectory=%h
|
|
33
50
|
# Absolute paths on purpose: systemd starts with a minimal PATH, and the node
|
|
34
51
|
# that installed Pier is usually not on it.
|
|
35
|
-
ExecStart=${execPath} ${entry}
|
|
36
|
-
|
|
52
|
+
ExecStart=${quote(execPath, true)} ${quote(entry, true)}
|
|
53
|
+
${environment("NODE_ENV", "production")}
|
|
37
54
|
# Loopback by default. Put a reverse proxy in front before widening this —
|
|
38
55
|
# whoever reaches this port can drive an agent that runs a shell.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
${pierHome ? `# Where the database, the boards and the password hash live.\
|
|
56
|
+
${environment("HOST", host)}
|
|
57
|
+
${environment("PORT", String(port))}
|
|
58
|
+
${pierHome ? `# Where the database, the boards and the password hash live.\n${environment("PIER_HOME", pierHome)}\n` : ""}Restart=always
|
|
42
59
|
RestartSec=2
|
|
43
60
|
# The journal is where the first-run password is printed, so keep it readable.
|
|
44
61
|
StandardOutput=journal
|
|
@@ -50,6 +67,46 @@ SyslogIdentifier=pier
|
|
|
50
67
|
WantedBy=default.target
|
|
51
68
|
`;
|
|
52
69
|
}
|
|
70
|
+
/** The 0.0.1 unit did not record npm. This one-time bridge can only use npm
|
|
71
|
+
* beside its recorded Node; a forced reinstall writes the exact executable. */
|
|
72
|
+
function legacyOptions(home) {
|
|
73
|
+
const text = readFileSync(unitPath(home), "utf8");
|
|
74
|
+
const start = text.match(/^ExecStart=(\S+) (\S+)$/m);
|
|
75
|
+
const host = text.match(/^Environment=HOST=(\S+)$/m)?.[1];
|
|
76
|
+
const rawPort = text.match(/^Environment=PORT=(\d+)$/m)?.[1];
|
|
77
|
+
const pierHome = text.match(/^Environment=PIER_HOME=(\S+)$/m)?.[1];
|
|
78
|
+
if (!start?.[1] || !start[2] || !host || !rawPort)
|
|
79
|
+
throw new Error("unit is not the 0.0.1 Pier shape");
|
|
80
|
+
return {
|
|
81
|
+
execPath: start[1],
|
|
82
|
+
npmPath: join(dirname(start[1]), "npm"),
|
|
83
|
+
entry: start[2],
|
|
84
|
+
host,
|
|
85
|
+
port: Number(rawPort),
|
|
86
|
+
...(pierHome ? { pierHome } : {}),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/** A separate cgroup stops Pier, takes a consistent backup, updates the exact
|
|
90
|
+
* npm installation recorded at install time, and always starts Pier again. */
|
|
91
|
+
export function renderUpdateUnit(options) {
|
|
92
|
+
const { execPath, npmPath, entry, pierHome } = options;
|
|
93
|
+
const cli = join(dirname(entry), "cli.js");
|
|
94
|
+
return `[Unit]
|
|
95
|
+
Description=Update Pier to the latest published version
|
|
96
|
+
Documentation=https://github.com/timqi/pier
|
|
97
|
+
|
|
98
|
+
[Service]
|
|
99
|
+
Type=oneshot
|
|
100
|
+
${pierHome ? `${environment("PIER_HOME", pierHome)}\n` : ""}ExecStart=systemctl --user stop ${UNIT_NAME}
|
|
101
|
+
ExecStart=${quote(execPath, true)} ${quote(cli, true)} backup
|
|
102
|
+
# npm runs under the recorded node: its shebang needs a node on PATH, and
|
|
103
|
+
# systemd's minimal PATH has none for fnm/nvm installs.
|
|
104
|
+
ExecStart=${quote(execPath, true)} ${quote(npmPath, true)} install -g @timqi/pier@latest
|
|
105
|
+
# ExecStopPost runs on success and failure, so a failed backup or npm install
|
|
106
|
+
# does not leave the previously working service stopped.
|
|
107
|
+
ExecStopPost=systemctl --user start ${UNIT_NAME}
|
|
108
|
+
`;
|
|
109
|
+
}
|
|
53
110
|
/**
|
|
54
111
|
* Sized as a share of the machine, not as "how much should Pier need": the
|
|
55
112
|
* limit covers node, every subagent and every command a turn ran, and it
|
|
@@ -74,8 +131,8 @@ OOMScoreAdjust=200
|
|
|
74
131
|
OOMPolicy=continue
|
|
75
132
|
`;
|
|
76
133
|
}
|
|
77
|
-
/**
|
|
78
|
-
*
|
|
134
|
+
/** A command failure is printed here and propagated by its caller. Linger is
|
|
135
|
+
* the only deliberately best-effort step. */
|
|
79
136
|
const runner = (say) => (argv) => {
|
|
80
137
|
try {
|
|
81
138
|
execFileSync(argv[0], argv.slice(1), { stdio: "pipe" });
|
|
@@ -91,13 +148,16 @@ export function install(options) {
|
|
|
91
148
|
const { force, home = homedir(), say, exec, ...unit } = options;
|
|
92
149
|
const run = exec ?? runner(say);
|
|
93
150
|
const path = unitPath(home);
|
|
94
|
-
|
|
151
|
+
const replacing = existsSync(path);
|
|
152
|
+
if (replacing && !force) {
|
|
95
153
|
say(`${path} already exists — nothing written. Re-run with --force to replace it.`);
|
|
96
|
-
return;
|
|
154
|
+
return true;
|
|
97
155
|
}
|
|
98
156
|
mkdirSync(dirname(path), { recursive: true });
|
|
99
157
|
writeFileSync(path, renderUnit(unit), { mode: 0o644 });
|
|
158
|
+
writeFileSync(updateUnitPath(home), renderUpdateUnit(unit), { mode: 0o644 });
|
|
100
159
|
say(`wrote ${path}`);
|
|
160
|
+
say(`wrote ${updateUnitPath(home)}`);
|
|
101
161
|
const limits = limitsPath(home);
|
|
102
162
|
if (existsSync(limits)) {
|
|
103
163
|
say(`kept ${limits}`); // tuned by hand, by definition
|
|
@@ -107,28 +167,142 @@ export function install(options) {
|
|
|
107
167
|
writeFileSync(limits, renderLimits(), { mode: 0o644 });
|
|
108
168
|
say(`wrote ${limits}`);
|
|
109
169
|
}
|
|
110
|
-
run(["systemctl", "--user", "daemon-reload"])
|
|
170
|
+
if (!run(["systemctl", "--user", "daemon-reload"]))
|
|
171
|
+
return false;
|
|
111
172
|
// Without lingering, the user manager stops at logout and takes every
|
|
112
173
|
// scheduled task with it. It can need a polkit prompt, hence best-effort.
|
|
113
174
|
if (!run(["loginctl", "enable-linger", userInfo().username])) {
|
|
114
175
|
say(` run it yourself so Pier survives logout: loginctl enable-linger ${userInfo().username}`);
|
|
115
176
|
}
|
|
116
|
-
if (
|
|
117
|
-
|
|
177
|
+
if (replacing) {
|
|
178
|
+
if (!run(["systemctl", "--user", "enable", UNIT_NAME]))
|
|
179
|
+
return false;
|
|
180
|
+
if (!run(["systemctl", "--user", "restart", UNIT_NAME]))
|
|
181
|
+
return false;
|
|
118
182
|
}
|
|
183
|
+
else if (!run(["systemctl", "--user", "enable", "--now", UNIT_NAME]))
|
|
184
|
+
return false;
|
|
185
|
+
say(`started. The first run prints a password once: journalctl --user -u pier -e`);
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Why the installed updater could not do its job, or `null` when nothing is
|
|
190
|
+
* wrong. Checked while Pier is still alive, because the alternative is finding
|
|
191
|
+
* out at the next restart, from a service that no longer starts.
|
|
192
|
+
*
|
|
193
|
+
* The absolute node and npm paths in the unit are deliberate — systemd's PATH
|
|
194
|
+
* has neither — but they pin the unit to one directory of one version manager.
|
|
195
|
+
* `fnm install 26 && fnm uninstall 24` leaves ExecStart naming a Node that is
|
|
196
|
+
* gone; the running process survives (Linux keeps a deleted binary mapped),
|
|
197
|
+
* so nothing would notice until the update, or the next boot, failed.
|
|
198
|
+
*/
|
|
199
|
+
export function updaterProblem(home = homedir()) {
|
|
200
|
+
if (!existsSync(unitPath(home)))
|
|
201
|
+
return null; // not a service install; nothing to check
|
|
202
|
+
const path = updateUnitPath(home);
|
|
203
|
+
let unit;
|
|
204
|
+
if (existsSync(path)) {
|
|
205
|
+
try {
|
|
206
|
+
unit = readFileSync(path, "utf8");
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
return `${path} cannot be read: ${String(err)}`;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
// A 0.0.1 install: startUpdate generates the updater from the main unit,
|
|
214
|
+
// so a missing file is only a problem when that bridge cannot either —
|
|
215
|
+
// and the generated text gets the same executable check below.
|
|
216
|
+
try {
|
|
217
|
+
unit = renderUpdateUnit(legacyOptions(home));
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return `${UPDATE_UNIT_NAME} is missing — run: pier service install --force`;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// The one line that names both executables, quoted and escaped by quote().
|
|
224
|
+
// Unparseable means hand-edited, which is not this function's business to
|
|
225
|
+
// judge; the escaping is undone before existsSync sees a path (a `%` or `$`
|
|
226
|
+
// in it would otherwise read as gone on a working updater).
|
|
227
|
+
const install = unit.match(/^ExecStart="((?:\\.|[^"\r\n])+)" "((?:\\.|[^"\r\n])+)" install -g/m);
|
|
228
|
+
if (!install)
|
|
229
|
+
return null;
|
|
230
|
+
const unescape = (word) => word.replaceAll("$$", "$").replaceAll("%%", "%").replace(/\\(.)/g, "$1");
|
|
231
|
+
for (const [what, bin] of [["node", unescape(install[1])], ["npm", unescape(install[2])]]) {
|
|
232
|
+
if (!existsSync(bin)) {
|
|
233
|
+
return `the ${what} the updater would use is gone (${bin}) — a version manager removed it; run: pier service install --force`;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
function runningPierHome(home) {
|
|
239
|
+
const pid = Number(execFileSync("systemctl", ["--user", "show", UNIT_NAME, "--property=MainPID", "--value"], { encoding: "utf8" }).trim());
|
|
240
|
+
if (Number.isInteger(pid) && pid >= 1) {
|
|
241
|
+
const value = readFileSync(`/proc/${pid}/environ`)
|
|
242
|
+
.toString()
|
|
243
|
+
.split("\0")
|
|
244
|
+
.find((item) => item.startsWith("PIER_HOME="))
|
|
245
|
+
?.slice("PIER_HOME=".length);
|
|
246
|
+
return value || join(home, ".pier");
|
|
247
|
+
}
|
|
248
|
+
// Installed but stopped: the unit file records any override (quoted and
|
|
249
|
+
// escaped since 0.0.2, bare in the 0.0.1 shape) — undo quote()'s escaping
|
|
250
|
+
// or the drop-in would carry `%%`/`\\"` into a real path.
|
|
251
|
+
const raw = readFileSync(unitPath(home), "utf8")
|
|
252
|
+
.match(/^Environment="?PIER_HOME=((?:\\.|[^"\r\n])+)"?$/m)?.[1];
|
|
253
|
+
const fromUnit = raw?.replaceAll("%%", "%").replace(/\\(.)/g, "$1");
|
|
254
|
+
return fromUnit || join(home, ".pier");
|
|
255
|
+
}
|
|
256
|
+
/** Start the updater recorded at install time. Its tiny drop-in captures the
|
|
257
|
+
* running service's effective home, including an operator override. */
|
|
258
|
+
export function startUpdate(options) {
|
|
259
|
+
const { home = homedir(), say } = options;
|
|
260
|
+
const run = options.exec ?? runner(say);
|
|
261
|
+
if (!existsSync(unitPath(home)))
|
|
262
|
+
return "not-installed";
|
|
263
|
+
try {
|
|
264
|
+
if (!existsSync(updateUnitPath(home))) {
|
|
265
|
+
writeFileSync(updateUnitPath(home), renderUpdateUnit(legacyOptions(home)), { mode: 0o644 });
|
|
266
|
+
say(`generated a legacy updater; run "pier service install --force" after this update to record the exact npm path.`);
|
|
267
|
+
}
|
|
268
|
+
const runtime = updateRuntimePath(home);
|
|
269
|
+
mkdirSync(dirname(runtime), { recursive: true });
|
|
270
|
+
writeFileSync(runtime, `[Service]\n${environment("PIER_HOME", (options.effectiveHome ?? (() => runningPierHome(home)))())}\n`, { mode: 0o644 });
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
say(`! cannot prepare update — ${String(err)}`);
|
|
274
|
+
return "failed";
|
|
275
|
+
}
|
|
276
|
+
if (!run(["systemctl", "--user", "daemon-reload"]))
|
|
277
|
+
return "failed";
|
|
278
|
+
if (!run(["systemctl", "--user", "start", "--no-block", UPDATE_UNIT_NAME]))
|
|
279
|
+
return "failed";
|
|
280
|
+
say(`updating in the background — follow it with: journalctl --user -u ${UPDATE_UNIT_NAME} -f`);
|
|
281
|
+
say(`Pier stops, snapshots pier.db.release.bak, installs, then starts again.`);
|
|
282
|
+
return "started";
|
|
119
283
|
}
|
|
120
284
|
export function uninstall(home = homedir(), say = console.log, exec) {
|
|
121
285
|
const run = exec ?? runner(say);
|
|
122
|
-
run(["systemctl", "--user", "disable", "--now", UNIT_NAME])
|
|
123
|
-
|
|
286
|
+
if (!run(["systemctl", "--user", "disable", "--now", UNIT_NAME]))
|
|
287
|
+
return false;
|
|
288
|
+
for (const path of [unitPath(home), limitsPath(home), updateUnitPath(home), updateRuntimePath(home)]) {
|
|
124
289
|
if (!existsSync(path))
|
|
125
290
|
continue;
|
|
126
291
|
rmSync(path, { force: true });
|
|
127
292
|
say(`removed ${path}`);
|
|
128
293
|
}
|
|
129
|
-
|
|
130
|
-
|
|
294
|
+
for (const dir of [dirname(limitsPath(home)), dirname(updateRuntimePath(home))]) {
|
|
295
|
+
try {
|
|
296
|
+
rmdirSync(dir); // non-recursive: drop-ins the operator added are not ours to delete
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
if (existsSync(dir))
|
|
300
|
+
say(`kept ${dir} — it has files Pier did not write`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const ok = run(["systemctl", "--user", "daemon-reload"]);
|
|
131
304
|
// Left alone on purpose: the database, the boards, and linger — none of them
|
|
132
305
|
// are this command's to decide about.
|
|
133
306
|
say(`$PIER_HOME is untouched; linger is still enabled.`);
|
|
307
|
+
return ok;
|
|
134
308
|
}
|
package/dist/settings.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
// Instance settings: the facts about *this* Pier that are neither a credential
|
|
2
|
-
// nor per-session
|
|
3
|
-
//
|
|
4
|
-
// is whatever a proxy chose to pass on, and an agent writing a board has no
|
|
5
|
-
// request at all.
|
|
2
|
+
// nor per-session — the public URL (nothing in the process can discover it:
|
|
3
|
+
// a Host header is whatever a proxy passed on) and the operator's model menu.
|
|
6
4
|
//
|
|
7
5
|
// A key-value table, so the next setting is not the next table and not a third
|
|
8
6
|
// kind of storage. It used to be a JSON file, justified by "the agent reads it
|
|
9
7
|
// too" — the agent is told the URL in its system prompt (core/reply.ts), and
|
|
10
8
|
// nothing outside this process ever opened that file.
|
|
9
|
+
import { isThinkingLevel } from "./core/types.js";
|
|
11
10
|
import { pierDb } from "./db.js";
|
|
11
|
+
import { logger } from "./log.js";
|
|
12
|
+
const log = logger("settings");
|
|
12
13
|
/**
|
|
13
14
|
* `""` clears it, `null` rejects it. Rejecting rather than repairing: a
|
|
14
15
|
* mistyped host quietly turned into a URL produces board links that 404 for
|
|
@@ -33,22 +34,90 @@ export function normalizePublicUrl(raw) {
|
|
|
33
34
|
return null;
|
|
34
35
|
return `${url.origin}${url.pathname}`.replace(/\/+$/, "");
|
|
35
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Boundary check for a menu, rejecting rather than repairing (same contract as
|
|
39
|
+
* `normalizePublicUrl`): a silently "fixed" entry would advertise a model the
|
|
40
|
+
* operator never picked. Notes are capped — they are one line of intent, and
|
|
41
|
+
* every session that asks for the menu pays for their tokens.
|
|
42
|
+
*/
|
|
43
|
+
export function normalizeModelMenu(raw) {
|
|
44
|
+
if (!Array.isArray(raw) || raw.length > 32)
|
|
45
|
+
return null;
|
|
46
|
+
const menu = [];
|
|
47
|
+
for (const item of raw) {
|
|
48
|
+
if (typeof item !== "object" || item === null)
|
|
49
|
+
return null;
|
|
50
|
+
const { provider, id, thinking, note } = item;
|
|
51
|
+
if (typeof provider !== "string" || !provider.trim())
|
|
52
|
+
return null;
|
|
53
|
+
if (typeof id !== "string" || !id.trim())
|
|
54
|
+
return null;
|
|
55
|
+
if (thinking !== undefined && !isThinkingLevel(thinking))
|
|
56
|
+
return null;
|
|
57
|
+
if (note !== undefined && typeof note !== "string")
|
|
58
|
+
return null;
|
|
59
|
+
const cleaned = note?.trim().slice(0, 200);
|
|
60
|
+
menu.push({
|
|
61
|
+
provider: provider.trim(),
|
|
62
|
+
id: id.trim(),
|
|
63
|
+
...(thinking !== undefined ? { thinking } : {}),
|
|
64
|
+
...(cleaned ? { note: cleaned } : {}),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return menu;
|
|
68
|
+
}
|
|
36
69
|
export class SettingsStore {
|
|
37
70
|
#db;
|
|
38
71
|
constructor(db = pierDb()) {
|
|
39
72
|
this.#db = db;
|
|
40
73
|
}
|
|
41
74
|
get() {
|
|
42
|
-
return {
|
|
75
|
+
return {
|
|
76
|
+
publicUrl: this.#value("publicUrl") ?? "",
|
|
77
|
+
modelMenu: this.#menu(),
|
|
78
|
+
autoUpdate: this.#value("autoUpdate") === "1",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
#menu() {
|
|
82
|
+
const raw = this.#value("modelMenu");
|
|
83
|
+
if (!raw)
|
|
84
|
+
return [];
|
|
85
|
+
let parsed;
|
|
86
|
+
try {
|
|
87
|
+
parsed = JSON.parse(raw);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// Only a hand-edited row can get here; named, not silently served as [].
|
|
91
|
+
log.warn("settings.modelMenu is not JSON — ignoring it");
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
const menu = normalizeModelMenu(parsed);
|
|
95
|
+
if (!menu) {
|
|
96
|
+
log.warn("settings.modelMenu is not a valid menu — ignoring it");
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
return menu;
|
|
43
100
|
}
|
|
44
101
|
/** Store an already-normalized value — validation belongs at the boundary
|
|
45
102
|
* that received it, so this never has to guess what the caller meant. */
|
|
46
103
|
setPublicUrl(publicUrl) {
|
|
104
|
+
this.#set("publicUrl", publicUrl);
|
|
105
|
+
return this.get();
|
|
106
|
+
}
|
|
107
|
+
/** Same contract: hand this `normalizeModelMenu`'s output, not raw input. */
|
|
108
|
+
setModelMenu(menu) {
|
|
109
|
+
this.#set("modelMenu", JSON.stringify(menu));
|
|
110
|
+
return this.get();
|
|
111
|
+
}
|
|
112
|
+
setAutoUpdate(on) {
|
|
113
|
+
this.#set("autoUpdate", on ? "1" : "0");
|
|
114
|
+
return this.get();
|
|
115
|
+
}
|
|
116
|
+
#set(key, value) {
|
|
47
117
|
this.#db.prepare(`
|
|
48
|
-
INSERT INTO settings(key, value) VALUES (
|
|
118
|
+
INSERT INTO settings(key, value) VALUES (?, ?)
|
|
49
119
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
|
50
|
-
`).run(
|
|
51
|
-
return this.get();
|
|
120
|
+
`).run(key, value);
|
|
52
121
|
}
|
|
53
122
|
#value(key) {
|
|
54
123
|
const row = this.#db.prepare("SELECT value FROM settings WHERE key = ?").get(key);
|
package/dist/tasks/agent.js
CHANGED
|
@@ -1,8 +1,27 @@
|
|
|
1
|
+
import { quietLabel, splitReply } from "../core/reply.js";
|
|
1
2
|
import { Router } from "../core/router.js";
|
|
2
3
|
import { TaskMessenger } from "./messages.js";
|
|
3
4
|
import { TaskStore } from "./store.js";
|
|
4
5
|
const MAX_ACTIVE_AGENTS = 4;
|
|
5
6
|
const MAX_ACTIVE_PER_ROOT = 4;
|
|
7
|
+
/** What a child cannot know unless told. Every session gets the chat-surface
|
|
8
|
+
* contract (<pier>/AGENTS.md), task runs included — so the delegation prompt
|
|
9
|
+
* says which of it does not apply here, and a supervised run how to reach the
|
|
10
|
+
* agent that is waiting on it. Skipped on resume: the session already saw it. */
|
|
11
|
+
const preamble = (run) => {
|
|
12
|
+
// A cron/watch task with a session callback is read by an agent too.
|
|
13
|
+
const audience = run.invokedBySessionId
|
|
14
|
+
? "read by the agent that delegated this run"
|
|
15
|
+
: run.callbackSessionId
|
|
16
|
+
? "read by the agent session it is delivered to"
|
|
17
|
+
: "read by the operator";
|
|
18
|
+
const contact = run.invokedBySessionId
|
|
19
|
+
? ' Mid-run, the task tool\'s contact operation reaches that agent: reason "progress" is fire-and-forget, "decision" waits for a reply — state what you await and end your turn.'
|
|
20
|
+
: "";
|
|
21
|
+
return `[Pier task run ${run.id} — "${run.context.definition.name}"] ` +
|
|
22
|
+
`Your final reply is recorded verbatim as the run result, ${audience}; ` +
|
|
23
|
+
`next-step buttons and file:// attachments do not render there.${contact}\n\n`;
|
|
24
|
+
};
|
|
6
25
|
export class AgentTaskRunner {
|
|
7
26
|
factory;
|
|
8
27
|
router;
|
|
@@ -28,10 +47,12 @@ export class AgentTaskRunner {
|
|
|
28
47
|
start();
|
|
29
48
|
// No input is no block: `<task_input>\nnull\n</task_input>` is four
|
|
30
49
|
// lines telling the agent nothing, on every run that has no input.
|
|
50
|
+
// Compact for the same reason tool results are (agent/pi.ts), and
|
|
51
|
+
// `<\/` is the same JSON — a value cannot close the fence early.
|
|
31
52
|
const input = run.input === undefined || run.input === null
|
|
32
53
|
? ""
|
|
33
|
-
: `\n\n<task_input>\n${JSON.stringify(run.input,
|
|
34
|
-
const prompt = run.context.resumePrompt ?? `${action.prompt}${input}`;
|
|
54
|
+
: `\n\n<task_input>\n${JSON.stringify(run.input).replaceAll("</task_input>", "<\\/task_input>")}\n</task_input>`;
|
|
55
|
+
const prompt = run.context.resumePrompt ?? `${preamble(run)}${action.prompt}${input}`;
|
|
35
56
|
run.context.sessionId = session.id;
|
|
36
57
|
run.context.model = session.model;
|
|
37
58
|
run.context.renderedPrompt = prompt;
|
|
@@ -41,7 +62,16 @@ export class AgentTaskRunner {
|
|
|
41
62
|
if (event.type === "turn-end")
|
|
42
63
|
text = event.text;
|
|
43
64
|
});
|
|
44
|
-
|
|
65
|
+
// The race below also settles this attempt if Pi ignores the abort:
|
|
66
|
+
// a hung turn must not hold one of the 4 slots (and its waiters)
|
|
67
|
+
// forever — the same guard execution.ts gives task-type children.
|
|
68
|
+
let rejectAborted = () => { };
|
|
69
|
+
const abortedTurn = new Promise((_, reject) => { rejectAborted = reject; });
|
|
70
|
+
abortedTurn.catch(() => { }); // handled via the race; never unhandled
|
|
71
|
+
const abort = () => {
|
|
72
|
+
void session.abort();
|
|
73
|
+
rejectAborted(new Error("cancelled"));
|
|
74
|
+
};
|
|
45
75
|
signal.addEventListener("abort", abort, { once: true });
|
|
46
76
|
// A pre-aborted signal never fires the listener: check before the
|
|
47
77
|
// prompt starts or a cancelled run would hang until its timeout.
|
|
@@ -56,14 +86,20 @@ export class AgentTaskRunner {
|
|
|
56
86
|
}, "prompt");
|
|
57
87
|
await Promise.resolve();
|
|
58
88
|
this.messages.deliverPendingControls(run);
|
|
59
|
-
await turn;
|
|
89
|
+
await Promise.race([turn, abortedTurn]);
|
|
60
90
|
if (signal.aborted)
|
|
61
91
|
throw new Error("cancelled");
|
|
62
92
|
if (!text) {
|
|
63
93
|
const history = await session.history();
|
|
64
94
|
text = [...history].reverse().find((turn) => turn.role === "assistant")?.text ?? "";
|
|
65
95
|
}
|
|
66
|
-
|
|
96
|
+
// The chat contract is injected into task sessions too, so a child's
|
|
97
|
+
// reply may carry chat-only markup. The result is read by a
|
|
98
|
+
// supervisor or the Console, never a chat renderer: buttons are
|
|
99
|
+
// dropped, and a turn that said nothing names which kind of nothing
|
|
100
|
+
// it was (principle 5b) instead of storing an empty result.
|
|
101
|
+
const reply = splitReply(text);
|
|
102
|
+
return { type: "agent", text: reply.text || quietLabel(reply.silence), sessionId: session.id };
|
|
67
103
|
}
|
|
68
104
|
finally {
|
|
69
105
|
signal.removeEventListener("abort", abort);
|
package/dist/tasks/callbacks.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
// What a finished run says to the session that delegated it. Delivery itself
|
|
2
|
+
// belongs to outbox.ts; this file owns the run vocabulary and the batching.
|
|
1
3
|
import { Router } from "../core/router.js";
|
|
2
|
-
import {
|
|
4
|
+
import { Outbox } from "./outbox.js";
|
|
3
5
|
import { TaskStore } from "./store.js";
|
|
4
|
-
const log = logger("tasks");
|
|
5
6
|
export function runResultText(run) {
|
|
6
7
|
let result = run.error ?? "No result";
|
|
7
8
|
if (run.result?.type === "agent")
|
|
@@ -13,18 +14,31 @@ export function runResultText(run) {
|
|
|
13
14
|
if (run.result?.type === "watch")
|
|
14
15
|
result = "Watch condition did not match";
|
|
15
16
|
if (result.length > 8000)
|
|
16
|
-
result = `${result.slice(0, 8000)}\n[truncated
|
|
17
|
+
result = `${result.slice(0, 8000)}\n[truncated — task tool get run_id ${run.id} returns the full text]`;
|
|
17
18
|
return result;
|
|
18
19
|
}
|
|
19
20
|
export class TaskCallbacks {
|
|
20
21
|
store;
|
|
21
|
-
|
|
22
|
-
changed
|
|
23
|
-
delivering = new Set();
|
|
24
|
-
constructor(store, router, changed) {
|
|
22
|
+
outbox;
|
|
23
|
+
constructor(store, router, changed, unreachable) {
|
|
25
24
|
this.store = store;
|
|
26
|
-
this.
|
|
27
|
-
|
|
25
|
+
this.outbox = new Outbox(router, {
|
|
26
|
+
id: (run) => run.id,
|
|
27
|
+
reload: (id) => this.store.getRun(id),
|
|
28
|
+
save: (run) => { this.store.saveRun(run); },
|
|
29
|
+
changed,
|
|
30
|
+
input: (runs) => ({
|
|
31
|
+
text: this.text(runs),
|
|
32
|
+
origin: {
|
|
33
|
+
kind: "task-callback",
|
|
34
|
+
taskId: runs[0].taskId,
|
|
35
|
+
runId: runs[0].id,
|
|
36
|
+
sourceSessionId: runs[0].targetSessionId,
|
|
37
|
+
runIds: runs.map((run) => run.id),
|
|
38
|
+
},
|
|
39
|
+
}),
|
|
40
|
+
describe: (run) => `the result of "${run.context.definition.name}"`,
|
|
41
|
+
}, unreachable);
|
|
28
42
|
}
|
|
29
43
|
target(callback, origin) {
|
|
30
44
|
if (callback.type === "session")
|
|
@@ -37,94 +51,20 @@ export class TaskCallbacks {
|
|
|
37
51
|
for (const run of this.store.listPendingCallbacks(now))
|
|
38
52
|
void this.deliver(run);
|
|
39
53
|
}
|
|
40
|
-
/** Delivers the candidate
|
|
41
|
-
*
|
|
42
|
-
* backlog instead of one turn per run. */
|
|
54
|
+
/** Delivers the candidate together with every other deliverable callback
|
|
55
|
+
* aimed at the same session. */
|
|
43
56
|
async deliver(candidate) {
|
|
44
|
-
if (this.delivering.has(candidate.id))
|
|
45
|
-
return;
|
|
46
57
|
const first = this.store.getRun(candidate.id);
|
|
47
58
|
if (!first?.callbackSessionId || (first.callbackState !== "pending" && first.callbackState !== "failed"))
|
|
48
59
|
return;
|
|
49
60
|
const sessionId = first.callbackSessionId;
|
|
50
|
-
//
|
|
61
|
+
// Retry due-times are ignored when sweeping the batch: once one callback is
|
|
51
62
|
// deliverable, everything pending for the session rides along.
|
|
52
|
-
const batch = this.store.listPendingCallbacks(Number.MAX_SAFE_INTEGER)
|
|
63
|
+
const batch = this.store.listPendingCallbacks(Number.MAX_SAFE_INTEGER)
|
|
64
|
+
.filter((run) => run.callbackSessionId === sessionId);
|
|
53
65
|
if (!batch.some((run) => run.id === first.id))
|
|
54
66
|
return;
|
|
55
|
-
|
|
56
|
-
this.delivering.add(run.id);
|
|
57
|
-
try {
|
|
58
|
-
const session = await this.router.ensure({ channelId: "task", conversationId: sessionId });
|
|
59
|
-
// Crash-window idempotency: any run id already present in a persisted
|
|
60
|
-
// callback input (single or batched) must not be sent again.
|
|
61
|
-
const seen = new Set();
|
|
62
|
-
for (const turn of await session.history()) {
|
|
63
|
-
if (turn.role !== "system" || turn.origin?.kind !== "task-callback")
|
|
64
|
-
continue;
|
|
65
|
-
for (const id of turn.origin.runIds ?? [turn.origin.runId])
|
|
66
|
-
seen.add(id);
|
|
67
|
-
}
|
|
68
|
-
const fresh = batch.filter((run) => !seen.has(run.id));
|
|
69
|
-
// Waiting for a busy target is not a delivery attempt: counting it would
|
|
70
|
-
// inflate `callbackAttempts` once per second and skip the real failure
|
|
71
|
-
// backoff straight to its ceiling.
|
|
72
|
-
if (fresh.length > 0 && session.state === "streaming") {
|
|
73
|
-
for (const run of batch) {
|
|
74
|
-
run.callbackNextAttemptAt = Date.now() + 1000;
|
|
75
|
-
this.store.saveRun(run);
|
|
76
|
-
}
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
for (const run of batch) {
|
|
80
|
-
run.callbackAttempts += 1;
|
|
81
|
-
run.callbackState = "pending";
|
|
82
|
-
run.callbackError = null;
|
|
83
|
-
this.store.saveRun(run);
|
|
84
|
-
}
|
|
85
|
-
// `systemInput` resolves when the turn it triggers settles, not when Pi
|
|
86
|
-
// accepts the input — so mark delivered first and let a rejection below
|
|
87
|
-
// flip it to failed. Otherwise a recipient turn that runs for minutes
|
|
88
|
-
// leaves the run "pending" and a restart in that window re-delivers.
|
|
89
|
-
const sent = fresh.length > 0
|
|
90
|
-
? session.systemInput(this.text(fresh), {
|
|
91
|
-
kind: "task-callback",
|
|
92
|
-
taskId: fresh[0].taskId,
|
|
93
|
-
runId: fresh[0].id,
|
|
94
|
-
sourceSessionId: fresh[0].targetSessionId,
|
|
95
|
-
runIds: fresh.map((run) => run.id),
|
|
96
|
-
}, "followUp")
|
|
97
|
-
: Promise.resolve();
|
|
98
|
-
for (const run of batch) {
|
|
99
|
-
run.callbackState = "delivered";
|
|
100
|
-
run.callbackNextAttemptAt = null;
|
|
101
|
-
this.store.saveRun(run);
|
|
102
|
-
this.changed(run);
|
|
103
|
-
}
|
|
104
|
-
if (fresh.length > 0) {
|
|
105
|
-
log.debug(`callback for ${fresh.map((run) => run.id).join(", ")} → session ${sessionId}`);
|
|
106
|
-
}
|
|
107
|
-
await sent;
|
|
108
|
-
}
|
|
109
|
-
catch (error) {
|
|
110
|
-
// The delegating agent is waiting for an answer that is now late: the
|
|
111
|
-
// retry is silent, so this line is the only sign it is being retried.
|
|
112
|
-
log.warn(`callback to session ${sessionId} failed, will retry`, error);
|
|
113
|
-
for (const stale of batch) {
|
|
114
|
-
const run = this.store.getRun(stale.id);
|
|
115
|
-
if (!run)
|
|
116
|
-
continue;
|
|
117
|
-
run.callbackState = "failed";
|
|
118
|
-
run.callbackError = String(error);
|
|
119
|
-
run.callbackNextAttemptAt = Date.now() + Math.min(60_000, 1000 * 2 ** Math.min(run.callbackAttempts, 6));
|
|
120
|
-
this.store.saveRun(run);
|
|
121
|
-
this.changed(run);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
finally {
|
|
125
|
-
for (const run of batch)
|
|
126
|
-
this.delivering.delete(run.id);
|
|
127
|
-
}
|
|
67
|
+
await this.outbox.deliver(sessionId, batch);
|
|
128
68
|
}
|
|
129
69
|
text(runs) {
|
|
130
70
|
const sections = runs.map((run) => [
|
|
@@ -45,6 +45,8 @@ function parseTrigger(raw) {
|
|
|
45
45
|
}
|
|
46
46
|
throw new Error("unknown trigger type");
|
|
47
47
|
}
|
|
48
|
+
/** Always computed from `from` (boot recomputes from *now*): cron runs missed
|
|
49
|
+
* while Pier was down are skipped, never caught up — no double fire. */
|
|
48
50
|
export function nextRunAt(trigger, from) {
|
|
49
51
|
if (trigger.type === "manual")
|
|
50
52
|
return null;
|
|
@@ -266,12 +268,6 @@ export class TaskDefinitions {
|
|
|
266
268
|
await this.assertDirectory(cwd);
|
|
267
269
|
session = { mode: "fork", ...(cwd ? { cwd } : {}) };
|
|
268
270
|
}
|
|
269
|
-
else if (typeof raw.sessionId === "string" && raw.sessionId.trim()) {
|
|
270
|
-
const sessionId = raw.sessionId.trim();
|
|
271
|
-
if (!(await this.sessionExists(sessionId)))
|
|
272
|
-
throw new Error(`unknown session: ${sessionId}`);
|
|
273
|
-
session = { mode: "reuse", sessionId };
|
|
274
|
-
}
|
|
275
271
|
else {
|
|
276
272
|
// Validation never mutates: a dedicated session is created explicitly
|
|
277
273
|
// (POST /api/sessions) and then referenced with mode:"reuse".
|