@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 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; `pier reload` recycles
72
- idle, unwatched sessions so their next message uses the current files.
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, or
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 checks `registry.npmjs.org` in the background and shows `v0.0.1 0.0.2`
143
- in the footer when a release is out. It never updates automatically: this
144
- process holds provider keys and can run a shell, so rewriting its own code on a
145
- timer would be a supply-chain surface. `pier update` is a command someone
146
- types; unlike `pier restart`, it hard-stops the service, writes
147
- `~/.pier/db/pier.db.release.bak`, updates the npm installation recorded when
148
- the service was installed, and starts Pier again.
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: ["context", "read_channel", "read_thread", "read_message", "post", "channels"],
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 run the workbench in this terminal
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
- allowOnly([], "pier");
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 use;\n` +
195
- `it needs no arguments and keeps its state in $PIER_HOME (~/.pier).\n`);
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,
@@ -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 once by a graceful restart (src/drain.ts); never unset — the process
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: new UpdateCheck(),
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`): 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.
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 channels.reload();
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("session recycle failed", err));
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, and
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 { publicUrl: this.#value("publicUrl") ?? "", modelMenu: this.#menu() };
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 (?, ?)
@@ -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();