@timqi/pier 0.1.14 → 0.2.0

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 (126) hide show
  1. package/README.md +44 -22
  2. package/dist/agent/config.js +2 -2
  3. package/dist/agent/events.js +32 -3
  4. package/dist/agent/packages.js +6 -10
  5. package/dist/agent/pi.js +111 -68
  6. package/dist/agent/roles.js +103 -0
  7. package/dist/agent/types.js +78 -0
  8. package/dist/boards/boards.js +42 -32
  9. package/dist/channels/chains.js +0 -4
  10. package/dist/channels/commands.js +7 -12
  11. package/dist/channels/config.js +27 -1
  12. package/dist/channels/control.js +5 -16
  13. package/dist/channels/conversations.js +0 -5
  14. package/dist/channels/lark-api.js +20 -26
  15. package/dist/channels/lark-outbound.js +16 -29
  16. package/dist/channels/lark-panel.js +1 -1
  17. package/dist/channels/lark.js +61 -39
  18. package/dist/channels/lines.js +33 -1
  19. package/dist/channels/panel.js +17 -107
  20. package/dist/channels/routes.js +11 -21
  21. package/dist/channels/runtime.js +34 -25
  22. package/dist/channels/slack-outbound.js +6 -15
  23. package/dist/channels/slack-panel.js +1 -1
  24. package/dist/channels/slack-render.js +1 -1
  25. package/dist/channels/slack.js +63 -57
  26. package/dist/channels/types.js +1 -1
  27. package/dist/config-sync.js +42 -37
  28. package/dist/core/chain.js +202 -0
  29. package/dist/core/identity.js +34 -11
  30. package/dist/core/inbound-file.js +4 -3
  31. package/dist/core/reply.js +96 -80
  32. package/dist/core/router.js +111 -118
  33. package/dist/core/types.js +36 -77
  34. package/dist/db.js +33 -2
  35. package/dist/main.js +46 -37
  36. package/dist/settings.js +25 -18
  37. package/dist/tasks/agent.js +11 -5
  38. package/dist/tasks/callbacks.js +55 -5
  39. package/dist/tasks/cli.js +22 -11
  40. package/dist/tasks/definitions.js +30 -17
  41. package/dist/tasks/execution.js +2 -2
  42. package/dist/tasks/groups.js +5 -2
  43. package/dist/tasks/messages.js +7 -7
  44. package/dist/tasks/open-items.js +70 -0
  45. package/dist/tasks/operations.js +76 -39
  46. package/dist/tasks/outbox.js +19 -6
  47. package/dist/tasks/routes.js +19 -249
  48. package/dist/tasks/service.js +145 -34
  49. package/dist/tasks/store.js +119 -56
  50. package/dist/tasks/types.js +5 -0
  51. package/dist/tools.js +2 -2
  52. package/dist/web/auth.js +2 -2
  53. package/dist/web/config-sync.js +4 -5
  54. package/dist/web/config.js +9 -2
  55. package/dist/web/explorer.js +2 -2
  56. package/dist/web/fs.js +16 -1
  57. package/dist/web/instance.js +8 -5
  58. package/dist/web/packages.js +1 -1
  59. package/dist/web/passkeys.js +3 -3
  60. package/dist/web/providers.js +3 -3
  61. package/dist/web/public/assets/code-BZwvPani.js +2 -0
  62. package/dist/web/public/assets/code-BZwvPani.js.br +0 -0
  63. package/dist/web/public/assets/code-BZwvPani.js.gz +0 -0
  64. package/dist/web/public/assets/explorer-DDoVzMLQ.js +5 -0
  65. package/dist/web/public/assets/explorer-DDoVzMLQ.js.br +0 -0
  66. package/dist/web/public/assets/explorer-DDoVzMLQ.js.gz +0 -0
  67. package/dist/web/public/assets/highlight-5Bl8zZ8q.js +72 -0
  68. package/dist/web/public/assets/highlight-5Bl8zZ8q.js.br +0 -0
  69. package/dist/web/public/assets/highlight-5Bl8zZ8q.js.gz +0 -0
  70. package/dist/web/public/assets/{hljs-tWqyD59G.js → hljs-BRz6a8Dq.js} +2 -2
  71. package/dist/web/public/assets/hljs-BRz6a8Dq.js.br +0 -0
  72. package/dist/web/public/assets/hljs-BRz6a8Dq.js.gz +0 -0
  73. package/dist/web/public/assets/index-BpFzCwlo.js +7 -0
  74. package/dist/web/public/assets/index-BpFzCwlo.js.br +0 -0
  75. package/dist/web/public/assets/index-BpFzCwlo.js.gz +0 -0
  76. package/dist/web/public/assets/index-wAGxFFPj.css +2 -0
  77. package/dist/web/public/assets/index-wAGxFFPj.css.br +0 -0
  78. package/dist/web/public/assets/index-wAGxFFPj.css.gz +0 -0
  79. package/dist/web/public/assets/report-zjE8Bt8S.js +8 -0
  80. package/dist/web/public/assets/report-zjE8Bt8S.js.br +0 -0
  81. package/dist/web/public/assets/report-zjE8Bt8S.js.gz +0 -0
  82. package/dist/web/public/assets/settings-Cv7rbeqf.js +9 -0
  83. package/dist/web/public/assets/settings-Cv7rbeqf.js.br +0 -0
  84. package/dist/web/public/assets/settings-Cv7rbeqf.js.gz +0 -0
  85. package/dist/web/public/index.html +40 -109
  86. package/dist/web/public/index.html.br +0 -0
  87. package/dist/web/public/index.html.gz +0 -0
  88. package/dist/web/public/manifest.webmanifest +0 -2
  89. package/dist/web/push.js +1 -1
  90. package/dist/web/server.js +91 -116
  91. package/dist/web/session-state.js +5 -40
  92. package/docs/deploy.md +5 -3
  93. package/package.json +2 -1
  94. package/skills/pier-boards/SKILL.md +14 -4
  95. package/skills/pier-help/SKILL.md +57 -23
  96. package/skills/pier-tasks/SKILL.md +58 -18
  97. package/dist/channels/handoff.js +0 -94
  98. package/dist/web/public/assets/activity-Ds6fCHnb.js +0 -5
  99. package/dist/web/public/assets/activity-Ds6fCHnb.js.br +0 -0
  100. package/dist/web/public/assets/activity-Ds6fCHnb.js.gz +0 -0
  101. package/dist/web/public/assets/boards-BKCj6EwK.js +0 -1
  102. package/dist/web/public/assets/boards-BKCj6EwK.js.br +0 -0
  103. package/dist/web/public/assets/boards-BKCj6EwK.js.gz +0 -0
  104. package/dist/web/public/assets/explorer-DnTm975c.js +0 -4
  105. package/dist/web/public/assets/explorer-DnTm975c.js.br +0 -0
  106. package/dist/web/public/assets/explorer-DnTm975c.js.gz +0 -0
  107. package/dist/web/public/assets/hljs-tWqyD59G.js.br +0 -0
  108. package/dist/web/public/assets/hljs-tWqyD59G.js.gz +0 -0
  109. package/dist/web/public/assets/index-BQo-haPN.js +0 -85
  110. package/dist/web/public/assets/index-BQo-haPN.js.br +0 -0
  111. package/dist/web/public/assets/index-BQo-haPN.js.gz +0 -0
  112. package/dist/web/public/assets/index-DiHj0w1i.css +0 -2
  113. package/dist/web/public/assets/index-DiHj0w1i.css.br +0 -0
  114. package/dist/web/public/assets/index-DiHj0w1i.css.gz +0 -0
  115. package/dist/web/public/assets/runs-C_AthWcW.js +0 -1
  116. package/dist/web/public/assets/runs-C_AthWcW.js.br +0 -0
  117. package/dist/web/public/assets/runs-C_AthWcW.js.gz +0 -0
  118. package/dist/web/public/assets/settings-DDaAtFlc.js +0 -5
  119. package/dist/web/public/assets/settings-DDaAtFlc.js.br +0 -0
  120. package/dist/web/public/assets/settings-DDaAtFlc.js.gz +0 -0
  121. package/dist/web/public/assets/task-runs-0pvdITiV.js +0 -3
  122. package/dist/web/public/assets/task-runs-0pvdITiV.js.br +0 -0
  123. package/dist/web/public/assets/task-runs-0pvdITiV.js.gz +0 -0
  124. package/dist/web/public/assets/tasks-Bz29caHJ.js +0 -4
  125. package/dist/web/public/assets/tasks-Bz29caHJ.js.br +0 -0
  126. package/dist/web/public/assets/tasks-Bz29caHJ.js.gz +0 -0
@@ -1,8 +1,10 @@
1
1
  // Channel lifecycle: which adapters are running; one call for the Console to
2
2
  // apply a config change.
3
+ import { Refused } from "../core/router.js";
3
4
  import { logger } from "../log.js";
4
5
  import { LarkChannel } from "./lark.js";
5
6
  import { SlackChannel } from "./slack.js";
7
+ import { chatOf } from "./types.js";
6
8
  const ADAPTERS = [
7
9
  { platform: "slack", build: (deps) => new SlackChannel(deps) },
8
10
  // Lark's "token" is the App ID and "appToken" the App Secret.
@@ -15,19 +17,17 @@ const warn = (m) => log.warn(m);
15
17
  export class ChannelRuntime {
16
18
  store;
17
19
  router;
20
+ chain;
18
21
  control;
19
- handoff;
20
22
  log;
21
- live = new Map();
23
+ running = new Map();
22
24
  reloading = Promise.resolve();
23
25
  stopped = false;
24
- constructor(store, router, control,
25
- /** The pull half only; the push half needs this runtime, so main.ts closes the loop. */
26
- handoff, log = warn) {
26
+ constructor(store, router, chain, control, log = warn) {
27
27
  this.store = store;
28
28
  this.router = router;
29
+ this.chain = chain;
29
30
  this.control = control;
30
- this.handoff = handoff;
31
31
  this.log = log;
32
32
  }
33
33
  /** Serialized: two concurrent Console saves would race into duplicate live
@@ -48,9 +48,10 @@ export class ChannelRuntime {
48
48
  }
49
49
  async restart(adapter) {
50
50
  const { platform, build } = adapter;
51
- const existing = this.live.get(platform);
51
+ const existing = this.running.get(platform);
52
52
  if (existing) {
53
- this.live.delete(platform);
53
+ this.running.delete(platform);
54
+ this.router.unregisterChannel(platform);
54
55
  // Never fatal: the config still has to be applied.
55
56
  await existing.stop().catch((err) => this.log(`${platform} did not stop cleanly: ${String(err)}`));
56
57
  }
@@ -66,10 +67,11 @@ export class ChannelRuntime {
66
67
  store: this.store,
67
68
  log: (m) => this.log(`${platform}: ${m}`),
68
69
  control: this.control,
69
- handoff: this.handoff,
70
70
  });
71
71
  try {
72
72
  await channel.start((msg) => {
73
+ if (this.control.isHome(msg.key))
74
+ return this.toHead(channel, msg);
73
75
  void this.router.dispatch(msg).catch((err) => this.log(`dispatch failed: ${String(err)}`));
74
76
  });
75
77
  }
@@ -77,38 +79,45 @@ export class ChannelRuntime {
77
79
  this.log(`${platform} failed to start: ${String(err)}`);
78
80
  return;
79
81
  }
82
+ // Running before registered: the router asks main.ts's chatKeyOf, which asks `live`.
83
+ this.running.set(platform, channel);
80
84
  this.router.registerChannel(channel);
81
- this.live.set(platform, channel);
82
85
  log.info(`${platform} started`);
83
86
  }
87
+ /** The home chat's every message, threaded or not, is the head's under the
88
+ * chat's key. A refusal is the chat's to see (§5): the adapter only logs. */
89
+ toHead(channel, msg) {
90
+ const conversationId = chatOf(msg.key.conversationId);
91
+ this.chain.send(msg, { channelId: msg.key.channelId, conversationId }).catch((err) => {
92
+ this.log(`the conversation did not take a message: ${String(err)}`);
93
+ if (err instanceof Refused)
94
+ return;
95
+ const text = err instanceof Error ? err.message : String(err);
96
+ void channel.notify(conversationId, { text, origin: { kind: "error" } })
97
+ .catch((e) => this.log(`could not report it to ${channel.id}: ${String(e)}`));
98
+ });
99
+ }
100
+ live(platform) {
101
+ return this.running.has(platform);
102
+ }
84
103
  /** For restart-note delivery (src/drain.ts), which has no session to report
85
104
  * through; false means the platform is not running. */
86
105
  async notify(platform, conversationId, text) {
87
- const channel = this.live.get(platform);
106
+ const channel = this.running.get(platform);
88
107
  if (!channel)
89
108
  return false;
90
109
  await channel.notify(conversationId, { text, origin: { kind: "error" } });
91
110
  return true;
92
111
  }
93
- running() {
94
- return [...this.live.keys()];
95
- }
96
- /** Throws by name when the platform is not running: the caller's answer is
97
- * "enable it in Settings", not a silent no-op. */
98
- async openThread(platform, chatId, note) {
99
- const channel = this.live.get(platform);
100
- if (!channel)
101
- throw new Error(`${platform} is not running`);
102
- return channel.openThread(chatId, note);
103
- }
104
112
  async stop() {
105
- // An in-flight restart could otherwise register an adapter after `live`
113
+ // An in-flight restart could otherwise register an adapter after `running`
106
114
  // was cleared — running, unstoppable.
107
115
  this.stopped = true;
108
116
  await this.reloading.catch(() => { });
109
- for (const channel of this.live.values()) {
117
+ for (const channel of this.running.values()) {
118
+ this.router.unregisterChannel(channel.id);
110
119
  await channel.stop().catch((err) => this.log(`${channel.id} did not stop cleanly: ${String(err)}`));
111
120
  }
112
- this.live.clear();
121
+ this.running.clear();
113
122
  }
114
123
  }
@@ -1,7 +1,8 @@
1
- // How a turn becomes messages in a Slack thread: which renderer, how to chunk
2
- // against its limit, and what an empty turn still has to say.
3
- import { formatTurnMeta, isSilentReply, originLabel, quietLabel } from "../core/reply.js";
1
+ // How a turn becomes messages in a Slack thread, or the home DM's main flow:
2
+ // which renderer, how to chunk against its limit, and what an empty turn still has to say.
3
+ import { formatTurnMeta, isSilentReply, quietLabel } from "../core/reply.js";
4
4
  import { sendAttachments, splitAttachments } from "./attach.js";
5
+ import { noteBody } from "./lines.js";
5
6
  import { isBlockRejection } from "./slack-api.js";
6
7
  import { actions, chunk, context, escapeMrkdwn, markdown, MARKDOWN_MAX, MRKDWN_MAX, sections, toMrkdwn, } from "./slack-render.js";
7
8
  const footerText = (meta) => escapeMrkdwn(formatTurnMeta(meta));
@@ -14,6 +15,7 @@ export class SlackOutbound {
14
15
  this.api = api;
15
16
  this.log = log;
16
17
  }
18
+ /** No `threadTs` is the home DM's main flow (slack.ts target). */
17
19
  async reply(channel, threadTs, reply) {
18
20
  // A local file link is dead in Slack: the bytes are uploaded instead.
19
21
  const { text: spoken, paths } = splitAttachments(reply.text);
@@ -43,23 +45,12 @@ export class SlackOutbound {
43
45
  /** No footer: the turn this input triggers has not ended. Answers with the
44
46
  * `ts` of the last message posted, where the caller puts the 👀. */
45
47
  async note(channel, threadTs, note) {
46
- const body = note.text.split("\n").map((line) => `> ${line}`).join("\n");
47
48
  let ts;
48
- for (const part of chunk(`_${originLabel(note.origin)}_\n${body}`, this.budget())) {
49
+ for (const part of chunk(noteBody(note, "_"), this.budget())) {
49
50
  ts = await this.post(channel, threadTs, part, []);
50
51
  }
51
52
  return ts;
52
53
  }
53
- /** The one root Pier posts (no `thread_ts`): the thread a web session is
54
- * continued in has no user message to hang from. Answers the root's `ts`. */
55
- async open(channel, note) {
56
- const link = note.url || "_(no public URL set — Settings → Instance)_";
57
- const sent = await this.api.postMessage({
58
- channel,
59
- text: `Continued from web: *${escapeMrkdwn(note.title)}*\n${link}\n_Reply in this thread to continue._`,
60
- });
61
- return sent.ts;
62
- }
63
54
  budget() {
64
55
  return this.markdownBlocks ? MARKDOWN_MAX : MRKDWN_MAX;
65
56
  }
@@ -18,7 +18,7 @@ const row = (buttons, value) => ({
18
18
  type: "actions",
19
19
  elements: buttons.map((b) => button(b, value)),
20
20
  });
21
- const fresh = (channel, ts, draft) => ({ chatId: channel, ts, draft, dirs: [], sessions: [] });
21
+ const fresh = (channel, ts, draft) => ({ chatId: channel, ts, draft, dirs: [] });
22
22
  export class SlackPanel extends ChatPanel {
23
23
  deps;
24
24
  fence = ["`", "`"];
@@ -97,7 +97,7 @@ export const context = (text) => ({
97
97
  // --- next-step buttons -------------------------------------------------------
98
98
  /** `action_id` carries an index: the label is read back off the message Slack
99
99
  * echoes with the click, so a button survives a reload. */
100
- export const OFFER_PREFIX = "sg:";
100
+ const OFFER_PREFIX = "sg:";
101
101
  /** Slack refuses a button label over BUTTON_MAX: every button, the panel's too. */
102
102
  export const truncate = (label) => cut(label, BUTTON_MAX);
103
103
  /** Slack wraps buttons on its own, so there is no row packing to budget. */
@@ -1,11 +1,14 @@
1
1
  // Slack adapter: normalize inbound Socket Mode envelopes, render outbound turns.
2
- // Pier never posts into a channel's main flow: a conversation is
3
- // `<channel>/<threadTs>` and the thread is the session. Slack-specific: the
4
- // client intercepts unregistered slash commands, so `stop` and `settings` are
5
- // bare words; reactions are short names (`reactions.add` rejects 👀 with
2
+ // Pier never posts into a channel's main flow but the home DM's
3
+ // (docs/design/11-im-conversation.md), which is one conversation keyed `<channel>`:
4
+ // any other is `<channel>/<threadTs>` and the thread is the session. Slack-specific: the
5
+ // client intercepts unregistered slash commands, so `%stop` is the spelling
6
+ // that arrives; reactions are short names (`reactions.add` rejects 👀 with
6
7
  // `invalid_name`); unacked envelopes are redelivered, so `event_id` is deduplicated.
8
+ import { isChatCommand } from "../core/types.js";
7
9
  import { saveInboundAll } from "../core/inbox.js";
8
10
  import { MAX_INBOUND_BYTES } from "../core/inbound-file.js";
11
+ import { skillsText } from "../core/chain.js";
9
12
  import { awaitsTurn } from "../core/reply.js";
10
13
  import { bindHint, bindResult, picked, STALE_OPTION, STOPPED } from "./lines.js";
11
14
  import { logger } from "../log.js";
@@ -30,12 +33,9 @@ const DRAIN_TIMEOUT_MS = 5000;
30
33
  /** Only has to cover a redelivery that crossed our immediate ack. */
31
34
  const DEDUP_TTL_MS = 5 * 60_000;
32
35
  const DEDUP_MAX = 2000;
33
- /** Bare words with exact arity, since there is no leading `/` to key on: "stop
34
- * the deploy and tell me why" is a sentence for the agent, not an abort. */
35
- const BARE_COMMANDS = new Map([["stop", 0], ["settings", 0], ["bind", 1]]);
36
36
  /** The only definition of the conversation id format; control.ts decodes with it. */
37
37
  const conversationId = (channel, threadTs) => `${channel}/${threadTs}`;
38
- export const parseConversation = (id) => {
38
+ const parseConversation = (id) => {
39
39
  const at = id.indexOf("/");
40
40
  return at < 0
41
41
  ? { channel: id, threadTs: "" }
@@ -54,18 +54,6 @@ const sharedFiles = (share) => share.files ?? share.original_message?.files ?? [
54
54
  /** A token budget, not a Slack limit: past this the agent gets the
55
55
  * coordinates and decides for itself. */
56
56
  const INLINE_REPLY_MAX = 30;
57
- /** `/stop` is accepted for muscle memory; a bare `stop` is what actually arrives. */
58
- function slackCommand(text) {
59
- const slash = parseCommand(text);
60
- if (slash)
61
- return { name: slash.name, args: slash.args };
62
- const words = text.trim().split(/\s+/).filter(Boolean);
63
- const name = words[0]?.toLowerCase() ?? "";
64
- const arity = BARE_COMMANDS.get(name);
65
- if (arity === undefined || words.length - 1 !== arity)
66
- return undefined;
67
- return { name, args: words.slice(1).join(" ") };
68
- }
69
57
  export class SlackChannel {
70
58
  deps;
71
59
  id = "slack";
@@ -103,13 +91,8 @@ export class SlackChannel {
103
91
  ? this.api.addReaction(channel, ts, emoji)
104
92
  : this.api.removeReaction(channel, ts, WORKING),
105
93
  }, deps.receipts ?? new ReceiptLedger("slack"), this.log, WORKING, RECEIPT_STALE_MS, (conversationId) => deps.control?.working({ channelId: this.id, conversationId }) ?? false);
106
- if (deps.control && deps.handoff) {
107
- this.panel = new SlackPanel({
108
- api: this.api,
109
- control: deps.control,
110
- handoff: deps.handoff,
111
- log: this.log,
112
- });
94
+ if (deps.control) {
95
+ this.panel = new SlackPanel({ api: this.api, control: deps.control, log: this.log });
113
96
  }
114
97
  }
115
98
  async start(onMessage) {
@@ -203,13 +186,15 @@ export class SlackChannel {
203
186
  this.deps.store.discoverChat("slack", { id: channel, name, kind });
204
187
  }
205
188
  const text = this.stripMention(raw);
206
- const command = slackCommand(text);
189
+ const command = parseCommand(text);
207
190
  const threadTs = threadOf(event);
208
- const here = { channelId: this.id, conversationId: conversationId(channel, threadTs) };
191
+ // Every message in the home DM, in a thread or not, is the head's, under one key.
192
+ const home = this.isHome(channel);
193
+ const here = { channelId: this.id, conversationId: home ? channel : conversationId(channel, threadTs) };
209
194
  const bindRequest = command?.name === "bind" && isDm;
210
195
  const admitted = this.gate.admit("message", channel, {
211
196
  isDm,
212
- addressed: this.addressed(raw, event, here),
197
+ addressed: this.addressed(raw, event, here, home),
213
198
  userId: event.user,
214
199
  bindRequest,
215
200
  });
@@ -220,23 +205,30 @@ export class SlackChannel {
220
205
  }
221
206
  if (bindRequest)
222
207
  return this.bind(channel, event.user, threadTs, command?.args ?? "");
223
- if (command?.name === "stop")
208
+ // The head has no panel and takes `/stop` and `/skills` as chat commands (core/chain.ts).
209
+ if (!home && command?.name === "stop")
224
210
  return this.abortTurn(here, channel, threadTs);
211
+ if (!home && command?.name === "skills")
212
+ return this.listSkills(here);
213
+ if (!text && !files.length && !shares.length)
214
+ return this.log(`empty message in ${here.conversationId}, dropped`);
225
215
  // Downloading only past the gate: an unauthorized sender must not make the
226
216
  // bot pull bytes on their behalf.
227
217
  const markers = await this.saveAttachments(files);
228
218
  const shared = await Promise.all(shares.map((share) => this.sharedBlock(share)));
229
- // A bare `@bot` and `settings` are the same request; `s <text>` drafts a
230
- // session, so only where this message would start one: a thread root. The
231
- // held question carries its markers, so Start sends what the user sent.
219
+ // `/s <text>` drafts a session, so only where this message would start one:
220
+ // a thread root. The held question carries its markers, so Start sends what the user sent.
232
221
  const question = threadTs === ts ? settingsDraft(text) : undefined;
233
- if (this.panel && (question || command?.name === "settings" || (!text && !files.length && !shares.length))) {
222
+ if (this.panel && !home && (question || command?.name === "settings")) {
234
223
  return this.panel.open(here, channel, threadTs, question && [question, ...shared, ...markers].join("\n"));
235
224
  }
236
225
  // Resolved before the mark: any await between mark() and dispatch is a
237
226
  // window in which a previous turn can settle and take this receipt with it.
238
227
  const sender = { id: event.user, name: await this.directory.user(this.api, event.user) };
239
- this.receipts.mark(here.conversationId, channel, ts);
228
+ // The head answers a chat command with a note, not a turn: nothing would take a 👀 off it.
229
+ const answered = home && command && !command.args && isChatCommand(command.name);
230
+ if (!answered)
231
+ this.receipts.mark(here.conversationId, channel, ts);
240
232
  // Steer: a follow-up is the wrong default when the human is watching a 👀.
241
233
  onMessage({
242
234
  key: here,
@@ -267,10 +259,13 @@ export class SlackChannel {
267
259
  this.log("incomplete block_actions payload, dropped");
268
260
  return;
269
261
  }
270
- const threadTs = message.thread_ts ?? message.ts;
262
+ // Any click in the home DM is the head's, echoed where the button was;
263
+ // anywhere else a top-level message roots a thread.
264
+ const home = this.isHome(channel);
265
+ const threadTs = home ? message.thread_ts : message.thread_ts ?? message.ts;
271
266
  const key = {
272
267
  channelId: this.id,
273
- conversationId: conversationId(channel, threadTs),
268
+ conversationId: home ? channel : conversationId(channel, message.thread_ts ?? message.ts),
274
269
  };
275
270
  const admitted = this.gate.admit("action", channel, {
276
271
  isDm: (await this.directory.channel(this.api, channel)).kind === "dm",
@@ -281,7 +276,8 @@ export class SlackChannel {
281
276
  return;
282
277
  // Start's question: the card is the message the click was on, so it carries the 👀.
283
278
  const run = (text) => this.deliver(key, channel, message.ts, user, text, onMessage);
284
- if (await this.panel?.onAction(interaction, key, actionId, run))
279
+ // The head has no panel: a card left in the DM before it became the home is stale.
280
+ if (!home && (await this.panel?.onAction(interaction, key, actionId, run)))
285
281
  return;
286
282
  const text = offeredLabel(message.blocks, actionId);
287
283
  if (text === undefined) {
@@ -324,6 +320,11 @@ export class SlackChannel {
324
320
  await this.deps.control?.abort(key);
325
321
  await this.api.postMessage({ channel, thread_ts: threadTs, text: STOPPED });
326
322
  }
323
+ /** The head's `/skills` is MainChain's; a thread has no chain, so it is answered here. */
324
+ async listSkills(key) {
325
+ const text = skillsText((await this.deps.control?.skills(key)) ?? []);
326
+ await this.notify(key.conversationId, { text, origin: { kind: "chat-command", command: "skills" } });
327
+ }
327
328
  // --- bind ------------------------------------------------------------------
328
329
  /** Channels stay silent, but a DM that swallows every message looks broken
329
330
  * rather than locked. */
@@ -333,7 +334,7 @@ export class SlackChannel {
333
334
  await this.api.postMessage({
334
335
  channel,
335
336
  thread_ts: threadTs,
336
- text: bindHint("`bind <code>`"),
337
+ text: bindHint("`%bind <code>`"),
337
338
  }).catch((err) => this.log(`bind hint failed: ${String(err)}`));
338
339
  }
339
340
  async bind(channel, userId, threadTs, code) {
@@ -347,11 +348,21 @@ export class SlackChannel {
347
348
  }
348
349
  // --- addressing ------------------------------------------------------------
349
350
  /** Mentioned, or continuing a thread Pier already owns — durable, so it
350
- * holds after a restart. */
351
- addressed(raw, event, key) {
351
+ * holds after a restart. The home key has no row to know. */
352
+ addressed(raw, event, key, home) {
352
353
  if (this.me && raw.includes(`<@${this.me}>`))
353
354
  return true;
354
- return !!event.thread_ts && !!this.deps.control?.knows(key);
355
+ return !home && !!event.thread_ts && !!this.deps.control?.knows(key);
356
+ }
357
+ isHome(channel) {
358
+ return this.deps.control?.isHome({ channelId: this.id, conversationId: channel }) ?? false;
359
+ }
360
+ /** A thread, or the home DM's main flow (no `threadTs`); undefined for any other channel's. */
361
+ target(conversation) {
362
+ const { channel, threadTs } = parseConversation(conversation);
363
+ if (threadTs)
364
+ return { channel, threadTs };
365
+ return this.isHome(channel) ? { channel } : undefined;
355
366
  }
356
367
  /** Slack does not strip the mention for us. */
357
368
  stripMention(text) {
@@ -441,32 +452,27 @@ export class SlackChannel {
441
452
  }
442
453
  // --- outbound --------------------------------------------------------------
443
454
  async send(conversation, reply) {
444
- const { channel, threadTs } = parseConversation(conversation);
445
- // No thread is a foreign id; posting it would put a turn in the channel's
446
- // main flow. Refused loudly, receipts still cleared.
447
- if (!threadTs) {
455
+ const to = this.target(conversation);
456
+ // No thread outside the home DM is a foreign id; posting it would put a
457
+ // turn in the channel's main flow. Refused loudly, receipts still cleared.
458
+ if (!to) {
448
459
  this.log(`refusing to answer ${conversation}: no thread in the conversation id`);
449
460
  await this.receipts.settle(conversation);
450
461
  return;
451
462
  }
452
463
  // The turn ended either way; a 👀 left up by a failed send looks like work.
453
- await this.receipts.settleAfter(conversation, () => this.out.reply(channel, threadTs, reply), reply.meta);
454
- }
455
- /** A web session's thread: the root is the one message Pier posts into a
456
- * channel's main flow (channels/handoff.ts). */
457
- async openThread(chatId, note) {
458
- return conversationId(chatId, await this.out.open(chatId, note));
464
+ await this.receipts.settleAfter(conversation, () => this.out.reply(to.channel, to.threadTs, reply), reply.meta);
459
465
  }
460
466
  /** The 👀 goes on the note itself: the turn it triggers has no message of
461
467
  * the user's to carry them. */
462
468
  async notify(conversation, note) {
463
- const { channel, threadTs } = parseConversation(conversation);
464
- if (!threadTs) {
469
+ const to = this.target(conversation);
470
+ if (!to) {
465
471
  this.log(`refusing to post a system note to ${conversation}: no thread in the conversation id`);
466
472
  return;
467
473
  }
468
- const ts = await this.out.note(channel, threadTs, note);
474
+ const ts = await this.out.note(to.channel, to.threadTs, note);
469
475
  if (ts && awaitsTurn(note.origin))
470
- this.receipts.mark(conversation, channel, ts, note.at);
476
+ this.receipts.mark(conversation, to.channel, ts, note.at);
471
477
  }
472
478
  }
@@ -2,7 +2,7 @@
2
2
  // adapters and the Console view (type-only, so no node builtins here). Defaults
3
3
  // are least-privilege, and platform values are seeds copied into a chat on
4
4
  // discovery, not a runtime fallback: a switch means what it says.
5
- const PLATFORMS = ["slack", "lark"];
5
+ export const PLATFORMS = ["slack", "lark"];
6
6
  /** Validate at the boundary: an unknown platform is a 404, not a new row. */
7
7
  export const isChannelPlatform = (v) => typeof v === "string" && PLATFORMS.includes(v);
8
8
  /** Every adapter spells its ids `<chatId>` or `<chatId>/<thread>`, so the chat
@@ -1,14 +1,26 @@
1
1
  // One serialized configuration subscriber, shared by Settings and the hourly
2
- // task. Only a successfully applied response advances its persistent ETag.
3
- // The source is fetched over HTTPS only, redirects followed while they stay
4
- // HTTPS, JSON capped at 1 MiB so a hostile or broken source cannot exhaust memory.
5
- import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
2
+ // task. The source is the authority: what it publishes replaces the local copy
3
+ // of every field the document carries, and a source on another schema version
4
+ // pauses the subscription rather than being merged. Fetched over HTTPS only,
5
+ // redirects followed while they stay HTTPS, JSON capped at 1 MiB so a hostile
6
+ // or broken source cannot exhaust memory.
7
+ import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
6
8
  import { transact } from "./db.js";
7
9
  import { logger } from "./log.js";
8
- import { normalizeModelMenu } from "./settings.js";
10
+ import { parseModelMenu } from "./settings.js";
9
11
  const log = logger("config-sync");
10
12
  export const CONFIG_SYNC_BYTES = 1024 * 1024;
13
+ /** Bumped by any change to the document's shape — a menu row's fields, the
14
+ * snapshot's, the envelope's. A subscriber applies its own version and
15
+ * nothing else: no field-level compatibility, the older instance upgrades. */
16
+ export const CONFIG_SCHEMA_VERSION = 2;
11
17
  const MAX_REDIRECTS = 5;
18
+ class SchemaMismatch extends Error {
19
+ constructor(published) {
20
+ super(`Source publishes configuration schema ${typeof published === "number" ? `v${String(published)}` : "of an unknown version"}; `
21
+ + `this Pier reads v${String(CONFIG_SCHEMA_VERSION)} — upgrade the older instance, then resume the subscription`);
22
+ }
23
+ }
12
24
  export function configSourceUrl(raw) {
13
25
  let url;
14
26
  try {
@@ -22,16 +34,13 @@ export function configSourceUrl(raw) {
22
34
  }
23
35
  return url;
24
36
  }
25
- export async function downloadConfig(raw, etag, signal) {
37
+ export async function downloadConfig(raw, signal) {
26
38
  let url = configSourceUrl(raw);
27
39
  for (let hop = 0;; hop++) {
28
40
  signal.throwIfAborted();
29
41
  let res;
30
42
  try {
31
- res = await fetch(url, {
32
- method: "GET", redirect: "manual", signal,
33
- headers: { accept: "application/json", ...(etag ? { "if-none-match": etag } : {}) },
34
- });
43
+ res = await fetch(url, { method: "GET", redirect: "manual", signal, headers: { accept: "application/json" } });
35
44
  }
36
45
  catch {
37
46
  throw new Error(signal.aborted ? "Configuration download timed out or was cancelled" : "Could not connect to source");
@@ -49,18 +58,17 @@ export async function downloadConfig(raw, etag, signal) {
49
58
  }
50
59
  continue;
51
60
  }
52
- if (res.status !== 200 && res.status !== 304) {
61
+ if (res.status !== 200) {
53
62
  await res.body?.cancel().catch(() => { });
54
63
  throw new Error(res.status === 404 || res.status === 410
55
64
  ? "Source link was revoked or does not exist"
56
65
  : `Source returned HTTP ${String(res.status)}`);
57
66
  }
58
- const status = res.status === 304 ? 304 : 200;
59
- if (status === 200 && !/^application\/json(?:\s*;|$)/i.test(res.headers.get("content-type") ?? "")) {
67
+ if (!/^application\/json(?:\s*;|$)/i.test(res.headers.get("content-type") ?? "")) {
60
68
  await res.body?.cancel().catch(() => { });
61
69
  throw new Error("Source did not return JSON");
62
70
  }
63
- return { status, etag: res.headers.get("etag"), body: await read(res, signal) };
71
+ return read(res, signal);
64
72
  }
65
73
  }
66
74
  async function read(res, signal) {
@@ -94,7 +102,6 @@ export function configJson(value) {
94
102
  return JSON.stringify(value, (_key, entry) => entry && typeof entry === "object" && !Array.isArray(entry)
95
103
  ? Object.fromEntries(Object.entries(entry).sort(([a], [b]) => a.localeCompare(b, "en"))) : entry);
96
104
  }
97
- const hash = (value) => createHash("sha256").update(configJson(value)).digest("hex");
98
105
  export class ConfigSync {
99
106
  deps;
100
107
  #state;
@@ -102,9 +109,12 @@ export class ConfigSync {
102
109
  constructor(deps) {
103
110
  this.deps = deps;
104
111
  const row = deps.db.prepare("SELECT value FROM settings WHERE key = ?").get(KEY);
105
- this.#state = row ? JSON.parse(row.value) : {
106
- instanceId: randomUUID(), token: null, url: "", enabled: false, etag: null,
107
- appliedHash: null, lastChecked: null, lastApplied: null, error: null, needsReload: false,
112
+ // Field by field: a row an older Pier wrote carries keys this one no longer keeps.
113
+ const stored = row ? JSON.parse(row.value) : {};
114
+ this.#state = {
115
+ instanceId: stored.instanceId ?? randomUUID(), token: stored.token ?? null, url: stored.url ?? "",
116
+ enabled: stored.enabled ?? false, lastChecked: stored.lastChecked ?? null, lastApplied: stored.lastApplied ?? null,
117
+ error: stored.error ?? null, needsReload: stored.needsReload ?? false,
108
118
  };
109
119
  if (!row)
110
120
  this.#save();
@@ -154,11 +164,11 @@ export class ConfigSync {
154
164
  const local = await this.#local();
155
165
  if (this.#state.token !== expected)
156
166
  return null;
157
- const document = { schemaVersion: 1, instanceId: this.#state.instanceId, ...local };
167
+ const document = { schemaVersion: CONFIG_SCHEMA_VERSION, instanceId: this.#state.instanceId, ...local };
158
168
  const body = configJson(document);
159
169
  if (Buffer.byteLength(body) > CONFIG_SYNC_BYTES)
160
170
  throw new Error("Configuration exceeds 1 MiB");
161
- return { body, etag: `"${hash(document)}"` };
171
+ return body;
162
172
  }
163
173
  #parse(raw) {
164
174
  if (Buffer.byteLength(raw) > CONFIG_SYNC_BYTES)
@@ -173,8 +183,8 @@ export class ConfigSync {
173
183
  if (!value || typeof value !== "object" || Array.isArray(value))
174
184
  throw new Error("Invalid configuration document");
175
185
  const object = value;
176
- if (object.schemaVersion !== 1)
177
- throw new Error("Unsupported configuration schema version");
186
+ if (object.schemaVersion !== CONFIG_SCHEMA_VERSION)
187
+ throw new SchemaMismatch(object.schemaVersion);
178
188
  if (Object.keys(object).some((key) => !["schemaVersion", "instanceId", "agent", "modelMenu"].includes(key))) {
179
189
  throw new Error("Unexpected configuration fields");
180
190
  }
@@ -182,9 +192,9 @@ export class ConfigSync {
182
192
  throw new Error("Invalid source instance ID");
183
193
  if (object.instanceId === this.#state.instanceId)
184
194
  throw new Error("An instance cannot subscribe to itself");
185
- const modelMenu = normalizeModelMenu(object.modelMenu);
186
- if (!modelMenu)
187
- throw new Error("Invalid model menu");
195
+ const modelMenu = parseModelMenu(object.modelMenu);
196
+ if (typeof modelMenu === "string")
197
+ throw new Error(`Invalid model menu: ${modelMenu}`);
188
198
  return { agent: this.deps.normalizeAgent(object.agent), modelMenu };
189
199
  }
190
200
  subscribe(url) {
@@ -208,14 +218,10 @@ export class ConfigSync {
208
218
  async #sync(rawUrl, signal) {
209
219
  try {
210
220
  const url = configSourceUrl(rawUrl).href;
211
- const local = await this.#local();
212
- const etag = url === this.#state.url && hash(local) === this.#state.appliedHash ? this.#state.etag : null;
213
221
  const timeout = AbortSignal.timeout(15_000);
214
- const response = await (this.deps.download ?? downloadConfig)(url, etag, signal ? AbortSignal.any([signal, timeout]) : timeout);
222
+ const response = await (this.deps.download ?? downloadConfig)(url, signal ? AbortSignal.any([signal, timeout]) : timeout);
215
223
  signal?.throwIfAborted();
216
- if (response.status === 304 && !etag)
217
- throw new Error("Source returned 304 without a matching local configuration");
218
- const next = response.status === 304 ? local : this.#parse(response.body);
224
+ const next = this.#parse(response);
219
225
  let changed = false;
220
226
  const previous = structuredClone(this.#state);
221
227
  const commit = (filesChanged) => transact(this.deps.db, () => {
@@ -225,15 +231,12 @@ export class ConfigSync {
225
231
  if (menuChanged)
226
232
  this.deps.settings.setModelMenu(next.modelMenu);
227
233
  this.#save({
228
- url, enabled: true, etag: response.status === 304 ? etag : response.etag,
229
- appliedHash: hash(next), lastChecked: Date.now(),
234
+ url, enabled: true, lastChecked: Date.now(),
230
235
  lastApplied: changed ? Date.now() : previous.lastApplied,
231
236
  needsReload: changed || previous.needsReload, error: null,
232
237
  });
233
238
  });
234
239
  try {
235
- // Reconcile under the file lock even on 304: local edits may have
236
- // completed while the conditional download was in flight.
237
240
  await this.deps.config.applySnapshot(next.agent, commit);
238
241
  }
239
242
  catch (err) {
@@ -249,13 +252,15 @@ export class ConfigSync {
249
252
  }
250
253
  this.#save({ needsReload: false });
251
254
  }
252
- return changed ? "Configuration applied; reload completed" : `Configuration unchanged (${response.status})`;
255
+ return changed ? "Configuration applied; reload completed" : "Configuration unchanged";
253
256
  }
254
257
  catch (err) {
255
258
  const error = err instanceof Error ? err.message : "Configuration sync failed";
256
259
  // The message is the operator's; the cause, when there is one, is the log's.
257
260
  log.error(error, err instanceof Error ? err.cause : err);
258
- this.#save({ lastChecked: Date.now(), error });
261
+ // A version gap is not retried hourly: the fix is an upgrade, and the
262
+ // notice stands until the operator resumes.
263
+ this.#save({ lastChecked: Date.now(), error, ...(err instanceof SchemaMismatch ? { enabled: false } : {}) });
259
264
  throw new Error(error);
260
265
  }
261
266
  }