paperclip-plugin-mattermost 0.11.2 → 0.12.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.
package/README.md CHANGED
@@ -5,7 +5,7 @@ Your [Paperclip](https://github.com/paperclipai/paperclip) agents as Mattermost
5
5
  ## What it does
6
6
 
7
7
  - **A bot per agent.** Bots are created, renamed and switched off as agents change (every 5 minutes). Choose all agents, or only the ones you pick.
8
- - **You see it working.** The agent's bot reacts 👀 to your message straight away. Its reply starts as _thinking…_ and lists the steps it runs, then the answer replaces the list.
8
+ - **You see it working.** The agent's bot reacts 👀 to your message straight away, and shows Mattermost's "is typing…" indicator until its answer arrives.
9
9
  - **Files both ways.** Attach files to a message and the agent can read them. Files the agent saves are attached to its reply.
10
10
  - **Choices as buttons.** An agent can offer options (`choices: A | B | C` on its last line), which appear as buttons. A click is sent back as your answer.
11
11
  - **Approvals with buttons.** Every Paperclip approval is posted in an approvals channel with Approve / Reject buttons, including hires, budget overrides, board approvals and high-risk tool calls. An approval raised during a conversation also appears in that thread.
@@ -60,7 +60,6 @@ Or install it from **Settings → Plugins** in Paperclip.
60
60
  - **Multi-choice questions** and free-text "Other" answers are answered in Paperclip; the card links there.
61
61
  - **Mattermost can't hide bots** from particular people. Someone without access can still DM an agent bot; they get a private note and the agent doesn't run.
62
62
  - **Agents with a custom uploaded portrait** keep the default bot picture, because those images need a Paperclip login to download.
63
- - **Live progress** shows steps for agents run through ACPX (Claude Code, and others Paperclip runs that way). Other adapters show only _thinking…_.
64
63
 
65
64
  ## Development
66
65
 
package/dist/manifest.js CHANGED
@@ -2,7 +2,7 @@
2
2
  var manifest = {
3
3
  id: "mattermost-bridge",
4
4
  apiVersion: 1,
5
- version: "0.11.2",
5
+ version: "0.12.0",
6
6
  displayName: "Mattermost",
7
7
  description: "Every agent becomes its own Mattermost bot. DM an agent or mention it in a channel and it answers in the thread, showing its steps as it works; follow-ups continue the conversation. Paperclip approvals arrive with Approve / Reject buttons.",
8
8
  author: "Haseeb Azhar",
package/dist/worker.js CHANGED
@@ -44992,57 +44992,9 @@ function chosenProps(choice, who) {
44992
44992
  return { attachments: [{ text: `${who} chose **${choice}**`, actions: [] }] };
44993
44993
  }
44994
44994
 
44995
- // src/progress.ts
44996
- var THINKING = "_thinking\u2026_";
44997
- var MAX_STEPS = 6;
44998
- var MAX_LABEL2 = 100;
44999
- var Progress = class {
45000
- buffer = "";
45001
- steps = /* @__PURE__ */ new Map();
45002
- /** Feeds one stdout chunk. Returns true when what `render` shows changed. */
45003
- feed(chunk) {
45004
- this.buffer += chunk;
45005
- const lines = this.buffer.split("\n");
45006
- this.buffer = lines.pop() ?? "";
45007
- let changed = false;
45008
- for (const line of lines) {
45009
- if (!line.startsWith("{")) continue;
45010
- let e;
45011
- try {
45012
- e = JSON.parse(line);
45013
- } catch {
45014
- continue;
45015
- }
45016
- if (e.type !== "acpx.tool_call" || !e.toolCallId) continue;
45017
- const prev = this.steps.get(e.toolCallId);
45018
- const tool3 = prev?.tool ?? e.name ?? "tool";
45019
- const label2 = e.name && e.name !== tool3 ? e.name : prev?.label ?? tool3;
45020
- const status = e.status ?? prev?.status ?? "pending";
45021
- if (prev?.label !== label2 || prev?.status !== status) {
45022
- this.steps.set(e.toolCallId, { tool: tool3, label: label2, status });
45023
- changed = true;
45024
- }
45025
- }
45026
- return changed;
45027
- }
45028
- render() {
45029
- const head = THINKING;
45030
- const all = [...this.steps.values()];
45031
- if (!all.length) return head;
45032
- const shown = all.slice(-MAX_STEPS);
45033
- const lines = shown.map((s) => {
45034
- const mark = s.status === "completed" ? "\u2713" : s.status === "failed" ? "\u2717" : "\u2026";
45035
- const label2 = s.label.replace(/\s+/g, " ").trim();
45036
- return `- ${mark} \`${(label2.length > MAX_LABEL2 ? label2.slice(0, MAX_LABEL2) + "\u2026" : label2).replace(/`/g, "'")}\``;
45037
- });
45038
- if (all.length > shown.length) lines.unshift(`- _${all.length - shown.length} earlier steps_`);
45039
- return `${head}
45040
- ${lines.join("\n")}`;
45041
- }
45042
- };
45043
-
45044
44995
  // src/bridge.ts
45045
- var PROGRESS_EVERY_MS = 2e3;
44996
+ var TYPING_EVERY_MS = 3e3;
44997
+ var OLD_PLACEHOLDER = /^_(thinking…|.+ is working on it…)_/;
45046
44998
  var MAX_POST = 15e3;
45047
44999
  var MAX_HISTORY = 8e3;
45048
45000
  var MENTION = /(?:^|[^\w@])@([a-z0-9][a-z0-9._-]*)/gi;
@@ -45128,7 +45080,7 @@ var Bridge = class {
45128
45080
  const roster = this.roster();
45129
45081
  const lines = [];
45130
45082
  for (const p of await this.directory.threadPosts(rootId)) {
45131
- if (p.id === currentId || p.type || !p.message.trim() || p.message.startsWith(THINKING)) continue;
45083
+ if (p.id === currentId || p.type || !p.message.trim() || OLD_PLACEHOLDER.test(p.message)) continue;
45132
45084
  const who = p.user_id === me.botUserId ? "You" : `@${roster.byBotId(p.user_id)?.username ?? (await this.directory.userName(p.user_id)).username}`;
45133
45085
  lines.push(`${who}: ${p.message}`);
45134
45086
  }
@@ -45159,10 +45111,23 @@ var Bridge = class {
45159
45111
  const sender = await this.directory.userName(post.user_id);
45160
45112
  const where = isDirect ? "a direct message" : `#${evt.channelName}`;
45161
45113
  const history = post.root_id ? await this.history(post.root_id, post.id, member) : "";
45162
- const progress = new Progress();
45163
- const placeholder = await member.chat.createPost(post.channel_id, rootId, progress.render());
45164
- await member.chat.typing(post.channel_id, rootId).catch(() => {
45165
- });
45114
+ const typing = () => {
45115
+ void member.chat.typing(post.channel_id, rootId).catch(() => {
45116
+ });
45117
+ void member.chat.typing(post.channel_id, "").catch(() => {
45118
+ });
45119
+ };
45120
+ typing();
45121
+ const typingTimer = setInterval(typing, TYPING_EVERY_MS);
45122
+ try {
45123
+ await this.answer(evt, member, rootId, threadKey, thread, { sender, where, history, text: text2, fileIds });
45124
+ } finally {
45125
+ clearInterval(typingTimer);
45126
+ }
45127
+ }
45128
+ async answer(evt, member, rootId, threadKey, thread, m) {
45129
+ const { post } = evt;
45130
+ const { sender, where, history, text: text2, fileIds } = m;
45166
45131
  const attached = fileIds.length ? await this.files.receive(member.chat, post.id, fileIds) : [];
45167
45132
  const outbox = this.files ? await this.files.outbox(post.id).catch(() => null) : null;
45168
45133
  const prompt = `${sender.displayName} (@${sender.username}) wrote to you (@${member.username}) in ${where} on Mattermost. Your reply is posted back there, so answer in Markdown. To offer choices, end your reply with one line \`choices: First | Second\` (2 to ${MAX_CHOICES} short options); they are shown as buttons, and the pick comes back as the next message.` + (history ? `
@@ -45178,23 +45143,6 @@ Attached files, saved on disk (read them at these paths):
45178
45143
  ${attached.join("\n")}` : "") + (outbox ? `
45179
45144
 
45180
45145
  To send files back with your reply, save them in ${outbox}` : "");
45181
- let timer = null;
45182
- let edit = Promise.resolve();
45183
- let finished = false;
45184
- const onOutput = (chunk) => {
45185
- if (finished || !progress.feed(chunk) || timer) return;
45186
- timer = setTimeout(() => {
45187
- timer = null;
45188
- if (finished) return;
45189
- edit = edit.then(() => member.chat.patchPost(placeholder.id, progress.render())).catch(() => {
45190
- });
45191
- }, PROGRESS_EVERY_MS);
45192
- };
45193
- const settle2 = async () => {
45194
- finished = true;
45195
- if (timer) clearTimeout(timer);
45196
- await edit;
45197
- };
45198
45146
  let reply;
45199
45147
  const here = { channelId: post.channel_id, rootId };
45200
45148
  this.active.set(member.agentId, here);
@@ -45202,21 +45150,19 @@ To send files back with your reply, save them in ${outbox}` : "");
45202
45150
  let sessionId = thread && thread.agentId === member.agentId ? thread.sessionId : null;
45203
45151
  try {
45204
45152
  if (!sessionId) throw new Error("no session yet");
45205
- reply = await this.agents.ask(sessionId, prompt, onOutput);
45153
+ reply = await this.agents.ask(sessionId, prompt);
45206
45154
  } catch (first2) {
45207
45155
  if (sessionId) this.log("session unusable, starting a new one", { sessionId, err: String(first2) });
45208
45156
  sessionId = await this.agents.createSession(member.agentId, `mattermost:${rootId}`);
45209
45157
  await this.store.set(threadKey, { agentId: member.agentId, sessionId });
45210
- reply = await this.agents.ask(sessionId, prompt, onOutput);
45158
+ reply = await this.agents.ask(sessionId, prompt);
45211
45159
  }
45212
45160
  } catch (err) {
45213
45161
  if (this.active.get(member.agentId) === here) this.active.delete(member.agentId);
45214
- await settle2();
45215
- await member.chat.patchPost(placeholder.id, `:warning: ${member.agentName} could not answer: ${String(err?.message ?? err).slice(0, 500)}`);
45162
+ await member.chat.createPost(post.channel_id, rootId, `:warning: ${member.agentName} could not answer: ${String(err?.message ?? err).slice(0, 500)}`);
45216
45163
  return;
45217
45164
  }
45218
45165
  if (this.active.get(member.agentId) === here) this.active.delete(member.agentId);
45219
- await settle2();
45220
45166
  const { text: body, choices } = extractChoices(reply ?? "");
45221
45167
  const origin = { channelId: post.channel_id, rootId, channelType: evt.channelType, channelName: evt.channelName };
45222
45168
  const buttons = choices.length && this.choiceButtons ? this.choiceButtons(origin, choices) : null;
@@ -45224,7 +45170,7 @@ To send files back with your reply, save them in ${outbox}` : "");
45224
45170
 
45225
45171
  ${choices.map((c, i) => `${i + 1}. ${c}`).join("\n")}` : body;
45226
45172
  const [first, ...more] = splitMessage(shown.trim() || `_${member.agentName} finished without a reply._`);
45227
- await member.chat.patchPost(placeholder.id, first);
45173
+ await member.chat.createPost(post.channel_id, rootId, first);
45228
45174
  for (const part of more) await member.chat.createPost(post.channel_id, rootId, part);
45229
45175
  if (buttons) await member.chat.createPost(post.channel_id, rootId, "", buttons);
45230
45176
  if (outbox) {
@@ -45281,7 +45227,7 @@ var MattermostClient = class {
45281
45227
  body: body === void 0 ? void 0 : JSON.stringify(body),
45282
45228
  signal: AbortSignal.timeout(3e4)
45283
45229
  });
45284
- if (!r.ok) throw new Error(`Mattermost ${method} ${path3}: ${r.status} ${(await r.text()).slice(0, 200)}`);
45230
+ if (!r.ok) throw Object.assign(new Error(`Mattermost ${method} ${path3}: ${r.status} ${(await r.text()).slice(0, 200)}`), { status: r.status });
45285
45231
  return r.status === 204 ? null : await r.json();
45286
45232
  }
45287
45233
  async init() {
@@ -45316,9 +45262,6 @@ var MattermostClient = class {
45316
45262
  async react(postId, emoji3) {
45317
45263
  await this.request("POST", "/reactions", { user_id: this.botUserId, post_id: postId, emoji_name: emoji3 });
45318
45264
  }
45319
- async patchPost(postId, message2) {
45320
- await this.request("PUT", `/posts/${postId}/patch`, { message: message2 });
45321
- }
45322
45265
  async typing(channelId, rootId) {
45323
45266
  await this.request("POST", "/users/me/typing", { channel_id: channelId, parent_id: rootId });
45324
45267
  }
@@ -45541,18 +45484,19 @@ async function verifyUrl(url2, pluginKey) {
45541
45484
  }
45542
45485
 
45543
45486
  // src/roster.ts
45487
+ var notFound = (err) => err?.status === 404;
45544
45488
  var TOKEN_DESCRIPTION = "paperclip-plugin";
45545
45489
  var AgentBots = class {
45546
45490
  members = /* @__PURE__ */ new Map();
45547
45491
  manager;
45548
- url;
45492
+ newClient;
45549
45493
  listAgents;
45550
45494
  store;
45551
45495
  log;
45552
45496
  onPosted;
45553
- constructor(manager, url2, listAgents, store, log, onPosted) {
45497
+ constructor(manager, url2, listAgents, store, log, onPosted, newClient) {
45554
45498
  this.manager = manager;
45555
- this.url = url2;
45499
+ this.newClient = newClient ?? ((token) => new MattermostClient(url2, token, log));
45556
45500
  this.listAgents = listAgents;
45557
45501
  this.store = store;
45558
45502
  this.log = log;
@@ -45578,13 +45522,33 @@ var AgentBots = class {
45578
45522
  get size() {
45579
45523
  return this.members.size;
45580
45524
  }
45525
+ /**
45526
+ * The bot, or null only when Mattermost says it does not exist. Any other failure (the
45527
+ * server restarting, a timeout) throws, so the agent is skipped this round rather than
45528
+ * given a second bot: that is how Carma's Operations Agent became @operations-agent-agent
45529
+ * while @operations-agent, the one people mention, was left with nobody behind it.
45530
+ */
45581
45531
  async bot(id) {
45582
45532
  try {
45583
45533
  return await this.manager.request("GET", `/bots/${id}?include_deleted=true`);
45584
- } catch {
45585
- return null;
45534
+ } catch (err) {
45535
+ if (notFound(err)) return null;
45536
+ throw err;
45586
45537
  }
45587
45538
  }
45539
+ /** The bot at an agent's own username, if it is one of ours and no other agent uses it. */
45540
+ async reclaimable(agent, map2) {
45541
+ let user2;
45542
+ try {
45543
+ user2 = await this.manager.request("GET", `/users/username/${botUsername(agent.name)}`);
45544
+ } catch (err) {
45545
+ if (notFound(err)) return null;
45546
+ throw err;
45547
+ }
45548
+ if (!user2.is_bot || Object.entries(map2).some(([other, id]) => other !== agent.id && id === user2.id)) return null;
45549
+ const bot = await this.bot(user2.id);
45550
+ return bot && bot.owner_id === this.manager.botUserId ? bot : null;
45551
+ }
45588
45552
  async createBot(agent) {
45589
45553
  const base = botUsername(agent.name);
45590
45554
  let last;
@@ -45614,9 +45578,19 @@ var AgentBots = class {
45614
45578
  try {
45615
45579
  let bot = map2[agent.id] ? await this.bot(map2[agent.id]) : null;
45616
45580
  if (!bot) {
45617
- bot = await this.createBot(agent);
45618
- this.log("created agent bot", { agent: agent.name, username: bot.username });
45619
- } else {
45581
+ const mine = await this.reclaimable(agent, map2);
45582
+ bot = mine ?? await this.createBot(agent);
45583
+ this.log(mine ? "reused the agent's existing bot" : "created agent bot", { agent: agent.name, username: bot.username });
45584
+ } else if (bot.username !== botUsername(agent.name)) {
45585
+ const original = await this.reclaimable(agent, map2);
45586
+ if (original && original.user_id !== bot.user_id) {
45587
+ await this.manager.request("POST", `/bots/${bot.user_id}/disable`).catch(() => {
45588
+ });
45589
+ this.log("moved agent back to its own bot", { agent: agent.name, from: bot.username, to: original.username });
45590
+ bot = original;
45591
+ }
45592
+ }
45593
+ {
45620
45594
  if (bot.delete_at > 0) await this.manager.request("POST", `/bots/${bot.user_id}/enable`);
45621
45595
  if (bot.display_name !== agent.name) {
45622
45596
  bot = await this.manager.request("PUT", `/bots/${bot.user_id}`, { username: bot.username, display_name: agent.name, description: agent.title || bot.description });
@@ -45644,7 +45618,7 @@ var AgentBots = class {
45644
45618
  continue;
45645
45619
  }
45646
45620
  current?.client.close();
45647
- const client = new MattermostClient(this.url, await this.mintToken(bot.user_id), this.log);
45621
+ const client = this.newClient(await this.mintToken(bot.user_id));
45648
45622
  await client.init();
45649
45623
  client.connect(this.onPosted);
45650
45624
  this.members.set(agent.id, { agentId: agent.id, agentName: agent.name, botUserId: bot.user_id, username: bot.username, chat: client, client });
@@ -45683,7 +45657,7 @@ var AgentBots = class {
45683
45657
  var manifest = {
45684
45658
  id: "mattermost-bridge",
45685
45659
  apiVersion: 1,
45686
- version: "0.11.2",
45660
+ version: "0.12.0",
45687
45661
  displayName: "Mattermost",
45688
45662
  description: "Every agent becomes its own Mattermost bot. DM an agent or mention it in a channel and it answers in the thread, showing its steps as it works; follow-ups continue the conversation. Paperclip approvals arrive with Approve / Reject buttons.",
45689
45663
  author: "Haseeb Azhar",
@@ -45811,14 +45785,13 @@ var Tenant = class {
45811
45785
  const key = `plugin:${PLUGIN_KEY}:session:${taskKey}`;
45812
45786
  return (await ctx.agents.sessions.create(agentId, companyId, { taskKey: key, reason: "Mattermost thread" })).sessionId;
45813
45787
  },
45814
- ask(sessionId, prompt, onOutput) {
45788
+ ask(sessionId, prompt) {
45815
45789
  return new Promise((resolve, reject) => {
45816
45790
  const timer = setTimeout(() => reject(new Error("no reply after 30 minutes")), ASK_TIMEOUT_MS);
45817
45791
  ctx.agents.sessions.sendMessage(sessionId, companyId, {
45818
45792
  prompt,
45819
45793
  reason: "Mattermost message",
45820
45794
  onEvent: (e) => {
45821
- if (e.eventType === "chunk" && e.stream === "stdout" && e.message) onOutput?.(e.message);
45822
45795
  if (e.eventType === "done") {
45823
45796
  clearTimeout(timer);
45824
45797
  resolve(e.message);
@@ -46002,11 +45975,12 @@ var Tenant = class {
46002
45975
  const who = req.user_name ? `@${req.user_name}` : "Someone";
46003
45976
  await this.manager.request("PUT", `/posts/${req.post_id}/patch`, { props: chosenProps(choice.choice, who) }).catch(() => {
46004
45977
  });
46005
- void this.bridge.handle({
45978
+ const bridge = this.bridge;
45979
+ detached(() => void bridge.handle({
46006
45980
  post: { id: `choice:${req.post_id}`, user_id: req.user_id, channel_id: choice.channelId, root_id: choice.rootId, message: choice.choice },
46007
45981
  channelType: choice.channelType,
46008
45982
  channelName: choice.channelName
46009
- });
45983
+ }));
46010
45984
  return;
46011
45985
  }
46012
45986
  if (this.approvals) await this.notify(req, await this.approvals.click(req).catch(failed));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paperclip-plugin-mattermost",
3
- "version": "0.11.2",
3
+ "version": "0.12.0",
4
4
  "description": "Paperclip agents in Mattermost: a bot per agent, live progress, files, approval and decision buttons, and per-person access, for each Paperclip company.",
5
5
  "type": "module",
6
6
  "license": "MIT",