@timqi/pier 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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-B89_hH7q.js} +1 -1
- package/dist/web/public/assets/activity-B89_hH7q.js.br +0 -0
- package/dist/web/public/assets/activity-B89_hH7q.js.gz +0 -0
- package/dist/web/public/assets/{boards-DYuf4Mlj.js → boards-BeKW0ZXK.js} +1 -1
- package/dist/web/public/assets/boards-BeKW0ZXK.js.br +0 -0
- package/dist/web/public/assets/boards-BeKW0ZXK.js.gz +0 -0
- package/dist/web/public/assets/explorer-DIuMlaV3.js +4 -0
- package/dist/web/public/assets/explorer-DIuMlaV3.js.br +0 -0
- package/dist/web/public/assets/explorer-DIuMlaV3.js.gz +0 -0
- package/dist/web/public/assets/index-DzXDXra_.js +85 -0
- package/dist/web/public/assets/index-DzXDXra_.js.br +0 -0
- package/dist/web/public/assets/index-DzXDXra_.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-Cwy0mN8i.js} +1 -1
- package/dist/web/public/assets/runs-Cwy0mN8i.js.br +0 -0
- package/dist/web/public/assets/runs-Cwy0mN8i.js.gz +0 -0
- package/dist/web/public/assets/{settings-BrdVh-Zi.js → settings-DzZLmujq.js} +1 -1
- package/dist/web/public/assets/settings-DzZLmujq.js.br +0 -0
- package/dist/web/public/assets/settings-DzZLmujq.js.gz +0 -0
- package/dist/web/public/assets/{task-runs-CeQS1rxa.js → task-runs-BCakxFk8.js} +1 -1
- package/dist/web/public/assets/task-runs-BCakxFk8.js.br +0 -0
- package/dist/web/public/assets/task-runs-BCakxFk8.js.gz +0 -0
- package/dist/web/public/assets/tasks-BlzEbk11.js +4 -0
- package/dist/web/public/assets/tasks-BlzEbk11.js.br +0 -0
- package/dist/web/public/assets/tasks-BlzEbk11.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/secrets.js
CHANGED
|
@@ -1,19 +1,9 @@
|
|
|
1
|
-
// Layer-1 secret encryption
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// raw key in the file (the no-vt fallback — same at-rest level as the
|
|
8
|
-
// plaintext files it replaces, and the mode is the operator's explicit
|
|
9
|
-
// choice, never a silent downgrade).
|
|
10
|
-
//
|
|
11
|
-
// Both keys live in `master.key` (JSON: kek, wrapped dek, dek id), not the
|
|
12
|
-
// database: rotation is then a single atomic rename, with no crash window
|
|
13
|
-
// where the file holds the new KEK and the database a DEK wrapped by the old
|
|
14
|
-
// one. Layer 2 — secrets needing per-use approval — never passes through
|
|
15
|
-
// here: those stay `vt://` strings Pier cannot read, and the agent runs vt
|
|
16
|
-
// itself.
|
|
1
|
+
// Layer-1 secret encryption for the credentials Pier must read by itself.
|
|
2
|
+
// Standard envelope: a KEK from `master.key` (a `vt://` record, or a raw key in
|
|
3
|
+
// file mode — the operator's explicit choice) wraps a DEK, and only the DEK
|
|
4
|
+
// touches data, so rotating the KEK rewrites one file and zero rows. Both keys
|
|
5
|
+
// live in that one file so rotation is a single atomic rename. Layer 2
|
|
6
|
+
// (per-use approval) never passes here: the agent runs vt itself.
|
|
17
7
|
import { spawn } from "node:child_process";
|
|
18
8
|
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
19
9
|
import { chmodSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
@@ -22,6 +12,9 @@ import { pierPath } from "./paths.js";
|
|
|
22
12
|
const log = logger("secrets");
|
|
23
13
|
const KEY_BYTES = 32; // AES-256
|
|
24
14
|
const IV_BYTES = 12; // GCM standard nonce
|
|
15
|
+
/** Anything not matching predates sealing: honored as plaintext, and re-sealed
|
|
16
|
+
* by whichever store owns the row. */
|
|
17
|
+
export const isSealed = (blob) => /^v1:[0-9a-f]{8}:/.test(blob);
|
|
25
18
|
export class Secrets {
|
|
26
19
|
path;
|
|
27
20
|
vt;
|
|
@@ -39,18 +32,12 @@ export class Secrets {
|
|
|
39
32
|
get mode() {
|
|
40
33
|
return this.#file ? (this.#file.kek.startsWith("vt://") ? "vt" : "file") : undefined;
|
|
41
34
|
}
|
|
42
|
-
/** Why decrypt is refused right now — "" once unlocked. Shown in the
|
|
43
|
-
* Console's Security tab, which is where a refused unlock gets repaired. */
|
|
44
35
|
get lockedReason() {
|
|
45
36
|
return this.#lockedReason;
|
|
46
37
|
}
|
|
47
|
-
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* failed vt approval or a corrupt file, and remembers why: the process must
|
|
51
|
-
* keep serving (web is how the operator unlocks or repairs), but every
|
|
52
|
-
* refused decrypt names the reason instead of pretending to be empty.
|
|
53
|
-
*/
|
|
38
|
+
/** Created on first boot in file mode, so an unattended start needs no
|
|
39
|
+
* ceremony. Throws and remembers why: the process must keep serving (web is
|
|
40
|
+
* how the operator repairs), and every refused decrypt names the reason. */
|
|
54
41
|
async unlock() {
|
|
55
42
|
try {
|
|
56
43
|
let raw;
|
|
@@ -58,9 +45,8 @@ export class Secrets {
|
|
|
58
45
|
raw = readFileSync(this.path, "utf8");
|
|
59
46
|
}
|
|
60
47
|
catch (err) {
|
|
61
|
-
// Only
|
|
62
|
-
//
|
|
63
|
-
// fresh key over the existing one and destroy every sealed credential.
|
|
48
|
+
// Only ENOENT is first boot: any other error falling through to
|
|
49
|
+
// #create() would rename a fresh key over the existing one.
|
|
64
50
|
if (err.code !== "ENOENT")
|
|
65
51
|
throw err;
|
|
66
52
|
this.#file = this.#create();
|
|
@@ -98,12 +84,8 @@ export class Secrets {
|
|
|
98
84
|
throw new Error(`sealed by unknown key ${dekId}, have ${file.dekId}`);
|
|
99
85
|
return open(dek, rest.join(":"), `v1:${dekId}`).toString("utf8");
|
|
100
86
|
}
|
|
101
|
-
/**
|
|
102
|
-
*
|
|
103
|
-
* the new KEK is protected (entering vt mode runs `vt create`, one
|
|
104
|
-
* approval); omitted, the current mode is kept. The rewrapped file lands by
|
|
105
|
-
* atomic rename — a crash leaves either the old working file or the new one.
|
|
106
|
-
*/
|
|
87
|
+
/** New KEK, same DEK: every stored envelope stays valid. Omitted `mode`
|
|
88
|
+
* keeps the current one. */
|
|
107
89
|
async rotateKek(mode = this.mode ?? "file") {
|
|
108
90
|
const { file } = this.#unlocked();
|
|
109
91
|
const kek = randomBytes(KEY_BYTES);
|
|
@@ -119,16 +101,10 @@ export class Secrets {
|
|
|
119
101
|
this.#file = next;
|
|
120
102
|
log.info(`KEK rotated (${mode} mode, dek ${file.dekId} unchanged)`);
|
|
121
103
|
}
|
|
122
|
-
/**
|
|
123
|
-
* What vt says about itself. A refused or impossible unlock is almost never
|
|
124
|
-
* Pier's fault — missing binary, no agent listening, config pointing
|
|
125
|
-
* elsewhere — and `lockedReason` only carries the last error. Read-only, so
|
|
126
|
-
* it is safe to run while locked; vt's own report is the repair instruction.
|
|
127
|
-
*/
|
|
104
|
+
/** Read-only, safe while locked; vt's own report is the repair instruction. */
|
|
128
105
|
doctor() {
|
|
129
106
|
return this.vt.doctor();
|
|
130
107
|
}
|
|
131
|
-
/** First boot: random KEK and DEK, file mode. */
|
|
132
108
|
#create() {
|
|
133
109
|
const kek = randomBytes(KEY_BYTES);
|
|
134
110
|
const dekId = randomBytes(4).toString("hex");
|
|
@@ -170,8 +146,6 @@ function open(key, sealed, aad) {
|
|
|
170
146
|
decipher.setAuthTag(tag);
|
|
171
147
|
return Buffer.concat([decipher.update(ct), decipher.final()]);
|
|
172
148
|
}
|
|
173
|
-
/** The real vt CLI. Absent binary or denied approval both surface as the
|
|
174
|
-
* spawn/exit error — unlock() records it and the operator reads it. */
|
|
175
149
|
const vtCli = {
|
|
176
150
|
read: (record) => run("vt", ["read", record]),
|
|
177
151
|
create: async (plaintext) => {
|
|
@@ -181,8 +155,7 @@ const vtCli = {
|
|
|
181
155
|
throw new Error("vt create printed no vt:// record");
|
|
182
156
|
return record;
|
|
183
157
|
},
|
|
184
|
-
//
|
|
185
|
-
// a hung probe would leave the Console waiting on a diagnosis forever.
|
|
158
|
+
// doctor probes over the network; a hung probe would hang the Console.
|
|
186
159
|
doctor: () => run("vt", ["doctor"], undefined, 15_000),
|
|
187
160
|
};
|
|
188
161
|
function run(cmd, args, stdin, timeoutMs) {
|
package/dist/service.js
CHANGED
|
@@ -1,32 +1,22 @@
|
|
|
1
|
-
// The systemd user unit Pier writes for itself
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// dedicated service user it would be an agent that cannot touch the files you
|
|
6
|
-
// wanted it to work on.
|
|
7
|
-
//
|
|
8
|
-
// Writing this file is a command rather than a page of documentation to copy
|
|
9
|
-
// because two of its lines are only knowable at runtime: the absolute path of
|
|
10
|
-
// the node that is running (systemd starts with a minimal PATH, so a node
|
|
11
|
-
// installed by fnm/nvm/asdf is not on it) and the absolute path of the
|
|
12
|
-
// installed entry point.
|
|
1
|
+
// The systemd *user* unit Pier writes for itself: Pier runs as you, so it can
|
|
2
|
+
// touch the files you wanted it to work on. Written by a command because two
|
|
3
|
+
// lines are only knowable at runtime — the absolute node (systemd's minimal
|
|
4
|
+
// PATH has no fnm/nvm node) and the installed entry point.
|
|
13
5
|
import { execFileSync } from "node:child_process";
|
|
14
6
|
import { existsSync, mkdirSync, readFileSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
|
|
15
7
|
import { homedir, userInfo } from "node:os";
|
|
16
8
|
import { dirname, join } from "node:path";
|
|
17
9
|
export const UNIT_NAME = "pier.service";
|
|
18
10
|
const UPDATE_UNIT_NAME = "pier-update.service";
|
|
19
|
-
/** `~/.config/systemd/user/pier.service` — where a user unit belongs. */
|
|
20
11
|
export const unitPath = (home = homedir()) => join(home, ".config", "systemd", "user", UNIT_NAME);
|
|
21
|
-
/**
|
|
22
|
-
*
|
|
12
|
+
/** Written once and never touched again: what the unit may consume is the
|
|
13
|
+
* operator's call. */
|
|
23
14
|
export const limitsPath = (home = homedir()) => join(dirname(unitPath(home)), `${UNIT_NAME}.d`, "limits.conf");
|
|
24
|
-
/** The oneshot that installs a new version, beside the unit it restarts. */
|
|
25
15
|
export const updateUnitPath = (home = homedir()) => join(dirname(unitPath(home)), UPDATE_UNIT_NAME);
|
|
26
|
-
/**
|
|
16
|
+
/** Regenerated before each updater run. */
|
|
27
17
|
export const updateRuntimePath = (home = homedir()) => join(dirname(unitPath(home)), `${UPDATE_UNIT_NAME}.d`, "runtime.conf");
|
|
28
|
-
/**
|
|
29
|
-
*
|
|
18
|
+
/** Percent is doubled because specifier expansion runs after parsing; dollar
|
|
19
|
+
* only for command lines. */
|
|
30
20
|
function quote(value, command = false) {
|
|
31
21
|
if (/[\0\r\n]/.test(value))
|
|
32
22
|
throw new Error("systemd values cannot contain control characters");
|
|
@@ -36,14 +26,9 @@ function quote(value, command = false) {
|
|
|
36
26
|
return `"${escaped}"`;
|
|
37
27
|
}
|
|
38
28
|
const environment = (key, value) => `Environment=${quote(`${key}=${value}`)}`;
|
|
39
|
-
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* Recorded at install rather than sourced from a login shell at start, which
|
|
44
|
-
* would hand a dotfile the power to decide whether Pier boots and which node
|
|
45
|
-
* npm installs into. Relative entries are dropped: they would resolve against
|
|
46
|
-
* WorkingDirectory, which is not where the operator was standing. */
|
|
29
|
+
/** Recorded at install rather than sourced from a login shell at start, which
|
|
30
|
+
* would hand a dotfile the power to decide whether Pier boots. Relative
|
|
31
|
+
* entries would resolve against WorkingDirectory, so they are dropped. */
|
|
47
32
|
function pathEnv(execPath, shellPath) {
|
|
48
33
|
const seen = new Set();
|
|
49
34
|
return [dirname(execPath), ...(shellPath ?? "").split(":"), "/usr/local/bin", "/usr/bin", "/bin"]
|
|
@@ -86,8 +71,7 @@ SyslogIdentifier=pier
|
|
|
86
71
|
WantedBy=default.target
|
|
87
72
|
`;
|
|
88
73
|
}
|
|
89
|
-
/**
|
|
90
|
-
* beside its recorded Node; a forced reinstall writes the exact executable. */
|
|
74
|
+
/** A unit that recorded no npm: the bridge assumes npm beside its Node. */
|
|
91
75
|
function legacyOptions(home) {
|
|
92
76
|
const text = readFileSync(unitPath(home), "utf8");
|
|
93
77
|
const start = text.match(/^ExecStart=(\S+) (\S+)$/m);
|
|
@@ -105,14 +89,9 @@ function legacyOptions(home) {
|
|
|
105
89
|
...(pierHome ? { pierHome } : {}),
|
|
106
90
|
};
|
|
107
91
|
}
|
|
108
|
-
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
* Both slow steps run while the service is still up, so the downtime is one
|
|
112
|
-
* stop and one start rather than the ~10s an install takes. The snapshot is
|
|
113
|
-
* consistent on a live database — `VACUUM INTO` off a read-only connection
|
|
114
|
-
* (db.ts) — and it still runs from the tree npm is about to replace, so the
|
|
115
|
-
* copy carries the version whose schema it pairs with. */
|
|
92
|
+
/** Snapshot and install run while the service is still up, so downtime is one
|
|
93
|
+
* stop and one start. The snapshot runs from the tree npm is about to replace,
|
|
94
|
+
* so the copy carries the version whose schema it pairs with. */
|
|
116
95
|
export function renderUpdateUnit(options) {
|
|
117
96
|
const { execPath, npmPath, entry, pierHome, shellPath } = options;
|
|
118
97
|
const cli = join(dirname(entry), "cli.js");
|
|
@@ -140,12 +119,8 @@ ExecStart=systemctl --user stop ${UNIT_NAME}
|
|
|
140
119
|
ExecStopPost=systemctl --user start ${UNIT_NAME}
|
|
141
120
|
`;
|
|
142
121
|
}
|
|
143
|
-
/**
|
|
144
|
-
*
|
|
145
|
-
* limit covers node, every subagent and every command a turn ran, and it
|
|
146
|
-
* exists to protect the OS and sshd outside it. Written commented so the
|
|
147
|
-
* operator tuning it can see what each line buys.
|
|
148
|
-
*/
|
|
122
|
+
/** A share of the machine, not "how much Pier needs": the limit covers every
|
|
123
|
+
* command a turn ran, and exists to protect the OS and sshd outside it. */
|
|
149
124
|
function renderLimits() {
|
|
150
125
|
return `[Service]
|
|
151
126
|
# Soft ceiling: past this the kernel reclaims hard and lets the unit crawl
|
|
@@ -164,8 +139,6 @@ OOMScoreAdjust=200
|
|
|
164
139
|
OOMPolicy=continue
|
|
165
140
|
`;
|
|
166
141
|
}
|
|
167
|
-
/** A command failure is printed here and propagated by its caller. Linger is
|
|
168
|
-
* the only deliberately best-effort step. */
|
|
169
142
|
const runner = (say) => (argv) => {
|
|
170
143
|
try {
|
|
171
144
|
execFileSync(argv[0], argv.slice(1), { stdio: "pipe" });
|
|
@@ -202,8 +175,8 @@ export function install(options) {
|
|
|
202
175
|
}
|
|
203
176
|
if (!run(["systemctl", "--user", "daemon-reload"]))
|
|
204
177
|
return false;
|
|
205
|
-
// Without lingering
|
|
206
|
-
//
|
|
178
|
+
// Without lingering the user manager stops at logout, taking every scheduled
|
|
179
|
+
// task with it. It can need a polkit prompt, hence best-effort.
|
|
207
180
|
if (!run(["loginctl", "enable-linger", userInfo().username])) {
|
|
208
181
|
say(` run it yourself so Pier survives logout: loginctl enable-linger ${userInfo().username}`);
|
|
209
182
|
}
|
|
@@ -218,20 +191,13 @@ export function install(options) {
|
|
|
218
191
|
say(`started. The first run prints a password once: journalctl --user -u pier -e`);
|
|
219
192
|
return true;
|
|
220
193
|
}
|
|
221
|
-
/**
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
* The absolute node and npm paths in the unit are deliberate — systemd's PATH
|
|
227
|
-
* has neither — but they pin the unit to one directory of one version manager.
|
|
228
|
-
* `fnm install 26 && fnm uninstall 24` leaves ExecStart naming a Node that is
|
|
229
|
-
* gone; the running process survives (Linux keeps a deleted binary mapped),
|
|
230
|
-
* so nothing would notice until the update, or the next boot, failed.
|
|
231
|
-
*/
|
|
194
|
+
/** The absolute node and npm paths pin the unit to one version manager's
|
|
195
|
+
* directory: `fnm uninstall 24` leaves ExecStart naming a Node that is gone,
|
|
196
|
+
* and the running process survives it (Linux keeps a deleted binary mapped),
|
|
197
|
+
* so nothing would notice until the next boot failed. */
|
|
232
198
|
export function updaterProblem(home = homedir()) {
|
|
233
199
|
if (!existsSync(unitPath(home)))
|
|
234
|
-
return null; // not a service install
|
|
200
|
+
return null; // not a service install
|
|
235
201
|
const path = updateUnitPath(home);
|
|
236
202
|
let unit;
|
|
237
203
|
if (existsSync(path)) {
|
|
@@ -243,9 +209,7 @@ export function updaterProblem(home = homedir()) {
|
|
|
243
209
|
}
|
|
244
210
|
}
|
|
245
211
|
else {
|
|
246
|
-
//
|
|
247
|
-
// so a missing file is only a problem when that bridge cannot either —
|
|
248
|
-
// and the generated text gets the same executable check below.
|
|
212
|
+
// startUpdate generates a missing updater from the main unit; check that text.
|
|
249
213
|
try {
|
|
250
214
|
unit = renderUpdateUnit(legacyOptions(home));
|
|
251
215
|
}
|
|
@@ -253,10 +217,8 @@ export function updaterProblem(home = homedir()) {
|
|
|
253
217
|
return `${UPDATE_UNIT_NAME} is missing — run: pier service install --force`;
|
|
254
218
|
}
|
|
255
219
|
}
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
// judge; the escaping is undone before existsSync sees a path (a `%` or `$`
|
|
259
|
-
// in it would otherwise read as gone on a working updater).
|
|
220
|
+
// Unparseable means hand-edited, not this function's to judge. Unescaped
|
|
221
|
+
// before existsSync: a `%` or `$` in the path would otherwise read as gone.
|
|
260
222
|
const install = unit.match(/^ExecStart="((?:\\.|[^"\r\n])+)" "((?:\\.|[^"\r\n])+)" install -g/m);
|
|
261
223
|
if (!install)
|
|
262
224
|
return null;
|
|
@@ -278,16 +240,14 @@ function runningPierHome(home) {
|
|
|
278
240
|
?.slice("PIER_HOME=".length);
|
|
279
241
|
return value || join(home, ".pier");
|
|
280
242
|
}
|
|
281
|
-
// Installed but stopped: the unit
|
|
282
|
-
//
|
|
283
|
-
// or the drop-in would carry `%%`/`\\"` into a real path.
|
|
243
|
+
// Installed but stopped: the unit records any override, quoted or bare;
|
|
244
|
+
// undo quote()'s escaping or the drop-in would carry `%%` into a real path.
|
|
284
245
|
const raw = readFileSync(unitPath(home), "utf8")
|
|
285
246
|
.match(/^Environment="?PIER_HOME=((?:\\.|[^"\r\n])+)"?$/m)?.[1];
|
|
286
247
|
const fromUnit = raw?.replaceAll("%%", "%").replace(/\\(.)/g, "$1");
|
|
287
248
|
return fromUnit || join(home, ".pier");
|
|
288
249
|
}
|
|
289
|
-
/**
|
|
290
|
-
* running service's effective home, including an operator override. */
|
|
250
|
+
/** The drop-in captures the running service's effective home, override included. */
|
|
291
251
|
export function startUpdate(options) {
|
|
292
252
|
const { home = homedir(), say } = options;
|
|
293
253
|
const run = options.exec ?? runner(say);
|
|
@@ -334,8 +294,6 @@ export function uninstall(home = homedir(), say = console.log, exec) {
|
|
|
334
294
|
}
|
|
335
295
|
}
|
|
336
296
|
const ok = run(["systemctl", "--user", "daemon-reload"]);
|
|
337
|
-
// Left alone on purpose: the database, the boards, and linger — none of them
|
|
338
|
-
// are this command's to decide about.
|
|
339
297
|
say(`$PIER_HOME is untouched; linger is still enabled.`);
|
|
340
298
|
return ok;
|
|
341
299
|
}
|
package/dist/settings.js
CHANGED
|
@@ -1,33 +1,18 @@
|
|
|
1
1
|
// Instance settings: the facts about *this* Pier that are neither a credential
|
|
2
|
-
// nor per-session
|
|
3
|
-
// a Host header is whatever a proxy passed on) and the operator's model menu.
|
|
4
|
-
//
|
|
5
|
-
// A key-value table, so the next setting is not the next table and not a third
|
|
6
|
-
// kind of storage. It used to be a JSON file, justified by "the agent reads it
|
|
7
|
-
// too" — the agent is told the URL in its system prompt (core/reply.ts), and
|
|
8
|
-
// nothing outside this process ever opened that file.
|
|
2
|
+
// nor per-session. A key-value table, so the next setting is not the next table.
|
|
9
3
|
import { isThinkingLevel } from "./core/types.js";
|
|
10
4
|
import { pierDb, transact } from "./db.js";
|
|
11
5
|
import { logger } from "./log.js";
|
|
12
|
-
// The one place the custom-tool vocabulary lives (names, ubix sources, the
|
|
13
|
-
// names Pier already owns). Imported rather than copied: a second validator
|
|
14
|
-
// would be the third-copy bug one release later, and tools.ts is root-layer
|
|
15
|
-
// like this file, so nothing crosses a seam.
|
|
16
6
|
import { normalizeCustomTools } from "./tools.js";
|
|
17
7
|
const log = logger("settings");
|
|
18
|
-
/**
|
|
19
|
-
*
|
|
20
|
-
* mistyped host quietly turned into a URL produces board links that 404 for
|
|
21
|
-
* the person they were sent to, and the sender never finds out.
|
|
22
|
-
*/
|
|
8
|
+
/** `""` clears it, `null` rejects it: a mistyped host quietly turned into a URL
|
|
9
|
+
* produces board links that 404 for the person they were sent to. */
|
|
23
10
|
export function normalizePublicUrl(raw) {
|
|
24
11
|
const text = raw.trim();
|
|
25
12
|
if (!text)
|
|
26
13
|
return "";
|
|
27
14
|
let url;
|
|
28
15
|
try {
|
|
29
|
-
// Scheme-less input is the common way to type a host, and https is the
|
|
30
|
-
// only guess worth making for something on the internet.
|
|
31
16
|
url = new URL(text.includes("://") ? text : `https://${text}`);
|
|
32
17
|
}
|
|
33
18
|
catch {
|
|
@@ -39,12 +24,8 @@ export function normalizePublicUrl(raw) {
|
|
|
39
24
|
return null;
|
|
40
25
|
return `${url.origin}${url.pathname}`.replace(/\/+$/, "");
|
|
41
26
|
}
|
|
42
|
-
/**
|
|
43
|
-
*
|
|
44
|
-
* `normalizePublicUrl`): a silently "fixed" entry would advertise a model the
|
|
45
|
-
* operator never picked. Notes are capped — they are one line of intent, and
|
|
46
|
-
* every session that asks for the menu pays for their tokens.
|
|
47
|
-
*/
|
|
27
|
+
/** Rejecting rather than repairing: a "fixed" entry would advertise a model the
|
|
28
|
+
* operator never picked. Notes are capped; every session pays for their tokens. */
|
|
48
29
|
export function normalizeModelMenu(raw) {
|
|
49
30
|
if (!Array.isArray(raw) || raw.length > 32)
|
|
50
31
|
return null;
|
|
@@ -54,9 +35,8 @@ export function normalizeModelMenu(raw) {
|
|
|
54
35
|
if (!ref)
|
|
55
36
|
return null;
|
|
56
37
|
const { thinking, note } = item;
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
// required have none, and dropping the menu over it would lose the pins.
|
|
38
|
+
// Repaired, not rejected: rows stored before the level was required have
|
|
39
|
+
// none, and dropping the menu over it would lose the pins.
|
|
60
40
|
const level = thinking === undefined ? "medium" : thinking;
|
|
61
41
|
if (!isThinkingLevel(level))
|
|
62
42
|
return null;
|
|
@@ -71,9 +51,8 @@ export function normalizeModelMenu(raw) {
|
|
|
71
51
|
}
|
|
72
52
|
return menu;
|
|
73
53
|
}
|
|
74
|
-
/**
|
|
75
|
-
*
|
|
76
|
-
* agent's, and a model that went away is reported by the call that fails. */
|
|
54
|
+
/** Existence is not checked: the catalog is the agent's, and a model that went
|
|
55
|
+
* away is reported by the call that fails. */
|
|
77
56
|
export function normalizeModelRef(raw) {
|
|
78
57
|
if (typeof raw !== "object" || raw === null)
|
|
79
58
|
return null;
|
|
@@ -84,19 +63,12 @@ export function normalizeModelRef(raw) {
|
|
|
84
63
|
return null;
|
|
85
64
|
return { provider: provider.trim(), id: id.trim() };
|
|
86
65
|
}
|
|
87
|
-
/**
|
|
88
|
-
*
|
|
89
|
-
* what Pier bundles (that catalog is code, and importing it would drag the Pi
|
|
90
|
-
* SDK into the instance layer); agent/ matches the names it recognizes and
|
|
91
|
-
* ignores the rest, which is also what keeps a downgrade from losing a
|
|
92
|
-
* setting it cannot currently explain.
|
|
93
|
-
*/
|
|
66
|
+
/** Shape only: the catalog is code behind the Pi SDK, and an unknown name is
|
|
67
|
+
* ignored there, so a downgrade cannot lose a setting it cannot explain. */
|
|
94
68
|
export function normalizeExtensions(raw) {
|
|
95
69
|
return normalizeNames(raw);
|
|
96
70
|
}
|
|
97
|
-
/**
|
|
98
|
-
* Shape only again: tools.ts owns the catalog, and a name it does not know is
|
|
99
|
-
* ignored there rather than rejected here, so a downgrade cannot lose one. */
|
|
71
|
+
/** Shape only, for the same reason: tools.ts owns the catalog. */
|
|
100
72
|
export function normalizeTools(raw) {
|
|
101
73
|
return normalizeNames(raw);
|
|
102
74
|
}
|
|
@@ -128,17 +100,11 @@ export class SettingsStore {
|
|
|
128
100
|
autoUpdate: this.#value("autoUpdate") === "1",
|
|
129
101
|
extensions: this.#json("extensions", normalizeExtensions, "a list of names") ?? [],
|
|
130
102
|
tools: this.#json("tools", normalizeTools, "a list of names") ?? [],
|
|
131
|
-
// `"drop"`: a
|
|
132
|
-
|
|
133
|
-
// every other tool declared beside it (see normalizeCustomTools).
|
|
134
|
-
customTools: this.#json("customTools", (raw) => normalizeCustomTools(raw, [], "drop"), "a list of {name, spec}") ?? [],
|
|
103
|
+
// `"drop"`: a row the bundled catalog has since taken is redundant, not malformed.
|
|
104
|
+
customTools: this.#json("customTools", (raw) => normalizeCustomTools(raw, [], "drop"), "a list of {name, toml}") ?? [],
|
|
135
105
|
};
|
|
136
106
|
}
|
|
137
|
-
/**
|
|
138
|
-
* A JSON-valued row, validated on the way out. Only a hand-edited row can be
|
|
139
|
-
* malformed, and it is named rather than silently served as the empty value:
|
|
140
|
-
* a setting that stopped applying without saying so is the bug this logs.
|
|
141
|
-
*/
|
|
107
|
+
/** A malformed row is named, not silently served as the empty value (§5). */
|
|
142
108
|
#json(key, normalize, expected) {
|
|
143
109
|
const raw = this.#value(key);
|
|
144
110
|
if (!raw)
|
|
@@ -156,19 +122,16 @@ export class SettingsStore {
|
|
|
156
122
|
log.warn(`settings.${key} is not ${expected} — ignoring it`);
|
|
157
123
|
return value;
|
|
158
124
|
}
|
|
159
|
-
/**
|
|
160
|
-
* that received it, so this never has to guess what the caller meant. */
|
|
125
|
+
/** Setters take already-normalized values: validation belongs at the boundary. */
|
|
161
126
|
setPublicUrl(publicUrl) {
|
|
162
127
|
this.#set("publicUrl", publicUrl);
|
|
163
128
|
return this.get();
|
|
164
129
|
}
|
|
165
|
-
/** Same contract: hand this `normalizeModelMenu`'s output, not raw input. */
|
|
166
130
|
setModelMenu(menu) {
|
|
167
131
|
this.#set("modelMenu", JSON.stringify(menu));
|
|
168
132
|
return this.get();
|
|
169
133
|
}
|
|
170
|
-
/**
|
|
171
|
-
* auto-titling off. */
|
|
134
|
+
/** null switches auto-titling off. */
|
|
172
135
|
setTitleModel(ref) {
|
|
173
136
|
this.#set("titleModel", ref ? JSON.stringify(ref) : "");
|
|
174
137
|
return this.get();
|
|
@@ -177,27 +140,20 @@ export class SettingsStore {
|
|
|
177
140
|
this.#set("autoUpdate", on ? "1" : "0");
|
|
178
141
|
return this.get();
|
|
179
142
|
}
|
|
180
|
-
/** Same contract again: hand this `normalizeExtensions`'s output. */
|
|
181
143
|
setExtensions(names) {
|
|
182
144
|
this.#set("extensions", JSON.stringify(names));
|
|
183
145
|
return this.get();
|
|
184
146
|
}
|
|
185
|
-
/** Same contract again: hand this `normalizeTools`'s output. */
|
|
186
147
|
setTools(names) {
|
|
187
148
|
this.#set("tools", JSON.stringify(names));
|
|
188
149
|
return this.get();
|
|
189
150
|
}
|
|
190
|
-
/** Same contract again: hand this `normalizeCustomTools`'s output. */
|
|
191
151
|
setCustomTools(tools) {
|
|
192
152
|
this.#set("customTools", JSON.stringify(tools));
|
|
193
153
|
return this.get();
|
|
194
154
|
}
|
|
195
|
-
/**
|
|
196
|
-
*
|
|
197
|
-
* switch that turns it on must not be able to store one without the other:
|
|
198
|
-
* half of that pair is a switch nobody can explain — on and undeclared, or
|
|
199
|
-
* declared and invisible.
|
|
200
|
-
*/
|
|
155
|
+
/** A request that declares a tool and switches it on must not store one
|
|
156
|
+
* without the other. */
|
|
201
157
|
transact(work) {
|
|
202
158
|
return transact(this.#db, work);
|
|
203
159
|
}
|
package/dist/tasks/agent.js
CHANGED
|
@@ -1,17 +1,15 @@
|
|
|
1
|
-
// A run that *is* a Pi session: which session it opens
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// agents specifically — a bash run costs a process, an agent run costs a
|
|
5
|
-
// model's context and someone's rate limit.
|
|
1
|
+
// A run that *is* a Pi session: which session it opens, what the child is told
|
|
2
|
+
// before the prompt, and how many may run at once (an agent run costs a
|
|
3
|
+
// model's context and someone's rate limit, so the caps are here).
|
|
6
4
|
import { quietLabel, splitReply } from "../core/reply.js";
|
|
7
5
|
import { logger } from "../log.js";
|
|
8
6
|
import { runSource } from "./callbacks.js";
|
|
9
|
-
|
|
7
|
+
// Agent runs are I/O-bound: the cap is there for API pressure and runaway
|
|
8
|
+
// fan-out, not for this machine's CPU.
|
|
9
|
+
const MAX_ACTIVE_AGENTS = 6;
|
|
10
10
|
const log = logger("tasks");
|
|
11
|
-
/**
|
|
12
|
-
*
|
|
13
|
-
* says which of it does not apply here, and a supervised run how to reach the
|
|
14
|
-
* agent that is waiting on it. Skipped on resume: the session already saw it. */
|
|
11
|
+
/** Every session gets the chat-surface contract, task runs included, so the
|
|
12
|
+
* delegation prompt says which of it does not apply. Skipped on resume. */
|
|
15
13
|
const preamble = (run) => {
|
|
16
14
|
// A cron/watch task with a session callback is read by an agent too.
|
|
17
15
|
const audience = run.invokedBySessionId
|
|
@@ -55,35 +53,25 @@ export class AgentTaskRunner {
|
|
|
55
53
|
// A reused session may have become busy while we waited for a slot.
|
|
56
54
|
await this.waitUntilIdle(session, signal);
|
|
57
55
|
signal.throwIfAborted();
|
|
58
|
-
// Task requests come seconds apart
|
|
59
|
-
//
|
|
60
|
-
// resolveSession: this covers create, reuse and resume alike, on
|
|
61
|
-
// every attempt — and only after the session is idle, so a reused
|
|
62
|
-
// interactive session's in-flight turn keeps its 1h writes.
|
|
56
|
+
// Task requests come seconds apart, so the 1h cache-write premium never
|
|
57
|
+
// earns back; after idle, so a reused session's in-flight turn keeps its 1h.
|
|
63
58
|
session.setCacheRetention("short");
|
|
64
59
|
start();
|
|
65
|
-
// No input is no block
|
|
66
|
-
//
|
|
67
|
-
// Compact for the same reason tool results are (agent/pi.ts), and
|
|
68
|
-
// `<\/` is the same JSON — a value cannot close the fence early.
|
|
60
|
+
// No input is no block. `<\/` is the same JSON, so a value cannot close
|
|
61
|
+
// the fence early.
|
|
69
62
|
const input = run.input === undefined || run.input === null
|
|
70
63
|
? ""
|
|
71
64
|
: `\n\n<task_input>\n${JSON.stringify(run.input).replaceAll("</task_input>", "<\\/task_input>")}\n</task_input>`;
|
|
72
65
|
const prompt = run.context.resumePrompt ?? `${preamble(run)}${action.prompt}${input}`;
|
|
73
66
|
run.context.sessionId = session.id;
|
|
74
67
|
run.context.model = session.model;
|
|
75
|
-
// The level the session settled on
|
|
76
|
-
// an unspecified effort inherits the caller's, and the card in the
|
|
77
|
-
// subagent's own transcript should say which one that was.
|
|
68
|
+
// The level the session settled on: an unspecified effort inherits the caller's.
|
|
78
69
|
run.context.thinking = session.thinkingLevel;
|
|
79
70
|
run.context.renderedPrompt = prompt;
|
|
80
71
|
this.store.saveRun(run);
|
|
81
72
|
let text = "";
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
// reply" — the one wording for a turn that *chose* to say nothing, so
|
|
85
|
-
// the outage arrives at the caller looking like an answer (§5b). Pi has
|
|
86
|
-
// already retried by the time this lands; the run only reports.
|
|
73
|
+
// How it ended, too: a provider outage ends with an empty reply, which
|
|
74
|
+
// would otherwise report as a turn that chose to say nothing (§5).
|
|
87
75
|
let failure;
|
|
88
76
|
const unsubscribe = session.subscribe((event) => {
|
|
89
77
|
if (event.type === "turn-end") {
|
|
@@ -113,27 +101,21 @@ export class AgentTaskRunner {
|
|
|
113
101
|
await this.untilAborted(turn, signal);
|
|
114
102
|
if (signal.aborted)
|
|
115
103
|
throw new Error("cancelled");
|
|
116
|
-
// Before the fallback
|
|
117
|
-
//
|
|
118
|
-
// this run's result.
|
|
104
|
+
// Before the fallback: on a reused session it would read the previous
|
|
105
|
+
// turn's answer back as this run's result.
|
|
119
106
|
if (failure)
|
|
120
107
|
throw new Error(failure);
|
|
121
108
|
if (!text) {
|
|
122
109
|
const history = await this.untilAborted(session.history(), signal);
|
|
123
110
|
text = [...history].reverse().find((turn) => turn.role === "assistant")?.text ?? "";
|
|
124
111
|
}
|
|
125
|
-
// The
|
|
126
|
-
//
|
|
127
|
-
// supervisor or the Console, never a chat renderer: buttons are
|
|
128
|
-
// dropped, and a turn that said nothing names which kind of nothing
|
|
129
|
-
// it was (principle 5b) instead of storing an empty result.
|
|
112
|
+
// The result is read by a supervisor, never a chat renderer: buttons
|
|
113
|
+
// are dropped, and an empty turn names which kind of nothing (§5).
|
|
130
114
|
const reply = splitReply(text);
|
|
131
115
|
return { type: "agent", text: reply.text || quietLabel(reply.silence), sessionId: session.id };
|
|
132
116
|
}
|
|
133
117
|
finally {
|
|
134
|
-
//
|
|
135
|
-
// back to chat afterwards. For task-created sessions this is moot —
|
|
136
|
-
// idle until the next run downgrades them again.
|
|
118
|
+
// A reused interactive session goes back to chat afterwards.
|
|
137
119
|
session.setCacheRetention("long");
|
|
138
120
|
signal.removeEventListener("abort", abort);
|
|
139
121
|
unsubscribe();
|
|
@@ -150,9 +132,8 @@ export class AgentTaskRunner {
|
|
|
150
132
|
return this.untilAborted(this.router.ensure({ channelId: "task", conversationId: run.targetSessionId }), signal);
|
|
151
133
|
}
|
|
152
134
|
const policy = action.session;
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
// author meant, and a child in the wrong tree edits real files.
|
|
135
|
+
// Stored definitions may still say `"fork"`. Refused by name: any directory
|
|
136
|
+
// picked instead is a guess, and a child in the wrong tree edits real files.
|
|
156
137
|
if (run.sessionMode === "fork" || policy.mode === "fork") {
|
|
157
138
|
throw new Error(`task "${run.context.definition.name}" uses the removed fork session mode; recreate it with {"mode":"fresh","cwd":"/abs/path"}`);
|
|
158
139
|
}
|
|
@@ -172,10 +153,8 @@ export class AgentTaskRunner {
|
|
|
172
153
|
thinking: action.launch?.thinking,
|
|
173
154
|
};
|
|
174
155
|
const opening = this.factory.create(opts).then(async (session) => {
|
|
175
|
-
// SDK creation cannot be cancelled
|
|
176
|
-
//
|
|
177
|
-
// Remember it on the live run too: cancellation's final save can race
|
|
178
|
-
// this callback before it finishes writing the terminal record.
|
|
156
|
+
// SDK creation cannot be cancelled; a late session still belongs to this
|
|
157
|
+
// run. Cancellation's final save can race this callback, so both records get it.
|
|
179
158
|
run.targetSessionId = session.id;
|
|
180
159
|
run.context.sessionId = session.id;
|
|
181
160
|
run.context.cwd = cwd;
|