@timqi/pier 0.1.3 → 0.1.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.
Files changed (97) hide show
  1. package/README.md +8 -3
  2. package/dist/agent/config.js +9 -3
  3. package/dist/agent/events.js +1 -2
  4. package/dist/agent/packages.js +1 -7
  5. package/dist/agent/pi.js +6 -47
  6. package/dist/channels/config.js +30 -20
  7. package/dist/channels/routes.js +0 -3
  8. package/dist/channels/slack-api.js +19 -10
  9. package/dist/channels/slack-cli.js +503 -0
  10. package/dist/channels/slack-directory.js +2 -2
  11. package/dist/channels/slack-outbound.js +1 -1
  12. package/dist/channels/slack-thread.js +41 -0
  13. package/dist/channels/slack-transcript.js +107 -0
  14. package/dist/channels/slack.js +9 -17
  15. package/dist/channels/types.js +0 -1
  16. package/dist/cli.js +158 -7
  17. package/dist/core/identity.js +20 -11
  18. package/dist/core/reply.js +16 -15
  19. package/dist/core/router.js +4 -1
  20. package/dist/db.js +28 -0
  21. package/dist/main.js +19 -32
  22. package/dist/paths.js +3 -0
  23. package/dist/secrets.js +2 -1
  24. package/dist/socket.js +95 -0
  25. package/dist/tasks/agent.js +5 -6
  26. package/dist/tasks/callbacks.js +2 -2
  27. package/dist/tasks/cli.js +215 -0
  28. package/dist/tasks/definitions.js +3 -3
  29. package/dist/tasks/execution.js +1 -3
  30. package/dist/tasks/groups.js +2 -5
  31. package/dist/tasks/messages.js +18 -158
  32. package/dist/tasks/operations.js +323 -0
  33. package/dist/tasks/routes.js +1 -11
  34. package/dist/tasks/runs.js +5 -20
  35. package/dist/tasks/service.js +18 -43
  36. package/dist/tasks/store.js +15 -15
  37. package/dist/tools.js +17 -0
  38. package/dist/vault.js +107 -0
  39. package/dist/web/auth.js +2 -1
  40. package/dist/web/public/assets/{activity-BSMeRcN2.js → activity-NLS8W9yl.js} +2 -2
  41. package/dist/web/public/assets/activity-NLS8W9yl.js.br +0 -0
  42. package/dist/web/public/assets/activity-NLS8W9yl.js.gz +0 -0
  43. package/dist/web/public/assets/{boards-BCWQMZry.js → boards-DleegoiC.js} +1 -1
  44. package/dist/web/public/assets/boards-DleegoiC.js.br +0 -0
  45. package/dist/web/public/assets/boards-DleegoiC.js.gz +0 -0
  46. package/dist/web/public/assets/{explorer-Cr4XTi4j.js → explorer-vJvV1Sx0.js} +1 -1
  47. package/dist/web/public/assets/explorer-vJvV1Sx0.js.br +0 -0
  48. package/dist/web/public/assets/explorer-vJvV1Sx0.js.gz +0 -0
  49. package/dist/web/public/assets/{index-DVIt5Gio.css → index-Bs-gol9o.css} +1 -1
  50. package/dist/web/public/assets/index-Bs-gol9o.css.br +0 -0
  51. package/dist/web/public/assets/{index-DVIt5Gio.css.gz → index-Bs-gol9o.css.gz} +0 -0
  52. package/dist/web/public/assets/index-Cyp2DKKF.js +85 -0
  53. package/dist/web/public/assets/index-Cyp2DKKF.js.br +0 -0
  54. package/dist/web/public/assets/index-Cyp2DKKF.js.gz +0 -0
  55. package/dist/web/public/assets/{runs-DdERzeac.js → runs-C5FveZ75.js} +1 -1
  56. package/dist/web/public/assets/runs-C5FveZ75.js.br +0 -0
  57. package/dist/web/public/assets/runs-C5FveZ75.js.gz +0 -0
  58. package/dist/web/public/assets/settings-nJHIBbAz.js +5 -0
  59. package/dist/web/public/assets/settings-nJHIBbAz.js.br +0 -0
  60. package/dist/web/public/assets/settings-nJHIBbAz.js.gz +0 -0
  61. package/dist/web/public/assets/{task-runs-C-dGUDsH.js → task-runs-CJJ2j5Ks.js} +1 -1
  62. package/dist/web/public/assets/task-runs-CJJ2j5Ks.js.br +0 -0
  63. package/dist/web/public/assets/task-runs-CJJ2j5Ks.js.gz +0 -0
  64. package/dist/web/public/assets/{tasks-CY30H1u1.js → tasks-R2Pv0Abg.js} +1 -1
  65. package/dist/web/public/assets/tasks-R2Pv0Abg.js.br +0 -0
  66. package/dist/web/public/assets/tasks-R2Pv0Abg.js.gz +0 -0
  67. package/dist/web/public/index.html +2 -2
  68. package/dist/web/public/index.html.br +0 -0
  69. package/dist/web/public/index.html.gz +0 -0
  70. package/dist/web/vault.js +68 -0
  71. package/docs/deploy.md +33 -10
  72. package/package.json +1 -1
  73. package/skills/pier-help/SKILL.md +10 -6
  74. package/skills/pier-slack/SKILL.md +52 -71
  75. package/skills/pier-tasks/SKILL.md +48 -145
  76. package/skills/pier-vault/SKILL.md +36 -0
  77. package/dist/channels/slack-tool.js +0 -416
  78. package/dist/tasks/tool.js +0 -416
  79. package/dist/web/public/assets/activity-BSMeRcN2.js.br +0 -0
  80. package/dist/web/public/assets/activity-BSMeRcN2.js.gz +0 -0
  81. package/dist/web/public/assets/boards-BCWQMZry.js.br +0 -0
  82. package/dist/web/public/assets/boards-BCWQMZry.js.gz +0 -0
  83. package/dist/web/public/assets/explorer-Cr4XTi4j.js.br +0 -0
  84. package/dist/web/public/assets/explorer-Cr4XTi4j.js.gz +0 -0
  85. package/dist/web/public/assets/index-C7tA0Ufu.js +0 -85
  86. package/dist/web/public/assets/index-C7tA0Ufu.js.br +0 -0
  87. package/dist/web/public/assets/index-C7tA0Ufu.js.gz +0 -0
  88. package/dist/web/public/assets/index-DVIt5Gio.css.br +0 -0
  89. package/dist/web/public/assets/runs-DdERzeac.js.br +0 -0
  90. package/dist/web/public/assets/runs-DdERzeac.js.gz +0 -0
  91. package/dist/web/public/assets/settings-CQDAHoMM.js +0 -5
  92. package/dist/web/public/assets/settings-CQDAHoMM.js.br +0 -0
  93. package/dist/web/public/assets/settings-CQDAHoMM.js.gz +0 -0
  94. package/dist/web/public/assets/task-runs-C-dGUDsH.js.br +0 -0
  95. package/dist/web/public/assets/task-runs-C-dGUDsH.js.gz +0 -0
  96. package/dist/web/public/assets/tasks-CY30H1u1.js.br +0 -0
  97. package/dist/web/public/assets/tasks-CY30H1u1.js.gz +0 -0
@@ -17,8 +17,8 @@ import { ReceiptLedger, Receipts } from "./receipts.js";
17
17
  import { SlackDirectory } from "./slack-directory.js";
18
18
  import { SlackApi, } from "./slack-api.js";
19
19
  import { SlackOutbound } from "./slack-outbound.js";
20
- import { readThread, slackToolAvailable } from "./slack-tool.js";
21
20
  import { SlackPanel } from "./slack-panel.js";
21
+ import { readThread } from "./slack-thread.js";
22
22
  import { context, escapeMrkdwn, offeredLabel } from "./slack-render.js";
23
23
  const WORKING = "eyes";
24
24
  // The envelope is already acked, so this bounds concurrency (sockets,
@@ -90,7 +90,7 @@ export class SlackChannel {
90
90
  const config = deps.store.get("slack");
91
91
  this.log = deps.log ?? ((m) => logger("slack").warn(m));
92
92
  this.chains = new Chains(this.log, MAX_ACTIVE_CHATS);
93
- this.directory = deps.directory ?? new SlackDirectory(this.log);
93
+ this.directory = new SlackDirectory(this.log);
94
94
  this.gate = new Gatekeeper(deps.store, "slack", this.log, "channel");
95
95
  this.seen = new Dedup(this.log, DEDUP_TTL_MS, DEDUP_MAX);
96
96
  this.api = deps.client ?? new SlackApi(config.token, config.appToken, this.log);
@@ -354,10 +354,8 @@ export class SlackChannel {
354
354
  }
355
355
  return name ?? channel;
356
356
  }
357
- /** The eager thread read runs whether or not `agentTool` is on: a human
358
- * handing the agent a message is the same act as an upload, which nothing
359
- * gates. `agentTool` only changes the hint, which must not name a tool this
360
- * session does not have. */
357
+ /** The eager thread read is not gated: a human handing the agent a message
358
+ * is the same act as an upload. */
361
359
  async sharedBlock(share) {
362
360
  const source = share.original_message;
363
361
  const ts = share.ts ?? source?.ts;
@@ -386,23 +384,19 @@ export class SlackChannel {
386
384
  const thread = parent && parent.replies <= INLINE_REPLY_MAX
387
385
  ? await this.sharedThread(parent.channel, parent.ts, parent.replies)
388
386
  : { transcript: false, lines: [] };
389
- // The coordinates carry the tool's own parameter names; naming the tool is
390
- // a lie when the Console has switched agent access off.
391
- const how = slackToolAvailable(this.deps.store) ? "read with the slack tool: " : "";
387
+ // The coordinates in `pier slack`'s own words (skills/pier-slack).
392
388
  const hint = parent && !thread.transcript
393
- ? `[thread: ${parent.replies} replies — ${how}channel ${parent.channel}, thread_ts ${parent.ts}]`
389
+ ? `[thread: ${parent.replies} replies — channel ${parent.channel}, thread_ts ${parent.ts}]`
394
390
  : "";
395
391
  // The transcript opens with the shared message itself.
396
392
  return [`[${head}]`, thread.transcript ? "" : body, ...thread.lines, hint]
397
393
  .filter(Boolean).join("\n");
398
394
  }
399
- /** Through the slack tool's own read, so paging and dedup exist once. A read
400
- * that fails or comes back cut says so in the prompt (§5). */
395
+ /** A read that fails or comes back cut says so in the prompt (§5). */
401
396
  async sharedThread(channel, ts, replies) {
402
- const deps = { directory: this.directory, log: this.log };
403
397
  try {
404
398
  // One over the budget, so an undercounting reply_count still reports as cut.
405
- const read = await readThread(deps, this.api, channel, ts, undefined, INLINE_REPLY_MAX + 2);
399
+ const read = await readThread(this.directory, this.api, channel, ts, INLINE_REPLY_MAX + 2);
406
400
  if (!read.messages.length)
407
401
  return { transcript: false, lines: [] };
408
402
  return {
@@ -410,9 +404,7 @@ export class SlackChannel {
410
404
  lines: [
411
405
  `[thread: ${replies} replies, oldest first — ${read.format}]`,
412
406
  ...read.messages,
413
- ...(read.incomplete || read.truncated
414
- ? [`[thread partly read: ${read.incomplete ?? `cut at ${read.count} lines`}]`]
415
- : []),
407
+ ...(read.truncated ? [`[thread partly read: cut at ${read.count} lines]`] : []),
416
408
  ],
417
409
  };
418
410
  }
@@ -12,7 +12,6 @@ export const defaultChannelConfig = () => ({
12
12
  enabled: false,
13
13
  token: "",
14
14
  appToken: "",
15
- agentTool: true,
16
15
  requireMention: true,
17
16
  requireBind: true,
18
17
  topicMode: true,
package/dist/cli.js CHANGED
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  // What `pier` does when typed. Dispatch only, and no server imports until a
3
3
  // command needs them: `pier service install` must not open a database.
4
- import { execFileSync } from "node:child_process";
4
+ import { execFileSync, spawn } from "node:child_process";
5
5
  import { accessSync, constants, realpathSync } from "node:fs";
6
+ import { request } from "node:http";
7
+ import { constants as osConstants } from "node:os";
6
8
  import { delimiter, join, resolve } from "node:path";
7
9
  import { parseArgs } from "node:util";
8
10
  import { fileURLToPath } from "node:url";
@@ -21,6 +23,10 @@ Usage
21
23
  pier restart finish running turns first, then restart the service
22
24
  pier reload re-read channel config and recycle idle sessions
23
25
  pier backup snapshot pier.db before a manual update
26
+ pier vault run [ENV=NAME | NAME]... -- <command> [args...]
27
+ run a command with named secrets in its env
28
+ pier slack <subcommand> ... Slack from a shell, token from the vault (pier slack --help)
29
+ pier task <command> ... subagents and scheduled tasks from a shell (pier task --help)
24
30
  pier --version | --help
25
31
 
26
32
  Options for "service install"
@@ -33,13 +39,21 @@ The workbench is behind a password generated on first run and printed once.
33
39
  Under systemd that print lands in the journal: journalctl --user -u pier -e
34
40
  `;
35
41
  const say = (message) => void process.stdout.write(`${message}\n`);
36
- const fail = (message) => {
37
- process.stderr.write(`pier: ${message}\n`);
42
+ /** Every socket route answers from memory and the database, so a Pier that
43
+ * takes longer than this is stuck, and an agent's shell must not hang with it. */
44
+ const SOCKET_TIMEOUT_MS = 30_000;
45
+ /** Typed on the binding: only then does a call narrow the code after it. */
46
+ const die = (message) => {
47
+ process.stderr.write(`${message}\n`);
38
48
  process.exit(2);
39
49
  };
50
+ const fail = (message) => die(`pier: ${message}`);
51
+ const argv = process.argv.slice(2);
40
52
  const parsed = (() => {
41
53
  try {
42
54
  return parseArgs({
55
+ // `slack` and `task` own their options; only the name is parsed here.
56
+ args: argv[0] === "slack" || argv[0] === "task" ? [argv[0]] : argv,
43
57
  allowPositionals: true,
44
58
  strict: true,
45
59
  options: {
@@ -59,7 +73,8 @@ const parsed = (() => {
59
73
  })();
60
74
  const { values, positionals } = parsed;
61
75
  const [command, subcommand, ...extra] = positionals;
62
- if (extra.length)
76
+ // `vault run` reads process.argv itself: parseArgs cannot say where the `--` was.
77
+ if (command !== "vault" && extra.length)
63
78
  fail(`unexpected argument "${extra[0]}"`);
64
79
  const allowOnly = (allowed, usage) => {
65
80
  const invalid = Object.keys(values).find((key) => !allowed.includes(key));
@@ -100,6 +115,16 @@ else if (command === "update") {
100
115
  else if (command === "tools") {
101
116
  await tools(subcommand);
102
117
  }
118
+ else if (command === "vault") {
119
+ await vault(subcommand, argv);
120
+ }
121
+ else if (command === "slack") {
122
+ await slack(argv.slice(1));
123
+ }
124
+ else if (command === "task") {
125
+ const { runTaskCli } = await import("./tasks/cli.js");
126
+ process.exitCode = await runTaskCli(argv.slice(1), (params) => askPier("/task", { params }));
127
+ }
103
128
  else if (command === "restart" || command === "reload") {
104
129
  if (subcommand)
105
130
  fail(`unexpected argument "${subcommand}"`);
@@ -196,7 +221,130 @@ async function backup() {
196
221
  const path = backupDb(version, PIER_DB);
197
222
  process.stdout.write(path ? `backed up ${path}\n` : `no database yet — nothing to back up.\n`);
198
223
  }
199
- function commandPath(name) {
224
+ /** Everything after `--` runs with the named secrets in its env — plain values
225
+ * directly, `vt://` records through `vt inject`, which swaps them after the
226
+ * operator's approval. Nothing here prints a value. */
227
+ async function vault(action, argv) {
228
+ const usage = "usage: pier vault run [ENV=NAME | NAME]... -- <command> [args...]";
229
+ const split = argv.indexOf("--");
230
+ const cmd = argv.slice(split + 1);
231
+ if (action !== "run" || split < 2 || !cmd.length)
232
+ die(usage);
233
+ // `NAME` alone is `NAME=NAME`: names are env-var shaped so the common case needs no mapping.
234
+ const wanted = argv.slice(2, split).map((spec) => {
235
+ const parts = spec.split("=");
236
+ const [env, name = env] = parts;
237
+ if (parts.length > 2 || !env || !name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(env))
238
+ die(usage);
239
+ return [env, name];
240
+ });
241
+ if (!wanted.length)
242
+ die(usage);
243
+ const values = await resolveSecrets([...new Set(wanted.map(([, name]) => name))]);
244
+ return inject(wanted, values, cmd);
245
+ }
246
+ /** `cmd` with each secret in its env; `approve` records go through `vt inject`,
247
+ * which swaps them after approval. Never returns: the child's exit is ours. */
248
+ function inject(wanted, values, cmd) {
249
+ const env = { ...process.env };
250
+ const records = [];
251
+ for (const [envName, name] of wanted) {
252
+ const hit = values[name];
253
+ env[envName] = hit.value;
254
+ if (hit.kind === "record")
255
+ records.push(envName);
256
+ }
257
+ let [file, ...args] = cmd;
258
+ if (records.length) {
259
+ if (!findCommand("vt")) {
260
+ const names = wanted.filter(([envName]) => records.includes(envName)).map(([, name]) => name);
261
+ die(`vault: vt is required for ${names.join(", ")} (approve level) and was not found`);
262
+ }
263
+ // Only the record-carrying variables are swapped; everything else passes through untouched.
264
+ [file, ...args] = ["vt", "inject", "--only-env", records.join(","), "--", ...cmd];
265
+ }
266
+ const child = spawn(file, args, { stdio: "inherit", env });
267
+ for (const signal of ["SIGINT", "SIGTERM"])
268
+ process.on(signal, () => child.kill(signal));
269
+ child.on("error", (err) => die(`vault: ${cmd[0]}: ${err.message}`));
270
+ child.on("exit", (code, signal) => {
271
+ // The shell's convention for a signal death, so a caller sees the same number it would without us.
272
+ process.exit(code ?? 128 + (signal ? osConstants.signals[signal] : 0));
273
+ });
274
+ return new Promise(() => { });
275
+ }
276
+ /** One request to the running Pier over its socket, signed with the caller's
277
+ * session (`PIER_SESSION_ID`, the harness variable mapped by the shim). Not
278
+ * running, silent, unreadable, or an identity or body Pier refuses, is one
279
+ * `pier:` line and exit 2 before any route reads the answer. */
280
+ async function askPier(path, body) {
281
+ const { PIER_SOCK } = await import("./paths.js");
282
+ const answer = await new Promise((done, reject) => {
283
+ let responded = false;
284
+ const req = request({ socketPath: PIER_SOCK, method: "POST", path, headers: { "content-type": "application/json" }, timeout: SOCKET_TIMEOUT_MS }, (res) => {
285
+ responded = true;
286
+ let raw = "";
287
+ const unreadable = () => reject(new Error(`unreadable answer from ${PIER_SOCK} (status ${String(res.statusCode ?? 0)})`));
288
+ res.on("data", (chunk) => (raw += chunk.toString()));
289
+ // A connection cut mid-body reports on the response, and `end` never comes.
290
+ res.on("error", unreadable);
291
+ res.on("end", () => {
292
+ try {
293
+ done({ status: res.statusCode ?? 0, body: JSON.parse(raw) });
294
+ }
295
+ catch {
296
+ unreadable();
297
+ }
298
+ });
299
+ });
300
+ req.on("timeout", () => req.destroy(new Error(`Pier did not answer within ${String(SOCKET_TIMEOUT_MS / 1000)} s`)));
301
+ // A 413 arrives while the body is still being written; the EPIPE after it is not the news.
302
+ req.on("error", (err) => responded || reject(err));
303
+ req.end(JSON.stringify({ ...body, sessionId: process.env.PIER_SESSION_ID }));
304
+ }).catch((err) =>
305
+ // A crash leaves the file with nobody behind it: that is "not running" too.
306
+ err.code === "ENOENT" || err.code === "ECONNREFUSED"
307
+ ? fail(`Pier is not running (no ${PIER_SOCK})`)
308
+ : fail(err.message));
309
+ if (answer.status === 400 || answer.status === 403 || answer.status === 413)
310
+ fail(answer.body.error ?? `socket answered ${String(answer.status)}`);
311
+ return answer;
312
+ }
313
+ /** The running Pier's answer for `names`, every name present; any failure is
314
+ * one `vault:` line and exit 2, so an agent reads words, not an empty variable. */
315
+ async function resolveSecrets(names) {
316
+ const { status, body } = await askPier("/resolve", { names });
317
+ if (status === 404)
318
+ die(`vault: ${body.error ?? "unknown name"} — file it at ${body.file ?? "the Console (Settings → Vault)"}`);
319
+ const values = body.values;
320
+ if (status !== 200 || !values)
321
+ die(`vault: ${body.error ?? `socket answered ${String(status)}`}`);
322
+ const missing = names.find((name) => !values[name]);
323
+ if (missing)
324
+ die(`vault: no secret named ${missing}`);
325
+ return values;
326
+ }
327
+ /** `$SLACK_BOT_TOKEN` when set (the re-exec below, or a test); otherwise the
328
+ * vault's `SLACK_TOKEN`. An `approve` record is only readable inside `vt
329
+ * inject`, so the command re-runs itself under it — the agent never sees
330
+ * the vault. Lazy: `--help` and usage errors never touch the socket. */
331
+ async function slack(args) {
332
+ const token = async () => {
333
+ const given = process.env.SLACK_BOT_TOKEN;
334
+ if (given)
335
+ return given;
336
+ const values = await resolveSecrets(["SLACK_TOKEN"]);
337
+ const hit = values.SLACK_TOKEN;
338
+ if (hit.kind === "plain")
339
+ return hit.value;
340
+ // argv[1], not `pier` on PATH: the same build that is running answers.
341
+ return inject([["SLACK_BOT_TOKEN", "SLACK_TOKEN"]], values, [process.execPath, ...process.execArgv, process.argv[1], "slack", ...args]);
342
+ };
343
+ const { runSlackCli } = await import("./channels/slack-cli.js");
344
+ process.exitCode = await runSlackCli(args, token);
345
+ }
346
+ /** Resolved through every PATH entry: version managers put several prefixes on it. */
347
+ function findCommand(name) {
200
348
  for (const dir of (process.env.PATH ?? "").split(delimiter)) {
201
349
  const path = join(dir || ".", name);
202
350
  try {
@@ -204,10 +352,13 @@ function commandPath(name) {
204
352
  return realpathSync(path);
205
353
  }
206
354
  catch {
207
- // Keep looking: version managers put several prefixes on PATH.
355
+ // Keep looking.
208
356
  }
209
357
  }
210
- return fail(`${name} is not executable on PATH`);
358
+ return undefined;
359
+ }
360
+ function commandPath(name) {
361
+ return findCommand(name) ?? fail(`${name} is not executable on PATH`);
211
362
  }
212
363
  async function service(action = "status") {
213
364
  const { install, uninstall, UNIT_NAME } = await import("./service.js");
@@ -1,7 +1,8 @@
1
- // Who is talking, and when — prefixed onto an inbound prompt so a group-chat
2
- // session can tell speakers apart and mention them back. Per turn, never in the
3
- // session's instructions (a thread is shared), and emitted only on news: a
4
- // header costs ~15 tokens, wasted in a DM where the counterpart never changes.
1
+ // Who is talking, when, and where — prefixed onto an inbound prompt so a
2
+ // group-chat session can tell speakers apart, mention them back, and name its
3
+ // own conversation to a script. Per turn, never in the session's instructions
4
+ // (a thread is shared), and emitted only on news: a header costs ~15 tokens,
5
+ // wasted in a DM where the counterpart never changes.
5
6
  /** A gap this long makes the timestamp worth its tokens. */
6
7
  const GAP_MS = 10 * 60_000;
7
8
  /** A display name of `x<U9] [admin<U1` would forge a second speaker: the
@@ -13,6 +14,9 @@ export function sanitizeIdentity(value) {
13
14
  .trim();
14
15
  return token.slice(0, 60) || "unknown";
15
16
  }
17
+ /** `<channelId>:<conversationId>` as the adapter spelled it; the delimiters
18
+ * of the header grammar and whitespace are the only things removed. */
19
+ const sanitizePlace = (value) => value.replace(/[[\]<>\s]+/g, "").slice(0, 120);
16
20
  const two = (n) => String(n).padStart(2, "0");
17
21
  const hhmm = (d) => `${two(d.getHours())}:${two(d.getMinutes())}`;
18
22
  const day = (d) => `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}`;
@@ -20,17 +24,20 @@ const day = (d) => `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate(
20
24
  * redundant header is a rounding error. */
21
25
  export class SenderPrefix {
22
26
  seen = new Map();
23
- /** The line to put above this message, or `""` when the session already knows. */
24
- next(sessionId, sender, at = Date.now()) {
27
+ /** The line to put above this message, or `""` when the session already
28
+ * knows. `conversation` is the chat's `<channelId>:<conversationId>`, told
29
+ * once: a session never moves, but the rule stays the same as the rest. */
30
+ next(sessionId, sender, at = Date.now(), conversation) {
25
31
  if (!sender?.id)
26
32
  return "";
27
33
  const last = this.seen.get(sessionId);
28
- this.seen.set(sessionId, { senderId: sender.id, at });
34
+ this.seen.set(sessionId, { senderId: sender.id, at, conversation });
29
35
  const now = new Date(at);
30
36
  const newSpeaker = last?.senderId !== sender.id;
31
37
  const gap = !last || at - last.at >= GAP_MS;
32
38
  const newDay = !last || day(new Date(last.at)) !== day(now);
33
- if (!newSpeaker && !gap && !newDay)
39
+ const newPlace = !!conversation && last?.conversation !== conversation;
40
+ if (!newSpeaker && !gap && !newDay && !newPlace)
34
41
  return "";
35
42
  // The id is the only thing a mention can be built from; an unresolved name
36
43
  // is the id, and `U123<U123>` would read as a broken record.
@@ -38,7 +45,8 @@ export class SenderPrefix {
38
45
  const label = sanitizeIdentity(sender.name);
39
46
  const who = newSpeaker ? (label === id ? `<${id}>` : `${label}<${id}>`) : "";
40
47
  const when = gap || newDay ? `${newDay ? `${day(now)} ` : ""}${hhmm(now)}` : "";
41
- return `[${[who, when].filter(Boolean).join(" ")}]`;
48
+ const where = newPlace ? sanitizePlace(conversation) : "";
49
+ return `[${[who, when, where].filter(Boolean).join(" ")}]`;
42
50
  }
43
51
  forget(sessionId) {
44
52
  this.seen.delete(sessionId);
@@ -47,17 +55,18 @@ export class SenderPrefix {
47
55
  export const withPrefix = (prefix, text) => prefix ? `${prefix}\n${text}` : text;
48
56
  // Only the shapes `next()` emits, newline included: a human typing
49
57
  // `[14:23] on my way` is body text and must come back untouched.
50
- const HEADER = /^\[(?:([^\n[\]<>]*)<([^\n[\]<>]+)>)? ?((?:\d{4}-\d{2}-\d{2} )?\d{1,2}:\d{2})?\]\n/;
58
+ const HEADER = /^\[(?:([^\n[\]<>]*)<([^\n[\]<>]+)>)? ?((?:\d{4}-\d{2}-\d{2} )?\d{1,2}:\d{2})? ?([a-z]+:[^\s[\]<>]+)?\]\n/;
51
59
  /** Read back a header this module wrote: the prefix is for the model, and a
52
60
  * surface showing a stored message renders the speaker its own way. */
53
61
  export function splitSpeaker(text) {
54
62
  const m = HEADER.exec(text);
55
- if (!m?.[2] && !m?.[3])
63
+ if (!m?.[2] && !m?.[3] && !m?.[4])
56
64
  return { text };
57
65
  return {
58
66
  ...(m[1] ? { name: m[1] } : {}),
59
67
  ...(m[2] ? { id: m[2] } : {}),
60
68
  ...(m[3] ? { when: m[3] } : {}),
69
+ ...(m[4] ? { where: m[4] } : {}),
61
70
  text: text.slice(m[0].length),
62
71
  };
63
72
  }
@@ -24,10 +24,13 @@ conventions:
24
24
  including humans talking to each other: stay silent rather than acknowledge
25
25
  what was not addressed to you.
26
26
 
27
- A message may start with \`[name<id> time]\` — the sender, added by Pier, not
28
- typed by them. It appears only on a change — new speaker, a ~10-minute gap, a
29
- new day — so the last one still applies; a gap alone shows as time only, like
30
- \`[14:23]\`. Use that \`id\` to mention someone; never ask for their own.
27
+ A message may start with \`[name<id> time place]\` — the sender and the chat,
28
+ added by Pier, not typed by them. It appears only on a change — new speaker, a
29
+ ~10-minute gap, a new day — so the last one still applies; a gap alone shows as
30
+ time only, like \`[14:23]\`. Use that \`id\` to mention someone; never ask for
31
+ their own. \`place\` is \`<platform>:<conversation>\` (Slack:
32
+ \`slack:<channel>/<thread_ts>\`), said once per session: the channel and thread a
33
+ script takes.
31
34
  `;
32
35
  /** Deployment facts an agent cannot discover: a guessed path is wrong wherever
33
36
  * `PIER_HOME` moved and fails as "nothing is configured"; GPT models carry
@@ -56,14 +59,7 @@ export function originLabel(origin) {
56
59
  if (origin.kind !== "task-message") {
57
60
  return origin.kind === "task-delegation" ? "\u25b6 delegated task" : "\u21a9 task callback";
58
61
  }
59
- const kinds = {
60
- steer: "\u270e steer",
61
- follow_up: "\uff0b follow-up",
62
- progress: "\u25c7 progress",
63
- decision: "\u2753 decision needed",
64
- reply: "\u21a9 reply",
65
- };
66
- return `from a subagent \u00b7 ${kinds[origin.messageKind] ?? origin.messageKind}`;
62
+ return `from a supervisor \u00b7 ${origin.messageKind === "steer" ? "\u270e steer" : "\uff0b follow-up"}`;
67
63
  }
68
64
  /** Is a turn coming once this note is posted? On IM the note is the only
69
65
  * message that turn has to wear the 👀; an error note reports a turn that
@@ -130,7 +126,12 @@ const MAX_SUGGESTIONS = 5;
130
126
  /** A deliberate non-answer. Stripped here so an adapter needs no new concept:
131
127
  * an empty turn already posts nothing and retires its per-turn UI. The reason
132
128
  * stays in the transcript, auditable without being broadcast. */
133
- const SILENT = /<silent>([\s\S]*?)<\/silent>/gi;
129
+ // A model has been seen emitting `<s<U+200B>ilent>`: zero-width characters inside
130
+ // the tag, which would post the literal tag to a channel instead of silence.
131
+ const ZW = String.raw `[\u200B-\u200D\u2060\uFEFF]*`;
132
+ const tag = (literal) => literal.split("").join(ZW);
133
+ const SILENT = new RegExp(`${tag("<silent>")}([\\s\\S]*?)${tag("</silent>")}`, "gi");
134
+ const SILENT_TAG = new RegExp(`<(\\/?)${tag("silent>")}`, "gi");
134
135
  /** Why the agent stayed quiet; hidden from the chat, shown on the workbench,
135
136
  * where a silent turn must not look like a broken one. */
136
137
  export function silentReason(markdown) {
@@ -193,8 +194,8 @@ export function stableBlockEnd(markdown, from = 0) {
193
194
  else if (open && run[0] === open[0] && run.length >= open.length && !fence?.[2]?.trim())
194
195
  open = "";
195
196
  if (!open && (!run || silent)) {
196
- for (const tag of line.matchAll(/<(\/?)silent>/gi))
197
- silent = !tag[1];
197
+ for (const t of line.matchAll(SILENT_TAG))
198
+ silent = !t[1];
198
199
  }
199
200
  pending = -1;
200
201
  prev = line;
@@ -567,7 +567,10 @@ export class Router {
567
567
  this.refuseDraining(msg.key);
568
568
  this.spokenTo?.(session.id);
569
569
  const { action, text } = decide(msg, session.state);
570
- const prompt = withPrefix(this.senders.next(session.id, msg.sender), text);
570
+ // A chat is named so the agent can hand it to a script (skills/pier-slack);
571
+ // an alias names nothing a shell could reach.
572
+ const where = isAlias(msg.key) ? undefined : keyOf(msg.key);
573
+ const prompt = withPrefix(this.senders.next(session.id, msg.sender, Date.now(), where), text);
571
574
  log.debug(`${action} ${keyOf(msg.key)} → session ${session.id} (${String(prompt.length)} chars)`);
572
575
  // A rejected call surfaces on the event stream, never as a throw across the seam.
573
576
  session[action](prompt).catch((err) => {
package/dist/db.js CHANGED
@@ -272,6 +272,34 @@ const MIGRATIONS = [
272
272
  -- Every row goes so the next scan re-reads each transcript and fills the table above.
273
273
  DELETE FROM session_index;
274
274
  `,
275
+ // 23 — the vault: named secrets `pier vault run` injects into one command.
276
+ `
277
+ -- name is an env-var name (^[A-Z][A-Z0-9_]{0,63}$). value is a sealed
278
+ -- envelope (auto) or a vt:// record (approve): the shape is the level.
279
+ CREATE TABLE vault (
280
+ name TEXT PRIMARY KEY,
281
+ value TEXT NOT NULL,
282
+ updated_at INTEGER NOT NULL
283
+ );
284
+ `,
285
+ // 24 — channel credentials move into the vault under fixed names (channels/config.ts).
286
+ `
287
+ -- The blob moves verbatim: same DEK, no decrypt, so this runs locked like
288
+ -- every migration. A name filed in the vault first wins (DO NOTHING); the
289
+ -- channel copy is dropped either way.
290
+ WITH moved(platform, path, name) AS (VALUES
291
+ ('telegram', '$.token', 'TELEGRAM_TOKEN'),
292
+ ('slack', '$.token', 'SLACK_TOKEN'), ('slack', '$.appToken', 'SLACK_APP_TOKEN'),
293
+ ('lark', '$.token', 'LARK_APP_ID'), ('lark', '$.appToken', 'LARK_APP_SECRET'))
294
+ INSERT INTO vault(name, value, updated_at)
295
+ SELECT moved.name, json_extract(channels.json, moved.path), CAST(strftime('%s', 'now') AS INTEGER) * 1000
296
+ FROM moved JOIN channels ON channels.platform = moved.platform
297
+ WHERE json_extract(channels.json, moved.path) <> ''
298
+ ON CONFLICT(name) DO NOTHING;
299
+ UPDATE channels SET json = json_remove(json, '$.token', '$.appToken');
300
+ `,
301
+ // 25 — the mid-run decision channel is gone; a control message is steer or follow_up.
302
+ `DELETE FROM task_messages WHERE json_extract(json, '$.kind') IN ('progress', 'decision', 'reply');`,
275
303
  ];
276
304
  /** `BEGIN IMMEDIATE`: taking the write lock up front turns a race with another
277
305
  * Pier process into a wait, where deferred would be SQLITE_BUSY halfway through. */
package/dist/main.js CHANGED
@@ -17,10 +17,6 @@ import { createControl } from "./channels/control.js";
17
17
  import { ConversationStore, resolveConversation } from "./channels/conversations.js";
18
18
  import { registerChannelRoutes } from "./channels/routes.js";
19
19
  import { ChannelRuntime } from "./channels/runtime.js";
20
- import { SlackApi } from "./channels/slack-api.js";
21
- import { SlackDirectory } from "./channels/slack-directory.js";
22
- import { handleSlackTool, slackToolAvailable, slackToolSpec } from "./channels/slack-tool.js";
23
- import { parseConversation as parseSlackConversation } from "./channels/slack.js";
24
20
  import { EventHub } from "./core/hub.js";
25
21
  import { splitSpeaker } from "./core/identity.js";
26
22
  import { pierDb } from "./db.js";
@@ -33,19 +29,21 @@ import { logger } from "./log.js";
33
29
  import { registerTaskRoutes } from "./tasks/routes.js";
34
30
  import { TaskService } from "./tasks/service.js";
35
31
  import { TaskStore } from "./tasks/store.js";
36
- import { taskToolSpec } from "./tasks/tool.js";
37
32
  import { PIER_HOME, pierPath, resolveAgentDir } from "./paths.js";
38
- import { CUSTOM_TOOL_RULES, MANAGED, ManagedTools, normalizeCustomTools, prependPath } from "./tools.js";
33
+ import { CUSTOM_TOOL_RULES, MANAGED, ManagedTools, normalizeCustomTools, prependPath, writePierShim } from "./tools.js";
39
34
  import { toolsTask } from "./tools-task.js";
40
35
  import { Secrets } from "./secrets.js";
41
36
  import { startUpdate, unitPath, updaterProblem } from "./service.js";
42
37
  import { SettingsStore } from "./settings.js";
43
38
  import { currentVersion, startAutoUpdate, UpdateCheck } from "./update.js";
39
+ import { Vault } from "./vault.js";
40
+ import { servePier } from "./socket.js";
44
41
  import { AuthStore, registerAuthRoutes, requireAuth } from "./web/auth.js";
45
42
  import { registerConfigShareRoute, registerConfigSyncRoutes } from "./web/config-sync.js";
46
43
  import { PushStore, registerPushRoutes } from "./web/push.js";
47
44
  import { SessionStateStore } from "./web/session-state.js";
48
45
  import { createServer } from "./web/server.js";
46
+ import { registerVaultRoutes } from "./web/vault.js";
49
47
  const log = logger("pier");
50
48
  // Before any SDK call resolves a path, so everything Pi derives from its agent
51
49
  // dir lands under PIER_HOME. PIER_AGENT_DIR marks the value as ours: a second
@@ -54,6 +52,7 @@ const log = logger("pier");
54
52
  process.env.PI_CODING_AGENT_DIR = resolveAgentDir(process.env);
55
53
  process.env.PIER_AGENT_DIR = process.env.PI_CODING_AGENT_DIR;
56
54
  prependPath(process.env);
55
+ writePierShim();
57
56
  // Before the database and before task recovery, which would mark the running
58
57
  // instance's queued and active runs interrupted; the port is discovered far too
59
58
  // late, and a second Pier on another port would never notice at all.
@@ -77,12 +76,11 @@ const settings = new SettingsStore(db);
77
76
  // Unlocked below: vt mode waits on a human approval, and nothing that needs a
78
77
  // token may run before the key arrives.
79
78
  const secrets = new Secrets();
79
+ const vault = new Vault(secrets, db);
80
80
  let tasks;
81
81
  const conversations = new ConversationStore(db);
82
82
  let resolveIm;
83
83
  let channelStore;
84
- // Shared by the adapter and the tool: one display-name lookup per process.
85
- const slackDirectory = new SlackDirectory((m) => logger("slack").warn(m));
86
84
  let readyForConfigReload = false;
87
85
  const piConfig = new PiConfigStore();
88
86
  // Before anything reads settings.json: a first boot gets Pier's seed file.
@@ -92,28 +90,7 @@ const configSync = new ConfigSync({
92
90
  reload: () => readyForConfigReload ? reloadInstance() : Promise.resolve(),
93
91
  });
94
92
  const skillsDir = fileURLToPath(new URL("../skills", import.meta.url));
95
- const agentTools = [
96
- taskToolSpec((params, callerSessionId) => tasks.tool(params, callerSessionId)),
97
- slackToolSpec((params, callerSessionId) => handleSlackTool({
98
- store: channelStore,
99
- directory: slackDirectory,
100
- // Per call: the Console can change the token underneath us.
101
- client: () => {
102
- const config = channelStore.get("slack");
103
- return config.token ? new SlackApi(config.token, config.appToken) : null;
104
- },
105
- // Per call: the mapping is durable, the session is not.
106
- here: (sessionId) => {
107
- const key = router.conversationOf(sessionId);
108
- if (key?.channelId !== "slack")
109
- return null;
110
- const { channel, threadTs } = parseSlackConversation(key.conversationId);
111
- return channel && threadTs ? { channel, threadTs } : null;
112
- },
113
- log: (m) => logger("slack.tool").warn(m),
114
- }, params, callerSessionId), () => slackToolAvailable(channelStore)),
115
- ];
116
- const factory = new PiAgentFactory(agentTools,
93
+ const factory = new PiAgentFactory(
117
94
  // Getters, read per session open: a Console change reaches the next session
118
95
  // without a restart.
119
96
  () => surfacePrompt({ boardsDir: defaultBoardsDir(), publicUrl: settings.get().publicUrl }),
@@ -142,10 +119,10 @@ const toolsUpdate = toolsTask(tasks);
142
119
  const reconciled = await toolsUpdate.reconcile();
143
120
  if ("problem" in reconciled)
144
121
  log.error(`tools cannot be managed: ${reconciled.problem}`);
145
- const packages = new PiPackageStore(piConfig, { version: currentVersion(), settings, tools: agentTools }, [skillsDir]);
122
+ const packages = new PiPackageStore(piConfig, { version: currentVersion(), settings }, [skillsDir]);
146
123
  // At boot, not lazily: the answer waits for the next Console open (update.ts).
147
124
  packages.watchUpdates();
148
- channelStore = new ChannelStore(db, secrets);
125
+ channelStore = new ChannelStore(db, vault);
149
126
  const control = createControl({ router, factory, conversations, store: channelStore });
150
127
  const channels = new ChannelRuntime(channelStore, router, control);
151
128
  resolveIm = resolveConversation(conversations, factory, control.launchFor, (message) => logger("channels").warn(message));
@@ -272,6 +249,7 @@ registerConfigSyncRoutes(app, {
272
249
  });
273
250
  registerTaskRoutes(app, tasks, { factory, router });
274
251
  registerChannelRoutes(app, channelStore, channels);
252
+ registerVaultRoutes(app, { vault, doctor: () => secrets.doctor() });
275
253
  registerBoardRoutes(app);
276
254
  const sessionState = new SessionStateStore(db);
277
255
  registerPushRoutes(app, {
@@ -316,6 +294,15 @@ app.route("/", createServer({
316
294
  }));
317
295
  const port = Number(process.env.PORT ?? 3141);
318
296
  const hostname = process.env.HOST ?? "127.0.0.1";
297
+ servePier({
298
+ vault,
299
+ // The deep link an agent's "no secret named X" error carries; loopback when
300
+ // no public URL is set, since nothing in the process can discover one.
301
+ fileUrl: (name) => `${settings.get().publicUrl || `http://127.0.0.1:${String(port)}`}/#/settings/vault?name=${name}`,
302
+ task: (params, callerSessionId) => tasks.handle(params, callerSessionId),
303
+ // Live in the router, or on disk: the same two places a callback target is looked for.
304
+ knows: async (id) => router.stateOf(id) !== undefined || (await factory.find(id)) !== undefined,
305
+ });
319
306
  // Every command a turn runs inherits this env: `NODE_ENV=production` makes an
320
307
  // agent's `npm install` skip devDependencies, and PORT/HOST would aim its dev
321
308
  // server at Pier's socket.
package/dist/paths.js CHANGED
@@ -12,6 +12,9 @@ export const PIER_DB = pierPath("db", "pier.db");
12
12
  /** At the root of PIER_HOME: the claim is the whole instance directory, not the
13
13
  * database (lock.ts). */
14
14
  export const PIER_LOCK = pierPath("pier.lock");
15
+ /** Where the `pier` CLI reaches the running Pier; the CLI needs the name
16
+ * without opening the database (socket.ts). */
17
+ export const PIER_SOCK = pierPath("pier.sock");
15
18
  /** `PI_CODING_AGENT_DIR` is an operator override — unless it equals
16
19
  * `PIER_AGENT_DIR`, the value Pier itself set: then a Pier spawned from inside
17
20
  * another inherited it, and a leak is not an instruction. */
package/dist/secrets.js CHANGED
@@ -146,7 +146,8 @@ function open(key, sealed, aad) {
146
146
  decipher.setAuthTag(tag);
147
147
  return Buffer.concat([decipher.update(ct), decipher.final()]);
148
148
  }
149
- const vtCli = {
149
+ /** The real vt; vault.ts creates its `approve` records through the same client. */
150
+ export const vtCli = {
150
151
  read: (record) => run("vt", ["read", record]),
151
152
  create: async (plaintext) => {
152
153
  const out = await run("vt", ["create"], plaintext);