@timqi/pier 0.0.1 → 0.0.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 +76 -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.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 +186 -14
- package/dist/core/types.js +53 -0
- package/dist/db.js +54 -8
- package/dist/drain.js +145 -0
- package/dist/main.js +86 -18
- package/dist/secrets.js +10 -6
- package/dist/service.js +142 -18
- package/dist/settings.js +69 -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 +79 -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 +94 -0
- package/dist/web/auth.js +75 -22
- package/dist/web/explorer.js +146 -0
- package/dist/web/files.js +26 -11
- package/dist/web/instance.js +99 -0
- package/dist/web/provider-flows.js +249 -0
- package/dist/web/providers.js +129 -0
- package/dist/web/public/assets/index-BK64pHmP.js +90 -0
- package/dist/web/public/assets/index-De4GlOq4.css +2 -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 +43 -28
- package/dist/web/server.js +47 -120
- package/docs/deploy.md +120 -64
- package/package.json +1 -1
- package/skills/pier-help/SKILL.md +110 -0
- package/skills/pier-slack/SKILL.md +3 -2
- 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/main.js
CHANGED
|
@@ -18,6 +18,7 @@ import { handleSlackTool, slackToolSpec } from "./channels/slack-tool.js";
|
|
|
18
18
|
import { parseConversation as parseSlackConversation } from "./channels/slack.js";
|
|
19
19
|
import { EventHub } from "./core/hub.js";
|
|
20
20
|
import { pierDb } from "./db.js";
|
|
21
|
+
import { deliverLedger, drainForRestart, RestartLedger } from "./drain.js";
|
|
21
22
|
import { surfacePrompt } from "./core/reply.js";
|
|
22
23
|
import { Router } from "./core/router.js";
|
|
23
24
|
import { logger } from "./log.js";
|
|
@@ -28,6 +29,7 @@ import { taskToolSpec } from "./tasks/tool.js";
|
|
|
28
29
|
import { PIER_HOME, pierPath } from "./paths.js";
|
|
29
30
|
import { Secrets } from "./secrets.js";
|
|
30
31
|
import { SettingsStore } from "./settings.js";
|
|
32
|
+
import { UpdateCheck } from "./update.js";
|
|
31
33
|
import { AuthStore, registerAuthRoutes, requireAuth } from "./web/auth.js";
|
|
32
34
|
import { SessionStateStore } from "./web/session-state.js";
|
|
33
35
|
import { createServer } from "./web/server.js";
|
|
@@ -64,6 +66,7 @@ let channelStore;
|
|
|
64
66
|
// Shared by the adapter and the tool: a display name is looked up once per
|
|
65
67
|
// process, not once per message and again per transcript.
|
|
66
68
|
const slackDirectory = new SlackDirectory((m) => logger("slack").warn(m));
|
|
69
|
+
const piConfig = new PiConfigStore();
|
|
67
70
|
const factory = new PiAgentFactory([
|
|
68
71
|
taskToolSpec((params, callerSessionId) => tasks.tool(params, callerSessionId)),
|
|
69
72
|
slackToolSpec((params, callerSessionId) => handleSlackTool({
|
|
@@ -95,7 +98,9 @@ const factory = new PiAgentFactory([
|
|
|
95
98
|
[fileURLToPath(new URL("../skills", import.meta.url))],
|
|
96
99
|
// Provider credentials live sealed in pier.db; a leftover auth.json is
|
|
97
100
|
// imported on first use and renamed to auth.json.imported.
|
|
98
|
-
new CredentialStore(db, secrets)
|
|
101
|
+
new CredentialStore(db, secrets), piConfig,
|
|
102
|
+
// Operator pins ride ahead of the curated catalog in every model picker.
|
|
103
|
+
() => settings.get().modelMenu);
|
|
99
104
|
const hub = new EventHub();
|
|
100
105
|
const router = new Router(hub, (key) => {
|
|
101
106
|
// Web conversation ids ARE session ids; an IM conversation id is a chat or a
|
|
@@ -106,7 +111,12 @@ const router = new Router(hub, (key) => {
|
|
|
106
111
|
}
|
|
107
112
|
return resolveIm(key);
|
|
108
113
|
});
|
|
109
|
-
|
|
114
|
+
// An attached session holds a live Pi runtime and its transcript, and nothing
|
|
115
|
+
// else ever lets one go: without this, one per conversation ever answered.
|
|
116
|
+
const stopEviction = router.startIdleEviction();
|
|
117
|
+
tasks = new TaskService(new TaskStore(db), factory, router, hub, {
|
|
118
|
+
modelMenu: () => settings.get().modelMenu,
|
|
119
|
+
});
|
|
110
120
|
tasks.start();
|
|
111
121
|
channelStore = new ChannelStore(db, secrets);
|
|
112
122
|
const control = createControl({ router, factory, conversations, store: channelStore });
|
|
@@ -115,7 +125,16 @@ resolveIm = resolveConversation(conversations, factory, control.launchFor, (mess
|
|
|
115
125
|
// Channels connect only once tokens are readable. A refused unlock (vt denial,
|
|
116
126
|
// corrupt master.key) must not take the web surface down — it is where the
|
|
117
127
|
// operator goes to repair — but it is named loudly, not served as silence.
|
|
118
|
-
|
|
128
|
+
// Once they are up, the chats a previous restart cut off are told (drain.ts) —
|
|
129
|
+
// on this path and on a later Console unlock alike, because a note held back
|
|
130
|
+
// by locked secrets must not wait for yet another restart.
|
|
131
|
+
const restartLedger = new RestartLedger(db);
|
|
132
|
+
const startChannels = async () => {
|
|
133
|
+
await channels.reload();
|
|
134
|
+
await deliverLedger(restartLedger, (entry) => channels.notify(entry.channelId, entry.conversationId, entry.note))
|
|
135
|
+
.catch((err) => log.error("restart-note delivery failed", err));
|
|
136
|
+
};
|
|
137
|
+
void secrets.unlock().then(startChannels, (err) => log.error("secrets locked — channels not started; unlock from Console → Settings → Security, or repair master.key", err));
|
|
119
138
|
// Composition happens here so web/ and tasks/ never import each other.
|
|
120
139
|
const app = new Hono();
|
|
121
140
|
// A route that threw would otherwise answer 500 and leave no trace anywhere:
|
|
@@ -129,8 +148,8 @@ app.onError((err, c) => {
|
|
|
129
148
|
// so a surface added later is covered without knowing this exists. Built
|
|
130
149
|
// before the listener: a first run generates and prints its password here.
|
|
131
150
|
const auth = new AuthStore(db);
|
|
132
|
-
registerAuthRoutes(app, auth);
|
|
133
151
|
app.use("*", requireAuth(auth));
|
|
152
|
+
registerAuthRoutes(app, auth);
|
|
134
153
|
registerTaskRoutes(app, tasks, { factory, router });
|
|
135
154
|
registerChannelRoutes(app, channelStore, channels);
|
|
136
155
|
registerBoardRoutes(app);
|
|
@@ -139,11 +158,13 @@ app.route("/", createServer({
|
|
|
139
158
|
router,
|
|
140
159
|
hub,
|
|
141
160
|
sessions: new SessionStateStore(db),
|
|
142
|
-
config:
|
|
161
|
+
config: piConfig,
|
|
162
|
+
providers: factory,
|
|
143
163
|
settings,
|
|
144
164
|
secrets,
|
|
165
|
+
updates: new UpdateCheck(),
|
|
145
166
|
// Unlocked from the Console: start the channels boot held back.
|
|
146
|
-
onUnlocked: () => void
|
|
167
|
+
onUnlocked: () => void startChannels(),
|
|
147
168
|
backgroundRuns: (id) => tasks.backgroundRuns(id),
|
|
148
169
|
}));
|
|
149
170
|
const port = Number(process.env.PORT ?? 3141);
|
|
@@ -164,20 +185,67 @@ process.on("uncaughtException", (err) => {
|
|
|
164
185
|
process.on("unhandledRejection", (reason) => {
|
|
165
186
|
log.error("unhandled rejection", reason);
|
|
166
187
|
});
|
|
188
|
+
let shuttingDown = false;
|
|
189
|
+
const shutdown = (stopTasks = true) => {
|
|
190
|
+
// Once: SIGTERM can land while a drain is finishing, and two teardowns
|
|
191
|
+
// racing each other close the same sockets twice.
|
|
192
|
+
if (shuttingDown)
|
|
193
|
+
return;
|
|
194
|
+
shuttingDown = true;
|
|
195
|
+
// Best-effort, and bounded: a socket an adapter cannot close must not turn
|
|
196
|
+
// `systemctl restart` into a 90-second wait for SIGKILL.
|
|
197
|
+
setTimeout(() => process.exit(0), 3000).unref();
|
|
198
|
+
stopEviction();
|
|
199
|
+
// The drain path leaves task runs alone: aborting them here would record
|
|
200
|
+
// them cancelled and race their callbacks against dying channels, when the
|
|
201
|
+
// boot-time interrupted marking is the recovery that was promised.
|
|
202
|
+
if (stopTasks)
|
|
203
|
+
tasks.stop();
|
|
204
|
+
void channels.stop().finally(() => {
|
|
205
|
+
server.close(() => process.exit(0));
|
|
206
|
+
// Every workbench tab holds an SSE stream open, so `close()` alone would
|
|
207
|
+
// always wait out the timer above. (`in` because the served type is a
|
|
208
|
+
// union with HTTP/2, which has no such method — and no such problem.)
|
|
209
|
+
if ("closeAllConnections" in server)
|
|
210
|
+
server.closeAllConnections();
|
|
211
|
+
});
|
|
212
|
+
};
|
|
167
213
|
for (const signal of ["SIGTERM", "SIGINT"]) {
|
|
168
214
|
process.once(signal, () => {
|
|
169
215
|
log.info(`${signal} received, shutting down`);
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
216
|
+
shutdown();
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
// The slow restart (`pier restart`): refuse new work, let running turns finish
|
|
220
|
+
// — bounded by the drain deadline — then exit for `Restart=always` to bring the
|
|
221
|
+
// next process up. SIGTERM above stays the fast path systemd expects. `on`,
|
|
222
|
+
// not `once`: a second SIGUSR2 with no handler would fall back to Node's
|
|
223
|
+
// default and kill the drain it meant to hurry.
|
|
224
|
+
let draining = false;
|
|
225
|
+
process.on("SIGUSR2", () => {
|
|
226
|
+
if (draining) {
|
|
227
|
+
log.info("SIGUSR2 received again — already draining");
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
draining = true;
|
|
231
|
+
log.info("SIGUSR2 received, draining for restart");
|
|
232
|
+
void drainForRestart({ router, tasks, ledger: restartLedger })
|
|
233
|
+
.catch((err) => log.error("drain failed — shutting down anyway", err))
|
|
234
|
+
.then(() => shutdown(false));
|
|
235
|
+
});
|
|
236
|
+
// Reload without a restart (`pier reload`): adapters re-read their config, and
|
|
237
|
+
// idle sessions are let go so the next message re-opens them with the current
|
|
238
|
+
// skills, extensions and prompts — all applied at attach, none stored in a
|
|
239
|
+
// transcript. Streaming or watched sessions pick the change up at their next
|
|
240
|
+
// natural eviction. Only under systemd (the CLI signals through systemctl):
|
|
241
|
+
// a foreground `pier` keeps SIGHUP's default, dying with its terminal instead
|
|
242
|
+
// of surviving as an orphan that holds the port.
|
|
243
|
+
if (process.env.INVOCATION_ID) {
|
|
244
|
+
process.on("SIGHUP", () => {
|
|
245
|
+
log.info("SIGHUP received, reloading channels and recycling idle sessions");
|
|
246
|
+
void channels.reload();
|
|
247
|
+
void router.evictIdle(0)
|
|
248
|
+
.then((n) => log.info(`recycled ${String(n)} idle session(s)`))
|
|
249
|
+
.catch((err) => log.error("session recycle failed", err));
|
|
182
250
|
});
|
|
183
251
|
}
|
package/dist/secrets.js
CHANGED
|
@@ -26,7 +26,6 @@ export class Secrets {
|
|
|
26
26
|
path;
|
|
27
27
|
vt;
|
|
28
28
|
#dek;
|
|
29
|
-
#kek;
|
|
30
29
|
#file;
|
|
31
30
|
/** Why decrypt is refused right now — "" once unlocked. */
|
|
32
31
|
#lockedReason = "unlock() has not run";
|
|
@@ -58,7 +57,12 @@ export class Secrets {
|
|
|
58
57
|
try {
|
|
59
58
|
raw = readFileSync(this.path, "utf8");
|
|
60
59
|
}
|
|
61
|
-
catch {
|
|
60
|
+
catch (err) {
|
|
61
|
+
// Only a missing file means first boot. Any other read error (EACCES,
|
|
62
|
+
// EISDIR…) must not fall through to #create(), which would rename a
|
|
63
|
+
// fresh key over the existing one and destroy every sealed credential.
|
|
64
|
+
if (err.code !== "ENOENT")
|
|
65
|
+
throw err;
|
|
62
66
|
this.#file = this.#create();
|
|
63
67
|
log.info(`created ${this.path} (file mode)`);
|
|
64
68
|
raw = readFileSync(this.path, "utf8");
|
|
@@ -66,12 +70,12 @@ export class Secrets {
|
|
|
66
70
|
const file = JSON.parse(raw);
|
|
67
71
|
if (!file.kek || !file.dek || !file.dekId)
|
|
68
72
|
throw new Error(`${this.path} is malformed`);
|
|
69
|
-
|
|
73
|
+
const kek = file.kek.startsWith("vt://")
|
|
70
74
|
? Buffer.from(await this.vt.read(file.kek), "base64")
|
|
71
75
|
: Buffer.from(file.kek, "base64");
|
|
72
|
-
if (
|
|
76
|
+
if (kek.length !== KEY_BYTES)
|
|
73
77
|
throw new Error(`${this.path} KEK is not ${KEY_BYTES} bytes`);
|
|
74
|
-
this.#dek = open(
|
|
78
|
+
this.#dek = open(kek, file.dek, `kek:${file.dekId}`);
|
|
75
79
|
this.#file = file;
|
|
76
80
|
this.#lockedReason = "";
|
|
77
81
|
log.info(`secrets unlocked (${this.mode} mode, dek ${file.dekId})`);
|
|
@@ -112,7 +116,6 @@ export class Secrets {
|
|
|
112
116
|
throw new Error("vt create did not return a vt:// record");
|
|
113
117
|
}
|
|
114
118
|
this.#write(next);
|
|
115
|
-
this.#kek = kek;
|
|
116
119
|
this.#file = next;
|
|
117
120
|
log.info(`KEK rotated (${mode} mode, dek ${file.dekId} unchanged)`);
|
|
118
121
|
}
|
|
@@ -184,6 +187,7 @@ function run(cmd, args, stdin) {
|
|
|
184
187
|
else
|
|
185
188
|
reject(new Error(`${cmd} ${args[0]} exited ${code}: ${err.trim() || out.trim()}`));
|
|
186
189
|
});
|
|
190
|
+
child.stdin.on("error", reject); // EPIPE if vt exits before reading
|
|
187
191
|
if (stdin !== undefined)
|
|
188
192
|
child.stdin.write(stdin);
|
|
189
193
|
child.stdin.end();
|
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,92 @@ 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
|
+
function runningPierHome(home) {
|
|
189
|
+
const pid = Number(execFileSync("systemctl", ["--user", "show", UNIT_NAME, "--property=MainPID", "--value"], { encoding: "utf8" }).trim());
|
|
190
|
+
if (Number.isInteger(pid) && pid >= 1) {
|
|
191
|
+
const value = readFileSync(`/proc/${pid}/environ`)
|
|
192
|
+
.toString()
|
|
193
|
+
.split("\0")
|
|
194
|
+
.find((item) => item.startsWith("PIER_HOME="))
|
|
195
|
+
?.slice("PIER_HOME=".length);
|
|
196
|
+
return value || join(home, ".pier");
|
|
197
|
+
}
|
|
198
|
+
// Installed but stopped: the unit file records any override (quoted and
|
|
199
|
+
// escaped since 0.0.2, bare in the 0.0.1 shape) — undo quote()'s escaping
|
|
200
|
+
// or the drop-in would carry `%%`/`\\"` into a real path.
|
|
201
|
+
const raw = readFileSync(unitPath(home), "utf8")
|
|
202
|
+
.match(/^Environment="?PIER_HOME=((?:\\.|[^"\r\n])+)"?$/m)?.[1];
|
|
203
|
+
const fromUnit = raw?.replaceAll("%%", "%").replace(/\\(.)/g, "$1");
|
|
204
|
+
return fromUnit || join(home, ".pier");
|
|
205
|
+
}
|
|
206
|
+
/** Start the updater recorded at install time. Its tiny drop-in captures the
|
|
207
|
+
* running service's effective home, including an operator override. */
|
|
208
|
+
export function startUpdate(options) {
|
|
209
|
+
const { home = homedir(), say } = options;
|
|
210
|
+
const run = options.exec ?? runner(say);
|
|
211
|
+
if (!existsSync(unitPath(home)))
|
|
212
|
+
return "not-installed";
|
|
213
|
+
try {
|
|
214
|
+
if (!existsSync(updateUnitPath(home))) {
|
|
215
|
+
writeFileSync(updateUnitPath(home), renderUpdateUnit(legacyOptions(home)), { mode: 0o644 });
|
|
216
|
+
say(`generated a legacy updater; run "pier service install --force" after this update to record the exact npm path.`);
|
|
217
|
+
}
|
|
218
|
+
const runtime = updateRuntimePath(home);
|
|
219
|
+
mkdirSync(dirname(runtime), { recursive: true });
|
|
220
|
+
writeFileSync(runtime, `[Service]\n${environment("PIER_HOME", (options.effectiveHome ?? (() => runningPierHome(home)))())}\n`, { mode: 0o644 });
|
|
221
|
+
}
|
|
222
|
+
catch (err) {
|
|
223
|
+
say(`! cannot prepare update — ${String(err)}`);
|
|
224
|
+
return "failed";
|
|
225
|
+
}
|
|
226
|
+
if (!run(["systemctl", "--user", "daemon-reload"]))
|
|
227
|
+
return "failed";
|
|
228
|
+
if (!run(["systemctl", "--user", "start", "--no-block", UPDATE_UNIT_NAME]))
|
|
229
|
+
return "failed";
|
|
230
|
+
say(`updating in the background — follow it with: journalctl --user -u ${UPDATE_UNIT_NAME} -f`);
|
|
231
|
+
say(`Pier stops, snapshots pier.db.release.bak, installs, then starts again.`);
|
|
232
|
+
return "started";
|
|
119
233
|
}
|
|
120
234
|
export function uninstall(home = homedir(), say = console.log, exec) {
|
|
121
235
|
const run = exec ?? runner(say);
|
|
122
|
-
run(["systemctl", "--user", "disable", "--now", UNIT_NAME])
|
|
123
|
-
|
|
236
|
+
if (!run(["systemctl", "--user", "disable", "--now", UNIT_NAME]))
|
|
237
|
+
return false;
|
|
238
|
+
for (const path of [unitPath(home), limitsPath(home), updateUnitPath(home), updateRuntimePath(home)]) {
|
|
124
239
|
if (!existsSync(path))
|
|
125
240
|
continue;
|
|
126
241
|
rmSync(path, { force: true });
|
|
127
242
|
say(`removed ${path}`);
|
|
128
243
|
}
|
|
129
|
-
|
|
130
|
-
|
|
244
|
+
for (const dir of [dirname(limitsPath(home)), dirname(updateRuntimePath(home))]) {
|
|
245
|
+
try {
|
|
246
|
+
rmdirSync(dir); // non-recursive: drop-ins the operator added are not ours to delete
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
if (existsSync(dir))
|
|
250
|
+
say(`kept ${dir} — it has files Pier did not write`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const ok = run(["systemctl", "--user", "daemon-reload"]);
|
|
131
254
|
// Left alone on purpose: the database, the boards, and linger — none of them
|
|
132
255
|
// are this command's to decide about.
|
|
133
256
|
say(`$PIER_HOME is untouched; linger is still enabled.`);
|
|
257
|
+
return ok;
|
|
134
258
|
}
|
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,82 @@ 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 { publicUrl: this.#value("publicUrl") ?? "" };
|
|
75
|
+
return { publicUrl: this.#value("publicUrl") ?? "", modelMenu: this.#menu() };
|
|
76
|
+
}
|
|
77
|
+
#menu() {
|
|
78
|
+
const raw = this.#value("modelMenu");
|
|
79
|
+
if (!raw)
|
|
80
|
+
return [];
|
|
81
|
+
let parsed;
|
|
82
|
+
try {
|
|
83
|
+
parsed = JSON.parse(raw);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// Only a hand-edited row can get here; named, not silently served as [].
|
|
87
|
+
log.warn("settings.modelMenu is not JSON — ignoring it");
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
const menu = normalizeModelMenu(parsed);
|
|
91
|
+
if (!menu) {
|
|
92
|
+
log.warn("settings.modelMenu is not a valid menu — ignoring it");
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
return menu;
|
|
43
96
|
}
|
|
44
97
|
/** Store an already-normalized value — validation belongs at the boundary
|
|
45
98
|
* that received it, so this never has to guess what the caller meant. */
|
|
46
99
|
setPublicUrl(publicUrl) {
|
|
100
|
+
this.#set("publicUrl", publicUrl);
|
|
101
|
+
return this.get();
|
|
102
|
+
}
|
|
103
|
+
/** Same contract: hand this `normalizeModelMenu`'s output, not raw input. */
|
|
104
|
+
setModelMenu(menu) {
|
|
105
|
+
this.#set("modelMenu", JSON.stringify(menu));
|
|
106
|
+
return this.get();
|
|
107
|
+
}
|
|
108
|
+
#set(key, value) {
|
|
47
109
|
this.#db.prepare(`
|
|
48
|
-
INSERT INTO settings(key, value) VALUES (
|
|
110
|
+
INSERT INTO settings(key, value) VALUES (?, ?)
|
|
49
111
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
|
50
|
-
`).run(
|
|
51
|
-
return this.get();
|
|
112
|
+
`).run(key, value);
|
|
52
113
|
}
|
|
53
114
|
#value(key) {
|
|
54
115
|
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);
|