@timqi/pier 0.1.3 → 0.1.5

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 (136) hide show
  1. package/README.md +12 -7
  2. package/dist/agent/config.js +9 -3
  3. package/dist/agent/events.js +13 -5
  4. package/dist/agent/listing.js +10 -2
  5. package/dist/agent/packages.js +4 -20
  6. package/dist/agent/pi.js +18 -86
  7. package/dist/channels/attach.js +2 -2
  8. package/dist/channels/commands.js +10 -8
  9. package/dist/channels/config.js +32 -24
  10. package/dist/channels/control.js +60 -14
  11. package/dist/channels/conversations.js +60 -13
  12. package/dist/channels/handoff.js +90 -0
  13. package/dist/channels/lark-api.js +7 -0
  14. package/dist/channels/lark-outbound.js +19 -0
  15. package/dist/channels/lark-panel.js +41 -26
  16. package/dist/channels/lark-render.js +2 -1
  17. package/dist/channels/lark.js +35 -26
  18. package/dist/channels/lines.js +1 -1
  19. package/dist/channels/panel.js +324 -115
  20. package/dist/channels/receipts.js +25 -12
  21. package/dist/channels/routes.js +21 -7
  22. package/dist/channels/runtime.js +21 -7
  23. package/dist/channels/slack-api.js +19 -10
  24. package/dist/channels/slack-cli.js +503 -0
  25. package/dist/channels/slack-directory.js +2 -2
  26. package/dist/channels/slack-outbound.js +11 -1
  27. package/dist/channels/slack-panel.js +81 -40
  28. package/dist/channels/slack-render.js +3 -1
  29. package/dist/channels/slack-thread.js +41 -0
  30. package/dist/channels/slack-transcript.js +107 -0
  31. package/dist/channels/slack.js +38 -31
  32. package/dist/channels/types.js +1 -3
  33. package/dist/cli.js +166 -7
  34. package/dist/core/identity.js +61 -17
  35. package/dist/core/reply.js +19 -15
  36. package/dist/core/router.js +23 -3
  37. package/dist/db.js +33 -0
  38. package/dist/main.js +61 -41
  39. package/dist/paths.js +3 -0
  40. package/dist/secrets.js +2 -1
  41. package/dist/settings.js +3 -12
  42. package/dist/socket.js +99 -0
  43. package/dist/tasks/agent.js +5 -6
  44. package/dist/tasks/callbacks.js +2 -2
  45. package/dist/tasks/cli.js +225 -0
  46. package/dist/tasks/definitions.js +3 -3
  47. package/dist/tasks/execution.js +1 -3
  48. package/dist/tasks/groups.js +2 -5
  49. package/dist/tasks/messages.js +18 -158
  50. package/dist/tasks/operations.js +335 -0
  51. package/dist/tasks/routes.js +1 -11
  52. package/dist/tasks/runs.js +5 -20
  53. package/dist/tasks/service.js +18 -43
  54. package/dist/tasks/store.js +15 -15
  55. package/dist/tools.js +18 -3
  56. package/dist/vault.js +107 -0
  57. package/dist/web/auth.js +2 -1
  58. package/dist/web/public/assets/{activity-BSMeRcN2.js → activity-CrybM-E8.js} +2 -2
  59. package/dist/web/public/assets/activity-CrybM-E8.js.br +0 -0
  60. package/dist/web/public/assets/activity-CrybM-E8.js.gz +0 -0
  61. package/dist/web/public/assets/{boards-BCWQMZry.js → boards-Cw7_6J6L.js} +1 -1
  62. package/dist/web/public/assets/boards-Cw7_6J6L.js.br +0 -0
  63. package/dist/web/public/assets/boards-Cw7_6J6L.js.gz +0 -0
  64. package/dist/web/public/assets/{explorer-Cr4XTi4j.js → explorer-DV066dUD.js} +1 -1
  65. package/dist/web/public/assets/explorer-DV066dUD.js.br +0 -0
  66. package/dist/web/public/assets/explorer-DV066dUD.js.gz +0 -0
  67. package/dist/web/public/assets/index-BrNHu2qj.js +85 -0
  68. package/dist/web/public/assets/index-BrNHu2qj.js.br +0 -0
  69. package/dist/web/public/assets/index-BrNHu2qj.js.gz +0 -0
  70. package/dist/web/public/assets/index-DLszkDUV.css +2 -0
  71. package/dist/web/public/assets/index-DLszkDUV.css.br +0 -0
  72. package/dist/web/public/assets/index-DLszkDUV.css.gz +0 -0
  73. package/dist/web/public/assets/{runs-DdERzeac.js → runs-DDagTNaM.js} +1 -1
  74. package/dist/web/public/assets/runs-DDagTNaM.js.br +0 -0
  75. package/dist/web/public/assets/runs-DDagTNaM.js.gz +0 -0
  76. package/dist/web/public/assets/settings-BEdSeXpm.js +5 -0
  77. package/dist/web/public/assets/settings-BEdSeXpm.js.br +0 -0
  78. package/dist/web/public/assets/settings-BEdSeXpm.js.gz +0 -0
  79. package/dist/web/public/assets/{task-runs-C-dGUDsH.js → task-runs-BnMack9t.js} +1 -1
  80. package/dist/web/public/assets/task-runs-BnMack9t.js.br +0 -0
  81. package/dist/web/public/assets/task-runs-BnMack9t.js.gz +0 -0
  82. package/dist/web/public/assets/{tasks-CY30H1u1.js → tasks-BkW7YShZ.js} +1 -1
  83. package/dist/web/public/assets/tasks-BkW7YShZ.js.br +0 -0
  84. package/dist/web/public/assets/tasks-BkW7YShZ.js.gz +0 -0
  85. package/dist/web/public/index.html +2 -2
  86. package/dist/web/public/index.html.br +0 -0
  87. package/dist/web/public/index.html.gz +0 -0
  88. package/dist/web/push.js +4 -10
  89. package/dist/web/vault.js +68 -0
  90. package/dist/{extensions/web → websearch}/artifacts.js +2 -2
  91. package/dist/websearch/cli.js +73 -0
  92. package/dist/websearch/run.js +273 -0
  93. package/docs/deploy.md +34 -11
  94. package/package.json +2 -3
  95. package/skills/pier-help/SKILL.md +32 -16
  96. package/skills/pier-slack/SKILL.md +52 -71
  97. package/skills/pier-tasks/SKILL.md +60 -148
  98. package/skills/pier-vault/SKILL.md +36 -0
  99. package/skills/pier-web/SKILL.md +50 -0
  100. package/dist/channels/slack-tool.js +0 -416
  101. package/dist/channels/telegram-api.js +0 -86
  102. package/dist/channels/telegram-panel.js +0 -97
  103. package/dist/channels/telegram-render.js +0 -69
  104. package/dist/channels/telegram.js +0 -421
  105. package/dist/extensions/index.js +0 -10
  106. package/dist/extensions/web/index.js +0 -9
  107. package/dist/extensions/web/tools.js +0 -265
  108. package/dist/tasks/tool.js +0 -416
  109. package/dist/web/public/assets/activity-BSMeRcN2.js.br +0 -0
  110. package/dist/web/public/assets/activity-BSMeRcN2.js.gz +0 -0
  111. package/dist/web/public/assets/boards-BCWQMZry.js.br +0 -0
  112. package/dist/web/public/assets/boards-BCWQMZry.js.gz +0 -0
  113. package/dist/web/public/assets/explorer-Cr4XTi4j.js.br +0 -0
  114. package/dist/web/public/assets/explorer-Cr4XTi4j.js.gz +0 -0
  115. package/dist/web/public/assets/index-C7tA0Ufu.js +0 -85
  116. package/dist/web/public/assets/index-C7tA0Ufu.js.br +0 -0
  117. package/dist/web/public/assets/index-C7tA0Ufu.js.gz +0 -0
  118. package/dist/web/public/assets/index-DVIt5Gio.css +0 -2
  119. package/dist/web/public/assets/index-DVIt5Gio.css.br +0 -0
  120. package/dist/web/public/assets/index-DVIt5Gio.css.gz +0 -0
  121. package/dist/web/public/assets/runs-DdERzeac.js.br +0 -0
  122. package/dist/web/public/assets/runs-DdERzeac.js.gz +0 -0
  123. package/dist/web/public/assets/settings-CQDAHoMM.js +0 -5
  124. package/dist/web/public/assets/settings-CQDAHoMM.js.br +0 -0
  125. package/dist/web/public/assets/settings-CQDAHoMM.js.gz +0 -0
  126. package/dist/web/public/assets/task-runs-C-dGUDsH.js.br +0 -0
  127. package/dist/web/public/assets/task-runs-C-dGUDsH.js.gz +0 -0
  128. package/dist/web/public/assets/tasks-CY30H1u1.js.br +0 -0
  129. package/dist/web/public/assets/tasks-CY30H1u1.js.gz +0 -0
  130. /package/dist/{extensions/web → websearch}/anthropic.js +0 -0
  131. /package/dist/{extensions/web → websearch}/content.js +0 -0
  132. /package/dist/{extensions/web → websearch}/http.js +0 -0
  133. /package/dist/{extensions/web → websearch}/json.js +0 -0
  134. /package/dist/{extensions/web → websearch}/language.js +0 -0
  135. /package/dist/{extensions/web → websearch}/openai.js +0 -0
  136. /package/dist/{extensions/web → websearch}/provider.js +0 -0
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,11 @@ 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)
30
+ pier web search|fetch ... the public web through the provider's hosted tools (pier web --help)
24
31
  pier --version | --help
25
32
 
26
33
  Options for "service install"
@@ -33,13 +40,24 @@ The workbench is behind a password generated on first run and printed once.
33
40
  Under systemd that print lands in the journal: journalctl --user -u pier -e
34
41
  `;
35
42
  const say = (message) => void process.stdout.write(`${message}\n`);
36
- const fail = (message) => {
37
- process.stderr.write(`pier: ${message}\n`);
43
+ /** Every socket route answers from memory and the database, so a Pier that
44
+ * takes longer than this is stuck, and an agent's shell must not hang with it. */
45
+ const SOCKET_TIMEOUT_MS = 30_000;
46
+ /** `/web` is the one route that waits on a provider: its own 90 s ceiling
47
+ * (`websearch/run.ts`) answers first, so this only catches a stuck Pier. */
48
+ const WEB_TIMEOUT_MS = 120_000;
49
+ /** Typed on the binding: only then does a call narrow the code after it. */
50
+ const die = (message) => {
51
+ process.stderr.write(`${message}\n`);
38
52
  process.exit(2);
39
53
  };
54
+ const fail = (message) => die(`pier: ${message}`);
55
+ const argv = process.argv.slice(2);
40
56
  const parsed = (() => {
41
57
  try {
42
58
  return parseArgs({
59
+ // `slack`, `task` and `web` own their options; only the name is parsed here.
60
+ args: argv[0] === "slack" || argv[0] === "task" || argv[0] === "web" ? [argv[0]] : argv,
43
61
  allowPositionals: true,
44
62
  strict: true,
45
63
  options: {
@@ -59,7 +77,8 @@ const parsed = (() => {
59
77
  })();
60
78
  const { values, positionals } = parsed;
61
79
  const [command, subcommand, ...extra] = positionals;
62
- if (extra.length)
80
+ // `vault run` reads process.argv itself: parseArgs cannot say where the `--` was.
81
+ if (command !== "vault" && extra.length)
63
82
  fail(`unexpected argument "${extra[0]}"`);
64
83
  const allowOnly = (allowed, usage) => {
65
84
  const invalid = Object.keys(values).find((key) => !allowed.includes(key));
@@ -100,6 +119,20 @@ else if (command === "update") {
100
119
  else if (command === "tools") {
101
120
  await tools(subcommand);
102
121
  }
122
+ else if (command === "vault") {
123
+ await vault(subcommand, argv);
124
+ }
125
+ else if (command === "slack") {
126
+ await slack(argv.slice(1));
127
+ }
128
+ else if (command === "task") {
129
+ const { runTaskCli } = await import("./tasks/cli.js");
130
+ process.exitCode = await runTaskCli(argv.slice(1), (params) => askPier("/task", { params }));
131
+ }
132
+ else if (command === "web") {
133
+ const { runWebCli } = await import("./websearch/cli.js");
134
+ process.exitCode = await runWebCli(argv.slice(1), (params) => askPier("/web", { params }, WEB_TIMEOUT_MS));
135
+ }
103
136
  else if (command === "restart" || command === "reload") {
104
137
  if (subcommand)
105
138
  fail(`unexpected argument "${subcommand}"`);
@@ -196,7 +229,130 @@ async function backup() {
196
229
  const path = backupDb(version, PIER_DB);
197
230
  process.stdout.write(path ? `backed up ${path}\n` : `no database yet — nothing to back up.\n`);
198
231
  }
199
- function commandPath(name) {
232
+ /** Everything after `--` runs with the named secrets in its env — plain values
233
+ * directly, `vt://` records through `vt inject`, which swaps them after the
234
+ * operator's approval. Nothing here prints a value. */
235
+ async function vault(action, argv) {
236
+ const usage = "usage: pier vault run [ENV=NAME | NAME]... -- <command> [args...]";
237
+ const split = argv.indexOf("--");
238
+ const cmd = argv.slice(split + 1);
239
+ if (action !== "run" || split < 2 || !cmd.length)
240
+ die(usage);
241
+ // `NAME` alone is `NAME=NAME`: names are env-var shaped so the common case needs no mapping.
242
+ const wanted = argv.slice(2, split).map((spec) => {
243
+ const parts = spec.split("=");
244
+ const [env, name = env] = parts;
245
+ if (parts.length > 2 || !env || !name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(env))
246
+ die(usage);
247
+ return [env, name];
248
+ });
249
+ if (!wanted.length)
250
+ die(usage);
251
+ const values = await resolveSecrets([...new Set(wanted.map(([, name]) => name))]);
252
+ return inject(wanted, values, cmd);
253
+ }
254
+ /** `cmd` with each secret in its env; `approve` records go through `vt inject`,
255
+ * which swaps them after approval. Never returns: the child's exit is ours. */
256
+ function inject(wanted, values, cmd) {
257
+ const env = { ...process.env };
258
+ const records = [];
259
+ for (const [envName, name] of wanted) {
260
+ const hit = values[name];
261
+ env[envName] = hit.value;
262
+ if (hit.kind === "record")
263
+ records.push(envName);
264
+ }
265
+ let [file, ...args] = cmd;
266
+ if (records.length) {
267
+ if (!findCommand("vt")) {
268
+ const names = wanted.filter(([envName]) => records.includes(envName)).map(([, name]) => name);
269
+ die(`vault: vt is required for ${names.join(", ")} (approve level) and was not found`);
270
+ }
271
+ // Only the record-carrying variables are swapped; everything else passes through untouched.
272
+ [file, ...args] = ["vt", "inject", "--only-env", records.join(","), "--", ...cmd];
273
+ }
274
+ const child = spawn(file, args, { stdio: "inherit", env });
275
+ for (const signal of ["SIGINT", "SIGTERM"])
276
+ process.on(signal, () => child.kill(signal));
277
+ child.on("error", (err) => die(`vault: ${cmd[0]}: ${err.message}`));
278
+ child.on("exit", (code, signal) => {
279
+ // The shell's convention for a signal death, so a caller sees the same number it would without us.
280
+ process.exit(code ?? 128 + (signal ? osConstants.signals[signal] : 0));
281
+ });
282
+ return new Promise(() => { });
283
+ }
284
+ /** One request to the running Pier over its socket, signed with the caller's
285
+ * session (`PIER_SESSION_ID`, the harness variable mapped by the shim). Not
286
+ * running, silent, unreadable, or an identity or body Pier refuses, is one
287
+ * `pier:` line and exit 2 before any route reads the answer. */
288
+ async function askPier(path, body, timeout = SOCKET_TIMEOUT_MS) {
289
+ const { PIER_SOCK } = await import("./paths.js");
290
+ const answer = await new Promise((done, reject) => {
291
+ let responded = false;
292
+ const req = request({ socketPath: PIER_SOCK, method: "POST", path, headers: { "content-type": "application/json" }, timeout }, (res) => {
293
+ responded = true;
294
+ let raw = "";
295
+ const unreadable = () => reject(new Error(`unreadable answer from ${PIER_SOCK} (status ${String(res.statusCode ?? 0)})`));
296
+ res.on("data", (chunk) => (raw += chunk.toString()));
297
+ // A connection cut mid-body reports on the response, and `end` never comes.
298
+ res.on("error", unreadable);
299
+ res.on("end", () => {
300
+ try {
301
+ done({ status: res.statusCode ?? 0, body: JSON.parse(raw) });
302
+ }
303
+ catch {
304
+ unreadable();
305
+ }
306
+ });
307
+ });
308
+ req.on("timeout", () => req.destroy(new Error(`Pier did not answer within ${String(timeout / 1000)} s`)));
309
+ // A 413 arrives while the body is still being written; the EPIPE after it is not the news.
310
+ req.on("error", (err) => responded || reject(err));
311
+ req.end(JSON.stringify({ ...body, sessionId: process.env.PIER_SESSION_ID }));
312
+ }).catch((err) =>
313
+ // A crash leaves the file with nobody behind it: that is "not running" too.
314
+ err.code === "ENOENT" || err.code === "ECONNREFUSED"
315
+ ? fail(`Pier is not running (no ${PIER_SOCK})`)
316
+ : fail(err.message));
317
+ if (answer.status === 400 || answer.status === 403 || answer.status === 413)
318
+ fail(answer.body.error ?? `socket answered ${String(answer.status)}`);
319
+ return answer;
320
+ }
321
+ /** The running Pier's answer for `names`, every name present; any failure is
322
+ * one `vault:` line and exit 2, so an agent reads words, not an empty variable. */
323
+ async function resolveSecrets(names) {
324
+ const { status, body } = await askPier("/resolve", { names });
325
+ if (status === 404)
326
+ die(`vault: ${body.error ?? "unknown name"} — file it at ${body.file ?? "the Console (Settings → Vault)"}`);
327
+ const values = body.values;
328
+ if (status !== 200 || !values)
329
+ die(`vault: ${body.error ?? `socket answered ${String(status)}`}`);
330
+ const missing = names.find((name) => !values[name]);
331
+ if (missing)
332
+ die(`vault: no secret named ${missing}`);
333
+ return values;
334
+ }
335
+ /** `$SLACK_BOT_TOKEN` when set (the re-exec below, or a test); otherwise the
336
+ * vault's `SLACK_TOKEN`. An `approve` record is only readable inside `vt
337
+ * inject`, so the command re-runs itself under it — the agent never sees
338
+ * the vault. Lazy: `--help` and usage errors never touch the socket. */
339
+ async function slack(args) {
340
+ const token = async () => {
341
+ const given = process.env.SLACK_BOT_TOKEN;
342
+ if (given)
343
+ return given;
344
+ const values = await resolveSecrets(["SLACK_TOKEN"]);
345
+ const hit = values.SLACK_TOKEN;
346
+ if (hit.kind === "plain")
347
+ return hit.value;
348
+ // argv[1], not `pier` on PATH: the same build that is running answers.
349
+ return inject([["SLACK_BOT_TOKEN", "SLACK_TOKEN"]], values, [process.execPath, ...process.execArgv, process.argv[1], "slack", ...args]);
350
+ };
351
+ const { runSlackCli } = await import("./channels/slack-cli.js");
352
+ process.exitCode = await runSlackCli(args, token);
353
+ }
354
+ /** Resolved through every PATH entry: version managers put several prefixes on it. */
355
+ function findCommand(name) {
200
356
  for (const dir of (process.env.PATH ?? "").split(delimiter)) {
201
357
  const path = join(dir || ".", name);
202
358
  try {
@@ -204,10 +360,13 @@ function commandPath(name) {
204
360
  return realpathSync(path);
205
361
  }
206
362
  catch {
207
- // Keep looking: version managers put several prefixes on PATH.
363
+ // Keep looking.
208
364
  }
209
365
  }
210
- return fail(`${name} is not executable on PATH`);
366
+ return undefined;
367
+ }
368
+ function commandPath(name) {
369
+ return findCommand(name) ?? fail(`${name} is not executable on PATH`);
211
370
  }
212
371
  async function service(action = "status") {
213
372
  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,25 +24,35 @@ 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
+ * `opaqueIds` is the platform's (`core/types.ts`): its ids buy nothing. */
31
+ next(sessionId, sender, at = Date.now(), conversation, opaqueIds = false) {
25
32
  if (!sender?.id)
26
33
  return "";
27
34
  const last = this.seen.get(sessionId);
28
- this.seen.set(sessionId, { senderId: sender.id, at });
35
+ this.seen.set(sessionId, { senderId: sender.id, at, conversation });
29
36
  const now = new Date(at);
30
37
  const newSpeaker = last?.senderId !== sender.id;
31
38
  const gap = !last || at - last.at >= GAP_MS;
32
39
  const newDay = !last || day(new Date(last.at)) !== day(now);
33
- if (!newSpeaker && !gap && !newDay)
40
+ const newPlace = !!conversation && last?.conversation !== conversation;
41
+ if (!newSpeaker && !gap && !newDay && !newPlace)
34
42
  return "";
35
43
  // The id is the only thing a mention can be built from; an unresolved name
36
44
  // is the id, and `U123<U123>` would read as a broken record.
37
45
  const id = sanitizeIdentity(sender.id);
38
46
  const label = sanitizeIdentity(sender.name);
39
- const who = newSpeaker ? (label === id ? `<${id}>` : `${label}<${id}>`) : "";
40
- const when = gap || newDay ? `${newDay ? `${day(now)} ` : ""}${hhmm(now)}` : "";
41
- return `[${[who, when].filter(Boolean).join(" ")}]`;
47
+ const named = opaqueIds ? label : label === id ? `<${id}>` : `${label}<${id}>`;
48
+ const who = newSpeaker ? named : "";
49
+ // A bare name has no `<>` to be told apart by, so it is only unambiguous
50
+ // next to the time: with opaque ids the clock is written whenever it is.
51
+ const clock = gap || newDay || (opaqueIds && !!who);
52
+ const when = clock ? `${newDay ? `${day(now)} ` : ""}${hhmm(now)}` : "";
53
+ const place = opaqueIds ? conversation?.split(":")[0] : conversation;
54
+ const where = newPlace && place ? sanitizePlace(place) : "";
55
+ return `[${[who, when, where].filter(Boolean).join(" ")}]`;
42
56
  }
43
57
  forget(sessionId) {
44
58
  this.seen.delete(sessionId);
@@ -47,17 +61,26 @@ export class SenderPrefix {
47
61
  export const withPrefix = (prefix, text) => prefix ? `${prefix}\n${text}` : text;
48
62
  // Only the shapes `next()` emits, newline included: a human typing
49
63
  // `[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/;
64
+ const TIME = String.raw `(?<when>(?:\d{4}-\d{2}-\d{2} )?\d{1,2}:\d{2})`;
65
+ const WITH_ID = new RegExp(String.raw `^\[(?:(?<name>[^\n[\]<>]*)<(?<id>[^\n[\]<>]+)>)? ?${TIME}? ?(?<where>[a-z]+:[^\s[\]<>]+)?\]\n`);
66
+ /** The opaque-ids shape: a name with no `<>`, told apart from body text by the
67
+ * time that always follows it, and a platform with no conversation after it.
68
+ * A line of its own reading `[meeting 14:23]` is the price. */
69
+ const NAMED = new RegExp(String.raw `^\[(?<name>[^\n[\]<>]*?) ${TIME}(?: (?<where>[a-z]+))?\]\n`);
51
70
  /** Read back a header this module wrote: the prefix is for the model, and a
52
71
  * surface showing a stored message renders the speaker its own way. */
53
72
  export function splitSpeaker(text) {
54
- const m = HEADER.exec(text);
55
- if (!m?.[2] && !m?.[3])
73
+ const head = WITH_ID.exec(text);
74
+ const { id, when, where } = head?.groups ?? {};
75
+ // A name on its own proves nothing: try the shape that requires a time.
76
+ const m = id || when || where ? head : NAMED.exec(text);
77
+ if (!m?.groups)
56
78
  return { text };
57
79
  return {
58
- ...(m[1] ? { name: m[1] } : {}),
59
- ...(m[2] ? { id: m[2] } : {}),
60
- ...(m[3] ? { when: m[3] } : {}),
80
+ ...(m.groups.name ? { name: m.groups.name } : {}),
81
+ ...(m.groups.id ? { id: m.groups.id } : {}),
82
+ ...(m.groups.when ? { when: m.groups.when } : {}),
83
+ ...(m.groups.where ? { where: m.groups.where } : {}),
61
84
  text: text.slice(m[0].length),
62
85
  };
63
86
  }
@@ -85,3 +108,24 @@ export function readableTitle(title) {
85
108
  .trim();
86
109
  return said || title;
87
110
  }
111
+ /** How a session is named where it is announced (a push, a handoff): its
112
+ * readable title, else its directory. Never empty — a listing that could
113
+ * not answer must not silence the message. One line: every caller puts it
114
+ * inside emphasis or a button, where a newline breaks the markup. */
115
+ export const sessionLabel = (s) => readableTitle(s?.title)?.replace(/\s+/g, " ").trim() || s?.cwd.split("/").filter(Boolean).at(-1) || "Pier session";
116
+ /** Distinct directories, newest session first: the ground `projectCwds` picks
117
+ * from. */
118
+ export const distinctCwds = (list) => [...new Set([...list].sort((a, b) => b.createdAt - a.createdAt).map((s) => s.cwd))];
119
+ /** The distinct directories less the worktrees: `wt` puts a checkout beside its
120
+ * repository as `<repo>.<branch>`, and the next conversation about a project
121
+ * belongs in the project. A worktree with no such sibling stays. What every
122
+ * directory picker — web New-session menu, Settings scope, IM panel — offers. */
123
+ export function projectCwds(list) {
124
+ const all = distinctCwds(list);
125
+ const known = new Set(all);
126
+ return all.filter((cwd) => {
127
+ const slash = cwd.lastIndexOf("/");
128
+ const dot = cwd.indexOf(".", slash + 2); // not a leading dot: `.pier` is a name
129
+ return dot < 0 || !known.has(cwd.slice(0, dot));
130
+ });
131
+ }
@@ -24,10 +24,14 @@ 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. Where no tool of yours takes that platform's ids, the header
34
+ carries neither and reads \`[name time platform]\`.
31
35
  `;
32
36
  /** Deployment facts an agent cannot discover: a guessed path is wrong wherever
33
37
  * `PIER_HOME` moved and fails as "nothing is configured"; GPT models carry
@@ -56,14 +60,7 @@ export function originLabel(origin) {
56
60
  if (origin.kind !== "task-message") {
57
61
  return origin.kind === "task-delegation" ? "\u25b6 delegated task" : "\u21a9 task callback";
58
62
  }
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}`;
63
+ return `from a supervisor \u00b7 ${origin.messageKind === "steer" ? "\u270e steer" : "\uff0b follow-up"}`;
67
64
  }
68
65
  /** Is a turn coming once this note is posted? On IM the note is the only
69
66
  * message that turn has to wear the 👀; an error note reports a turn that
@@ -106,6 +103,8 @@ export const quietLabel = (silence) => silence ? `stayed silent — ${silence}`
106
103
  export const isSilentReply = (reply) => !reply.text.trim() && reply.suggestions.length === 0;
107
104
  /** How a reasoning level is spelled wherever a human reads it. */
108
105
  export const thinkingLabel = (level) => level === "xhigh" ? "Extra high" : level[0].toUpperCase() + level.slice(1);
106
+ /** `max` characters, the last one an ellipsis when something was cut. */
107
+ export const cut = (text, max) => (text.length > max ? `${text.slice(0, max - 1)}…` : text);
109
108
  /** 1200 → "1.2K", 12_000 → "12K" — absolute token counts read badly inline. */
110
109
  export const compact = (n) => {
111
110
  if (n < 1000)
@@ -130,7 +129,12 @@ const MAX_SUGGESTIONS = 5;
130
129
  /** A deliberate non-answer. Stripped here so an adapter needs no new concept:
131
130
  * an empty turn already posts nothing and retires its per-turn UI. The reason
132
131
  * stays in the transcript, auditable without being broadcast. */
133
- const SILENT = /<silent>([\s\S]*?)<\/silent>/gi;
132
+ // A model has been seen emitting `<s<U+200B>ilent>`: zero-width characters inside
133
+ // the tag, which would post the literal tag to a channel instead of silence.
134
+ const ZW = String.raw `[\u200B-\u200D\u2060\uFEFF]*`;
135
+ const tag = (literal) => literal.split("").join(ZW);
136
+ const SILENT = new RegExp(`${tag("<silent>")}([\\s\\S]*?)${tag("</silent>")}`, "gi");
137
+ const SILENT_TAG = new RegExp(`<(\\/?)${tag("silent>")}`, "gi");
134
138
  /** Why the agent stayed quiet; hidden from the chat, shown on the workbench,
135
139
  * where a silent turn must not look like a broken one. */
136
140
  export function silentReason(markdown) {
@@ -193,8 +197,8 @@ export function stableBlockEnd(markdown, from = 0) {
193
197
  else if (open && run[0] === open[0] && run.length >= open.length && !fence?.[2]?.trim())
194
198
  open = "";
195
199
  if (!open && (!run || silent)) {
196
- for (const tag of line.matchAll(/<(\/?)silent>/gi))
197
- silent = !tag[1];
200
+ for (const t of line.matchAll(SILENT_TAG))
201
+ silent = !t[1];
198
202
  }
199
203
  pending = -1;
200
204
  prev = line;
@@ -52,6 +52,7 @@ export class Router {
52
52
  hub;
53
53
  resolve;
54
54
  sessionIdOf;
55
+ chatKeyOf;
55
56
  byKey = new Map();
56
57
  bySession = new Map();
57
58
  /** Resolves in flight: two surfaces asking at once must share one session
@@ -70,10 +71,14 @@ export class Router {
70
71
  /** The durable chat → session mapping, read before opening: a chat and the
71
72
  * workbench asking for one transcript must share one lock and one object.
72
73
  * Undefined for a chat that has none yet. */
73
- sessionIdOf = () => undefined) {
74
+ sessionIdOf = () => undefined,
75
+ /** The inverse: the durable chat of a session an alias is opening, so the
76
+ * chat is the delivery key from the first turn (wired in main.ts). */
77
+ chatKeyOf = () => undefined) {
74
78
  this.hub = hub;
75
79
  this.resolve = resolve;
76
80
  this.sessionIdOf = sessionIdOf;
81
+ this.chatKeyOf = chatKeyOf;
77
82
  }
78
83
  registerChannel(channel) {
79
84
  this.channels.set(channel.id, channel);
@@ -233,7 +238,11 @@ export class Router {
233
238
  // triggers, so the answer has a visible cause. The hub carries it whole.
234
239
  if (payload.type === "system-input") {
235
240
  const channel = this.channels.get(key.channelId);
236
- channel?.notify(key.conversationId, { text: digest(payload.text), origin: payload.origin })
241
+ channel?.notify(key.conversationId, {
242
+ text: digest(payload.text),
243
+ origin: payload.origin,
244
+ at: payload.at,
245
+ })
237
246
  .catch((err) => {
238
247
  log.error(`notify ${key.channelId} failed`, err);
239
248
  this.hub.emit(session.id, {
@@ -263,6 +272,13 @@ export class Router {
263
272
  }),
264
273
  };
265
274
  this.bySession.set(session.id, attached);
275
+ // The durable chat outranks the alias that happened to open the session
276
+ // first (a restart, the web speaking first), same rule as `reached`.
277
+ const chat = isAlias(key) ? this.chatKeyOf(session.id) : undefined;
278
+ if (chat) {
279
+ this.byKey.set(keyOf(chat), session);
280
+ attached.key = chat;
281
+ }
266
282
  }
267
283
  /** An adapter's send is several platform calls (chunks, then attachments),
268
284
  * so two answers left to overlap interleave in the chat. Per conversation:
@@ -567,7 +583,11 @@ export class Router {
567
583
  this.refuseDraining(msg.key);
568
584
  this.spokenTo?.(session.id);
569
585
  const { action, text } = decide(msg, session.state);
570
- const prompt = withPrefix(this.senders.next(session.id, msg.sender), text);
586
+ // A chat is named so the agent can hand it to a script (skills/pier-slack);
587
+ // an alias names nothing a shell could reach.
588
+ const where = isAlias(msg.key) ? undefined : keyOf(msg.key);
589
+ const opaque = this.channels.get(msg.key.channelId)?.opaqueIds;
590
+ const prompt = withPrefix(this.senders.next(session.id, msg.sender, Date.now(), where, opaque), text);
571
591
  log.debug(`${action} ${keyOf(msg.key)} → session ${session.id} (${String(prompt.length)} chars)`);
572
592
  // A rejected call surfaces on the event stream, never as a throw across the seam.
573
593
  session[action](prompt).catch((err) => {
package/dist/db.js CHANGED
@@ -271,6 +271,39 @@ const MIGRATIONS = [
271
271
  );
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
+ `,
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
+ ('slack', '$.token', 'SLACK_TOKEN'), ('slack', '$.appToken', 'SLACK_APP_TOKEN'),
292
+ ('lark', '$.token', 'LARK_APP_ID'), ('lark', '$.appToken', 'LARK_APP_SECRET'))
293
+ INSERT INTO vault(name, value, updated_at)
294
+ SELECT moved.name, json_extract(channels.json, moved.path), CAST(strftime('%s', 'now') AS INTEGER) * 1000
295
+ FROM moved JOIN channels ON channels.platform = moved.platform
296
+ WHERE json_extract(channels.json, moved.path) <> ''
297
+ ON CONFLICT(name) DO NOTHING;
298
+ UPDATE channels SET json = json_remove(json, '$.token', '$.appToken');
299
+ `,
300
+ // 25 — the mid-run decision channel is gone; a control message is steer or follow_up.
301
+ `DELETE FROM task_messages WHERE json_extract(json, '$.kind') IN ('progress', 'decision', 'reply');`,
302
+ // 26 — a session created from the panel is re-created from its own launch
303
+ // record (channels/conversations.ts); the index serves the session → thread lookups.
304
+ `
305
+ ALTER TABLE conversations ADD COLUMN launch TEXT;
306
+ CREATE INDEX conversations_session ON conversations(session_id);
274
307
  `,
275
308
  ];
276
309
  /** `BEGIN IMMEDIATE`: taking the write lock up front turns a race with another