@timqi/pier 0.0.15 → 0.0.17

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  A self-hosted workspace for coding agents. Pier puts a web workbench and your
4
4
  IM channels in front of [Pi](https://github.com/earendil-works/pi) sessions:
5
- you talk to the same agent from a browser, from Slack or from Telegram, steer a
5
+ you talk to the same agent from a browser, from Slack, Telegram or Lark, steer a
6
6
  running turn, schedule tasks, watch what every session is doing, and publish a
7
7
  static page when something is worth showing.
8
8
 
@@ -89,6 +89,11 @@ Console → Settings is the normal setup path:
89
89
  `web_search` on an authenticated Anthropic or OpenAI model, `web_fetch` on an
90
90
  Anthropic one (OpenAI hosts no fetch tool), and no other key or service. The
91
91
  page says which tools a switch adds and what each needs before you flip it.
92
+ Below it, the same tab switches on the command-line tools Pier manages —
93
+ `rtk`, `rg`, `fd`, `wt`, `jq`, or a tool of your own written as a
94
+ [ubix](https://github.com/timqi/ubix) block. A switch installs the binary
95
+ into `~/.pier/tools/bin`, which is first on the PATH every session, task and
96
+ terminal inherits, and a task you can read keeps them current.
92
97
 
93
98
  On first credential access, Pier imports an existing `auth.json` into its sealed
94
99
  store and renames the source to `auth.json.imported`. Literal provider keys left
@@ -111,6 +116,7 @@ pier restart # drain running work, then restart
111
116
  pier reload # re-read channel config and recycle idle sessions
112
117
  pier backup # snapshot the database before a manual update
113
118
  pier update # latest release, then hard-stop/restart the service
119
+ pier tools sync # install/update the managed CLI tools by hand
114
120
  ```
115
121
 
116
122
  `pier restart` refuses new work, waits up to five minutes for active turns and
package/dist/agent/pi.js CHANGED
@@ -42,12 +42,22 @@ const PIER_SYSTEM_PROMPT = `You are a general-purpose agent with a live workspac
42
42
  # Communication
43
43
  These rules govern conversational replies. When the reply *is* the deliverable — a report that was asked for, a review, a task run whose result another agent reads — the work sets the length: complete beats brief, and nothing below caps it.
44
44
  - Answer with the conclusion only. Reasons, process, trade-offs, alternatives: only when asked.
45
- - Cap per reply: 100 words (or 100 Chinese chars), max 3 bullets; 300 when explicitly asked why or how. Code blocks, diffs and commands don't count.
45
+ - Cap per reply: 60 words (90 Chinese chars), max 3 bullets; 180 words (270 Chinese chars) when explicitly asked why or how. Code blocks, diffs and commands don't count.
46
+ - Reply in the language of the request; code, paths, identifiers and quoted output stay verbatim.
46
47
  - Never: preamble, restating the question, closing summaries, "I'm going to..." narration, listing changes already visible in the diff.
47
48
  - After edits, say only: file(s) touched + one line on the result. Don't explain self-evident code.
48
- - Show file paths as \`path:line\`.
49
+ - Show file paths as \`path:line\`, or the path alone when no single line is the point — never invent a number.
49
50
  - If the honest answer needs more than the cap, give the conclusion plus one short "want the details?" — don't dump it.
50
- - Blocked on a decision only the person you work for can make? Ask one short question. Otherwise pick the sensible default and note it.`;
51
+ - Blocked on a decision only the person you work for can make? Ask one short question. Otherwise pick the sensible default and note it.
52
+
53
+ # Working style (any machine)
54
+ These hold wherever Pier runs; a user's SYSTEM.md adds the local ones (which tools exist, which hosts, which paths).
55
+ - Orient first — list and search before you act. Never guess a path.
56
+ - Read before you edit. Match the surrounding code's style, naming, and comment density.
57
+ - Do exactly what was asked. No unrequested refactors, no extra files, no README updates.
58
+ - Destructive or irreversible actions (rm, force push, migrations, deploys): ask first.
59
+ - Say plainly when something failed, was skipped, or is unverified. Never claim a test passed without running it.
60
+ - Every bash call already runs in the working directory this prompt names — don't prefix \`cd <cwd> &&\`, \`cd\` only to go somewhere else. Each call is a fresh shell: \`cd\`, \`export\`, \`source\` never carry over, so chain what must share state into one command.`;
51
61
  export const pierSystemPrompt = (userPrompt) => userPrompt ? `${PIER_SYSTEM_PROMPT}\n\n${userPrompt}` : PIER_SYSTEM_PROMPT;
52
62
  /** Patching the call is cheaper than replacing the tool: the built-in keeps its
53
63
  * shell settings, and the agent spends no tokens deciding a timeout. */
package/dist/db.js CHANGED
@@ -253,8 +253,63 @@ const MIGRATIONS = [
253
253
  token TEXT NOT NULL,
254
254
  heartbeat_at INTEGER NOT NULL
255
255
  );
256
+ `,
257
+ // 13 — a signed-in browser can be signed out on its own (web/auth.ts).
258
+ `
259
+ -- One row per signed-in browser. The cookie carries "<id>.<token>" and only
260
+ -- the token's SHA-256 is stored, so a copy of this database cannot be turned
261
+ -- into a session — and deleting a row is what revocation is. seen_at is the
262
+ -- whole lifetime: the session ends one TTL after it, so there is no second
263
+ -- column that can disagree about when.
264
+ CREATE TABLE web_sessions (
265
+ id TEXT PRIMARY KEY,
266
+ token_hash TEXT NOT NULL,
267
+ created_at INTEGER NOT NULL,
268
+ seen_at INTEGER NOT NULL,
269
+ ip TEXT NOT NULL,
270
+ agent TEXT NOT NULL
271
+ );
272
+ `,
273
+ // 14 — signing a browser out also stops notifying it (web/push.ts).
274
+ `
275
+ -- A subscription belongs to the web session that made it, and dies with it:
276
+ -- the cascade is the rule, so no code has to remember to run it — revoking a
277
+ -- session, changing the password and recovering it all reach here for free.
278
+ -- Rebuilt rather than altered because a foreign key cannot be added to an
279
+ -- existing table; nothing is carried over, since migration 13 invalidated
280
+ -- every cookie and each of these rows belongs to a browser that is now
281
+ -- signed out. A browser re-subscribes on its next load.
282
+ DROP TABLE push_subscriptions;
283
+ CREATE TABLE push_subscriptions (
284
+ endpoint TEXT PRIMARY KEY,
285
+ p256dh TEXT NOT NULL,
286
+ auth TEXT NOT NULL,
287
+ label TEXT NOT NULL,
288
+ created_at INTEGER NOT NULL,
289
+ session_id TEXT NOT NULL REFERENCES web_sessions(id) ON DELETE CASCADE
290
+ );
256
291
  `,
257
292
  ];
293
+ /**
294
+ * Several writes as one, or none. `BEGIN IMMEDIATE` because every writer here
295
+ * competes with another Pier process on the same file: taking the write lock
296
+ * up front turns a race into a wait, where deferred would turn it into
297
+ * SQLITE_BUSY halfway through. The rollback is the reason this is shared —
298
+ * three modules had written the same seven lines, and a `catch` that forgets
299
+ * to roll back leaves the connection in a transaction forever.
300
+ */
301
+ export function transact(db, work) {
302
+ db.exec("BEGIN IMMEDIATE");
303
+ try {
304
+ const result = work();
305
+ db.exec("COMMIT");
306
+ return result;
307
+ }
308
+ catch (err) {
309
+ db.exec("ROLLBACK");
310
+ throw err;
311
+ }
312
+ }
258
313
  let shared;
259
314
  /**
260
315
  * The process's one connection, opened and migrated on first use. Every store
@@ -295,6 +350,12 @@ export function openDb(path, migrations = MIGRATIONS) {
295
350
  // file, and SQLite refuses to change it inside one.
296
351
  db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
297
352
  db.exec("PRAGMA journal_mode = WAL");
353
+ // Off by default in SQLite, and a declared relationship nothing enforces is
354
+ // a comment. Set before migrate(): it is a per-connection switch and a no-op
355
+ // inside a transaction. Nothing older declares a key, so this changes the
356
+ // behaviour of exactly one table — push_subscriptions, whose rows must not
357
+ // outlive the session that made them.
358
+ db.exec("PRAGMA foreign_keys = ON");
298
359
  migrate(db, path, migrations);
299
360
  if (path !== ":memory:")
300
361
  restrict(path);
@@ -1,3 +1,8 @@
1
+ // What a provider's answer becomes on the way to the model: sources, results,
2
+ // the queries actually searched, usage, and the text of a fetched document.
3
+ // One shape for both backends, so a tool renders its answer once instead of
4
+ // per wire format — anthropic.ts and openai.ts parse into these, and nothing
5
+ // past this file knows which one replied.
1
6
  import { isObject } from "./json.js";
2
7
  import { languageLabel } from "./language.js";
3
8
  /**
@@ -1,3 +1,8 @@
1
+ // The language-preservation policy in words: what the model is told to search
2
+ // in, and how to tell afterwards whether it did. This is the reason the
3
+ // extension exists at all — a hosted search that quietly translates a Chinese
4
+ // query answers a question nobody asked — so the policy is one file, and the
5
+ // audit that checks it reads from the same one.
1
6
  export function searchPrompt(query, mode) {
2
7
  const policy = mode === "preserve"
3
8
  ? "Use only the original language. Later searches may refine wording in that language, but must not translate or transliterate it."
@@ -1,3 +1,8 @@
1
+ // The two tools as the model sees them: web_search and web_fetch — their
2
+ // parameters, which backend answers a call, and what comes back when one
3
+ // cannot. Every parameter is context the model pays for on every turn, so the
4
+ // surface is deliberately small; the wire formats behind it are anthropic.ts
5
+ // and openai.ts, and the answer's shape is content.ts.
1
6
  import { defineTool } from "@earendil-works/pi-coding-agent";
2
7
  import { Type } from "typebox";
3
8
  import { callNativeTool } from "./anthropic.js";
package/dist/settings.js CHANGED
@@ -7,7 +7,7 @@
7
7
  // too" — the agent is told the URL in its system prompt (core/reply.ts), and
8
8
  // nothing outside this process ever opened that file.
9
9
  import { isThinkingLevel } from "./core/types.js";
10
- import { pierDb } from "./db.js";
10
+ import { pierDb, transact } from "./db.js";
11
11
  import { logger } from "./log.js";
12
12
  // The one place the custom-tool vocabulary lives (names, ubix sources, the
13
13
  // names Pier already owns). Imported rather than copied: a second validator
@@ -201,16 +201,7 @@ export class SettingsStore {
201
201
  * declared and invisible.
202
202
  */
203
203
  transact(work) {
204
- this.#db.exec("BEGIN IMMEDIATE");
205
- try {
206
- const result = work();
207
- this.#db.exec("COMMIT");
208
- return result;
209
- }
210
- catch (err) {
211
- this.#db.exec("ROLLBACK");
212
- throw err;
213
- }
204
+ return transact(this.#db, work);
214
205
  }
215
206
  #set(key, value) {
216
207
  this.#db.prepare(`
@@ -1,3 +1,8 @@
1
+ // A run that *is* a Pi session: which session it opens (reuse, fresh, fork),
2
+ // what the child is told before the prompt, and how many may run at once. The
3
+ // concurrency caps are here rather than in execution.ts because they bound
4
+ // agents specifically — a bash run costs a process, an agent run costs a
5
+ // model's context and someone's rate limit.
1
6
  import { quietLabel, splitReply } from "../core/reply.js";
2
7
  import { Router } from "../core/router.js";
3
8
  import { runSource } from "./callbacks.js";
@@ -1,4 +1,9 @@
1
+ // A bash task's script, run in its cwd with its input on stdin. The output is
2
+ // capped as it arrives rather than after: a run that printed a gigabyte is a
3
+ // run whose result still has to fit in a row, a transcript and a callback.
1
4
  import { spawn } from "node:child_process";
5
+ import { logger } from "../log.js";
6
+ const log = logger("tasks");
2
7
  const OUTPUT_LIMIT = 1024 * 1024;
3
8
  class CappedOutput {
4
9
  chunks = [];
@@ -69,6 +74,16 @@ export function runBash(script, cwd, input, signal) {
69
74
  stderrTruncated: stderr.truncated,
70
75
  });
71
76
  });
77
+ // A script that never reads stdin is ordinary (`exit 0`, a one-line curl),
78
+ // and writing the input to a pipe nobody is holding raises EPIPE *here*.
79
+ // Unhandled, that is an `error` event on a stream, which is an uncaught
80
+ // exception, which is main.ts exiting the process: one task script could
81
+ // take every session and every other run down with it. The input not being
82
+ // wanted is not a failure of the run — anything else still gets said.
83
+ child.stdin.on("error", (err) => {
84
+ if (err.code !== "EPIPE")
85
+ log.warn(`run input could not be written: ${err.message}`);
86
+ });
72
87
  child.stdin.end(encodedInput);
73
88
  });
74
89
  }
@@ -1,3 +1,7 @@
1
+ // What a task *is* before it ever runs: the id it is minted with, the draft
2
+ // validated into a definition, and when its trigger is next due. Every way a
3
+ // definition can be created — HTTP, the task tool, Pier's own owned task —
4
+ // arrives here, so a field is checked in one place or nowhere.
1
5
  import { randomBytes } from "node:crypto";
2
6
  import { stat } from "node:fs/promises";
3
7
  import { Cron } from "croner";
@@ -1,3 +1,7 @@
1
+ // One queued run carried to a result: dispatched by action kind (a bash
2
+ // script, an agent session, another task), abortable while it goes, settled
3
+ // exactly once. What the two action kinds actually do lives in command.ts and
4
+ // agent.ts; this file owns only the lifecycle they share.
1
5
  import { logger } from "../log.js";
2
6
  import { AgentTaskRunner } from "./agent.js";
3
7
  import { TaskCallbacks } from "./callbacks.js";
@@ -1,3 +1,7 @@
1
+ // The fan-out join: members start detached, and the group is what turns their
2
+ // separate endings into one answer — when the join condition is met, which
3
+ // members are cancelled, and the single aggregated callback that goes back.
4
+ // Delivering it is the outbox's job; deciding it is this file's.
1
5
  import { Router } from "../core/router.js";
2
6
  import { logger } from "../log.js";
3
7
  import { runRef, runResultText } from "./callbacks.js";
@@ -1,3 +1,9 @@
1
+ // What a parent and a child say to each other while a run is going: steer,
2
+ // follow-up and resume in one direction, progress and decision questions in
3
+ // the other. Every message is a durable row before it is a delivery, because
4
+ // the two ends are different sessions and either may be mid-turn, gone, or
5
+ // finished — an undelivered message is retried, expired and *said*, never
6
+ // dropped (§5b).
1
7
  import { EventHub } from "../core/hub.js";
2
8
  import { Router } from "../core/router.js";
3
9
  import { logger } from "../log.js";
@@ -1,3 +1,7 @@
1
+ // The area's HTTP surface: tasks, runs, group and message routes for the
2
+ // Console, plus the Activity snapshot it draws its graph from. A route reads
3
+ // its body, names the caller and hands the decision to TaskService — policy
4
+ // that lives here would be policy the task tool does not get.
1
5
  import { record, requiredString } from "./definitions.js";
2
6
  const jsonBody = async (req) => req.json().catch(() => null);
3
7
  export function registerTaskRoutes(app, tasks, activity) {
@@ -1,3 +1,8 @@
1
+ // A definition plus an input becomes a queued run: where it came from, how
2
+ // deep in a subagent chain it sits, whether it overlaps a run already going,
3
+ // and which session hears about it. The limits that keep a chain from
4
+ // exploding (depth, children per root) are decided here, once, because every
5
+ // caller — scheduler, tool, HTTP — enqueues through this one door.
1
6
  import { logger } from "../log.js";
2
7
  import { TaskCallbacks } from "./callbacks.js";
3
8
  import { newId } from "./definitions.js";
@@ -1,3 +1,9 @@
1
+ // The one object the rest of Pier talks to about tasks, and the clock behind
2
+ // it: the tick that finds what is due, the boot recovery that writes off runs
3
+ // a restart interrupted, and the pause a drain needs. Every decision it looks
4
+ // like it makes belongs to a file beside it (definitions, runs, execution,
5
+ // groups, messages, callbacks) — what is genuinely here is scheduling and the
6
+ // facade, so the HTTP routes and the task tool cannot drift apart.
1
7
  import { EventHub } from "../core/hub.js";
2
8
  import { Router } from "../core/router.js";
3
9
  import { logger } from "../log.js";
@@ -1,3 +1,7 @@
1
+ // Every query this area makes against pier.db, and nothing else: definitions,
2
+ // runs, groups and messages are rows here, read and written through one
3
+ // connection db.ts opened. A store owns its queries, never its own tables or
4
+ // its own handle — the schema is db.ts's migration list.
1
5
  import { pierDb } from "../db.js";
2
6
  const clamp = (limit, cap) => Math.min(Math.max(limit, 1), cap);
3
7
  export class TaskStore {
@@ -1,3 +1,7 @@
1
+ // The vocabulary every file in this area shares: what a task, a run, a group
2
+ // and a control message *are*, plus the delivery constants the outbox and the
3
+ // messenger must agree on. Owner-defined and browser-importable type-only
4
+ // (architecture.md), so nothing here may reach for a runtime or a node builtin.
1
5
  export const retryDelay = (attempts) => Math.min(60_000, 1000 * 2 ** Math.min(attempts, 6));
2
6
  /** Attempts before a delivery is given up on and reported. With the backoff
3
7
  * above that is ~4 minutes: long enough to outlast a busy or restarting
package/dist/tools.js CHANGED
@@ -16,7 +16,7 @@ import { execFile } from "node:child_process";
16
16
  import { createHash, randomUUID } from "node:crypto";
17
17
  import { chmodSync, existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs";
18
18
  import { delimiter, join } from "node:path";
19
- import { pierDb } from "./db.js";
19
+ import { pierDb, transact } from "./db.js";
20
20
  import { logger } from "./log.js";
21
21
  import { pierPath, resolveAgentDir } from "./paths.js";
22
22
  const log = logger("tools");
@@ -253,21 +253,15 @@ export class SyncLock {
253
253
  * in one immediate transaction, so two waiters cannot both win. */
254
254
  #acquire(token) {
255
255
  const now = Date.now();
256
- this.#db.exec("BEGIN IMMEDIATE");
257
- try {
258
- const stale = this.#db.prepare("DELETE FROM tools_sync_lock WHERE heartbeat_at <= ?")
259
- .run(now - this.#timing.staleMs);
260
- const taken = this.#db.prepare("INSERT OR IGNORE INTO tools_sync_lock (id, token, heartbeat_at) VALUES (1, ?, ?)")
261
- .run(token, now);
262
- this.#db.exec("COMMIT");
263
- if (stale.changes && taken.changes)
264
- log.warn("took over a tools sync lock whose holder stopped beating");
265
- return taken.changes === 1;
266
- }
267
- catch (err) {
268
- this.#db.exec("ROLLBACK");
269
- throw err;
270
- }
256
+ const { stale, taken } = transact(this.#db, () => ({
257
+ stale: this.#db.prepare("DELETE FROM tools_sync_lock WHERE heartbeat_at <= ?")
258
+ .run(now - this.#timing.staleMs),
259
+ taken: this.#db.prepare("INSERT OR IGNORE INTO tools_sync_lock (id, token, heartbeat_at) VALUES (1, ?, ?)")
260
+ .run(token, now),
261
+ }));
262
+ if (stale.changes && taken.changes)
263
+ log.warn("took over a tools sync lock whose holder stopped beating");
264
+ return taken.changes === 1;
271
265
  }
272
266
  /** Still ours? The authority is the row, asked now — not the heartbeat's own
273
267
  * bookkeeping, which a stopped process does not get to run either. */