claude-threads 1.20.0 → 1.21.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/CHANGELOG.md CHANGED
@@ -5,6 +5,37 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.21.0] - 2026-08-07
9
+
10
+ ### Fixed
11
+ - **Task list display works again on modern Claude CLIs.** Claude Code moved task tracking from `TodoWrite` (whole list per call) to the incremental `TaskCreate`/`TaskUpdate` tools; verified against CLI 2.1.223, `TodoWrite` is never emitted anymore, so the bot's live task list in the thread had silently gone dark. A new per-session `TaskTracker` accumulates the incremental calls (a task's real id is only revealed by its tool result, which arrives later inside a `user` event) and feeds the existing task-list pipeline. `TaskUpdate` on an unknown id (tasks created before a resume, or by a subagent) shows a placeholder rather than nothing; `status: "deleted"` removes the task. `TodoWrite` still works for older CLIs. `TaskGet`/`TaskList` (read-only queries) are hidden from chat.
12
+ - **Tool completion indicators (`↳ ✓` / `↳ ❌ Error`) fire again.** The real CLI delivers tool results as `tool_result` blocks inside `user` events; the bot only handled a legacy top-level `tool_result` event shape that modern CLIs never emit — so per-tool completion/error indicators, elapsed times, and the tool-completion flush never triggered. The transformer now processes `user` events. Indicators are only emitted for tools that were actually displayed (hidden tools like `TaskCreate` no longer risk orphaned indicators), and elapsed time works on the real event flow (start times were previously only recorded on the legacy shape). Bug-report context tracking (tool uses/errors) was extended to the real event shapes; the legacy shapes remain handled for old captures and test fixtures.
13
+ - **Questions no longer trigger a duplicate permission prompt.** On modern CLIs `AskUserQuestion` routes through the `--permission-prompt-tool`, so next to the bot's proper question UI a generic "Permission requested: AskUserQuestion 👍✅👎" post appeared — and approving it just resolved the tool as unanswered. The MCP permission server now auto-allows `AskUserQuestion`; the question UI and reaction-answer flow are unchanged. Known limitation, verified on 2.1.223: with `--dangerously-skip-permissions` (bypass mode) the CLI does not expose `AskUserQuestion` at all, so bypass sessions cannot receive interactive questions — that is CLI behavior, not a bot regression. Also relevant: since CLI 2.1.200 an unanswered question no longer auto-continues after an idle timeout, which suits chat (users react late) — the previous auto-continue could race a slow reaction.
14
+
15
+ ### Added
16
+ - **Structured rate-limit detection.** Modern CLIs emit a `rate_limit_event` with `rate_limit_info: {status, resetsAt, rateLimitType}` on every turn. The bot now feeds **`status: "rejected"`** events into the existing account-cooldown path (`resetsAt` epoch-seconds, sanity-bounded to at most 8 days out; slightly-past values from clock skew clamp to a brief cooldown), complementing the stderr phrase-scraping which remains for older CLIs. The predicate is strict equality on `rejected` — the SDK's status union is `allowed | allowed_warning | rejected`, and `allowed_warning` (~70%+ utilization, request went through) must not bench a healthy account: cooldowns only ever extend, so one warning would have parked the account until its weekly reset.
17
+ - **The MCP plan-approval prompt now shows the plan.** On modern CLIs `ExitPlanMode` routes through the permission prompt; it used to render as a bare "Permission requested: ExitPlanMode" with no plan content. The prompt now carries the plan text (truncated at 1500 code points, with an unclosed code fence re-balanced so the reaction legend stays outside it; untruncated plans render exactly as authored). Known remaining conflict, left as follow-up work: the MCP prompt and the bot's plan-approval UI are two competing approval surfaces — reacting on the bot's UI alone lets the MCP prompt time out and deny the plan.
18
+
19
+ ### Hardening
20
+ Three adversarial review rounds ran over this change; everything they confirmed is fixed here, with red-green tests.
21
+
22
+ - **A resumed session can no longer lose its restored task list.** Task state is in-memory, so after a bot restart the resumed CLI session references task ids the fresh tracker has never seen. A single post-resume `TaskUpdate(completed)` created one completed placeholder, `allCompleted` fired on it, and the executor deleted the correctly-restored task-list post while reporting full completion. `allCompleted` now requires at least one task the tracker actually saw created, and a failed-create refresh on an empty tracker emits `update` (never `complete`) for the same reason. (Persisting tracker state across restarts, which would also restore full names instead of `Task #N` placeholders, is left as follow-up work.)
23
+ - **Fresh-CLI restarts clear accumulated task state — safely.** `!cd` and worktree switches respawn Claude as a fresh session whose task numbering restarts at #1; stale `TaskTracker` entries would collide with the new ids and corrupt updates. `MessageManager.clearClaudeSessionState()` now runs on every `resume: false` respawn (resume restarts like `!permissions` keep their state). The respawn sites await the old process before clearing, and `kill()` was reworked to resolve on stdio close (late-buffered stream-json can arrive after `exit`) with an escalation to SIGKILL and a bounded timeout — previously a failed spawn or a SIGTERM-immune process could leave `kill()` pending forever, freezing the session in `restarting`.
24
+ - **Failed `TaskCreate` calls no longer leave ghost tasks.** A create whose tool result is an error (or doesn't carry the expected "Task #N created" text — e.g. a future CLI rewording) removes the task and refreshes the display; previously it stayed forever as an un-updatable pending row that also pinned `allCompleted` at false. A non-error result with unexpected wording additionally logs a warning — that wording is the one CLI dependency whose drift would silently darken the task display again.
25
+ - **Sidechain events are filtered everywhere.** Events carrying `parent_tool_use_id` (subagent activity forwarded by some CLI versions) no longer reach the transformer — a subagent's `TaskCreate`/`TaskUpdate` calls would otherwise permanently pollute the main thread's task list with colliding ids — and the event handlers apply the same filter: subagent text no longer executes `!cd`-style Claude commands against the main session, and subagent tool uses/errors no longer pollute bug-report context. PR-URL detection intentionally still sees subagent text.
26
+ - **Task-list updates are coalesced per event.** A parallel burst of N `TaskCreate` calls in one assistant event — or N of their results failing in one `user` event — now yields one task-post update (the final snapshot) instead of N back-to-back `updatePost` calls that could trip platform rate limits.
27
+ - **Rate-limit cooldown precision.** A reset-less hit (the 1-hour default guess) is ignored while a cooldown from an explicit reset time is running, so the guess can't stretch a known-shorter deadline — but reset-less repeats during a guess-based cooldown still extend it (the documented behavior), and an explicit reset that arrives too late to re-emit still records its explicitness so later guesses defer to it regardless of arrival order.
28
+ - **Placeholder merge on late id resolution.** If a `TaskUpdate` for id N arrives before the corresponding create's result resolves that id, the placeholder and the real task are merged into one row (adopting the update's status and any rename) and the display refreshes immediately.
29
+ - **Tracker robustness.** Numeric `taskId` values are coerced instead of silently freezing the displayed list; multi-MB tool results are no longer copied when no `TaskCreate` is pending; and a session mixing both task dialects no longer flip-flops — the full-list `TodoWrite` clears the incremental tracker.
30
+
31
+ ### Changed
32
+ - **Verified against Claude CLI 2.1.223** (previously 2.1.116); suggested install version in error messages and onboarding updated. The compatible range is unchanged (`>=2.0.74 <2.2.0`).
33
+
34
+ ## [1.20.1] - 2026-08-06
35
+
36
+ ### Added
37
+ - **"How it compares" section on the website and README** - A short, respectful comparison with the other ways to drive a coding agent from chat: Anthropic's own Claude Code in Slack / Claude Tag, OpenAI's Codex in Slack, Cursor in Slack, Cognition's Devin, and community Slack bots. The framing is a single axis — where the session runs (vendor cloud vs. your machine) — with claude-threads positioned as the local, self-hosted option that also speaks Mattermost. A closing note names the strongest differentiators beyond that: in-thread permission approvals, multiplayer sessions, the multi-account pool, and no GitHub assumption. The website's "Why I built this" note about Anthropic's integration was updated to match (it is no longer Enterprise-gated: Pro/Max plans have Claude Code in Slack, Team/Enterprise have Claude Tag).
38
+
8
39
  ## [1.20.0] - 2026-08-06
9
40
 
10
41
  ### Added
package/README.md CHANGED
@@ -170,6 +170,27 @@ Restrict who can use the bot during setup, or reconfigure later with `claude-thr
170
170
 
171
171
  Who may do what (start sessions, react to permission prompts, approve guest messages) is written down in the [authorization model](https://github.com/anneschuth/claude-threads/blob/main/SECURITY.md).
172
172
 
173
+ ## How it compares
174
+
175
+ Driving a coding agent from chat is a busy space: Anthropic, OpenAI, Cursor, and Cognition each ship their own integration, and other open-source projects exist too — several of them excellent. The main difference is where the session actually runs.
176
+
177
+ | Product | Sessions run on | Chat platforms | Account |
178
+ | :----------------------------------------------------------------------------------------------------------------------------------- | :---------------------------------------------------------- | :---------------------------------------------- | :-------------------------------------------- |
179
+ | **claude-threads** (open source, Apache-2.0) | Your machine — local checkout, your MCP servers and plugins | Slack + Mattermost, multiple workspaces at once | Any Claude subscription or API key |
180
+ | [Claude Code in Slack](https://code.claude.com/docs/en/slack) / [Claude Tag](https://claude.com/docs/claude-tag/overview) (Anthropic) | Anthropic's cloud, against GitHub repos | Slack | Claude Pro/Max; Claude Tag on Team/Enterprise |
181
+ | [Codex in Slack](https://slack.com/marketplace/A09F5C369E3-openai-codex) (OpenAI) | OpenAI's Codex cloud | Slack | ChatGPT plan |
182
+ | [Cursor in Slack](https://cursor.com/docs/integrations/slack) | Cursor's cloud agents | Slack | Cursor plan |
183
+ | [Devin](https://devin.ai/) (Cognition) | Cognition's cloud | Slack (also Linear and Jira tickets) | Devin plan |
184
+ | Community bots such as [claude-code-slack-bot](https://github.com/mpociot/claude-code-slack-bot) | Your machine | Slack | Claude subscription or API key |
185
+
186
+ The cloud products share a working style: you delegate from a thread, the work runs in a vendor-hosted sandbox, and progress updates and a pull request come back — Claude Tag additionally stays in the conversation as a shared teammate the whole channel can talk to. If your repositories live on GitHub and you want nothing to host, they are a good choice; Anthropic's own integration in particular runs the same Claude Code this bot does.
187
+
188
+ claude-threads makes the opposite choice: the whole session stays in the thread and on your hardware. Output streams live, permission prompts land as emoji reactions, invited teammates type into the same session, and Claude works against a local checkout with whatever MCP servers and plugins you already configured. That fit matters when chat is self-hosted Mattermost, when code cannot leave the building, or when you want a session in chat to behave exactly like the one at your desk. The trade-off: you run the bot yourself, on a machine that has the repository.
189
+
190
+ A few more things set it apart. The full Claude Code permission model lives in the thread: every tool use can prompt for 👍/✅/👎 approval, with three modes switchable mid-session. Sessions are multiplayer: `!invite` teammates while Claude works, messages from uninvited users are gated behind approval, and commits credit everyone involved as co-author. An optional [account pool](https://github.com/anneschuth/claude-threads/blob/main/docs/CONFIGURATION.md#claude-accounts-optional-multi-account-mode) routes each new session to the Claude subscription with the most headroom and cools down rate-limited ones. And nothing assumes GitHub — the session works against any local checkout, whatever its host.
191
+
192
+ This table is current as of August 2026. All of these products move quickly — if it has gone stale, [open an issue](https://github.com/anneschuth/claude-threads/issues).
193
+
173
194
  ## Documentation
174
195
 
175
196
  - **[Setup Guide](https://github.com/anneschuth/claude-threads/blob/main/SETUP_GUIDE.md)** - Creating the bot account on Mattermost or Slack, step by step
package/dist/index.js CHANGED
@@ -51448,7 +51448,7 @@ function validateClaudeCli() {
51448
51448
  version: result.version,
51449
51449
  compatible: false,
51450
51450
  message: `Claude CLI version ${result.version} is not compatible. Required: ${CLAUDE_CLI_VERSION_RANGE}
51451
- ` + `Install a compatible version: npm install -g @anthropic-ai/claude-code@2.1.116`,
51451
+ ` + `Install a compatible version: npm install -g @anthropic-ai/claude-code@2.1.223`,
51452
51452
  rawOutput: result.rawOutput ?? undefined
51453
51453
  };
51454
51454
  }
@@ -51698,7 +51698,7 @@ async function runOnboarding(reconfigure = false) {
51698
51698
  console.log(dim(` ⚠️ Claude Code CLI ${claudeCheck.version} is not compatible`));
51699
51699
  console.log("");
51700
51700
  console.log(dim(` Install a compatible version:`));
51701
- console.log(dim(" npm install -g @anthropic-ai/claude-code@2.1.1"));
51701
+ console.log(dim(" npm install -g @anthropic-ai/claude-code@2.1.223"));
51702
51702
  } else {
51703
51703
  console.log(dim(" ⚠️ Claude Code CLI found but version could not be determined"));
51704
51704
  if (claudeCheck.rawOutput) {
@@ -55918,6 +55918,25 @@ function extractResetAt(text, now) {
55918
55918
  }
55919
55919
  return;
55920
55920
  }
55921
+ function parseRateLimitEvent(event, now = Date.now()) {
55922
+ const info = event?.rate_limit_info;
55923
+ if (!info || typeof info !== "object")
55924
+ return { detected: false };
55925
+ const { status, resetsAt } = info;
55926
+ if (status !== "rejected")
55927
+ return { detected: false };
55928
+ const PAST_SKEW_TOLERANCE_MS = 2 * 60000;
55929
+ let resetAtEpochMs;
55930
+ if (typeof resetsAt === "number") {
55931
+ const ms = resetsAt * 1000;
55932
+ if (ms > now && ms - now < 8 * 86400000) {
55933
+ resetAtEpochMs = ms;
55934
+ } else if (ms <= now && now - ms < PAST_SKEW_TOLERANCE_MS) {
55935
+ resetAtEpochMs = now + 60000;
55936
+ }
55937
+ }
55938
+ return { detected: true, matched: `rate_limit_event status=${status}`, resetAtEpochMs };
55939
+ }
55921
55940
 
55922
55941
  // src/claude/cli.ts
55923
55942
  var log8 = createLogger("claude");
@@ -56054,6 +56073,7 @@ class ClaudeCli extends EventEmitter2 {
56054
56073
  stderrBuffer = "";
56055
56074
  mcpConfigTempFile = null;
56056
56075
  lastEmittedRateLimitDeadline = 0;
56076
+ lastEmittedHitHadExplicitReset = false;
56057
56077
  log;
56058
56078
  constructor(options) {
56059
56079
  super();
@@ -56107,6 +56127,7 @@ class ClaudeCli extends EventEmitter2 {
56107
56127
  totalStderrBytes -= this.stderrBuffer.length;
56108
56128
  this.stderrBuffer = "";
56109
56129
  this.lastEmittedRateLimitDeadline = 0;
56130
+ this.lastEmittedHitHadExplicitReset = false;
56110
56131
  cleanupBrowserBridgeSockets();
56111
56132
  const claudePath = getClaudePath();
56112
56133
  const args = [
@@ -56256,18 +56277,31 @@ class ClaudeCli extends EventEmitter2 {
56256
56277
  if (event.type === "result" && isErrorResultEvent(event)) {
56257
56278
  this.maybeEmitRateLimit(trimmed);
56258
56279
  }
56280
+ if (event.type === "rate_limit_event") {
56281
+ this.maybeEmitRateLimitHit(parseRateLimitEvent(event));
56282
+ }
56259
56283
  } catch {}
56260
56284
  }
56261
56285
  }
56262
56286
  maybeEmitRateLimit(text) {
56263
- const hit = detectRateLimit(text);
56287
+ this.maybeEmitRateLimitHit(detectRateLimit(text));
56288
+ }
56289
+ maybeEmitRateLimitHit(hit) {
56264
56290
  if (!hit.detected)
56265
56291
  return;
56292
+ if (!hit.resetAtEpochMs && this.lastEmittedHitHadExplicitReset && this.lastEmittedRateLimitDeadline > Date.now()) {
56293
+ return;
56294
+ }
56266
56295
  const newDeadline = cooldownDeadline(hit);
56267
56296
  const MIN_ADVANCE_MS = 60000;
56268
- if (newDeadline - this.lastEmittedRateLimitDeadline < MIN_ADVANCE_MS)
56297
+ if (newDeadline - this.lastEmittedRateLimitDeadline < MIN_ADVANCE_MS) {
56298
+ if (hit.resetAtEpochMs !== undefined) {
56299
+ this.lastEmittedHitHadExplicitReset = true;
56300
+ }
56269
56301
  return;
56302
+ }
56270
56303
  this.lastEmittedRateLimitDeadline = newDeadline;
56304
+ this.lastEmittedHitHadExplicitReset = hit.resetAtEpochMs !== undefined;
56271
56305
  this.log.warn(`Rate limit detected: ${hit.matched ?? "(no match text)"}`);
56272
56306
  this.emit("rate-limit", hit);
56273
56307
  }
@@ -56322,12 +56356,22 @@ class ClaudeCli extends EventEmitter2 {
56322
56356
  proc.kill("SIGTERM");
56323
56357
  } catch {}
56324
56358
  }, 2000);
56325
- proc.once("exit", (code) => {
56326
- this.log.debug(`Claude process exited (code=${code})`);
56359
+ const settle = (reason) => {
56360
+ this.log.debug(`Claude process gone (${reason})`);
56327
56361
  clearTimeout(secondSigint);
56328
56362
  clearTimeout(forceKillTimeout);
56363
+ clearTimeout(lastResort);
56329
56364
  resolve4();
56330
- });
56365
+ };
56366
+ const lastResort = setTimeout(() => {
56367
+ try {
56368
+ this.log.warn("Claude process did not exit after SIGTERM — sending SIGKILL");
56369
+ proc.kill("SIGKILL");
56370
+ } catch {}
56371
+ settle("kill timeout");
56372
+ }, 5000);
56373
+ proc.once("close", (code) => settle(`closed, code=${code}`));
56374
+ proc.once("error", () => settle("spawn error"));
56331
56375
  });
56332
56376
  }
56333
56377
  interrupt() {
@@ -63986,11 +64030,26 @@ var bashToolFormatter = {
63986
64030
  };
63987
64031
  // src/operations/tool-formatters/task-tools.ts
63988
64032
  var taskToolsFormatter = {
63989
- toolNames: ["TodoWrite", "Task", "EnterPlanMode", "ExitPlanMode", "AskUserQuestion"],
63990
- format(toolName, _input, options) {
64033
+ toolNames: [
64034
+ "TodoWrite",
64035
+ "TaskCreate",
64036
+ "TaskUpdate",
64037
+ "TaskGet",
64038
+ "TaskList",
64039
+ "Task",
64040
+ "EnterPlanMode",
64041
+ "ExitPlanMode",
64042
+ "AskUserQuestion"
64043
+ ],
64044
+ format(toolName, input, options) {
63991
64045
  const { formatter } = options;
63992
64046
  switch (toolName) {
63993
64047
  case "TodoWrite":
64048
+ case "TaskCreate":
64049
+ case "TaskUpdate":
64050
+ return { display: null, hidden: true };
64051
+ case "TaskGet":
64052
+ case "TaskList":
63994
64053
  return { display: null, hidden: true };
63995
64054
  case "Task":
63996
64055
  return { display: null, hidden: true };
@@ -63999,8 +64058,25 @@ var taskToolsFormatter = {
63999
64058
  display: `\uD83D\uDCCB ${formatter.formatBold("Planning...")}`,
64000
64059
  permissionText: `\uD83D\uDCCB ${formatter.formatBold("Planning...")}`
64001
64060
  };
64002
- case "ExitPlanMode":
64003
- return { display: null, hidden: true };
64061
+ case "ExitPlanMode": {
64062
+ const plan = typeof input.plan === "string" ? input.plan : "";
64063
+ const points = Array.from(plan);
64064
+ const truncated = points.length > 1500;
64065
+ let preview = truncated ? `${points.slice(0, 1500).join("")}
64066
+ …` : plan;
64067
+ if (truncated) {
64068
+ const fenceCount = (preview.match(/^```/gm) || []).length;
64069
+ if (fenceCount % 2 === 1)
64070
+ preview += "\n```";
64071
+ }
64072
+ return {
64073
+ display: null,
64074
+ hidden: true,
64075
+ permissionText: preview ? `\uD83D\uDCCB ${formatter.formatBold("Plan approval requested")}
64076
+
64077
+ ${preview}` : `\uD83D\uDCCB ${formatter.formatBold("Plan approval requested")}`
64078
+ };
64079
+ }
64004
64080
  case "AskUserQuestion":
64005
64081
  return { display: null, hidden: true };
64006
64082
  default:
@@ -64539,9 +64615,14 @@ function createStatusUpdateOp(sessionId, options) {
64539
64615
  }
64540
64616
  // src/operations/transformer.ts
64541
64617
  function transformEvent(event, ctx) {
64618
+ if (event.parent_tool_use_id) {
64619
+ return [];
64620
+ }
64542
64621
  switch (event.type) {
64543
64622
  case "assistant":
64544
64623
  return transformAssistant(event, ctx);
64624
+ case "user":
64625
+ return transformUser(event, ctx);
64545
64626
  case "tool_use":
64546
64627
  return transformToolUse(event, ctx);
64547
64628
  case "tool_result":
@@ -64583,6 +64664,9 @@ function transformAssistant(event, ctx) {
64583
64664
  if (result.display && !result.hidden) {
64584
64665
  flushTextBuffer();
64585
64666
  operations.push(createAppendContentOp(ctx.sessionId, result.display, true));
64667
+ if (block.id) {
64668
+ ctx.toolStartTimes.set(block.id, Date.now());
64669
+ }
64586
64670
  }
64587
64671
  }
64588
64672
  } else if (block.type === "thinking" && block.thinking) {
@@ -64597,8 +64681,70 @@ function transformAssistant(event, ctx) {
64597
64681
  }
64598
64682
  }
64599
64683
  flushTextBuffer();
64684
+ const taskListOps = operations.filter((op) => op.type === "task_list");
64685
+ if (taskListOps.length > 1) {
64686
+ const last = taskListOps[taskListOps.length - 1];
64687
+ return operations.filter((op) => op.type !== "task_list" || op === last);
64688
+ }
64689
+ return operations;
64690
+ }
64691
+ function transformUser(event, ctx) {
64692
+ const msg = event.message;
64693
+ if (!Array.isArray(msg?.content)) {
64694
+ return [];
64695
+ }
64696
+ const operations = [];
64697
+ let indicatorCount = 0;
64698
+ for (const block of msg.content) {
64699
+ if (block?.type !== "tool_result" || !block.tool_use_id)
64700
+ continue;
64701
+ if (ctx.taskTracker.hasPendingCreate(block.tool_use_id)) {
64702
+ const resolution = ctx.taskTracker.resolveCreatedId(block.tool_use_id, toolResultContentText(block.content), block.is_error === true);
64703
+ if (resolution === "removed" || resolution === "merged") {
64704
+ const action = ctx.taskTracker.allCompleted ? "complete" : "update";
64705
+ operations.push(createTaskListOp(ctx.sessionId, action, ctx.taskTracker.toTaskItems()));
64706
+ }
64707
+ }
64708
+ if (ctx.toolStartTimes.has(block.tool_use_id)) {
64709
+ operations.push(createResultIndicatorOp(block.tool_use_id, block.is_error === true, ctx));
64710
+ indicatorCount++;
64711
+ }
64712
+ }
64713
+ const taskListOps = operations.filter((op) => op.type === "task_list");
64714
+ if (taskListOps.length > 1) {
64715
+ const last = taskListOps[taskListOps.length - 1];
64716
+ const coalesced = operations.filter((op) => op.type !== "task_list" || op === last);
64717
+ operations.length = 0;
64718
+ operations.push(...coalesced);
64719
+ }
64720
+ if (indicatorCount > 0) {
64721
+ operations.push(createFlushOp(ctx.sessionId, "tool_complete"));
64722
+ }
64600
64723
  return operations;
64601
64724
  }
64725
+ function toolResultContentText(content) {
64726
+ if (typeof content === "string")
64727
+ return content;
64728
+ if (Array.isArray(content)) {
64729
+ return content.map((b) => b && typeof b === "object" && typeof b.text === "string" ? b.text : "").join(`
64730
+ `);
64731
+ }
64732
+ return "";
64733
+ }
64734
+ function createResultIndicatorOp(toolUseId, isError, ctx) {
64735
+ let elapsed = "";
64736
+ const startTime = ctx.toolStartTimes.get(toolUseId);
64737
+ if (startTime) {
64738
+ const secs = Math.round((Date.now() - startTime) / 1000);
64739
+ if (secs >= 3) {
64740
+ elapsed = ` (${secs}s)`;
64741
+ }
64742
+ ctx.toolStartTimes.delete(toolUseId);
64743
+ }
64744
+ const icon = isError ? "❌" : "✓";
64745
+ const errorNote = isError ? " Error" : "";
64746
+ return createAppendContentOp(ctx.sessionId, ` ↳ ${icon}${errorNote}${elapsed}`, true);
64747
+ }
64602
64748
  function transformToolUse(event, ctx) {
64603
64749
  const tool = event.tool_use;
64604
64750
  if (tool.id) {
@@ -64623,23 +64769,10 @@ function transformToolResult(event, ctx) {
64623
64769
  return [];
64624
64770
  }
64625
64771
  const result = event.tool_result;
64626
- const operations = [];
64627
- let elapsed = "";
64628
- if (result.tool_use_id) {
64629
- const startTime = ctx.toolStartTimes.get(result.tool_use_id);
64630
- if (startTime) {
64631
- const secs = Math.round((Date.now() - startTime) / 1000);
64632
- if (secs >= 3) {
64633
- elapsed = ` (${secs}s)`;
64634
- }
64635
- ctx.toolStartTimes.delete(result.tool_use_id);
64636
- }
64637
- }
64638
- const icon = result.is_error ? "❌" : "✓";
64639
- const errorNote = result.is_error ? " Error" : "";
64640
- operations.push(createAppendContentOp(ctx.sessionId, ` ↳ ${icon}${errorNote}${elapsed}`, true));
64641
- operations.push(createFlushOp(ctx.sessionId, "tool_complete"));
64642
- return operations;
64772
+ return [
64773
+ createResultIndicatorOp(result.tool_use_id || "", result.is_error === true, ctx),
64774
+ createFlushOp(ctx.sessionId, "tool_complete")
64775
+ ];
64643
64776
  }
64644
64777
  function transformResult(event, ctx) {
64645
64778
  const operations = [];
@@ -64656,6 +64789,10 @@ function handleSpecialTool(toolName, toolUseId, input, ctx) {
64656
64789
  switch (toolName) {
64657
64790
  case "TodoWrite":
64658
64791
  return handleTodoWrite(input, ctx);
64792
+ case "TaskCreate":
64793
+ return handleTaskCreate(toolUseId, input, ctx);
64794
+ case "TaskUpdate":
64795
+ return handleTaskUpdate(input, ctx);
64659
64796
  case "Task":
64660
64797
  return handleTaskStart(toolUseId, input, ctx);
64661
64798
  case "AskUserQuestion":
@@ -64673,10 +64810,22 @@ function handleTodoWrite(input, ctx) {
64673
64810
  status: t.status,
64674
64811
  activeForm: t.activeForm
64675
64812
  }));
64813
+ ctx.taskTracker.clear();
64676
64814
  const allCompleted = tasks.every((t) => t.status === "completed");
64677
64815
  const action = allCompleted ? "complete" : "update";
64678
64816
  return [createTaskListOp(ctx.sessionId, action, tasks)];
64679
64817
  }
64818
+ function handleTaskCreate(toolUseId, input, ctx) {
64819
+ ctx.taskTracker.create(toolUseId, input);
64820
+ return [createTaskListOp(ctx.sessionId, "update", ctx.taskTracker.toTaskItems())];
64821
+ }
64822
+ function handleTaskUpdate(input, ctx) {
64823
+ const changed = ctx.taskTracker.update(input);
64824
+ if (!changed)
64825
+ return [];
64826
+ const action = ctx.taskTracker.allCompleted ? "complete" : "update";
64827
+ return [createTaskListOp(ctx.sessionId, action, ctx.taskTracker.toTaskItems())];
64828
+ }
64680
64829
  function handleTaskStart(toolUseId, input, ctx) {
64681
64830
  const description = input.description || input.prompt || "Subagent";
64682
64831
  const subagentType = input.subagent_type || "general-purpose";
@@ -64710,6 +64859,102 @@ function truncateAtWord2(text, maxLength) {
64710
64859
  }
64711
64860
  return truncated + "...";
64712
64861
  }
64862
+ // src/operations/task-tracker.ts
64863
+ var CREATED_RESULT_RE = /Task #(\S+) created/;
64864
+
64865
+ class TaskTracker {
64866
+ tasks = [];
64867
+ pendingCreates = new Map;
64868
+ unmatchedCreateResults = 0;
64869
+ create(toolUseId, input) {
64870
+ const task = {
64871
+ subject: typeof input.subject === "string" ? input.subject : "Task",
64872
+ activeForm: typeof input.activeForm === "string" ? input.activeForm : undefined,
64873
+ status: "pending"
64874
+ };
64875
+ this.tasks.push(task);
64876
+ if (toolUseId) {
64877
+ this.pendingCreates.set(toolUseId, task);
64878
+ }
64879
+ }
64880
+ hasPendingCreate(toolUseId) {
64881
+ return this.pendingCreates.has(toolUseId);
64882
+ }
64883
+ resolveCreatedId(toolUseId, resultContent, isError = false) {
64884
+ const task = this.pendingCreates.get(toolUseId);
64885
+ if (!task)
64886
+ return "ignored";
64887
+ this.pendingCreates.delete(toolUseId);
64888
+ const match = isError ? null : CREATED_RESULT_RE.exec(resultContent);
64889
+ if (!match) {
64890
+ if (!isError)
64891
+ this.unmatchedCreateResults++;
64892
+ this.tasks = this.tasks.filter((t) => t !== task);
64893
+ return "removed";
64894
+ }
64895
+ const placeholder = this.tasks.find((t) => t !== task && t.taskId === match[1]);
64896
+ if (placeholder) {
64897
+ task.status = placeholder.status;
64898
+ if (placeholder.subject !== `Task #${match[1]}`)
64899
+ task.subject = placeholder.subject;
64900
+ if (placeholder.activeForm && !task.activeForm)
64901
+ task.activeForm = placeholder.activeForm;
64902
+ this.tasks = this.tasks.filter((t) => t !== placeholder);
64903
+ }
64904
+ task.taskId = match[1];
64905
+ return placeholder ? "merged" : "resolved";
64906
+ }
64907
+ consumeUnmatchedCreateResultFlag() {
64908
+ if (this.unmatchedCreateResults === 0)
64909
+ return false;
64910
+ this.unmatchedCreateResults = 0;
64911
+ return true;
64912
+ }
64913
+ update(input) {
64914
+ const taskId = typeof input.taskId === "string" ? input.taskId : typeof input.taskId === "number" ? String(input.taskId) : undefined;
64915
+ if (!taskId)
64916
+ return false;
64917
+ let task = this.tasks.find((t) => t.taskId === taskId);
64918
+ const status = typeof input.status === "string" ? input.status : undefined;
64919
+ if (status === "deleted") {
64920
+ if (!task)
64921
+ return false;
64922
+ this.tasks = this.tasks.filter((t) => t !== task);
64923
+ return true;
64924
+ }
64925
+ if (!task) {
64926
+ task = { taskId, subject: `Task #${taskId}`, status: "pending", isPlaceholder: true };
64927
+ this.tasks.push(task);
64928
+ }
64929
+ if (typeof input.subject === "string")
64930
+ task.subject = input.subject;
64931
+ if (typeof input.activeForm === "string")
64932
+ task.activeForm = input.activeForm;
64933
+ if (status === "pending" || status === "in_progress" || status === "completed") {
64934
+ task.status = status;
64935
+ }
64936
+ return true;
64937
+ }
64938
+ toTaskItems() {
64939
+ return this.tasks.map((t) => ({
64940
+ content: t.subject,
64941
+ status: t.status,
64942
+ activeForm: t.activeForm ?? t.subject
64943
+ }));
64944
+ }
64945
+ get isEmpty() {
64946
+ return this.tasks.length === 0;
64947
+ }
64948
+ get allCompleted() {
64949
+ return this.tasks.length > 0 && this.tasks.every((t) => t.status === "completed") && this.tasks.some((t) => !t.isPlaceholder);
64950
+ }
64951
+ clear() {
64952
+ this.tasks = [];
64953
+ this.pendingCreates.clear();
64954
+ this.unmatchedCreateResults = 0;
64955
+ }
64956
+ }
64957
+
64713
64958
  // src/operations/executors/base.ts
64714
64959
  class BaseExecutor {
64715
64960
  state;
@@ -66462,6 +66707,7 @@ class MessageManager {
66462
66707
  startTypingCallback;
66463
66708
  emitSessionUpdateCallback;
66464
66709
  toolStartTimes = new Map;
66710
+ taskTracker = new TaskTracker;
66465
66711
  flushTimer = null;
66466
66712
  static DEFAULT_FLUSH_DELAY_MS = 500;
66467
66713
  flushDelayMs;
@@ -66532,10 +66778,14 @@ class MessageManager {
66532
66778
  sessionId: this.sessionId,
66533
66779
  formatter: this.platform.getFormatter(),
66534
66780
  toolStartTimes: this.toolStartTimes,
66781
+ taskTracker: this.taskTracker,
66535
66782
  detailed: true,
66536
66783
  worktreeInfo: this.worktreePath && this.worktreeBranch ? { path: this.worktreePath, branch: this.worktreeBranch } : undefined
66537
66784
  };
66538
66785
  const ops = transformEvent(event, transformCtx);
66786
+ if (this.taskTracker.consumeUnmatchedCreateResultFlag()) {
66787
+ logger.warn('TaskCreate result did not match the expected "Task #N created" wording — ' + "task dropped from the displayed list. If this repeats, the Claude CLI " + "likely changed its result text and the task display will be incomplete.");
66788
+ }
66539
66789
  if (ops.length === 0) {
66540
66790
  if (event.type !== "system") {
66541
66791
  logger.debug(`No operations from event: ${event.type}`);
@@ -66919,9 +67169,14 @@ class MessageManager {
66919
67169
  logger.debug("Reaction not handled by any executor");
66920
67170
  return false;
66921
67171
  }
67172
+ clearClaudeSessionState() {
67173
+ this.toolStartTimes.clear();
67174
+ this.taskTracker.clear();
67175
+ }
66922
67176
  reset() {
66923
67177
  this.cancelScheduledFlush();
66924
67178
  this.toolStartTimes.clear();
67179
+ this.taskTracker.clear();
66925
67180
  this.contentExecutor.reset();
66926
67181
  this.taskListExecutor.reset();
66927
67182
  this.questionApprovalExecutor.reset();
@@ -67957,7 +68212,7 @@ async function createAndSwitchToWorktree(session, branch, username, options) {
67957
68212
  if (session.claude.isRunning()) {
67958
68213
  options.stopTyping(session);
67959
68214
  transitionTo(session, "restarting");
67960
- session.claude.kill();
68215
+ await session.claude.kill();
67961
68216
  await options.flush(session);
67962
68217
  const newSessionId = randomUUID2();
67963
68218
  session.claudeSessionId = newSessionId;
@@ -67977,6 +68232,7 @@ async function createAndSwitchToWorktree(session, branch, username, options) {
67977
68232
  resume: false,
67978
68233
  appendSystemPrompt: await buildAppendSystemPrompt(session.platform, session.platformId, existing.path, session.threadId, session.startedBy, session.sessionAllowedUsers, options.appendSystemPrompt ?? "", options.githubEmailsStore, { omitSessionContext: !needsTitlePrompt, userAttribution: session.userAttribution })
67979
68234
  };
68235
+ session.messageManager?.clearClaudeSessionState();
67980
68236
  session.claude = new ClaudeCli(cliOptions);
67981
68237
  session.claude.on("event", (e) => options.handleEvent(session.sessionId, e));
67982
68238
  session.claude.on("exit", (code) => options.handleExit(session.sessionId, code));
@@ -68049,7 +68305,7 @@ ${fmt.formatItalic("Claude Code restarted in the worktree")}`);
68049
68305
  if (session.claude.isRunning()) {
68050
68306
  options.stopTyping(session);
68051
68307
  transitionTo(session, "restarting");
68052
- session.claude.kill();
68308
+ await session.claude.kill();
68053
68309
  await options.flush(session);
68054
68310
  const newSessionId = randomUUID2();
68055
68311
  session.claudeSessionId = newSessionId;
@@ -68069,6 +68325,7 @@ ${fmt.formatItalic("Claude Code restarted in the worktree")}`);
68069
68325
  resume: false,
68070
68326
  appendSystemPrompt: await buildAppendSystemPrompt(session.platform, session.platformId, worktreePath, session.threadId, session.startedBy, session.sessionAllowedUsers, options.appendSystemPrompt ?? "", options.githubEmailsStore, { omitSessionContext: !needsTitlePrompt, userAttribution: session.userAttribution })
68071
68327
  };
68328
+ session.messageManager?.clearClaudeSessionState();
68072
68329
  session.claude = new ClaudeCli(cliOptions);
68073
68330
  session.claude.on("event", (e) => options.handleEvent(session.sessionId, e));
68074
68331
  session.claude.on("exit", (code) => options.handleExit(session.sessionId, code));
@@ -68342,6 +68599,9 @@ function extractAndUpdatePullRequest(text, session, ctx) {
68342
68599
  ctx.ops.updateSessionHeader(session).catch(() => {});
68343
68600
  }
68344
68601
  }
68602
+ function isSidechainEvent(event) {
68603
+ return Boolean(event.parent_tool_use_id);
68604
+ }
68345
68605
  function handleEventPreProcessing(session, event, ctx) {
68346
68606
  session.threadLogger?.logEvent(event);
68347
68607
  resetSessionActivity(session);
@@ -68363,6 +68623,16 @@ function handleEventPreProcessing(session, event, ctx) {
68363
68623
  handleCompactionComplete(session, e.compact_metadata, ctx);
68364
68624
  }
68365
68625
  }
68626
+ if (event.type === "assistant" && !isSidechainEvent(event)) {
68627
+ const msg = event.message;
68628
+ if (Array.isArray(msg?.content)) {
68629
+ for (const block of msg.content) {
68630
+ if (block.type === "tool_use" && block.name) {
68631
+ trackEvent(session, "tool_use", block.name);
68632
+ }
68633
+ }
68634
+ }
68635
+ }
68366
68636
  if (event.type === "tool_use") {
68367
68637
  const tool = event.tool_use;
68368
68638
  trackEvent(session, "tool_use", tool.name);
@@ -68374,7 +68644,9 @@ function handleEventPostProcessing(session, event, ctx) {
68374
68644
  for (const block of msg?.content || []) {
68375
68645
  if (block.type === "text" && block.text) {
68376
68646
  extractAndUpdatePullRequest(block.text, session, ctx);
68377
- detectAndExecuteClaudeCommands(block.text, session, ctx);
68647
+ if (!isSidechainEvent(event)) {
68648
+ detectAndExecuteClaudeCommands(block.text, session, ctx);
68649
+ }
68378
68650
  }
68379
68651
  }
68380
68652
  }
@@ -68384,6 +68656,16 @@ function handleEventPostProcessing(session, event, ctx) {
68384
68656
  ctx.ops.emitSessionUpdate(session.sessionId, { status: getSessionStatus(session) });
68385
68657
  updateUsageStats(session, event, ctx);
68386
68658
  }
68659
+ if (event.type === "user" && !isSidechainEvent(event)) {
68660
+ const msg = event.message;
68661
+ if (Array.isArray(msg?.content)) {
68662
+ for (const block of msg.content) {
68663
+ if (block.type === "tool_result" && block.is_error) {
68664
+ trackEvent(session, "tool_error", "Tool execution failed");
68665
+ }
68666
+ }
68667
+ }
68668
+ }
68387
68669
  if (event.type === "tool_result") {
68388
68670
  const result = event.tool_result;
68389
68671
  if (result.is_error) {
@@ -68943,8 +69225,11 @@ function commonRestartCliOptions(session, ctx) {
68943
69225
  async function restartClaudeSession(session, cliOptions, ctx, actionName) {
68944
69226
  ctx.ops.stopTyping(session);
68945
69227
  transitionTo(session, "restarting");
68946
- session.claude.kill();
69228
+ await session.claude.kill();
68947
69229
  await ctx.ops.flush(session);
69230
+ if (!cliOptions.resume) {
69231
+ session.messageManager?.clearClaudeSessionState();
69232
+ }
68948
69233
  session.claude = new ClaudeCli(cliOptions);
68949
69234
  session.claude.on("event", (e) => ctx.ops.handleEvent(session.sessionId, e));
68950
69235
  session.claude.on("exit", (code) => ctx.ops.handleExit(session.sessionId, code));
@@ -48574,11 +48574,26 @@ var bashToolFormatter = {
48574
48574
  };
48575
48575
  // src/operations/tool-formatters/task-tools.ts
48576
48576
  var taskToolsFormatter = {
48577
- toolNames: ["TodoWrite", "Task", "EnterPlanMode", "ExitPlanMode", "AskUserQuestion"],
48578
- format(toolName, _input, options) {
48577
+ toolNames: [
48578
+ "TodoWrite",
48579
+ "TaskCreate",
48580
+ "TaskUpdate",
48581
+ "TaskGet",
48582
+ "TaskList",
48583
+ "Task",
48584
+ "EnterPlanMode",
48585
+ "ExitPlanMode",
48586
+ "AskUserQuestion"
48587
+ ],
48588
+ format(toolName, input, options) {
48579
48589
  const { formatter } = options;
48580
48590
  switch (toolName) {
48581
48591
  case "TodoWrite":
48592
+ case "TaskCreate":
48593
+ case "TaskUpdate":
48594
+ return { display: null, hidden: true };
48595
+ case "TaskGet":
48596
+ case "TaskList":
48582
48597
  return { display: null, hidden: true };
48583
48598
  case "Task":
48584
48599
  return { display: null, hidden: true };
@@ -48587,8 +48602,25 @@ var taskToolsFormatter = {
48587
48602
  display: `\uD83D\uDCCB ${formatter.formatBold("Planning...")}`,
48588
48603
  permissionText: `\uD83D\uDCCB ${formatter.formatBold("Planning...")}`
48589
48604
  };
48590
- case "ExitPlanMode":
48591
- return { display: null, hidden: true };
48605
+ case "ExitPlanMode": {
48606
+ const plan = typeof input.plan === "string" ? input.plan : "";
48607
+ const points = Array.from(plan);
48608
+ const truncated = points.length > 1500;
48609
+ let preview = truncated ? `${points.slice(0, 1500).join("")}
48610
+ …` : plan;
48611
+ if (truncated) {
48612
+ const fenceCount = (preview.match(/^```/gm) || []).length;
48613
+ if (fenceCount % 2 === 1)
48614
+ preview += "\n```";
48615
+ }
48616
+ return {
48617
+ display: null,
48618
+ hidden: true,
48619
+ permissionText: preview ? `\uD83D\uDCCB ${formatter.formatBold("Plan approval requested")}
48620
+
48621
+ ${preview}` : `\uD83D\uDCCB ${formatter.formatBold("Plan approval requested")}`
48622
+ };
48623
+ }
48592
48624
  case "AskUserQuestion":
48593
48625
  return { display: null, hidden: true };
48594
48626
  default:
@@ -49135,9 +49167,14 @@ function createStatusUpdateOp(sessionId, options) {
49135
49167
  }
49136
49168
  // src/operations/transformer.ts
49137
49169
  function transformEvent(event, ctx) {
49170
+ if (event.parent_tool_use_id) {
49171
+ return [];
49172
+ }
49138
49173
  switch (event.type) {
49139
49174
  case "assistant":
49140
49175
  return transformAssistant(event, ctx);
49176
+ case "user":
49177
+ return transformUser(event, ctx);
49141
49178
  case "tool_use":
49142
49179
  return transformToolUse(event, ctx);
49143
49180
  case "tool_result":
@@ -49179,6 +49216,9 @@ function transformAssistant(event, ctx) {
49179
49216
  if (result.display && !result.hidden) {
49180
49217
  flushTextBuffer();
49181
49218
  operations.push(createAppendContentOp(ctx.sessionId, result.display, true));
49219
+ if (block.id) {
49220
+ ctx.toolStartTimes.set(block.id, Date.now());
49221
+ }
49182
49222
  }
49183
49223
  }
49184
49224
  } else if (block.type === "thinking" && block.thinking) {
@@ -49193,8 +49233,70 @@ function transformAssistant(event, ctx) {
49193
49233
  }
49194
49234
  }
49195
49235
  flushTextBuffer();
49236
+ const taskListOps = operations.filter((op) => op.type === "task_list");
49237
+ if (taskListOps.length > 1) {
49238
+ const last = taskListOps[taskListOps.length - 1];
49239
+ return operations.filter((op) => op.type !== "task_list" || op === last);
49240
+ }
49196
49241
  return operations;
49197
49242
  }
49243
+ function transformUser(event, ctx) {
49244
+ const msg = event.message;
49245
+ if (!Array.isArray(msg?.content)) {
49246
+ return [];
49247
+ }
49248
+ const operations = [];
49249
+ let indicatorCount = 0;
49250
+ for (const block of msg.content) {
49251
+ if (block?.type !== "tool_result" || !block.tool_use_id)
49252
+ continue;
49253
+ if (ctx.taskTracker.hasPendingCreate(block.tool_use_id)) {
49254
+ const resolution = ctx.taskTracker.resolveCreatedId(block.tool_use_id, toolResultContentText(block.content), block.is_error === true);
49255
+ if (resolution === "removed" || resolution === "merged") {
49256
+ const action = ctx.taskTracker.allCompleted ? "complete" : "update";
49257
+ operations.push(createTaskListOp(ctx.sessionId, action, ctx.taskTracker.toTaskItems()));
49258
+ }
49259
+ }
49260
+ if (ctx.toolStartTimes.has(block.tool_use_id)) {
49261
+ operations.push(createResultIndicatorOp(block.tool_use_id, block.is_error === true, ctx));
49262
+ indicatorCount++;
49263
+ }
49264
+ }
49265
+ const taskListOps = operations.filter((op) => op.type === "task_list");
49266
+ if (taskListOps.length > 1) {
49267
+ const last = taskListOps[taskListOps.length - 1];
49268
+ const coalesced = operations.filter((op) => op.type !== "task_list" || op === last);
49269
+ operations.length = 0;
49270
+ operations.push(...coalesced);
49271
+ }
49272
+ if (indicatorCount > 0) {
49273
+ operations.push(createFlushOp(ctx.sessionId, "tool_complete"));
49274
+ }
49275
+ return operations;
49276
+ }
49277
+ function toolResultContentText(content) {
49278
+ if (typeof content === "string")
49279
+ return content;
49280
+ if (Array.isArray(content)) {
49281
+ return content.map((b) => b && typeof b === "object" && typeof b.text === "string" ? b.text : "").join(`
49282
+ `);
49283
+ }
49284
+ return "";
49285
+ }
49286
+ function createResultIndicatorOp(toolUseId, isError, ctx) {
49287
+ let elapsed = "";
49288
+ const startTime = ctx.toolStartTimes.get(toolUseId);
49289
+ if (startTime) {
49290
+ const secs = Math.round((Date.now() - startTime) / 1000);
49291
+ if (secs >= 3) {
49292
+ elapsed = ` (${secs}s)`;
49293
+ }
49294
+ ctx.toolStartTimes.delete(toolUseId);
49295
+ }
49296
+ const icon = isError ? "❌" : "✓";
49297
+ const errorNote = isError ? " Error" : "";
49298
+ return createAppendContentOp(ctx.sessionId, ` ↳ ${icon}${errorNote}${elapsed}`, true);
49299
+ }
49198
49300
  function transformToolUse(event, ctx) {
49199
49301
  const tool = event.tool_use;
49200
49302
  if (tool.id) {
@@ -49219,23 +49321,10 @@ function transformToolResult(event, ctx) {
49219
49321
  return [];
49220
49322
  }
49221
49323
  const result = event.tool_result;
49222
- const operations = [];
49223
- let elapsed = "";
49224
- if (result.tool_use_id) {
49225
- const startTime = ctx.toolStartTimes.get(result.tool_use_id);
49226
- if (startTime) {
49227
- const secs = Math.round((Date.now() - startTime) / 1000);
49228
- if (secs >= 3) {
49229
- elapsed = ` (${secs}s)`;
49230
- }
49231
- ctx.toolStartTimes.delete(result.tool_use_id);
49232
- }
49233
- }
49234
- const icon = result.is_error ? "❌" : "✓";
49235
- const errorNote = result.is_error ? " Error" : "";
49236
- operations.push(createAppendContentOp(ctx.sessionId, ` ↳ ${icon}${errorNote}${elapsed}`, true));
49237
- operations.push(createFlushOp(ctx.sessionId, "tool_complete"));
49238
- return operations;
49324
+ return [
49325
+ createResultIndicatorOp(result.tool_use_id || "", result.is_error === true, ctx),
49326
+ createFlushOp(ctx.sessionId, "tool_complete")
49327
+ ];
49239
49328
  }
49240
49329
  function transformResult(event, ctx) {
49241
49330
  const operations = [];
@@ -49252,6 +49341,10 @@ function handleSpecialTool(toolName, toolUseId, input, ctx) {
49252
49341
  switch (toolName) {
49253
49342
  case "TodoWrite":
49254
49343
  return handleTodoWrite(input, ctx);
49344
+ case "TaskCreate":
49345
+ return handleTaskCreate(toolUseId, input, ctx);
49346
+ case "TaskUpdate":
49347
+ return handleTaskUpdate(input, ctx);
49255
49348
  case "Task":
49256
49349
  return handleTaskStart(toolUseId, input, ctx);
49257
49350
  case "AskUserQuestion":
@@ -49269,10 +49362,22 @@ function handleTodoWrite(input, ctx) {
49269
49362
  status: t.status,
49270
49363
  activeForm: t.activeForm
49271
49364
  }));
49365
+ ctx.taskTracker.clear();
49272
49366
  const allCompleted = tasks.every((t) => t.status === "completed");
49273
49367
  const action = allCompleted ? "complete" : "update";
49274
49368
  return [createTaskListOp(ctx.sessionId, action, tasks)];
49275
49369
  }
49370
+ function handleTaskCreate(toolUseId, input, ctx) {
49371
+ ctx.taskTracker.create(toolUseId, input);
49372
+ return [createTaskListOp(ctx.sessionId, "update", ctx.taskTracker.toTaskItems())];
49373
+ }
49374
+ function handleTaskUpdate(input, ctx) {
49375
+ const changed = ctx.taskTracker.update(input);
49376
+ if (!changed)
49377
+ return [];
49378
+ const action = ctx.taskTracker.allCompleted ? "complete" : "update";
49379
+ return [createTaskListOp(ctx.sessionId, action, ctx.taskTracker.toTaskItems())];
49380
+ }
49276
49381
  function handleTaskStart(toolUseId, input, ctx) {
49277
49382
  const description = input.description || input.prompt || "Subagent";
49278
49383
  const subagentType = input.subagent_type || "general-purpose";
@@ -49306,6 +49411,102 @@ function truncateAtWord(text, maxLength) {
49306
49411
  }
49307
49412
  return truncated + "...";
49308
49413
  }
49414
+ // src/operations/task-tracker.ts
49415
+ var CREATED_RESULT_RE = /Task #(\S+) created/;
49416
+
49417
+ class TaskTracker {
49418
+ tasks = [];
49419
+ pendingCreates = new Map;
49420
+ unmatchedCreateResults = 0;
49421
+ create(toolUseId, input) {
49422
+ const task = {
49423
+ subject: typeof input.subject === "string" ? input.subject : "Task",
49424
+ activeForm: typeof input.activeForm === "string" ? input.activeForm : undefined,
49425
+ status: "pending"
49426
+ };
49427
+ this.tasks.push(task);
49428
+ if (toolUseId) {
49429
+ this.pendingCreates.set(toolUseId, task);
49430
+ }
49431
+ }
49432
+ hasPendingCreate(toolUseId) {
49433
+ return this.pendingCreates.has(toolUseId);
49434
+ }
49435
+ resolveCreatedId(toolUseId, resultContent, isError = false) {
49436
+ const task = this.pendingCreates.get(toolUseId);
49437
+ if (!task)
49438
+ return "ignored";
49439
+ this.pendingCreates.delete(toolUseId);
49440
+ const match = isError ? null : CREATED_RESULT_RE.exec(resultContent);
49441
+ if (!match) {
49442
+ if (!isError)
49443
+ this.unmatchedCreateResults++;
49444
+ this.tasks = this.tasks.filter((t) => t !== task);
49445
+ return "removed";
49446
+ }
49447
+ const placeholder = this.tasks.find((t) => t !== task && t.taskId === match[1]);
49448
+ if (placeholder) {
49449
+ task.status = placeholder.status;
49450
+ if (placeholder.subject !== `Task #${match[1]}`)
49451
+ task.subject = placeholder.subject;
49452
+ if (placeholder.activeForm && !task.activeForm)
49453
+ task.activeForm = placeholder.activeForm;
49454
+ this.tasks = this.tasks.filter((t) => t !== placeholder);
49455
+ }
49456
+ task.taskId = match[1];
49457
+ return placeholder ? "merged" : "resolved";
49458
+ }
49459
+ consumeUnmatchedCreateResultFlag() {
49460
+ if (this.unmatchedCreateResults === 0)
49461
+ return false;
49462
+ this.unmatchedCreateResults = 0;
49463
+ return true;
49464
+ }
49465
+ update(input) {
49466
+ const taskId = typeof input.taskId === "string" ? input.taskId : typeof input.taskId === "number" ? String(input.taskId) : undefined;
49467
+ if (!taskId)
49468
+ return false;
49469
+ let task = this.tasks.find((t) => t.taskId === taskId);
49470
+ const status = typeof input.status === "string" ? input.status : undefined;
49471
+ if (status === "deleted") {
49472
+ if (!task)
49473
+ return false;
49474
+ this.tasks = this.tasks.filter((t) => t !== task);
49475
+ return true;
49476
+ }
49477
+ if (!task) {
49478
+ task = { taskId, subject: `Task #${taskId}`, status: "pending", isPlaceholder: true };
49479
+ this.tasks.push(task);
49480
+ }
49481
+ if (typeof input.subject === "string")
49482
+ task.subject = input.subject;
49483
+ if (typeof input.activeForm === "string")
49484
+ task.activeForm = input.activeForm;
49485
+ if (status === "pending" || status === "in_progress" || status === "completed") {
49486
+ task.status = status;
49487
+ }
49488
+ return true;
49489
+ }
49490
+ toTaskItems() {
49491
+ return this.tasks.map((t) => ({
49492
+ content: t.subject,
49493
+ status: t.status,
49494
+ activeForm: t.activeForm ?? t.subject
49495
+ }));
49496
+ }
49497
+ get isEmpty() {
49498
+ return this.tasks.length === 0;
49499
+ }
49500
+ get allCompleted() {
49501
+ return this.tasks.length > 0 && this.tasks.every((t) => t.status === "completed") && this.tasks.some((t) => !t.isPlaceholder);
49502
+ }
49503
+ clear() {
49504
+ this.tasks = [];
49505
+ this.pendingCreates.clear();
49506
+ this.unmatchedCreateResults = 0;
49507
+ }
49508
+ }
49509
+
49309
49510
  // src/operations/executors/base.ts
49310
49511
  class BaseExecutor {
49311
49512
  state;
@@ -51367,6 +51568,7 @@ class MessageManager {
51367
51568
  startTypingCallback;
51368
51569
  emitSessionUpdateCallback;
51369
51570
  toolStartTimes = new Map;
51571
+ taskTracker = new TaskTracker;
51370
51572
  flushTimer = null;
51371
51573
  static DEFAULT_FLUSH_DELAY_MS = 500;
51372
51574
  flushDelayMs;
@@ -51437,10 +51639,14 @@ class MessageManager {
51437
51639
  sessionId: this.sessionId,
51438
51640
  formatter: this.platform.getFormatter(),
51439
51641
  toolStartTimes: this.toolStartTimes,
51642
+ taskTracker: this.taskTracker,
51440
51643
  detailed: true,
51441
51644
  worktreeInfo: this.worktreePath && this.worktreeBranch ? { path: this.worktreePath, branch: this.worktreeBranch } : undefined
51442
51645
  };
51443
51646
  const ops = transformEvent(event, transformCtx);
51647
+ if (this.taskTracker.consumeUnmatchedCreateResultFlag()) {
51648
+ logger.warn('TaskCreate result did not match the expected "Task #N created" wording — ' + "task dropped from the displayed list. If this repeats, the Claude CLI " + "likely changed its result text and the task display will be incomplete.");
51649
+ }
51444
51650
  if (ops.length === 0) {
51445
51651
  if (event.type !== "system") {
51446
51652
  logger.debug(`No operations from event: ${event.type}`);
@@ -51824,9 +52030,14 @@ class MessageManager {
51824
52030
  logger.debug("Reaction not handled by any executor");
51825
52031
  return false;
51826
52032
  }
52033
+ clearClaudeSessionState() {
52034
+ this.toolStartTimes.clear();
52035
+ this.taskTracker.clear();
52036
+ }
51827
52037
  reset() {
51828
52038
  this.cancelScheduledFlush();
51829
52039
  this.toolStartTimes.clear();
52040
+ this.taskTracker.clear();
51830
52041
  this.contentExecutor.reset();
51831
52042
  this.taskListExecutor.reset();
51832
52043
  this.questionApprovalExecutor.reset();
@@ -55629,6 +55840,25 @@ function extractResetAt(text, now) {
55629
55840
  }
55630
55841
  return;
55631
55842
  }
55843
+ function parseRateLimitEvent(event, now = Date.now()) {
55844
+ const info = event?.rate_limit_info;
55845
+ if (!info || typeof info !== "object")
55846
+ return { detected: false };
55847
+ const { status, resetsAt } = info;
55848
+ if (status !== "rejected")
55849
+ return { detected: false };
55850
+ const PAST_SKEW_TOLERANCE_MS = 2 * 60000;
55851
+ let resetAtEpochMs;
55852
+ if (typeof resetsAt === "number") {
55853
+ const ms = resetsAt * 1000;
55854
+ if (ms > now && ms - now < 8 * 86400000) {
55855
+ resetAtEpochMs = ms;
55856
+ } else if (ms <= now && now - ms < PAST_SKEW_TOLERANCE_MS) {
55857
+ resetAtEpochMs = now + 60000;
55858
+ }
55859
+ }
55860
+ return { detected: true, matched: `rate_limit_event status=${status}`, resetAtEpochMs };
55861
+ }
55632
55862
 
55633
55863
  // src/claude/cli.ts
55634
55864
  var log8 = createLogger("claude");
@@ -55765,6 +55995,7 @@ class ClaudeCli extends EventEmitter2 {
55765
55995
  stderrBuffer = "";
55766
55996
  mcpConfigTempFile = null;
55767
55997
  lastEmittedRateLimitDeadline = 0;
55998
+ lastEmittedHitHadExplicitReset = false;
55768
55999
  log;
55769
56000
  constructor(options) {
55770
56001
  super();
@@ -55818,6 +56049,7 @@ class ClaudeCli extends EventEmitter2 {
55818
56049
  totalStderrBytes -= this.stderrBuffer.length;
55819
56050
  this.stderrBuffer = "";
55820
56051
  this.lastEmittedRateLimitDeadline = 0;
56052
+ this.lastEmittedHitHadExplicitReset = false;
55821
56053
  cleanupBrowserBridgeSockets();
55822
56054
  const claudePath = getClaudePath();
55823
56055
  const args = [
@@ -55967,18 +56199,31 @@ class ClaudeCli extends EventEmitter2 {
55967
56199
  if (event.type === "result" && isErrorResultEvent(event)) {
55968
56200
  this.maybeEmitRateLimit(trimmed);
55969
56201
  }
56202
+ if (event.type === "rate_limit_event") {
56203
+ this.maybeEmitRateLimitHit(parseRateLimitEvent(event));
56204
+ }
55970
56205
  } catch {}
55971
56206
  }
55972
56207
  }
55973
56208
  maybeEmitRateLimit(text) {
55974
- const hit = detectRateLimit(text);
56209
+ this.maybeEmitRateLimitHit(detectRateLimit(text));
56210
+ }
56211
+ maybeEmitRateLimitHit(hit) {
55975
56212
  if (!hit.detected)
55976
56213
  return;
56214
+ if (!hit.resetAtEpochMs && this.lastEmittedHitHadExplicitReset && this.lastEmittedRateLimitDeadline > Date.now()) {
56215
+ return;
56216
+ }
55977
56217
  const newDeadline = cooldownDeadline(hit);
55978
56218
  const MIN_ADVANCE_MS = 60000;
55979
- if (newDeadline - this.lastEmittedRateLimitDeadline < MIN_ADVANCE_MS)
56219
+ if (newDeadline - this.lastEmittedRateLimitDeadline < MIN_ADVANCE_MS) {
56220
+ if (hit.resetAtEpochMs !== undefined) {
56221
+ this.lastEmittedHitHadExplicitReset = true;
56222
+ }
55980
56223
  return;
56224
+ }
55981
56225
  this.lastEmittedRateLimitDeadline = newDeadline;
56226
+ this.lastEmittedHitHadExplicitReset = hit.resetAtEpochMs !== undefined;
55982
56227
  this.log.warn(`Rate limit detected: ${hit.matched ?? "(no match text)"}`);
55983
56228
  this.emit("rate-limit", hit);
55984
56229
  }
@@ -56033,12 +56278,22 @@ class ClaudeCli extends EventEmitter2 {
56033
56278
  proc.kill("SIGTERM");
56034
56279
  } catch {}
56035
56280
  }, 2000);
56036
- proc.once("exit", (code) => {
56037
- this.log.debug(`Claude process exited (code=${code})`);
56281
+ const settle = (reason) => {
56282
+ this.log.debug(`Claude process gone (${reason})`);
56038
56283
  clearTimeout(secondSigint);
56039
56284
  clearTimeout(forceKillTimeout);
56285
+ clearTimeout(lastResort);
56040
56286
  resolve5();
56041
- });
56287
+ };
56288
+ const lastResort = setTimeout(() => {
56289
+ try {
56290
+ this.log.warn("Claude process did not exit after SIGTERM — sending SIGKILL");
56291
+ proc.kill("SIGKILL");
56292
+ } catch {}
56293
+ settle("kill timeout");
56294
+ }, 5000);
56295
+ proc.once("close", (code) => settle(`closed, code=${code}`));
56296
+ proc.once("error", () => settle("spawn error"));
56042
56297
  });
56043
56298
  }
56044
56299
  interrupt() {
@@ -58181,6 +58436,10 @@ async function handlePermissionWith(toolName, toolInput, cfg) {
58181
58436
  mcpLogger.debug(`Skipping standard prompt for ${toolName} (handler enforces its own gate)`);
58182
58437
  return { behavior: "allow", updatedInput: toolInput };
58183
58438
  }
58439
+ if (toolName === "AskUserQuestion") {
58440
+ mcpLogger.debug("Auto-allowing AskUserQuestion (question UI is handled by the main bot)");
58441
+ return { behavior: "allow", updatedInput: toolInput };
58442
+ }
58184
58443
  if (cfg.getAllowAll()) {
58185
58444
  mcpLogger.debug(`Auto-allowing ${toolName} (allow all active)`);
58186
58445
  return { behavior: "allow", updatedInput: toolInput };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-threads",
3
- "version": "1.20.0",
3
+ "version": "1.21.0",
4
4
  "description": "Run Claude Code from Slack or Mattermost. Sessions stream live into threads where your whole team can watch and steer.",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",