claude-threads 0.16.4 → 0.16.6

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/CHANGELOG.md CHANGED
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ### Added
11
+ - **Worktree context**: Replay first user prompt after mid-session worktree creation (`!worktree create`)
12
+ - **Thread context prompt**: When starting a session mid-thread (replying to an existing thread), offers to include previous conversation context
13
+ - Shows options for last 3, 5, or 10 messages (only options that make sense for available message count)
14
+ - "All X messages" option when message count doesn't match standard options
15
+ - 30-second timeout defaults to no context
16
+ - Context is prepended to the initial prompt so Claude understands the conversation history
17
+
18
+ ### Fixed
19
+ - **Plan mode approval**: Fixed API error "unexpected tool_use_id found in tool_result blocks" when approving plans
20
+ - Claude Code CLI handles ExitPlanMode internally; changed to send user message instead of duplicate tool_result
21
+ - **Question reactions**: Fixed 2nd+ questions not responding to emoji reactions
22
+ - Follow-up question posts weren't registered for reaction routing
23
+ - **Question answering**: Fixed duplicate tool_result when answering AskUserQuestion
24
+ - Claude Code CLI handles AskUserQuestion internally; changed to send user message
25
+ - Session timeout warning showing negative minutes (e.g., "-24min")
26
+ - Warning now fires 5 minutes before timeout instead of after 5 minutes idle
27
+ - Stale sessions are now cleaned from persistence on startup
28
+
10
29
  ## [0.16.3] - 2025-12-31
11
30
 
12
31
  ### Fixed
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env bun
2
2
  // @bun
3
3
  var __create = Object.create;
4
4
  var __getProtoOf = Object.getPrototypeOf;
@@ -13395,6 +13395,37 @@ class MattermostClient extends EventEmitter {
13395
13395
  return null;
13396
13396
  }
13397
13397
  }
13398
+ async getThreadHistory(threadId, options) {
13399
+ try {
13400
+ const response = await this.api("GET", `/posts/${threadId}/thread`);
13401
+ const messages = [];
13402
+ for (const postId of response.order) {
13403
+ const post = response.posts[postId];
13404
+ if (!post)
13405
+ continue;
13406
+ if (options?.excludeBotMessages && post.user_id === this.botUserId) {
13407
+ continue;
13408
+ }
13409
+ const user = await this.getUser(post.user_id);
13410
+ const username = user?.username || "unknown";
13411
+ messages.push({
13412
+ id: post.id,
13413
+ userId: post.user_id,
13414
+ username,
13415
+ message: post.message,
13416
+ createAt: post.create_at
13417
+ });
13418
+ }
13419
+ messages.sort((a, b) => a.createAt - b.createAt);
13420
+ if (options?.limit && messages.length > options.limit) {
13421
+ return messages.slice(-options.limit);
13422
+ }
13423
+ return messages;
13424
+ } catch (err) {
13425
+ console.error(` \u26A0\uFE0F Failed to get thread history for ${threadId}:`, err);
13426
+ return [];
13427
+ }
13428
+ }
13398
13429
  async connect() {
13399
13430
  await this.getBotUser();
13400
13431
  wsLogger.debug(`Bot user ID: ${this.botUserId}`);
@@ -14747,10 +14778,7 @@ function formatEvent(session, e, ctx) {
14747
14778
  async function handleExitPlanMode(session, toolUseId, ctx) {
14748
14779
  if (session.planApproved) {
14749
14780
  if (ctx.debug)
14750
- console.log(" \u21AA Plan already approved, sending acknowledgment");
14751
- if (session.claude.isRunning()) {
14752
- session.claude.sendToolResult(toolUseId, "Plan already approved. Proceeding.");
14753
- }
14781
+ console.log(" \u21AA Plan already approved, letting CLI handle it");
14754
14782
  return;
14755
14783
  }
14756
14784
  if (session.pendingApproval && session.pendingApproval.type === "plan") {
@@ -14933,7 +14961,7 @@ async function handleQuestionReaction(session, postId, emojiName, username, ctx)
14933
14961
  if (session.pendingQuestionSet.currentIndex < questions.length) {
14934
14962
  await postCurrentQuestion(session, {
14935
14963
  debug: ctx.debug,
14936
- registerPost: () => {},
14964
+ registerPost: ctx.registerPost,
14937
14965
  flush: async () => {},
14938
14966
  startTyping: ctx.startTyping,
14939
14967
  stopTyping: ctx.stopTyping,
@@ -14948,10 +14976,9 @@ async function handleQuestionReaction(session, postId, emojiName, username, ctx)
14948
14976
  }
14949
14977
  if (ctx.debug)
14950
14978
  console.log(" \u2705 All questions answered");
14951
- const toolUseId = session.pendingQuestionSet.toolUseId;
14952
14979
  session.pendingQuestionSet = null;
14953
14980
  if (session.claude.isRunning()) {
14954
- session.claude.sendToolResult(toolUseId, answersText);
14981
+ session.claude.sendMessage(answersText);
14955
14982
  ctx.startTyping(session);
14956
14983
  }
14957
14984
  }
@@ -14963,7 +14990,7 @@ async function handleApprovalReaction(session, emojiName, username, ctx) {
14963
14990
  const isReject = isDenialEmoji(emojiName);
14964
14991
  if (!isApprove && !isReject)
14965
14992
  return;
14966
- const { postId, toolUseId } = session.pendingApproval;
14993
+ const { postId } = session.pendingApproval;
14967
14994
  const shortId = session.threadId.substring(0, 8);
14968
14995
  console.log(` ${isApprove ? "\u2705" : "\u274C"} Plan ${isApprove ? "approved" : "rejected"} (${shortId}\u2026) by @${username}`);
14969
14996
  try {
@@ -14977,8 +15004,8 @@ async function handleApprovalReaction(session, emojiName, username, ctx) {
14977
15004
  session.planApproved = true;
14978
15005
  }
14979
15006
  if (session.claude.isRunning()) {
14980
- const response = isApprove ? "Approved. Please proceed with the implementation." : "Please revise the plan. I would like some changes.";
14981
- session.claude.sendToolResult(toolUseId, response);
15007
+ const message = isApprove ? "Plan approved! Please proceed with the implementation." : "Please revise the plan. I would like some changes.";
15008
+ session.claude.sendMessage(message);
14982
15009
  ctx.startTyping(session);
14983
15010
  }
14984
15011
  }
@@ -18995,6 +19022,7 @@ async function startSession(options, username, replyToPostId, platformId, ctx) {
18995
19022
  ctx.sessions.delete(session.sessionId);
18996
19023
  return;
18997
19024
  }
19025
+ session.firstPrompt = options.prompt;
18998
19026
  const shouldPrompt = await ctx.shouldPromptForWorktree(session);
18999
19027
  if (shouldPrompt) {
19000
19028
  session.queuedPrompt = options.prompt;
@@ -19004,6 +19032,13 @@ async function startSession(options, username, replyToPostId, platformId, ctx) {
19004
19032
  return;
19005
19033
  }
19006
19034
  const content = await ctx.buildMessageContent(options.prompt, session.platform, options.files);
19035
+ const messageText = typeof content === "string" ? content : options.prompt;
19036
+ if (replyToPostId) {
19037
+ const contextOffered = await ctx.offerContextPrompt(session, messageText);
19038
+ if (contextOffered) {
19039
+ return;
19040
+ }
19041
+ }
19007
19042
  claude.sendMessage(content);
19008
19043
  ctx.persistSession(session);
19009
19044
  }
@@ -19072,7 +19107,8 @@ async function resumeSession(state, ctx) {
19072
19107
  worktreeInfo: state.worktreeInfo,
19073
19108
  pendingWorktreePrompt: state.pendingWorktreePrompt,
19074
19109
  worktreePromptDisabled: state.worktreePromptDisabled,
19075
- queuedPrompt: state.queuedPrompt
19110
+ queuedPrompt: state.queuedPrompt,
19111
+ firstPrompt: state.firstPrompt
19076
19112
  };
19077
19113
  ctx.sessions.set(sessionId, session);
19078
19114
  if (state.sessionStartPostId) {
@@ -19241,8 +19277,9 @@ function cleanupIdleSessions(timeoutMs, warningMs, ctx) {
19241
19277
  killSession(session, false, ctx);
19242
19278
  continue;
19243
19279
  }
19244
- if (idleMs > warningMs && !session.timeoutWarningPosted) {
19245
- const remainingMins = Math.round((timeoutMs - idleMs) / 60000);
19280
+ const warningThresholdMs = timeoutMs - warningMs;
19281
+ if (idleMs > warningThresholdMs && !session.timeoutWarningPosted) {
19282
+ const remainingMins = Math.max(0, Math.round((timeoutMs - idleMs) / 60000));
19246
19283
  session.platform.createPost(`\u23F0 **Session idle** - will timeout in ~${remainingMins} minutes without activity`, session.threadId).catch(() => {});
19247
19284
  session.timeoutWarningPosted = true;
19248
19285
  console.log(` \u23F0 Session (${shortId}\u2026) idle warning posted`);
@@ -19560,9 +19597,14 @@ async function createAndSwitchToWorktree(session, branch, username, options) {
19560
19597
  session.lastActivityAt = new Date;
19561
19598
  session.timeoutWarningPosted = false;
19562
19599
  options.persistSession(session);
19563
- if (wasPending && queuedPrompt && session.claude.isRunning()) {
19564
- session.claude.sendMessage(queuedPrompt);
19565
- options.startTyping(session);
19600
+ if (session.claude.isRunning()) {
19601
+ if (wasPending && queuedPrompt) {
19602
+ session.claude.sendMessage(queuedPrompt);
19603
+ options.startTyping(session);
19604
+ } else if (!wasPending && session.firstPrompt) {
19605
+ session.claude.sendMessage(session.firstPrompt);
19606
+ options.startTyping(session);
19607
+ }
19566
19608
  }
19567
19609
  console.log(` \uD83C\uDF3F Session (${shortId}\u2026) switched to worktree ${branch} at ${shortWorktreePath}`);
19568
19610
  } catch (err) {
@@ -19655,6 +19697,117 @@ async function disableWorktreePrompt(session, username, persistSession) {
19655
19697
  await session.platform.createPost(`\u2705 Worktree prompts disabled for this session`, session.threadId);
19656
19698
  }
19657
19699
 
19700
+ // src/session/context-prompt.ts
19701
+ var CONTEXT_PROMPT_TIMEOUT_MS = 30000;
19702
+ var CONTEXT_OPTIONS = [3, 5, 10];
19703
+ async function getThreadContextCount(session, excludePostId) {
19704
+ try {
19705
+ const messages = await session.platform.getThreadHistory(session.threadId, { excludeBotMessages: true });
19706
+ const relevantMessages = excludePostId ? messages.filter((m) => m.id !== excludePostId) : messages;
19707
+ return relevantMessages.length;
19708
+ } catch {
19709
+ return 0;
19710
+ }
19711
+ }
19712
+ function getValidContextOptions(messageCount) {
19713
+ return CONTEXT_OPTIONS.filter((opt) => opt <= messageCount);
19714
+ }
19715
+ async function postContextPrompt(session, queuedPrompt, messageCount, registerPost, onTimeout) {
19716
+ const validOptions = getValidContextOptions(messageCount);
19717
+ let optionsText = "";
19718
+ const reactionOptions = [];
19719
+ for (let i = 0;i < validOptions.length; i++) {
19720
+ const opt = validOptions[i];
19721
+ const emoji = ["1\uFE0F\u20E3", "2\uFE0F\u20E3", "3\uFE0F\u20E3"][i];
19722
+ optionsText += `${emoji} Last ${opt} messages
19723
+ `;
19724
+ reactionOptions.push(NUMBER_EMOJIS[i]);
19725
+ }
19726
+ if (validOptions.length === 0 || messageCount > validOptions[validOptions.length - 1]) {
19727
+ const nextIndex = validOptions.length;
19728
+ if (nextIndex < 3) {
19729
+ const emoji = ["1\uFE0F\u20E3", "2\uFE0F\u20E3", "3\uFE0F\u20E3"][nextIndex];
19730
+ optionsText += `${emoji} All ${messageCount} messages
19731
+ `;
19732
+ reactionOptions.push(NUMBER_EMOJIS[nextIndex]);
19733
+ }
19734
+ }
19735
+ optionsText += `\u274C No context (default after 30s)`;
19736
+ reactionOptions.push(DENIAL_EMOJIS[0]);
19737
+ const message = `\uD83E\uDDF5 **Include thread context?**
19738
+ ` + `This thread has ${messageCount} message${messageCount === 1 ? "" : "s"} before this point.
19739
+ ` + `React to include previous messages, or continue without context.
19740
+
19741
+ ` + optionsText;
19742
+ const post = await session.platform.createInteractivePost(message, reactionOptions, session.threadId);
19743
+ registerPost(post.id, session.threadId);
19744
+ const timeoutId = setTimeout(onTimeout, CONTEXT_PROMPT_TIMEOUT_MS);
19745
+ const availableOptions = [...validOptions];
19746
+ if (validOptions.length === 0 || messageCount > validOptions[validOptions.length - 1]) {
19747
+ if (validOptions.length < 3) {
19748
+ availableOptions.push(messageCount);
19749
+ }
19750
+ }
19751
+ return {
19752
+ postId: post.id,
19753
+ queuedPrompt,
19754
+ threadMessageCount: messageCount,
19755
+ createdAt: Date.now(),
19756
+ timeoutId,
19757
+ availableOptions
19758
+ };
19759
+ }
19760
+ function getContextSelectionFromReaction(emojiName, availableOptions) {
19761
+ const numberIndex = getNumberEmojiIndex(emojiName);
19762
+ if (numberIndex >= 0 && numberIndex < availableOptions.length) {
19763
+ return availableOptions[numberIndex];
19764
+ }
19765
+ if (isDenialEmoji(emojiName)) {
19766
+ return 0;
19767
+ }
19768
+ if (emojiName === "x") {
19769
+ return 0;
19770
+ }
19771
+ return null;
19772
+ }
19773
+ async function getThreadMessagesForContext(session, limit, excludePostId) {
19774
+ const messages = await session.platform.getThreadHistory(session.threadId, { limit, excludeBotMessages: true });
19775
+ return excludePostId ? messages.filter((m) => m.id !== excludePostId) : messages;
19776
+ }
19777
+ function formatContextForClaude(messages) {
19778
+ if (messages.length === 0)
19779
+ return "";
19780
+ const lines = ["[Previous conversation in this thread:]", ""];
19781
+ for (const msg of messages) {
19782
+ const content = msg.message.length > 500 ? msg.message.substring(0, 500) + "..." : msg.message;
19783
+ lines.push(`@${msg.username}: ${content}`);
19784
+ }
19785
+ lines.push("", "---", "", "[Current request:]");
19786
+ return lines.join(`
19787
+ `);
19788
+ }
19789
+ async function updateContextPromptPost(session, postId, selection, username) {
19790
+ let message;
19791
+ if (selection === "timeout") {
19792
+ message = "\u23F1\uFE0F Continuing without context (no response)";
19793
+ } else if (selection === "skip" || selection === 0) {
19794
+ message = username ? `\u2705 Continuing without context (skipped by @${username})` : "\u2705 Continuing without context";
19795
+ } else {
19796
+ message = username ? `\u2705 Including last ${selection} messages (selected by @${username})` : `\u2705 Including last ${selection} messages`;
19797
+ }
19798
+ try {
19799
+ await session.platform.updatePost(postId, message);
19800
+ } catch (err) {
19801
+ console.error(" \u26A0\uFE0F Failed to update context prompt post:", err);
19802
+ }
19803
+ }
19804
+ function clearContextPromptTimeout(pending) {
19805
+ if (pending.timeoutId) {
19806
+ clearTimeout(pending.timeoutId);
19807
+ pending.timeoutId = undefined;
19808
+ }
19809
+ }
19810
+
19658
19811
  // src/session/types.ts
19659
19812
  var MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS || "5", 10);
19660
19813
  var SESSION_TIMEOUT_MS = parseInt(process.env.SESSION_TIMEOUT_MS || "1800000", 10);
@@ -19720,7 +19873,8 @@ class SessionManager {
19720
19873
  updateSessionHeader: (s) => this.updateSessionHeader(s),
19721
19874
  shouldPromptForWorktree: (s) => this.shouldPromptForWorktree(s),
19722
19875
  postWorktreePrompt: (s, r) => this.postWorktreePrompt(s, r),
19723
- buildMessageContent: (t, p, f) => this.buildMessageContent(t, p, f)
19876
+ buildMessageContent: (t, p, f) => this.buildMessageContent(t, p, f),
19877
+ offerContextPrompt: (s, q) => this.offerContextPrompt(s, q)
19724
19878
  };
19725
19879
  }
19726
19880
  getEventContext() {
@@ -19738,7 +19892,8 @@ class SessionManager {
19738
19892
  debug: this.debug,
19739
19893
  startTyping: (s) => this.startTyping(s),
19740
19894
  stopTyping: (s) => this.stopTyping(s),
19741
- updateSessionHeader: (s) => this.updateSessionHeader(s)
19895
+ updateSessionHeader: (s) => this.updateSessionHeader(s),
19896
+ registerPost: (pid, tid) => this.registerPost(pid, tid)
19742
19897
  };
19743
19898
  }
19744
19899
  getCommandContext() {
@@ -19785,6 +19940,10 @@ class SessionManager {
19785
19940
  await handleWorktreeSkip(session, username, (s) => this.persistSession(s), (s) => this.startTyping(s));
19786
19941
  return;
19787
19942
  }
19943
+ if (session.pendingContextPrompt?.postId === postId) {
19944
+ await this.handleContextPromptReaction(session, emojiName, username);
19945
+ return;
19946
+ }
19788
19947
  if (session.sessionStartPostId === postId) {
19789
19948
  if (isCancelEmoji(emojiName)) {
19790
19949
  await cancelSession(session, username, this.getCommandContext());
@@ -19808,6 +19967,74 @@ class SessionManager {
19808
19967
  return;
19809
19968
  }
19810
19969
  }
19970
+ async handleContextPromptReaction(session, emojiName, username) {
19971
+ if (!session.pendingContextPrompt)
19972
+ return;
19973
+ const selection = getContextSelectionFromReaction(emojiName, session.pendingContextPrompt.availableOptions);
19974
+ if (selection === null)
19975
+ return;
19976
+ const pending = session.pendingContextPrompt;
19977
+ clearContextPromptTimeout(pending);
19978
+ await updateContextPromptPost(session, pending.postId, selection, username);
19979
+ const queuedPrompt = pending.queuedPrompt;
19980
+ session.pendingContextPrompt = undefined;
19981
+ let messageToSend = queuedPrompt;
19982
+ if (selection > 0) {
19983
+ const messages = await getThreadMessagesForContext(session, selection, pending.postId);
19984
+ if (messages.length > 0) {
19985
+ const contextPrefix = formatContextForClaude(messages);
19986
+ messageToSend = contextPrefix + queuedPrompt;
19987
+ }
19988
+ }
19989
+ if (session.claude.isRunning()) {
19990
+ session.claude.sendMessage(messageToSend);
19991
+ this.startTyping(session);
19992
+ }
19993
+ this.persistSession(session);
19994
+ if (this.debug) {
19995
+ const shortId = session.threadId.substring(0, 8);
19996
+ console.log(` \uD83E\uDDF5 Session (${shortId}\u2026) context selection: ${selection === 0 ? "none" : `last ${selection} messages`} by @${username}`);
19997
+ }
19998
+ }
19999
+ async handleContextPromptTimeout(session) {
20000
+ if (!session.pendingContextPrompt)
20001
+ return;
20002
+ const pending = session.pendingContextPrompt;
20003
+ await updateContextPromptPost(session, pending.postId, "timeout");
20004
+ const queuedPrompt = pending.queuedPrompt;
20005
+ session.pendingContextPrompt = undefined;
20006
+ if (session.claude.isRunning()) {
20007
+ session.claude.sendMessage(queuedPrompt);
20008
+ this.startTyping(session);
20009
+ }
20010
+ this.persistSession(session);
20011
+ if (this.debug) {
20012
+ const shortId = session.threadId.substring(0, 8);
20013
+ console.log(` \uD83E\uDDF5 Session (${shortId}\u2026) context prompt timed out, continuing without context`);
20014
+ }
20015
+ }
20016
+ async offerContextPrompt(session, queuedPrompt) {
20017
+ const messageCount = await getThreadContextCount(session);
20018
+ if (messageCount === 0) {
20019
+ if (session.claude.isRunning()) {
20020
+ session.claude.sendMessage(queuedPrompt);
20021
+ this.startTyping(session);
20022
+ }
20023
+ return false;
20024
+ }
20025
+ const pending = await postContextPrompt(session, queuedPrompt, messageCount, (pid, tid) => this.registerPost(pid, tid), () => this.handleContextPromptTimeout(session));
20026
+ session.pendingContextPrompt = pending;
20027
+ this.persistSession(session);
20028
+ if (this.debug) {
20029
+ const shortId = session.threadId.substring(0, 8);
20030
+ console.log(` \uD83E\uDDF5 Session (${shortId}\u2026) context prompt posted (${messageCount} messages available)`);
20031
+ }
20032
+ return true;
20033
+ }
20034
+ hasPendingContextPrompt(threadId) {
20035
+ const session = this.findSessionByThreadId(threadId);
20036
+ return session?.pendingContextPrompt !== undefined;
20037
+ }
19811
20038
  handleEvent(sessionId, event) {
19812
20039
  const session = this.sessions.get(sessionId);
19813
20040
  if (!session)
@@ -19855,6 +20082,16 @@ class SessionManager {
19855
20082
  this.stopTyping(session);
19856
20083
  }
19857
20084
  persistSession(session) {
20085
+ let persistedContextPrompt;
20086
+ if (session.pendingContextPrompt) {
20087
+ persistedContextPrompt = {
20088
+ postId: session.pendingContextPrompt.postId,
20089
+ queuedPrompt: session.pendingContextPrompt.queuedPrompt,
20090
+ threadMessageCount: session.pendingContextPrompt.threadMessageCount,
20091
+ createdAt: session.pendingContextPrompt.createdAt,
20092
+ availableOptions: session.pendingContextPrompt.availableOptions
20093
+ };
20094
+ }
19858
20095
  const state = {
19859
20096
  platformId: session.platformId,
19860
20097
  threadId: session.threadId,
@@ -19872,7 +20109,9 @@ class SessionManager {
19872
20109
  worktreeInfo: session.worktreeInfo,
19873
20110
  pendingWorktreePrompt: session.pendingWorktreePrompt,
19874
20111
  worktreePromptDisabled: session.worktreePromptDisabled,
19875
- queuedPrompt: session.queuedPrompt
20112
+ queuedPrompt: session.queuedPrompt,
20113
+ firstPrompt: session.firstPrompt,
20114
+ pendingContextPrompt: persistedContextPrompt
19876
20115
  };
19877
20116
  this.sessionStore.save(session.sessionId, state);
19878
20117
  }
@@ -19883,6 +20122,10 @@ class SessionManager {
19883
20122
  await updateSessionHeader(session, this.getCommandContext());
19884
20123
  }
19885
20124
  async initialize() {
20125
+ const staleIds = this.sessionStore.cleanStale(SESSION_TIMEOUT_MS * 2);
20126
+ if (staleIds.length > 0) {
20127
+ console.log(` \uD83E\uDDF9 Cleaned ${staleIds.length} stale session(s) from persistence`);
20128
+ }
19886
20129
  const persisted = this.sessionStore.load();
19887
20130
  if (persisted.size === 0)
19888
20131
  return;
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env bun
2
2
  // @bun
3
3
  var __create = Object.create;
4
4
  var __getProtoOf = Object.getPrototypeOf;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-threads",
3
- "version": "0.16.4",
3
+ "version": "0.16.6",
4
4
  "description": "Share Claude Code sessions live in a Mattermost channel with interactive features",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",