@timqi/pier 0.1.14 → 0.3.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 (130) 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/listing.js +14 -10
  5. package/dist/agent/packages.js +6 -10
  6. package/dist/agent/pi.js +113 -70
  7. package/dist/agent/roles.js +104 -0
  8. package/dist/agent/types.js +78 -0
  9. package/dist/boards/boards.js +42 -32
  10. package/dist/channels/chains.js +0 -4
  11. package/dist/channels/commands.js +7 -12
  12. package/dist/channels/config.js +27 -1
  13. package/dist/channels/control.js +5 -16
  14. package/dist/channels/conversations.js +4 -9
  15. package/dist/channels/lark-api.js +20 -26
  16. package/dist/channels/lark-outbound.js +21 -29
  17. package/dist/channels/lark-panel.js +1 -1
  18. package/dist/channels/lark.js +81 -39
  19. package/dist/channels/lines.js +33 -1
  20. package/dist/channels/panel.js +17 -107
  21. package/dist/channels/routes.js +11 -21
  22. package/dist/channels/runtime.js +79 -25
  23. package/dist/channels/slack-outbound.js +12 -14
  24. package/dist/channels/slack-panel.js +1 -1
  25. package/dist/channels/slack-render.js +1 -1
  26. package/dist/channels/slack-thread.js +65 -0
  27. package/dist/channels/slack.js +85 -124
  28. package/dist/channels/types.js +1 -1
  29. package/dist/cli.js +7 -2
  30. package/dist/config-sync.js +42 -37
  31. package/dist/core/chain.js +221 -0
  32. package/dist/core/identity.js +36 -13
  33. package/dist/core/inbound-file.js +11 -4
  34. package/dist/core/reply.js +111 -81
  35. package/dist/core/router.js +111 -118
  36. package/dist/core/search.js +65 -0
  37. package/dist/core/types.js +38 -77
  38. package/dist/db.js +41 -2
  39. package/dist/drain.js +1 -1
  40. package/dist/main.js +65 -40
  41. package/dist/settings.js +26 -19
  42. package/dist/socket.js +1 -0
  43. package/dist/tasks/agent.js +11 -5
  44. package/dist/tasks/callbacks.js +55 -5
  45. package/dist/tasks/cli.js +22 -11
  46. package/dist/tasks/definitions.js +30 -17
  47. package/dist/tasks/execution.js +11 -5
  48. package/dist/tasks/groups.js +5 -2
  49. package/dist/tasks/messages.js +7 -7
  50. package/dist/tasks/open-items.js +88 -0
  51. package/dist/tasks/operations.js +76 -39
  52. package/dist/tasks/outbox.js +19 -6
  53. package/dist/tasks/routes.js +19 -249
  54. package/dist/tasks/service.js +166 -34
  55. package/dist/tasks/store.js +123 -57
  56. package/dist/tasks/types.js +3 -0
  57. package/dist/tools.js +12 -5
  58. package/dist/web/auth.js +2 -2
  59. package/dist/web/config-sync.js +4 -5
  60. package/dist/web/config.js +9 -2
  61. package/dist/web/explorer.js +2 -2
  62. package/dist/web/fs.js +16 -1
  63. package/dist/web/instance.js +8 -5
  64. package/dist/web/packages.js +1 -1
  65. package/dist/web/passkeys.js +3 -3
  66. package/dist/web/providers.js +3 -3
  67. package/dist/web/public/assets/code-CWT27KH1.js +2 -0
  68. package/dist/web/public/assets/code-CWT27KH1.js.br +0 -0
  69. package/dist/web/public/assets/code-CWT27KH1.js.gz +0 -0
  70. package/dist/web/public/assets/explorer-C4DhXFCm.js +5 -0
  71. package/dist/web/public/assets/explorer-C4DhXFCm.js.br +0 -0
  72. package/dist/web/public/assets/explorer-C4DhXFCm.js.gz +0 -0
  73. package/dist/web/public/assets/highlight-CDSk2hRJ.js +72 -0
  74. package/dist/web/public/assets/highlight-CDSk2hRJ.js.br +0 -0
  75. package/dist/web/public/assets/highlight-CDSk2hRJ.js.gz +0 -0
  76. package/dist/web/public/assets/{hljs-tWqyD59G.js → hljs-BRz6a8Dq.js} +2 -2
  77. package/dist/web/public/assets/hljs-BRz6a8Dq.js.br +0 -0
  78. package/dist/web/public/assets/hljs-BRz6a8Dq.js.gz +0 -0
  79. package/dist/web/public/assets/index-D0ZYdweB.js +14 -0
  80. package/dist/web/public/assets/index-D0ZYdweB.js.br +0 -0
  81. package/dist/web/public/assets/index-D0ZYdweB.js.gz +0 -0
  82. package/dist/web/public/assets/index-JN0Ic8K5.css +2 -0
  83. package/dist/web/public/assets/index-JN0Ic8K5.css.br +0 -0
  84. package/dist/web/public/assets/index-JN0Ic8K5.css.gz +0 -0
  85. package/dist/web/public/assets/settings-DSTeuIyc.js +9 -0
  86. package/dist/web/public/assets/settings-DSTeuIyc.js.br +0 -0
  87. package/dist/web/public/assets/settings-DSTeuIyc.js.gz +0 -0
  88. package/dist/web/public/index.html +39 -109
  89. package/dist/web/public/index.html.br +0 -0
  90. package/dist/web/public/index.html.gz +0 -0
  91. package/dist/web/public/manifest.webmanifest +0 -2
  92. package/dist/web/push.js +3 -3
  93. package/dist/web/server.js +89 -116
  94. package/dist/web/session-state.js +4 -40
  95. package/docs/deploy.md +5 -3
  96. package/package.json +2 -1
  97. package/skills/pier-boards/SKILL.md +14 -4
  98. package/skills/pier-help/SKILL.md +62 -23
  99. package/skills/pier-search/SKILL.md +27 -0
  100. package/skills/pier-tasks/SKILL.md +68 -18
  101. package/dist/channels/handoff.js +0 -94
  102. package/dist/web/public/assets/activity-Ds6fCHnb.js +0 -5
  103. package/dist/web/public/assets/activity-Ds6fCHnb.js.br +0 -0
  104. package/dist/web/public/assets/activity-Ds6fCHnb.js.gz +0 -0
  105. package/dist/web/public/assets/boards-BKCj6EwK.js +0 -1
  106. package/dist/web/public/assets/boards-BKCj6EwK.js.br +0 -0
  107. package/dist/web/public/assets/boards-BKCj6EwK.js.gz +0 -0
  108. package/dist/web/public/assets/explorer-DnTm975c.js +0 -4
  109. package/dist/web/public/assets/explorer-DnTm975c.js.br +0 -0
  110. package/dist/web/public/assets/explorer-DnTm975c.js.gz +0 -0
  111. package/dist/web/public/assets/hljs-tWqyD59G.js.br +0 -0
  112. package/dist/web/public/assets/hljs-tWqyD59G.js.gz +0 -0
  113. package/dist/web/public/assets/index-BQo-haPN.js +0 -85
  114. package/dist/web/public/assets/index-BQo-haPN.js.br +0 -0
  115. package/dist/web/public/assets/index-BQo-haPN.js.gz +0 -0
  116. package/dist/web/public/assets/index-DiHj0w1i.css +0 -2
  117. package/dist/web/public/assets/index-DiHj0w1i.css.br +0 -0
  118. package/dist/web/public/assets/index-DiHj0w1i.css.gz +0 -0
  119. package/dist/web/public/assets/runs-C_AthWcW.js +0 -1
  120. package/dist/web/public/assets/runs-C_AthWcW.js.br +0 -0
  121. package/dist/web/public/assets/runs-C_AthWcW.js.gz +0 -0
  122. package/dist/web/public/assets/settings-DDaAtFlc.js +0 -5
  123. package/dist/web/public/assets/settings-DDaAtFlc.js.br +0 -0
  124. package/dist/web/public/assets/settings-DDaAtFlc.js.gz +0 -0
  125. package/dist/web/public/assets/task-runs-0pvdITiV.js +0 -3
  126. package/dist/web/public/assets/task-runs-0pvdITiV.js.br +0 -0
  127. package/dist/web/public/assets/task-runs-0pvdITiV.js.gz +0 -0
  128. package/dist/web/public/assets/tasks-Bz29caHJ.js +0 -4
  129. package/dist/web/public/assets/tasks-Bz29caHJ.js.br +0 -0
  130. package/dist/web/public/assets/tasks-Bz29caHJ.js.gz +0 -0
@@ -1,13 +1,19 @@
1
1
  // Channel lifecycle: which adapters are running; one call for the Console to
2
2
  // apply a config change.
3
+ import { splitReply } from "../core/reply.js";
4
+ import { Refused } from "../core/router.js";
3
5
  import { logger } from "../log.js";
4
6
  import { LarkChannel } from "./lark.js";
5
7
  import { SlackChannel } from "./slack.js";
8
+ import { chatOf } from "./types.js";
6
9
  const ADAPTERS = [
7
10
  { platform: "slack", build: (deps) => new SlackChannel(deps) },
8
11
  // Lark's "token" is the App ID and "appToken" the App Secret.
9
12
  { platform: "lark", build: (deps) => new LarkChannel(deps) },
10
13
  ];
14
+ const rootLine = (lead, state) => state === "waiting" ? `▷ ${lead.name} · design — waiting for you`
15
+ : state === "final" ? `✓ ${lead.name} · design final`
16
+ : `⚠ ${lead.name} · design — ${lead.text}`;
11
17
  // The injected sink is for warnings; "slack started" is not one.
12
18
  const log = logger("channels");
13
19
  // The parameter below shadows `log` inside its own default expression.
@@ -15,19 +21,19 @@ const warn = (m) => log.warn(m);
15
21
  export class ChannelRuntime {
16
22
  store;
17
23
  router;
24
+ chain;
18
25
  control;
19
- handoff;
26
+ conversations;
20
27
  log;
21
- live = new Map();
28
+ running = new Map();
22
29
  reloading = Promise.resolve();
23
30
  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) {
31
+ constructor(store, router, chain, control, conversations, log = warn) {
27
32
  this.store = store;
28
33
  this.router = router;
34
+ this.chain = chain;
29
35
  this.control = control;
30
- this.handoff = handoff;
36
+ this.conversations = conversations;
31
37
  this.log = log;
32
38
  }
33
39
  /** Serialized: two concurrent Console saves would race into duplicate live
@@ -48,9 +54,10 @@ export class ChannelRuntime {
48
54
  }
49
55
  async restart(adapter) {
50
56
  const { platform, build } = adapter;
51
- const existing = this.live.get(platform);
57
+ const existing = this.running.get(platform);
52
58
  if (existing) {
53
- this.live.delete(platform);
59
+ this.running.delete(platform);
60
+ this.router.unregisterChannel(platform);
54
61
  // Never fatal: the config still has to be applied.
55
62
  await existing.stop().catch((err) => this.log(`${platform} did not stop cleanly: ${String(err)}`));
56
63
  }
@@ -66,10 +73,12 @@ export class ChannelRuntime {
66
73
  store: this.store,
67
74
  log: (m) => this.log(`${platform}: ${m}`),
68
75
  control: this.control,
69
- handoff: this.handoff,
70
76
  });
71
77
  try {
72
78
  await channel.start((msg) => {
79
+ // A thread of the home chat bound to a child (§Child threads) is that session's.
80
+ if (this.control.isHome(msg.key) && !this.control.knows(msg.key))
81
+ return this.toHead(channel, msg);
73
82
  void this.router.dispatch(msg).catch((err) => this.log(`dispatch failed: ${String(err)}`));
74
83
  });
75
84
  }
@@ -77,38 +86,83 @@ export class ChannelRuntime {
77
86
  this.log(`${platform} failed to start: ${String(err)}`);
78
87
  return;
79
88
  }
89
+ // Running before registered: the router asks main.ts's chatKeyOf, which asks `live`.
90
+ this.running.set(platform, channel);
80
91
  this.router.registerChannel(channel);
81
- this.live.set(platform, channel);
82
92
  log.info(`${platform} started`);
83
93
  }
94
+ /** The home chat's every message, threaded or not, is the head's under the
95
+ * chat's key. A refusal is the chat's to see (§5): the adapter only logs. */
96
+ toHead(channel, msg) {
97
+ const conversationId = chatOf(msg.key.conversationId);
98
+ this.chain.send(msg, { channelId: msg.key.channelId, conversationId }).catch((err) => {
99
+ this.log(`the conversation did not take a message: ${String(err)}`);
100
+ if (err instanceof Refused)
101
+ return;
102
+ const text = err instanceof Error ? err.message : String(err);
103
+ void channel.notify(conversationId, { text, origin: { kind: "error" } })
104
+ .catch((e) => this.log(`could not report it to ${channel.id}: ${String(e)}`));
105
+ });
106
+ }
107
+ live(platform) {
108
+ return this.running.has(platform);
109
+ }
110
+ /** A design lead waiting on the user gets a thread in the home DM bound to
111
+ * its session (docs/design/11 §Child threads); its later states edit the
112
+ * root. No home, its adapter down, or the session already in a chat: nothing
113
+ * — the web's needs-you carries it. Every failure is a note in the chat. */
114
+ async designLead(lead, state) {
115
+ const home = this.store.home();
116
+ const channel = home && this.running.get(home.platform);
117
+ if (!home || !channel)
118
+ return;
119
+ const note = { text: rootLine(lead, state), origin: lead.origin };
120
+ const bound = this.conversations.keyOf(lead.sessionId);
121
+ if (state === "waiting") {
122
+ if (bound)
123
+ return;
124
+ let thread;
125
+ try {
126
+ thread = await channel.openThread(home.chatId, note);
127
+ // The row first: `ensure` resolves the thread through it, as any thread's message would.
128
+ this.conversations.set({ channelId: home.platform, conversationId: thread }, lead.sessionId);
129
+ await this.router.ensure({ channelId: home.platform, conversationId: thread });
130
+ await channel.send(thread, splitReply(lead.text));
131
+ }
132
+ catch (err) {
133
+ // A root already posted is the thread's failure to report; none, the main flow's.
134
+ await this.report(channel, thread ?? home.chatId, `"${lead.name}" waits for you on the web; ${thread ? "its turn did not reach this thread" : "its thread could not be opened"}: ${String(err)}`);
135
+ }
136
+ return;
137
+ }
138
+ // Only a thread of the home chat has a root of ours to edit.
139
+ if (!bound || bound.channelId !== home.platform || chatOf(bound.conversationId) !== home.chatId)
140
+ return;
141
+ await channel.editRoot(bound.conversationId, note).catch((err) => this.report(channel, bound.conversationId, `the thread's root could not be updated to "${note.text}": ${String(err)}`));
142
+ }
143
+ async report(channel, conversationId, text) {
144
+ this.log(text);
145
+ await channel.notify(conversationId, { text, origin: { kind: "error" } })
146
+ .catch((e) => this.log(`could not report it to ${channel.id}: ${String(e)}`));
147
+ }
84
148
  /** For restart-note delivery (src/drain.ts), which has no session to report
85
149
  * through; false means the platform is not running. */
86
150
  async notify(platform, conversationId, text) {
87
- const channel = this.live.get(platform);
151
+ const channel = this.running.get(platform);
88
152
  if (!channel)
89
153
  return false;
90
154
  await channel.notify(conversationId, { text, origin: { kind: "error" } });
91
155
  return true;
92
156
  }
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
157
  async stop() {
105
- // An in-flight restart could otherwise register an adapter after `live`
158
+ // An in-flight restart could otherwise register an adapter after `running`
106
159
  // was cleared — running, unstoppable.
107
160
  this.stopped = true;
108
161
  await this.reloading.catch(() => { });
109
- for (const channel of this.live.values()) {
162
+ for (const channel of this.running.values()) {
163
+ this.router.unregisterChannel(channel.id);
110
164
  await channel.stop().catch((err) => this.log(`${channel.id} did not stop cleanly: ${String(err)}`));
111
165
  }
112
- this.live.clear();
166
+ this.running.clear();
113
167
  }
114
168
  }
@@ -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,22 +45,18 @@ 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;
54
+ /** A note's root rewritten in place (a child thread's state); one message,
55
+ * so the body is cut to the smaller budget rather than split. */
56
+ async edit(channel, ts, note) {
57
+ const body = chunk(noteBody(note, "_"), MRKDWN_MAX)[0] ?? "";
58
+ const blocks = this.markdownBlocks ? [markdown(body)] : sections(toMrkdwn(body));
59
+ await this.api.updateMessage({ channel, ts, text: body, blocks });
62
60
  }
63
61
  budget() {
64
62
  return this.markdownBlocks ? MARKDOWN_MAX : MRKDWN_MAX;
@@ -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. */
@@ -39,3 +39,68 @@ export async function readThread(directory, client, channel, threadTs, limit) {
39
39
  messages: transcript(window, names, { ts: true, ids: true, thread: true }),
40
40
  };
41
41
  }
42
+ /** A token budget, not a Slack limit: past this the agent gets the
43
+ * coordinates and decides for itself. */
44
+ const INLINE_REPLY_MAX = 30;
45
+ /** The eager thread read is not gated: a human handing the agent a message
46
+ * is the same act as an upload. */
47
+ export async function sharedBlock(directory, api, log, share) {
48
+ const source = share.original_message;
49
+ const ts = share.ts ?? source?.ts;
50
+ const threadTs = share.thread_ts ?? source?.thread_ts ?? ts;
51
+ const replies = share.reply_count ?? source?.reply_count;
52
+ // A share of a reply is one message; only a parent has a thread.
53
+ const parent = share.channel_id && ts && threadTs === ts && replies
54
+ ? { channel: share.channel_id, ts, replies }
55
+ : null;
56
+ // `name<id>` is the sender prefix's grammar (core/identity.ts).
57
+ const author = share.author_id
58
+ ? `${await directory.user(api, share.author_id)}<${share.author_id}>`
59
+ : share.author_name || share.author_subname;
60
+ const where = share.channel_name
61
+ ? `#${share.channel_name}${share.channel_id ? `<${share.channel_id}>` : ""}`
62
+ : share.channel_id;
63
+ const head = [
64
+ "shared message",
65
+ author && `from ${author}`,
66
+ where && `in ${where}`,
67
+ ts && `at ${ts}`,
68
+ ].filter(Boolean).join(" ");
69
+ // `fallback` is Slack's plain-text rendering when a share's `text` is
70
+ // empty (a file-only forward, or a body that is all blocks); "" is normal.
71
+ const body = (share.text || share.fallback || source?.text || "").trim();
72
+ const thread = parent && parent.replies <= INLINE_REPLY_MAX
73
+ ? await sharedThread(directory, api, log, parent.channel, parent.ts, parent.replies)
74
+ : { transcript: false, lines: [] };
75
+ // The coordinates in `pier slack`'s own words (skills/pier-slack).
76
+ const hint = parent && !thread.transcript
77
+ ? `[thread: ${parent.replies} replies — channel ${parent.channel}, thread_ts ${parent.ts}]`
78
+ : "";
79
+ // The transcript opens with the shared message itself.
80
+ return [`[${head}]`, thread.transcript ? "" : body, ...thread.lines, hint]
81
+ .filter(Boolean).join("\n");
82
+ }
83
+ /** A read that fails or comes back cut says so in the prompt (§5). */
84
+ export async function sharedThread(directory, api, log, channel, ts, replies) {
85
+ try {
86
+ // One over the budget, so an undercounting reply_count still reports as cut.
87
+ const read = await readThread(directory, api, channel, ts, INLINE_REPLY_MAX + 2);
88
+ if (!read.messages.length)
89
+ return { transcript: false, lines: [] };
90
+ return {
91
+ transcript: true,
92
+ lines: [
93
+ `[thread: ${replies} replies, oldest first — ${read.format}]`,
94
+ ...read.messages,
95
+ ...(read.truncated ? [`[thread partly read: cut at ${read.count} lines]`] : []),
96
+ ],
97
+ };
98
+ }
99
+ catch (err) {
100
+ log(`shared thread ${channel}/${ts} not read: ${String(err)}`);
101
+ return {
102
+ transcript: false,
103
+ lines: [`[thread not read: ${err instanceof Error ? err.message : String(err)}]`],
104
+ };
105
+ }
106
+ }
@@ -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";
@@ -18,7 +21,7 @@ import { SlackDirectory } from "./slack-directory.js";
18
21
  import { SlackApi, } from "./slack-api.js";
19
22
  import { SlackOutbound } from "./slack-outbound.js";
20
23
  import { SlackPanel } from "./slack-panel.js";
21
- import { readThread } from "./slack-thread.js";
24
+ import { sharedBlock } from "./slack-thread.js";
22
25
  import { context, escapeMrkdwn, offeredLabel } from "./slack-render.js";
23
26
  const WORKING = "eyes";
24
27
  // The envelope is already acked, so this bounds concurrency (sockets,
@@ -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: "" }
@@ -51,21 +51,6 @@ const READABLE_SUBTYPES = new Set(["file_share", "thread_broadcast", "message_sh
51
51
  * sender did not choose to forward; a real share carries both flags. */
52
52
  const sharesOf = (event) => (event.attachments ?? []).filter((a) => a.is_share === true || (event.subtype === "message_share" && !a.is_msg_unfurl));
53
53
  const sharedFiles = (share) => share.files ?? share.original_message?.files ?? [];
54
- /** A token budget, not a Slack limit: past this the agent gets the
55
- * coordinates and decides for itself. */
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
54
  export class SlackChannel {
70
55
  deps;
71
56
  id = "slack";
@@ -103,13 +88,8 @@ export class SlackChannel {
103
88
  ? this.api.addReaction(channel, ts, emoji)
104
89
  : this.api.removeReaction(channel, ts, WORKING),
105
90
  }, 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
- });
91
+ if (deps.control) {
92
+ this.panel = new SlackPanel({ api: this.api, control: deps.control, log: this.log });
113
93
  }
114
94
  }
115
95
  async start(onMessage) {
@@ -203,13 +183,16 @@ export class SlackChannel {
203
183
  this.deps.store.discoverChat("slack", { id: channel, name, kind });
204
184
  }
205
185
  const text = this.stripMention(raw);
206
- const command = slackCommand(text);
186
+ const command = parseCommand(text);
207
187
  const threadTs = threadOf(event);
208
- const here = { channelId: this.id, conversationId: conversationId(channel, threadTs) };
188
+ // The home DM is the head's, in a thread or not, under one key — except a
189
+ // child's thread (docs/design/11 §Child threads), which is that session's.
190
+ const home = this.isHead(channel, event.thread_ts);
191
+ const here = { channelId: this.id, conversationId: home ? channel : conversationId(channel, threadTs) };
209
192
  const bindRequest = command?.name === "bind" && isDm;
210
193
  const admitted = this.gate.admit("message", channel, {
211
194
  isDm,
212
- addressed: this.addressed(raw, event, here),
195
+ addressed: this.addressed(raw, event, here, home),
213
196
  userId: event.user,
214
197
  bindRequest,
215
198
  });
@@ -220,23 +203,30 @@ export class SlackChannel {
220
203
  }
221
204
  if (bindRequest)
222
205
  return this.bind(channel, event.user, threadTs, command?.args ?? "");
223
- if (command?.name === "stop")
206
+ // The head has no panel and takes `/stop` and `/skills` as chat commands (core/chain.ts).
207
+ if (!home && command?.name === "stop")
224
208
  return this.abortTurn(here, channel, threadTs);
209
+ if (!home && command?.name === "skills")
210
+ return this.listSkills(here);
211
+ if (!text && !files.length && !shares.length)
212
+ return this.log(`empty message in ${here.conversationId}, dropped`);
225
213
  // Downloading only past the gate: an unauthorized sender must not make the
226
214
  // bot pull bytes on their behalf.
227
215
  const markers = await this.saveAttachments(files);
228
- 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.
216
+ const shared = await Promise.all(shares.map((share) => sharedBlock(this.directory, this.api, this.log, share)));
217
+ // `/s <text>` drafts a session, so only where this message would start one:
218
+ // a thread root. The held question carries its markers, so Start sends what the user sent.
232
219
  const question = threadTs === ts ? settingsDraft(text) : undefined;
233
- if (this.panel && (question || command?.name === "settings" || (!text && !files.length && !shares.length))) {
220
+ if (this.panel && !home && (question || command?.name === "settings")) {
234
221
  return this.panel.open(here, channel, threadTs, question && [question, ...shared, ...markers].join("\n"));
235
222
  }
236
223
  // Resolved before the mark: any await between mark() and dispatch is a
237
224
  // window in which a previous turn can settle and take this receipt with it.
238
225
  const sender = { id: event.user, name: await this.directory.user(this.api, event.user) };
239
- this.receipts.mark(here.conversationId, channel, ts);
226
+ // The head answers a chat command with a note, not a turn: nothing would take a 👀 off it.
227
+ const answered = home && command && !command.args && isChatCommand(command.name);
228
+ if (!answered)
229
+ this.receipts.mark(here.conversationId, channel, ts);
240
230
  // Steer: a follow-up is the wrong default when the human is watching a 👀.
241
231
  onMessage({
242
232
  key: here,
@@ -267,10 +257,13 @@ export class SlackChannel {
267
257
  this.log("incomplete block_actions payload, dropped");
268
258
  return;
269
259
  }
270
- const threadTs = message.thread_ts ?? message.ts;
260
+ // Any click in the home DM is the head's, echoed where the button was,
261
+ // unless a child's thread; anywhere else a top-level message roots a thread.
262
+ const home = this.isHead(channel, message.thread_ts);
263
+ const threadTs = home ? message.thread_ts : message.thread_ts ?? message.ts;
271
264
  const key = {
272
265
  channelId: this.id,
273
- conversationId: conversationId(channel, threadTs),
266
+ conversationId: home ? channel : conversationId(channel, message.thread_ts ?? message.ts),
274
267
  };
275
268
  const admitted = this.gate.admit("action", channel, {
276
269
  isDm: (await this.directory.channel(this.api, channel)).kind === "dm",
@@ -281,7 +274,8 @@ export class SlackChannel {
281
274
  return;
282
275
  // Start's question: the card is the message the click was on, so it carries the 👀.
283
276
  const run = (text) => this.deliver(key, channel, message.ts, user, text, onMessage);
284
- if (await this.panel?.onAction(interaction, key, actionId, run))
277
+ // The head has no panel: a card left in the DM before it became the home is stale.
278
+ if (!home && (await this.panel?.onAction(interaction, key, actionId, run)))
285
279
  return;
286
280
  const text = offeredLabel(message.blocks, actionId);
287
281
  if (text === undefined) {
@@ -324,6 +318,11 @@ export class SlackChannel {
324
318
  await this.deps.control?.abort(key);
325
319
  await this.api.postMessage({ channel, thread_ts: threadTs, text: STOPPED });
326
320
  }
321
+ /** The head's `/skills` is MainChain's; a thread has no chain, so it is answered here. */
322
+ async listSkills(key) {
323
+ const text = skillsText((await this.deps.control?.skills(key)) ?? []);
324
+ await this.notify(key.conversationId, { text, origin: { kind: "chat-command", command: "skills" } });
325
+ }
327
326
  // --- bind ------------------------------------------------------------------
328
327
  /** Channels stay silent, but a DM that swallows every message looks broken
329
328
  * rather than locked. */
@@ -333,7 +332,7 @@ export class SlackChannel {
333
332
  await this.api.postMessage({
334
333
  channel,
335
334
  thread_ts: threadTs,
336
- text: bindHint("`bind <code>`"),
335
+ text: bindHint("`%bind <code>`"),
337
336
  }).catch((err) => this.log(`bind hint failed: ${String(err)}`));
338
337
  }
339
338
  async bind(channel, userId, threadTs, code) {
@@ -347,11 +346,26 @@ export class SlackChannel {
347
346
  }
348
347
  // --- addressing ------------------------------------------------------------
349
348
  /** Mentioned, or continuing a thread Pier already owns — durable, so it
350
- * holds after a restart. */
351
- addressed(raw, event, key) {
349
+ * holds after a restart. The home key has no row to know. */
350
+ addressed(raw, event, key, home) {
352
351
  if (this.me && raw.includes(`<@${this.me}>`))
353
352
  return true;
354
- return !!event.thread_ts && !!this.deps.control?.knows(key);
353
+ return !home && !!event.thread_ts && !!this.deps.control?.knows(key);
354
+ }
355
+ isHome(channel) {
356
+ return this.deps.control?.isHome({ channelId: this.id, conversationId: channel }) ?? false;
357
+ }
358
+ /** The head's: the home DM, outside any thread a session of its own is bound to. */
359
+ isHead(channel, threadTs) {
360
+ return this.isHome(channel) &&
361
+ !(threadTs && this.deps.control?.knows({ channelId: this.id, conversationId: conversationId(channel, threadTs) }));
362
+ }
363
+ /** A thread, or the home DM's main flow (no `threadTs`); undefined for any other channel's. */
364
+ target(conversation) {
365
+ const { channel, threadTs } = parseConversation(conversation);
366
+ if (threadTs)
367
+ return { channel, threadTs };
368
+ return this.isHome(channel) ? { channel } : undefined;
355
369
  }
356
370
  /** Slack does not strip the mention for us. */
357
371
  stripMention(text) {
@@ -367,68 +381,6 @@ export class SlackChannel {
367
381
  }
368
382
  return name ?? channel;
369
383
  }
370
- /** The eager thread read is not gated: a human handing the agent a message
371
- * is the same act as an upload. */
372
- async sharedBlock(share) {
373
- const source = share.original_message;
374
- const ts = share.ts ?? source?.ts;
375
- const threadTs = share.thread_ts ?? source?.thread_ts ?? ts;
376
- const replies = share.reply_count ?? source?.reply_count;
377
- // A share of a reply is one message; only a parent has a thread.
378
- const parent = share.channel_id && ts && threadTs === ts && replies
379
- ? { channel: share.channel_id, ts, replies }
380
- : null;
381
- // `name<id>` is the sender prefix's grammar (core/identity.ts).
382
- const author = share.author_id
383
- ? `${await this.directory.user(this.api, share.author_id)}<${share.author_id}>`
384
- : share.author_name || share.author_subname;
385
- const where = share.channel_name
386
- ? `#${share.channel_name}${share.channel_id ? `<${share.channel_id}>` : ""}`
387
- : share.channel_id;
388
- const head = [
389
- "shared message",
390
- author && `from ${author}`,
391
- where && `in ${where}`,
392
- ts && `at ${ts}`,
393
- ].filter(Boolean).join(" ");
394
- // `fallback` is Slack's plain-text rendering when a share's `text` is
395
- // empty (a file-only forward, or a body that is all blocks); "" is normal.
396
- const body = (share.text || share.fallback || source?.text || "").trim();
397
- const thread = parent && parent.replies <= INLINE_REPLY_MAX
398
- ? await this.sharedThread(parent.channel, parent.ts, parent.replies)
399
- : { transcript: false, lines: [] };
400
- // The coordinates in `pier slack`'s own words (skills/pier-slack).
401
- const hint = parent && !thread.transcript
402
- ? `[thread: ${parent.replies} replies — channel ${parent.channel}, thread_ts ${parent.ts}]`
403
- : "";
404
- // The transcript opens with the shared message itself.
405
- return [`[${head}]`, thread.transcript ? "" : body, ...thread.lines, hint]
406
- .filter(Boolean).join("\n");
407
- }
408
- /** A read that fails or comes back cut says so in the prompt (§5). */
409
- async sharedThread(channel, ts, replies) {
410
- try {
411
- // One over the budget, so an undercounting reply_count still reports as cut.
412
- const read = await readThread(this.directory, this.api, channel, ts, INLINE_REPLY_MAX + 2);
413
- if (!read.messages.length)
414
- return { transcript: false, lines: [] };
415
- return {
416
- transcript: true,
417
- lines: [
418
- `[thread: ${replies} replies, oldest first — ${read.format}]`,
419
- ...read.messages,
420
- ...(read.truncated ? [`[thread partly read: cut at ${read.count} lines]`] : []),
421
- ],
422
- };
423
- }
424
- catch (err) {
425
- this.log(`shared thread ${channel}/${ts} not read: ${String(err)}`);
426
- return {
427
- transcript: false,
428
- lines: [`[thread not read: ${err instanceof Error ? err.message : String(err)}]`],
429
- };
430
- }
431
- }
432
384
  saveAttachments(files) {
433
385
  return saveInboundAll(this.id, files.map((file) => ({
434
386
  label: file.name ?? "attachment",
@@ -441,32 +393,41 @@ export class SlackChannel {
441
393
  }
442
394
  // --- outbound --------------------------------------------------------------
443
395
  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) {
396
+ const to = this.target(conversation);
397
+ // No thread outside the home DM is a foreign id; posting it would put a
398
+ // turn in the channel's main flow. Refused loudly, receipts still cleared.
399
+ if (!to) {
448
400
  this.log(`refusing to answer ${conversation}: no thread in the conversation id`);
449
401
  await this.receipts.settle(conversation);
450
402
  return;
451
403
  }
452
404
  // 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));
405
+ await this.receipts.settleAfter(conversation, () => this.out.reply(to.channel, to.threadTs, reply), reply.meta);
459
406
  }
460
407
  /** The 👀 goes on the note itself: the turn it triggers has no message of
461
408
  * the user's to carry them. */
462
409
  async notify(conversation, note) {
463
- const { channel, threadTs } = parseConversation(conversation);
464
- if (!threadTs) {
410
+ const to = this.target(conversation);
411
+ if (!to) {
465
412
  this.log(`refusing to post a system note to ${conversation}: no thread in the conversation id`);
466
413
  return;
467
414
  }
468
- const ts = await this.out.note(channel, threadTs, note);
415
+ const ts = await this.out.note(to.channel, to.threadTs, note);
469
416
  if (ts && awaitsTurn(note.origin))
470
- this.receipts.mark(conversation, channel, ts, note.at);
417
+ this.receipts.mark(conversation, to.channel, ts, note.at);
418
+ }
419
+ async openThread(channel, note) {
420
+ if (!this.isHome(channel))
421
+ throw new Error(`refusing to open a thread in ${channel}: not the home DM`);
422
+ const ts = await this.out.note(channel, undefined, note);
423
+ if (!ts)
424
+ throw new Error(`Slack returned no ts for the root in ${channel}`);
425
+ return conversationId(channel, ts);
426
+ }
427
+ async editRoot(conversation, note) {
428
+ const { channel, threadTs } = parseConversation(conversation);
429
+ if (!threadTs)
430
+ throw new Error(`refusing to edit ${conversation}: no thread in the conversation id`);
431
+ await this.out.edit(channel, threadTs, note);
471
432
  }
472
433
  }