@timqi/pier 0.0.3 → 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
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,
package/dist/main.js CHANGED
@@ -135,6 +135,16 @@ const startChannels = async () => {
135
135
  await deliverLedger(restartLedger, (entry) => channels.notify(entry.channelId, entry.conversationId, entry.note))
136
136
  .catch((err) => log.error("restart-note delivery failed", err));
137
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
+ };
138
148
  void secrets.unlock().then(startChannels, (err) => log.error("secrets locked — channels not started; unlock from Console → Settings → Security, or repair master.key", err));
139
149
  // Replacing Pier is systemd's job, not this process's: the oneshot unit stops
140
150
  // the service, snapshots the database, installs and starts it again. Without
@@ -261,6 +271,7 @@ app.route("/", createServer({
261
271
  updater,
262
272
  // Unlocked from the Console: start the channels boot held back.
263
273
  onUnlocked: () => void startChannels(),
274
+ reload: () => reloadInstance(true),
264
275
  backgroundRuns: (id) => tasks.backgroundRuns(id),
265
276
  }));
266
277
  const port = Number(process.env.PORT ?? 3141);
@@ -327,19 +338,16 @@ process.on("SIGUSR2", () => {
327
338
  .catch((err) => log.error("drain failed — shutting down anyway", err))
328
339
  .then(() => shutdown(false));
329
340
  });
330
- // Reload without a restart (`pier reload`): adapters re-read their config, and
331
- // idle sessions are let go so the next message re-opens them with the current
332
- // skills, extensions and prompts all applied at attach, none stored in a
333
- // transcript. Streaming or watched sessions pick the change up at their next
334
- // natural eviction. Only under systemd (the CLI signals through systemctl):
335
- // a foreground `pier` keeps SIGHUP's default, dying with its terminal instead
336
- // 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.
337
346
  if (process.env.INVOCATION_ID) {
338
347
  process.on("SIGHUP", () => {
339
348
  log.info("SIGHUP received, reloading channels and recycling idle sessions");
340
- void channels.reload();
341
- void router.evictIdle(0)
349
+ void reloadInstance()
342
350
  .then((n) => log.info(`recycled ${String(n)} idle session(s)`))
343
- .catch((err) => log.error("session recycle failed", err));
351
+ .catch((err) => log.error("reload failed", err));
344
352
  });
345
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.
@@ -92,13 +92,13 @@ export function registerExplorerRoutes(app) {
92
92
  return { name: line.slice(0, tab), subject: line.slice(tab + 1) };
93
93
  });
94
94
  // Unit/record separators, because a body is multi-line by nature.
95
- const commits = (await git(root, "log", "-20", "--format=%h\u001f%at\u001f%an\u001f%s\u001f%b\u001e"))
95
+ const commits = (await git(root, "log", "-20", "--format=%h\u001f%at\u001f%an\u001f%ae\u001f%s\u001f%b\u001e"))
96
96
  .split("\u001e")
97
97
  .map((r) => r.trimStart())
98
98
  .filter(Boolean)
99
99
  .map((r) => {
100
- const [hash = "", at = "", author = "", subject = "", body = ""] = r.split("\u001f");
101
- return { hash, at: Number(at) * 1000, author, subject, body: body.trim() };
100
+ const [hash = "", at = "", author = "", email = "", subject = "", body = ""] = r.split("\u001f");
101
+ return { hash, at: Number(at) * 1000, author, email, subject, body: body.trim() };
102
102
  });
103
103
  return c.json({ branch, refs, commits });
104
104
  });