@timqi/pier 0.0.2 → 0.0.4
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 +26 -13
- package/dist/channels/slack-tool.js +28 -3
- package/dist/cli.js +15 -4
- package/dist/core/router.js +18 -4
- package/dist/main.js +116 -14
- package/dist/service.js +76 -4
- package/dist/settings.js +9 -1
- package/dist/tasks/service.js +13 -0
- package/dist/update.js +59 -7
- package/dist/web/auth.js +14 -4
- package/dist/web/explorer.js +15 -14
- package/dist/web/files.js +2 -1
- package/dist/web/instance.js +73 -7
- package/dist/web/providers.js +16 -4
- package/dist/web/public/assets/index-B3MvJUJP.js +90 -0
- package/dist/web/public/assets/index-CwBoxtXP.css +2 -0
- package/dist/web/public/index.html +10 -7
- package/dist/web/server.js +86 -6
- package/docs/deploy.md +42 -3
- package/package.json +1 -1
- package/skills/pier-boards/SKILL.md +16 -7
- package/skills/pier-slack/SKILL.md +17 -1
- package/dist/web/public/assets/index-BK64pHmP.js +0 -90
- package/dist/web/public/assets/index-De4GlOq4.css +0 -2
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ versioned from `0.0.1` on — earlier databases are not migrated. Read
|
|
|
26
26
|
|
|
27
27
|
```sh
|
|
28
28
|
npm install -g @timqi/pier
|
|
29
|
-
pier
|
|
29
|
+
pier serve
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
It listens on `127.0.0.1:3141` (`PORT`, `HOST`) and keeps everything under
|
|
@@ -57,7 +57,7 @@ installation. By default it is `$PIER_HOME/pi` (`~/.pier/pi`). Set
|
|
|
57
57
|
an existing Pi setup:
|
|
58
58
|
|
|
59
59
|
```sh
|
|
60
|
-
PI_CODING_AGENT_DIR="$HOME/.pi/agent" pier
|
|
60
|
+
PI_CODING_AGENT_DIR="$HOME/.pi/agent" pier serve
|
|
61
61
|
```
|
|
62
62
|
|
|
63
63
|
Console → Settings is the normal setup path:
|
|
@@ -68,8 +68,9 @@ Console → Settings is the normal setup path:
|
|
|
68
68
|
- **Agent files** edits `SYSTEM.md`, `AGENTS.md`, `settings.json`, and advanced
|
|
69
69
|
`models.json` structure in the Pi agent directory — globally, or per project
|
|
70
70
|
scope, where it also shows that project's `.pi/skills` and `.pi/extensions`
|
|
71
|
-
resources. Changes apply when a session next opens;
|
|
72
|
-
idle,
|
|
71
|
+
resources. Changes apply when a session next opens; saving here recycles the
|
|
72
|
+
idle ones for you, and **Settings → Instance → Reload** does it for files
|
|
73
|
+
something else changed — an agent, or an editor on the box.
|
|
73
74
|
|
|
74
75
|
On first credential access, Pier imports an existing `auth.json` into its sealed
|
|
75
76
|
store and renames the source to `auth.json.imported`. Literal provider keys left
|
|
@@ -109,8 +110,9 @@ find a version-managed one), a memory drop-in it never rewrites afterwards, and
|
|
|
109
110
|
turns on linger so scheduled tasks survive your logout. Install also records the
|
|
110
111
|
exact npm executable in a separate updater unit. Re-run `pier service install
|
|
111
112
|
--force` after changing the service settings or its Node/npm installation; this
|
|
112
|
-
rewrites both units and restarts Pier. On macOS run `pier` in a terminal,
|
|
113
|
-
under whatever supervisor you already use
|
|
113
|
+
rewrites both units and restarts Pier. On macOS run `pier serve` in a terminal,
|
|
114
|
+
or under whatever supervisor you already use — `pier` on its own only prints
|
|
115
|
+
the usage.
|
|
114
116
|
|
|
115
117
|
`docs/deploy.md` is the same thing written out by hand, plus what the memory
|
|
116
118
|
limits mean, how updates work (and why the updater is a second unit), how to
|
|
@@ -139,13 +141,24 @@ npm test # vitest
|
|
|
139
141
|
|
|
140
142
|
## Releases
|
|
141
143
|
|
|
142
|
-
Pier
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
144
|
+
Pier asks `registry.npmjs.org` at boot and every 30 minutes, and the version
|
|
145
|
+
beside the title turns into `v0.0.1 → 0.0.2` when a release is out. Clicking it
|
|
146
|
+
opens the panel: the source link, **Update now**, and **Update automatically**.
|
|
147
|
+
|
|
148
|
+
Nothing here installs anything itself — the work is handed to the second
|
|
149
|
+
systemd unit written at install time, because an npm running as a child of the
|
|
150
|
+
process being restarted would be killed by that restart. Off systemd there is
|
|
151
|
+
nothing to hand it to, so the panel says `pier update` instead.
|
|
152
|
+
|
|
153
|
+
`pier update` typed in a terminal hard-stops the service. The Console and the
|
|
154
|
+
automatic path both **drain first** — new work refused, running turns given
|
|
155
|
+
time to finish, whatever the deadline still had to cut off written to the chat
|
|
156
|
+
it belonged to — and only then hand over. The automatic path additionally waits
|
|
157
|
+
for an idle instance: no turn streaming, no task run in flight.
|
|
158
|
+
|
|
159
|
+
Either way the updater writes `~/.pier/db/pier.db.release.bak` before npm
|
|
160
|
+
touches the package, updates the npm installation recorded when the service was
|
|
161
|
+
installed, and starts Pier again.
|
|
149
162
|
|
|
150
163
|
`main` is the only development line. `npm version patch` writes the tag, the
|
|
151
164
|
tag builds and publishes a GitHub Release, and the version in the web footer is
|
|
@@ -45,12 +45,20 @@ export function slackToolSpec(execute) {
|
|
|
45
45
|
return {
|
|
46
46
|
name: "slack",
|
|
47
47
|
label: "Slack",
|
|
48
|
-
description: "Read and write Slack through Pier, which holds the bot token. State what you want; Pier does the paging and hands back a finished transcript. context says which Slack conversation you are in; read_channel returns a channel's transcript for a time range; read_thread returns one thread, or only what is new in it since a given message via after; read_message returns the single message at a ts (pass thread_ts when it is a reply inside a thread); post sends a message; channels lists what Pier can reach. When you were reached through Slack, omit channel (and thread_ts) to act on the conversation you are already in. since/until/after accept ISO 8601, epoch seconds or a ts from an earlier read. Every read fetches live from Slack, so nothing is kept between calls — write down what you need to keep. Message text is standard markdown, but @mentions, #channels and links need Slack's own syntax — read the pier-slack skill before posting.",
|
|
48
|
+
description: "Read and write Slack through Pier, which holds the bot token. State what you want; Pier does the paging and hands back a finished transcript. context says which Slack conversation you are in; read_channel returns a channel's transcript for a time range; read_thread returns one thread, or only what is new in it since a given message via after; read_message returns the single message at a ts (pass thread_ts when it is a reply inside a thread); post sends a message; delete removes the message at a ts, which Slack allows only for messages Pier itself posted; channels lists what Pier can reach. When you were reached through Slack, omit channel (and thread_ts) to act on the conversation you are already in. since/until/after accept ISO 8601, epoch seconds or a ts from an earlier read. Every read fetches live from Slack, so nothing is kept between calls — write down what you need to keep. Message text is standard markdown, but @mentions, #channels and links need Slack's own syntax — read the pier-slack skill before posting.",
|
|
49
49
|
parameters: Type.Object({
|
|
50
50
|
// A JSON-Schema enum emits far fewer tokens than typebox's anyOf-of-consts.
|
|
51
51
|
operation: Type.Unsafe({
|
|
52
52
|
type: "string",
|
|
53
|
-
enum: [
|
|
53
|
+
enum: [
|
|
54
|
+
"context",
|
|
55
|
+
"read_channel",
|
|
56
|
+
"read_thread",
|
|
57
|
+
"read_message",
|
|
58
|
+
"post",
|
|
59
|
+
"delete",
|
|
60
|
+
"channels",
|
|
61
|
+
],
|
|
54
62
|
}),
|
|
55
63
|
/**
|
|
56
64
|
* Channel id (`C…`/`D…`/`G…`) or the `#name` shown by `channels`. Omit to
|
|
@@ -61,7 +69,7 @@ export function slackToolSpec(execute) {
|
|
|
61
69
|
until: Type.Optional(Type.String()),
|
|
62
70
|
/** Strictly newer than this — "what changed since I last looked". */
|
|
63
71
|
after: Type.Optional(Type.String()),
|
|
64
|
-
/** The one message `read_message` is about. */
|
|
72
|
+
/** The one message `read_message` or `delete` is about. */
|
|
65
73
|
ts: Type.Optional(Type.String()),
|
|
66
74
|
limit: Type.Optional(Type.Number()),
|
|
67
75
|
thread_ts: Type.Optional(Type.String()),
|
|
@@ -174,6 +182,21 @@ export async function handleSlackTool(deps, raw, callerSessionId = "") {
|
|
|
174
182
|
threadTs: threadTs ?? sent.ts,
|
|
175
183
|
};
|
|
176
184
|
}
|
|
185
|
+
if (input.operation === "delete") {
|
|
186
|
+
// Never defaulted from `here`: the thread's ts is the parent message, and
|
|
187
|
+
// "delete" with an implied target is the one mistake with no undo.
|
|
188
|
+
const ts = required(input.ts, "ts");
|
|
189
|
+
try {
|
|
190
|
+
await client.deleteMessage(channel, ts);
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
throw new Error(explain(err));
|
|
194
|
+
}
|
|
195
|
+
// A removal leaves nothing behind to read, so the log is the only record
|
|
196
|
+
// that it happened at all.
|
|
197
|
+
deps.log(`slack tool deleted ${ts} in ${channel}`);
|
|
198
|
+
return { channel, ts, deleted: true };
|
|
199
|
+
}
|
|
177
200
|
throw new Error(`unknown slack operation: ${String(input.operation)}`);
|
|
178
201
|
}
|
|
179
202
|
/** Accept a `#name` or a bare name as well as an id — models prefer names. */
|
|
@@ -285,6 +308,8 @@ function explain(err) {
|
|
|
285
308
|
missing_scope: "Pier's Slack app lacks the scope for this read; the operator must reinstall it",
|
|
286
309
|
ratelimited: "Slack rate-limited this read; wait a minute or ask for a narrower range",
|
|
287
310
|
thread_not_found: "no thread with that ts in this channel",
|
|
311
|
+
cant_delete_message: "Slack only lets Pier delete what its own bot posted; a person's message has to be deleted by them",
|
|
312
|
+
message_not_found: "no message with that ts in this channel — a ts only means anything in the conversation it came from",
|
|
288
313
|
}[code] ?? String(err);
|
|
289
314
|
}
|
|
290
315
|
/** Strictly newer, so `after: <last ts I saw>` never repeats that message. */
|
package/dist/cli.js
CHANGED
|
@@ -15,7 +15,7 @@ const version = currentVersion();
|
|
|
15
15
|
const HELP = `pier ${version} — a self-hosted workspace for coding agents
|
|
16
16
|
|
|
17
17
|
Usage
|
|
18
|
-
pier
|
|
18
|
+
pier serve run the workbench in this terminal
|
|
19
19
|
pier service install write and start a systemd user unit (Linux)
|
|
20
20
|
pier service uninstall stop it and remove the unit
|
|
21
21
|
pier service status what systemd thinks of it
|
|
@@ -76,7 +76,15 @@ else if (values.version || command === "version") {
|
|
|
76
76
|
process.stdout.write(`${version}\n`);
|
|
77
77
|
}
|
|
78
78
|
else if (!command) {
|
|
79
|
-
|
|
79
|
+
// Typing the bare name is how someone finds out what this is, so it answers
|
|
80
|
+
// that and nothing else: it used to start a server, which is a surprising
|
|
81
|
+
// amount to have done by accident.
|
|
82
|
+
process.stdout.write(HELP);
|
|
83
|
+
}
|
|
84
|
+
else if (command === "serve") {
|
|
85
|
+
if (subcommand)
|
|
86
|
+
fail(`unexpected argument "${subcommand}"`);
|
|
87
|
+
allowOnly([], "pier serve");
|
|
80
88
|
// The server starts on import; this file stays a dispatcher.
|
|
81
89
|
await import("./main.js");
|
|
82
90
|
}
|
|
@@ -191,8 +199,8 @@ async function service(action = "status") {
|
|
|
191
199
|
const systemdAction = action === "install" || action === "uninstall";
|
|
192
200
|
if (systemdAction && process.platform !== "linux") {
|
|
193
201
|
process.stderr.write(`pier service is systemd, so Linux only — this is ${process.platform}.\n` +
|
|
194
|
-
`Run "pier" in a terminal, or under whatever supervisor you already
|
|
195
|
-
`it
|
|
202
|
+
`Run "pier serve" in a terminal, or under whatever supervisor you already\n` +
|
|
203
|
+
`use; it takes no arguments and keeps its state in $PIER_HOME (~/.pier).\n`);
|
|
196
204
|
process.exit(2);
|
|
197
205
|
}
|
|
198
206
|
switch (action) {
|
|
@@ -211,6 +219,9 @@ async function service(action = "status") {
|
|
|
211
219
|
if (!install({
|
|
212
220
|
execPath: process.execPath,
|
|
213
221
|
npmPath: commandPath("npm"),
|
|
222
|
+
// This command is typed in the operator's shell, so its PATH is the one
|
|
223
|
+
// they expect a turn's commands to see; the unit records it.
|
|
224
|
+
shellPath: process.env.PATH,
|
|
214
225
|
entry: fileURLToPath(new URL("./main.js", import.meta.url)),
|
|
215
226
|
host,
|
|
216
227
|
port,
|
package/dist/core/router.js
CHANGED
|
@@ -29,8 +29,9 @@ export class Router {
|
|
|
29
29
|
channels = new Map();
|
|
30
30
|
/** Who each session last heard from, so a header costs tokens only on news. */
|
|
31
31
|
senders = new SenderPrefix();
|
|
32
|
-
/** Set
|
|
33
|
-
* exits when the drain ends
|
|
32
|
+
/** Set by a graceful restart (src/drain.ts). Usually never unset, because
|
|
33
|
+
* the process exits when the drain ends — `endDrain` exists for the one
|
|
34
|
+
* caller that drains *speculatively* and may not get to exit. */
|
|
34
35
|
draining = false;
|
|
35
36
|
constructor(hub,
|
|
36
37
|
/** Create or resume the session owning a conversation (wired in main.ts). */
|
|
@@ -91,13 +92,19 @@ export class Router {
|
|
|
91
92
|
*
|
|
92
93
|
* Skipped for anything that would notice: a streaming turn, and a session
|
|
93
94
|
* someone is still watching over SSE.
|
|
95
|
+
*
|
|
96
|
+
* `includeWatched` is the one caller that may take a watched session too:
|
|
97
|
+
* configuration a session reads only when it opens has just changed, and the
|
|
98
|
+
* session most likely to need it is the one open in the tab that changed it.
|
|
99
|
+
* A turn in flight is still never touched — the exemption that stands is the
|
|
100
|
+
* one about interrupting work, not the one about being looked at.
|
|
94
101
|
*/
|
|
95
|
-
async evictIdle(ttlMs = IDLE_TTL_MS, now = Date.now()) {
|
|
102
|
+
async evictIdle(ttlMs = IDLE_TTL_MS, now = Date.now(), { includeWatched = false } = {}) {
|
|
96
103
|
let evicted = 0;
|
|
97
104
|
for (const [id, attached] of [...this.bySession]) {
|
|
98
105
|
if (attached.session.state === "streaming")
|
|
99
106
|
continue;
|
|
100
|
-
if (this.hub.hasSubscribers(id))
|
|
107
|
+
if (!includeWatched && this.hub.hasSubscribers(id))
|
|
101
108
|
continue;
|
|
102
109
|
if (now - attached.activeAt < ttlMs)
|
|
103
110
|
continue;
|
|
@@ -237,6 +244,13 @@ export class Router {
|
|
|
237
244
|
beginDrain() {
|
|
238
245
|
this.draining = true;
|
|
239
246
|
}
|
|
247
|
+
/** Take work again. The auto-updater closes the gate *before* handing over,
|
|
248
|
+
* so a turn cannot slip in behind the idle check — and when the handover
|
|
249
|
+
* never happens, a Pier left refusing every message forever would be a far
|
|
250
|
+
* worse outcome than the race it was avoiding. */
|
|
251
|
+
endDrain() {
|
|
252
|
+
this.draining = false;
|
|
253
|
+
}
|
|
240
254
|
/** For surfaces that mutate state before dispatching (the web's edit and
|
|
241
255
|
* queue-deliver routes): ask first, so a refused dispatch cannot cost a
|
|
242
256
|
* rewound transcript or a cleared queue. */
|
package/dist/main.js
CHANGED
|
@@ -28,8 +28,9 @@ import { TaskStore } from "./tasks/store.js";
|
|
|
28
28
|
import { taskToolSpec } from "./tasks/tool.js";
|
|
29
29
|
import { PIER_HOME, pierPath } from "./paths.js";
|
|
30
30
|
import { Secrets } from "./secrets.js";
|
|
31
|
+
import { startUpdate, unitPath, updaterProblem } from "./service.js";
|
|
31
32
|
import { SettingsStore } from "./settings.js";
|
|
32
|
-
import { UpdateCheck } from "./update.js";
|
|
33
|
+
import { startAutoUpdate, UpdateCheck } from "./update.js";
|
|
33
34
|
import { AuthStore, registerAuthRoutes, requireAuth } from "./web/auth.js";
|
|
34
35
|
import { SessionStateStore } from "./web/session-state.js";
|
|
35
36
|
import { createServer } from "./web/server.js";
|
|
@@ -134,7 +135,111 @@ const startChannels = async () => {
|
|
|
134
135
|
await deliverLedger(restartLedger, (entry) => channels.notify(entry.channelId, entry.conversationId, entry.note))
|
|
135
136
|
.catch((err) => log.error("restart-note delivery failed", err));
|
|
136
137
|
};
|
|
138
|
+
/** What "reload" means, in one place: the adapters re-read their configuration
|
|
139
|
+
* and sessions are let go, so the next message re-opens them with the current
|
|
140
|
+
* skills, extensions, prompts and credentials — all applied at attach, none
|
|
141
|
+
* stored in a transcript. SIGHUP (`pier reload`) and the Console's Reload are
|
|
142
|
+
* both this call; `includeWatched` is the only difference, and only because the
|
|
143
|
+
* Console knows a person asked from the session they are looking at. */
|
|
144
|
+
const reloadInstance = async (includeWatched = false) => {
|
|
145
|
+
await channels.reload();
|
|
146
|
+
return router.evictIdle(0, Date.now(), { includeWatched });
|
|
147
|
+
};
|
|
137
148
|
void secrets.unlock().then(startChannels, (err) => log.error("secrets locked — channels not started; unlock from Console → Settings → Security, or repair master.key", err));
|
|
149
|
+
// Replacing Pier is systemd's job, not this process's: the oneshot unit stops
|
|
150
|
+
// the service, snapshots the database, installs and starts it again. Without
|
|
151
|
+
// that unit there is nothing to hand the work to, and the Console says so
|
|
152
|
+
// instead of offering a button that cannot work.
|
|
153
|
+
const updates = new UpdateCheck();
|
|
154
|
+
// Asked once at boot, not lazily on the first page load: a restart is exactly
|
|
155
|
+
// when "am I current?" is worth knowing, and it puts the answer in the journal
|
|
156
|
+
// of a Pier nobody has a browser open on.
|
|
157
|
+
void updates.refresh();
|
|
158
|
+
/**
|
|
159
|
+
* Hand over, but not onto a running turn. The updater's first act is
|
|
160
|
+
* `systemctl stop`, i.e. a SIGTERM, which is the *fast* teardown — so anything
|
|
161
|
+
* that started since the idle check would be killed with no note anywhere. The
|
|
162
|
+
* gate closes first and the drain waits, exactly as `pier restart` does,
|
|
163
|
+
* ledger included; only then is the install handed over. A handover that never
|
|
164
|
+
* starts reopens the gate, because a Pier that silently refuses every message
|
|
165
|
+
* forever is worse than the race it was avoiding.
|
|
166
|
+
*/
|
|
167
|
+
// Shared restart state. The updater's handover, the SIGUSR2 drain (below) and
|
|
168
|
+
// the final teardown must see each other: without this, two paths drain the
|
|
169
|
+
// same Pier at once, and a failure on one reopens the gate the other still
|
|
170
|
+
// needs shut.
|
|
171
|
+
let handingOver = false;
|
|
172
|
+
let draining = false;
|
|
173
|
+
let shuttingDown = false;
|
|
174
|
+
const takeWorkAgain = (why) => {
|
|
175
|
+
handingOver = false;
|
|
176
|
+
log.error(`${why} — taking work again`);
|
|
177
|
+
// Not ours to reopen: a SIGUSR2 restart or the teardown owns the gate now,
|
|
178
|
+
// and reopening it would hand new work to a process that is exiting.
|
|
179
|
+
if (draining || shuttingDown)
|
|
180
|
+
return;
|
|
181
|
+
router.endDrain();
|
|
182
|
+
tasks.unpause();
|
|
183
|
+
// The drain may have deadline-aborted turns into the ledger. Without the
|
|
184
|
+
// restart that was supposed to follow, that debt would wait for one days
|
|
185
|
+
// away (§5b) — so the chats are told now, by the process that cut them off.
|
|
186
|
+
void deliverLedger(restartLedger, (entry) => channels.notify(entry.channelId, entry.conversationId, entry.note))
|
|
187
|
+
.catch((err) => log.error("restart-note delivery failed", err));
|
|
188
|
+
};
|
|
189
|
+
/** How long the handover has to actually stop us. `systemctl start --no-block`
|
|
190
|
+
* returns when the job is *queued*, so "started" is not proof of anything;
|
|
191
|
+
* the real outcome is a SIGTERM a second or two later. */
|
|
192
|
+
const HANDOVER_GRACE_MS = 60_000;
|
|
193
|
+
const handOverToUpdater = async () => {
|
|
194
|
+
// One handover at a time, and never on top of a restart: the Console button,
|
|
195
|
+
// the auto-update tick and SIGUSR2 would otherwise drain the same Pier
|
|
196
|
+
// twice, each believing the gate is its own to reopen on failure.
|
|
197
|
+
if (handingOver || draining || shuttingDown)
|
|
198
|
+
return "busy";
|
|
199
|
+
handingOver = true;
|
|
200
|
+
await drainForRestart({ router, tasks, ledger: restartLedger });
|
|
201
|
+
const started = startUpdate({ say: (message) => log.info(message) });
|
|
202
|
+
if (started !== "started") {
|
|
203
|
+
takeWorkAgain(`update not started (${started})`);
|
|
204
|
+
return started;
|
|
205
|
+
}
|
|
206
|
+
// The gate is closed and nothing in this process will open it again, so a
|
|
207
|
+
// handover that queues and then goes nowhere — npm failed, the unit was
|
|
208
|
+
// masked, the job sat behind another — would leave Pier alive and refusing
|
|
209
|
+
// every message with no way back. Unref'd: this must not be what keeps the
|
|
210
|
+
// process up while systemd is trying to stop it.
|
|
211
|
+
setTimeout(() => {
|
|
212
|
+
takeWorkAgain(`still running ${String(HANDOVER_GRACE_MS / 1000)}s after handing over — pier-update.service never stopped Pier` +
|
|
213
|
+
` (check: journalctl --user -u pier-update.service -e)`);
|
|
214
|
+
}, HANDOVER_GRACE_MS).unref();
|
|
215
|
+
return started;
|
|
216
|
+
};
|
|
217
|
+
const updater = process.platform === "linux" && existsSync(unitPath())
|
|
218
|
+
? { apply: handOverToUpdater, problem: () => updaterProblem() }
|
|
219
|
+
: null;
|
|
220
|
+
// Unattended only when the operator asked for it *and* nothing is running.
|
|
221
|
+
if (updater) {
|
|
222
|
+
const problem = updaterProblem();
|
|
223
|
+
// Loudly, at boot: this is the one moment the operator is looking, and the
|
|
224
|
+
// alternative is a restart that fails months from now.
|
|
225
|
+
if (problem)
|
|
226
|
+
log.warn(`the updater cannot run: ${problem}`);
|
|
227
|
+
startAutoUpdate(updates, {
|
|
228
|
+
enabled: () => settings.get().autoUpdate,
|
|
229
|
+
idle: () => router.busy().length === 0 && tasks.activeRunCount() === 0,
|
|
230
|
+
apply: async () => {
|
|
231
|
+
// Re-checked here, not only at boot: a version manager can remove the
|
|
232
|
+
// recorded Node months into an uptime, and draining for a handover that
|
|
233
|
+
// cannot happen would take the whole instance down with it.
|
|
234
|
+
const now = updaterProblem();
|
|
235
|
+
if (now) {
|
|
236
|
+
log.error(`auto-update skipped: ${now}`);
|
|
237
|
+
return "not-installed";
|
|
238
|
+
}
|
|
239
|
+
return handOverToUpdater();
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
}
|
|
138
243
|
// Composition happens here so web/ and tasks/ never import each other.
|
|
139
244
|
const app = new Hono();
|
|
140
245
|
// A route that threw would otherwise answer 500 and leave no trace anywhere:
|
|
@@ -162,9 +267,11 @@ app.route("/", createServer({
|
|
|
162
267
|
providers: factory,
|
|
163
268
|
settings,
|
|
164
269
|
secrets,
|
|
165
|
-
updates
|
|
270
|
+
updates,
|
|
271
|
+
updater,
|
|
166
272
|
// Unlocked from the Console: start the channels boot held back.
|
|
167
273
|
onUnlocked: () => void startChannels(),
|
|
274
|
+
reload: () => reloadInstance(true),
|
|
168
275
|
backgroundRuns: (id) => tasks.backgroundRuns(id),
|
|
169
276
|
}));
|
|
170
277
|
const port = Number(process.env.PORT ?? 3141);
|
|
@@ -185,7 +292,6 @@ process.on("uncaughtException", (err) => {
|
|
|
185
292
|
process.on("unhandledRejection", (reason) => {
|
|
186
293
|
log.error("unhandled rejection", reason);
|
|
187
294
|
});
|
|
188
|
-
let shuttingDown = false;
|
|
189
295
|
const shutdown = (stopTasks = true) => {
|
|
190
296
|
// Once: SIGTERM can land while a drain is finishing, and two teardowns
|
|
191
297
|
// racing each other close the same sockets twice.
|
|
@@ -221,7 +327,6 @@ for (const signal of ["SIGTERM", "SIGINT"]) {
|
|
|
221
327
|
// next process up. SIGTERM above stays the fast path systemd expects. `on`,
|
|
222
328
|
// not `once`: a second SIGUSR2 with no handler would fall back to Node's
|
|
223
329
|
// default and kill the drain it meant to hurry.
|
|
224
|
-
let draining = false;
|
|
225
330
|
process.on("SIGUSR2", () => {
|
|
226
331
|
if (draining) {
|
|
227
332
|
log.info("SIGUSR2 received again — already draining");
|
|
@@ -233,19 +338,16 @@ process.on("SIGUSR2", () => {
|
|
|
233
338
|
.catch((err) => log.error("drain failed — shutting down anyway", err))
|
|
234
339
|
.then(() => shutdown(false));
|
|
235
340
|
});
|
|
236
|
-
// Reload without a restart (`pier reload`):
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
// a foreground `pier` keeps SIGHUP's default, dying with its terminal instead
|
|
242
|
-
// of surviving as an orphan that holds the port.
|
|
341
|
+
// Reload without a restart (`pier reload`): reloadInstance above, leaving the
|
|
342
|
+
// sessions someone is watching alone — nobody asked from a browser here.
|
|
343
|
+
// Only under systemd (the CLI signals through systemctl): a foreground `pier
|
|
344
|
+
// serve` keeps SIGHUP's default, dying with its terminal instead of surviving
|
|
345
|
+
// as an orphan that holds the port.
|
|
243
346
|
if (process.env.INVOCATION_ID) {
|
|
244
347
|
process.on("SIGHUP", () => {
|
|
245
348
|
log.info("SIGHUP received, reloading channels and recycling idle sessions");
|
|
246
|
-
void
|
|
247
|
-
void router.evictIdle(0)
|
|
349
|
+
void reloadInstance()
|
|
248
350
|
.then((n) => log.info(`recycled ${String(n)} idle session(s)`))
|
|
249
|
-
.catch((err) => log.error("
|
|
351
|
+
.catch((err) => log.error("reload failed", err));
|
|
250
352
|
});
|
|
251
353
|
}
|
package/dist/service.js
CHANGED
|
@@ -36,8 +36,22 @@ function quote(value, command = false) {
|
|
|
36
36
|
return `"${escaped}"`;
|
|
37
37
|
}
|
|
38
38
|
const environment = (key, value) => `Environment=${quote(`${key}=${value}`)}`;
|
|
39
|
+
/** The PATH both units carry: the recorded node first, then the shell that ran
|
|
40
|
+
* the install (`pier service install` is typed in that shell, so its own PATH
|
|
41
|
+
* *is* the login one), with the standard directories as a floor.
|
|
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. */
|
|
47
|
+
function pathEnv(execPath, shellPath) {
|
|
48
|
+
const seen = new Set();
|
|
49
|
+
return [dirname(execPath), ...(shellPath ?? "").split(":"), "/usr/local/bin", "/usr/bin", "/bin"]
|
|
50
|
+
.filter((dir) => dir.startsWith("/") && !/[\0\r\n]/.test(dir) && !seen.has(dir) && seen.add(dir))
|
|
51
|
+
.join(":");
|
|
52
|
+
}
|
|
39
53
|
export function renderUnit(options) {
|
|
40
|
-
const { execPath, entry, host, port, pierHome } = options;
|
|
54
|
+
const { execPath, entry, host, port, pierHome, shellPath } = options;
|
|
41
55
|
return `[Unit]
|
|
42
56
|
Description=Pier — agent workspace
|
|
43
57
|
Documentation=https://github.com/timqi/pier
|
|
@@ -51,6 +65,10 @@ WorkingDirectory=%h
|
|
|
51
65
|
# that installed Pier is usually not on it.
|
|
52
66
|
ExecStart=${quote(execPath, true)} ${quote(entry, true)}
|
|
53
67
|
${environment("NODE_ENV", "production")}
|
|
68
|
+
# Inherited by every command a turn runs, which is why it is here and not just
|
|
69
|
+
# in the updater: an agent typing "npm test" on systemd's minimal PATH would be
|
|
70
|
+
# told node does not exist on a machine that installed Pier with it.
|
|
71
|
+
${environment("PATH", pathEnv(execPath, shellPath))}
|
|
54
72
|
# Loopback by default. Put a reverse proxy in front before widening this —
|
|
55
73
|
# whoever reaches this port can drive an agent that runs a shell.
|
|
56
74
|
${environment("HOST", host)}
|
|
@@ -89,7 +107,7 @@ function legacyOptions(home) {
|
|
|
89
107
|
/** A separate cgroup stops Pier, takes a consistent backup, updates the exact
|
|
90
108
|
* npm installation recorded at install time, and always starts Pier again. */
|
|
91
109
|
export function renderUpdateUnit(options) {
|
|
92
|
-
const { execPath, npmPath, entry, pierHome } = options;
|
|
110
|
+
const { execPath, npmPath, entry, pierHome, shellPath } = options;
|
|
93
111
|
const cli = join(dirname(entry), "cli.js");
|
|
94
112
|
return `[Unit]
|
|
95
113
|
Description=Update Pier to the latest published version
|
|
@@ -97,10 +115,14 @@ Documentation=https://github.com/timqi/pier
|
|
|
97
115
|
|
|
98
116
|
[Service]
|
|
99
117
|
Type=oneshot
|
|
118
|
+
# The absolute node below answers npm's own shebang and nothing else: a
|
|
119
|
+
# dependency's postinstall runs as "sh -c node scripts/postinstall", which
|
|
120
|
+
# resolves node from PATH, and systemd's minimal PATH has no fnm/nvm node —
|
|
121
|
+
# the install then dies with "node: not found" with the tree half written.
|
|
122
|
+
${environment("PATH", pathEnv(execPath, shellPath))}
|
|
100
123
|
${pierHome ? `${environment("PIER_HOME", pierHome)}\n` : ""}ExecStart=systemctl --user stop ${UNIT_NAME}
|
|
101
124
|
ExecStart=${quote(execPath, true)} ${quote(cli, true)} backup
|
|
102
|
-
# npm runs under the recorded node: its shebang needs a node on PATH
|
|
103
|
-
# systemd's minimal PATH has none for fnm/nvm installs.
|
|
125
|
+
# npm runs under the recorded node: its shebang needs a node on PATH too.
|
|
104
126
|
ExecStart=${quote(execPath, true)} ${quote(npmPath, true)} install -g @timqi/pier@latest
|
|
105
127
|
# ExecStopPost runs on success and failure, so a failed backup or npm install
|
|
106
128
|
# does not leave the previously working service stopped.
|
|
@@ -185,6 +207,56 @@ export function install(options) {
|
|
|
185
207
|
say(`started. The first run prints a password once: journalctl --user -u pier -e`);
|
|
186
208
|
return true;
|
|
187
209
|
}
|
|
210
|
+
/**
|
|
211
|
+
* Why the installed updater could not do its job, or `null` when nothing is
|
|
212
|
+
* wrong. Checked while Pier is still alive, because the alternative is finding
|
|
213
|
+
* out at the next restart, from a service that no longer starts.
|
|
214
|
+
*
|
|
215
|
+
* The absolute node and npm paths in the unit are deliberate — systemd's PATH
|
|
216
|
+
* has neither — but they pin the unit to one directory of one version manager.
|
|
217
|
+
* `fnm install 26 && fnm uninstall 24` leaves ExecStart naming a Node that is
|
|
218
|
+
* gone; the running process survives (Linux keeps a deleted binary mapped),
|
|
219
|
+
* so nothing would notice until the update, or the next boot, failed.
|
|
220
|
+
*/
|
|
221
|
+
export function updaterProblem(home = homedir()) {
|
|
222
|
+
if (!existsSync(unitPath(home)))
|
|
223
|
+
return null; // not a service install; nothing to check
|
|
224
|
+
const path = updateUnitPath(home);
|
|
225
|
+
let unit;
|
|
226
|
+
if (existsSync(path)) {
|
|
227
|
+
try {
|
|
228
|
+
unit = readFileSync(path, "utf8");
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
return `${path} cannot be read: ${String(err)}`;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
// A 0.0.1 install: startUpdate generates the updater from the main unit,
|
|
236
|
+
// so a missing file is only a problem when that bridge cannot either —
|
|
237
|
+
// and the generated text gets the same executable check below.
|
|
238
|
+
try {
|
|
239
|
+
unit = renderUpdateUnit(legacyOptions(home));
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return `${UPDATE_UNIT_NAME} is missing — run: pier service install --force`;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// The one line that names both executables, quoted and escaped by quote().
|
|
246
|
+
// Unparseable means hand-edited, which is not this function's business to
|
|
247
|
+
// judge; the escaping is undone before existsSync sees a path (a `%` or `$`
|
|
248
|
+
// in it would otherwise read as gone on a working updater).
|
|
249
|
+
const install = unit.match(/^ExecStart="((?:\\.|[^"\r\n])+)" "((?:\\.|[^"\r\n])+)" install -g/m);
|
|
250
|
+
if (!install)
|
|
251
|
+
return null;
|
|
252
|
+
const unescape = (word) => word.replaceAll("$$", "$").replaceAll("%%", "%").replace(/\\(.)/g, "$1");
|
|
253
|
+
for (const [what, bin] of [["node", unescape(install[1])], ["npm", unescape(install[2])]]) {
|
|
254
|
+
if (!existsSync(bin)) {
|
|
255
|
+
return `the ${what} the updater would use is gone (${bin}) — a version manager removed it; run: pier service install --force`;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
188
260
|
function runningPierHome(home) {
|
|
189
261
|
const pid = Number(execFileSync("systemctl", ["--user", "show", UNIT_NAME, "--property=MainPID", "--value"], { encoding: "utf8" }).trim());
|
|
190
262
|
if (Number.isInteger(pid) && pid >= 1) {
|
package/dist/settings.js
CHANGED
|
@@ -72,7 +72,11 @@ export class SettingsStore {
|
|
|
72
72
|
this.#db = db;
|
|
73
73
|
}
|
|
74
74
|
get() {
|
|
75
|
-
return {
|
|
75
|
+
return {
|
|
76
|
+
publicUrl: this.#value("publicUrl") ?? "",
|
|
77
|
+
modelMenu: this.#menu(),
|
|
78
|
+
autoUpdate: this.#value("autoUpdate") === "1",
|
|
79
|
+
};
|
|
76
80
|
}
|
|
77
81
|
#menu() {
|
|
78
82
|
const raw = this.#value("modelMenu");
|
|
@@ -105,6 +109,10 @@ export class SettingsStore {
|
|
|
105
109
|
this.#set("modelMenu", JSON.stringify(menu));
|
|
106
110
|
return this.get();
|
|
107
111
|
}
|
|
112
|
+
setAutoUpdate(on) {
|
|
113
|
+
this.#set("autoUpdate", on ? "1" : "0");
|
|
114
|
+
return this.get();
|
|
115
|
+
}
|
|
108
116
|
#set(key, value) {
|
|
109
117
|
this.#db.prepare(`
|
|
110
118
|
INSERT INTO settings(key, value) VALUES (?, ?)
|
package/dist/tasks/service.js
CHANGED
|
@@ -85,6 +85,9 @@ export class TaskService {
|
|
|
85
85
|
this.definitions.resetNextRuns(now);
|
|
86
86
|
this.callbacks.recover(now);
|
|
87
87
|
this.groups.recover(now);
|
|
88
|
+
this.runTimer(tickMs);
|
|
89
|
+
}
|
|
90
|
+
runTimer(tickMs) {
|
|
88
91
|
this.timer = setInterval(() => {
|
|
89
92
|
// The scheduler's own loop: a throw here would stop nothing (the next
|
|
90
93
|
// tick still fires) and say nothing, so due tasks would just stop.
|
|
@@ -92,6 +95,16 @@ export class TaskService {
|
|
|
92
95
|
}, tickMs);
|
|
93
96
|
this.timer.unref();
|
|
94
97
|
}
|
|
98
|
+
/** Undo a `pause()` that was not followed by an exit — the auto-updater
|
|
99
|
+
* drains before handing over, and a handover that never started must not
|
|
100
|
+
* leave the scheduler switched off. Deliberately not `start()`: the boot
|
|
101
|
+
* recovery in there would write off runs this process is still running. */
|
|
102
|
+
unpause(tickMs = 1000) {
|
|
103
|
+
if (this.timer)
|
|
104
|
+
return;
|
|
105
|
+
this.paused = false;
|
|
106
|
+
this.runTimer(tickMs);
|
|
107
|
+
}
|
|
95
108
|
stop() {
|
|
96
109
|
this.pause();
|
|
97
110
|
this.execution.stop();
|