claude-threads 1.38.1 → 1.39.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 +12 -0
- package/dist/index.js +498 -31
- package/dist/mcp/mcp-server.js +6 -5
- package/docs/CONFIGURATION.md +26 -0
- package/docs/quiet-tools-spec.md +232 -0
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,18 @@ 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.39.0] - 2026-09-25
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Per-platform `toolActivity`: `summary` or `hidden` instead of the tool stream** (#534, thanks @kaza). `summary` replaces the inline `Bash …` / `↳ ✓` lines with one live line at the top of the turn's post — `🔧 12 tools · 40 s · Bash`, naming the tool most recently started so the line stays a liveness signal; `hidden` says nothing about tools at all. `toolDetails: thread` keeps the full rendering in a thread under the reply, `none` drops it. Defaults are unchanged (`full` / `none`). Permission prompts, plan approvals, questions, task lists and errors are untouched in every mode. Part of #505. Hardened before release in #616: the turn marker keeps the summary line, details in a thread session arrive after the whole reply, and long or rate-limited turns lose no tool lines.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- **Long replies are no longer cut off at the platform limit** (#616). A reply that reached the chat in one large burst, with no post of its own yet, was truncated past the limit (about 12K characters on Slack, 16K on Mattermost), with "(truncated)" at the end. It is now split over several posts; a code block cut in two is closed and reopened with its language. Normal streaming replies are unchanged.
|
|
15
|
+
- **A refused post no longer loses part of a long reply** (#616). When the platform refused one post of a reply split over several (a rate limit, for instance), the text of that post was dropped once a later one went through, and anything Claude streamed meanwhile could be wiped. The refused part is now kept and posted on the next flush.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- `hono` 4.13.7 → 4.13.8 (#608).
|
|
19
|
+
|
|
8
20
|
## [1.38.1] - 2026-09-24
|
|
9
21
|
|
|
10
22
|
### Fixed
|
package/dist/index.js
CHANGED
|
@@ -2278,6 +2278,27 @@ function resolveTurnMarker(mode, emoji, platformType, fieldPath) {
|
|
|
2278
2278
|
return { mode: "reaction", emoji: emoji ?? DEFAULT_TURN_MARKER_EMOJI };
|
|
2279
2279
|
return { mode: m };
|
|
2280
2280
|
}
|
|
2281
|
+
function resolveToolActivity(activity, details, fieldPath) {
|
|
2282
|
+
const act = activity === undefined || activity === null ? "full" : activity;
|
|
2283
|
+
if (!TOOL_ACTIVITY_VALUES.includes(act)) {
|
|
2284
|
+
throw new Error(`Invalid ${fieldPath}.toolActivity: expected one of ${TOOL_ACTIVITY_VALUES.join(", ")}, got ${JSON.stringify(activity)}`);
|
|
2285
|
+
}
|
|
2286
|
+
const mode = act;
|
|
2287
|
+
if (details !== undefined && details !== null && !TOOL_DETAILS_VALUES.includes(details)) {
|
|
2288
|
+
throw new Error(`Invalid ${fieldPath}.toolDetails: expected one of ${TOOL_DETAILS_VALUES.join(", ")}, got ${JSON.stringify(details)}`);
|
|
2289
|
+
}
|
|
2290
|
+
const det = details;
|
|
2291
|
+
if (mode === "full") {
|
|
2292
|
+
if (det !== undefined && det !== null) {
|
|
2293
|
+
throw new Error(`Invalid ${fieldPath}.toolDetails: only meaningful with toolActivity summary or hidden`);
|
|
2294
|
+
}
|
|
2295
|
+
return { activity: "full", details: "none" };
|
|
2296
|
+
}
|
|
2297
|
+
if (mode === "hidden" && det === "thread") {
|
|
2298
|
+
throw new Error(`Invalid ${fieldPath}.toolDetails: hidden has no post of its own to thread under; use summary, or none`);
|
|
2299
|
+
}
|
|
2300
|
+
return { activity: mode, details: det ?? (mode === "summary" ? "thread" : "none") };
|
|
2301
|
+
}
|
|
2281
2302
|
function resolveMemoryConfig(value, fieldPath) {
|
|
2282
2303
|
if (value === undefined || value === null || value === true)
|
|
2283
2304
|
return DEFAULT_MEMORY_CONFIG;
|
|
@@ -2452,7 +2473,7 @@ function effectivePermissionMode(input) {
|
|
|
2452
2473
|
return "default";
|
|
2453
2474
|
return input.botWideMode;
|
|
2454
2475
|
}
|
|
2455
|
-
var OVERHEAD_VISIBILITY_VALUES, DEFAULT_OVERHEAD_VISIBILITY = "full", RECONNECT_POLICY_VALUES, DEFAULT_RECONNECT_POLICY = "retry", PRESENTATION_MODE_VALUES, PRESENTATION_MODE_EXPANSIONS, TURN_MARKER_VALUES, DEFAULT_TURN_MARKER, DEFAULT_TURN_MARKER_EMOJI = "checkered_flag", TURN_COMPLETE_EVENT_TYPE = "claude_threads_turn_complete", TURN_COMPLETE_PAYLOAD_VERSION = 1, DEFAULT_MEMORY_CONFIG, MEMORY_DISABLED, BOT_MCP_SERVER_NAME = "claude-threads-mcp", STDIO_KEYS, REMOTE_KEYS, LIMITS_DEFAULTS, MODE_INFO;
|
|
2476
|
+
var OVERHEAD_VISIBILITY_VALUES, DEFAULT_OVERHEAD_VISIBILITY = "full", RECONNECT_POLICY_VALUES, DEFAULT_RECONNECT_POLICY = "retry", PRESENTATION_MODE_VALUES, PRESENTATION_MODE_EXPANSIONS, TURN_MARKER_VALUES, DEFAULT_TURN_MARKER, DEFAULT_TURN_MARKER_EMOJI = "checkered_flag", TURN_COMPLETE_EVENT_TYPE = "claude_threads_turn_complete", TURN_COMPLETE_PAYLOAD_VERSION = 1, TOOL_ACTIVITY_VALUES, TOOL_DETAILS_VALUES, DEFAULT_TOOL_ACTIVITY, DEFAULT_MEMORY_CONFIG, MEMORY_DISABLED, BOT_MCP_SERVER_NAME = "claude-threads-mcp", STDIO_KEYS, REMOTE_KEYS, LIMITS_DEFAULTS, MODE_INFO;
|
|
2456
2477
|
var init_types = __esm(() => {
|
|
2457
2478
|
OVERHEAD_VISIBILITY_VALUES = ["full", "minimal", "hidden"];
|
|
2458
2479
|
RECONNECT_POLICY_VALUES = ["retry", "exit"];
|
|
@@ -2467,6 +2488,9 @@ var init_types = __esm(() => {
|
|
|
2467
2488
|
};
|
|
2468
2489
|
TURN_MARKER_VALUES = ["reaction", "metadata", "off"];
|
|
2469
2490
|
DEFAULT_TURN_MARKER = { mode: "off" };
|
|
2491
|
+
TOOL_ACTIVITY_VALUES = ["full", "summary", "hidden"];
|
|
2492
|
+
TOOL_DETAILS_VALUES = ["thread", "none"];
|
|
2493
|
+
DEFAULT_TOOL_ACTIVITY = { activity: "full", details: "none" };
|
|
2470
2494
|
DEFAULT_MEMORY_CONFIG = {
|
|
2471
2495
|
enabled: true,
|
|
2472
2496
|
repoLayer: true,
|
|
@@ -59501,6 +59525,9 @@ toolFormatterRegistry.register(playwrightToolsFormatter);
|
|
|
59501
59525
|
toolFormatterRegistry.register(figmaToolsFormatter);
|
|
59502
59526
|
toolFormatterRegistry.register(context7ToolsFormatter);
|
|
59503
59527
|
// src/operations/types.ts
|
|
59528
|
+
function isToolActivityOp(op) {
|
|
59529
|
+
return op.type === "tool_activity";
|
|
59530
|
+
}
|
|
59504
59531
|
function isContentOp(op) {
|
|
59505
59532
|
return op.type === "append_content";
|
|
59506
59533
|
}
|
|
@@ -59537,6 +59564,9 @@ function createAppendContentOp(sessionId, content, isToolOutput) {
|
|
|
59537
59564
|
isToolOutput
|
|
59538
59565
|
};
|
|
59539
59566
|
}
|
|
59567
|
+
function createToolActivityOp(sessionId, detail) {
|
|
59568
|
+
return { type: "tool_activity", sessionId, timestamp: Date.now(), ...detail };
|
|
59569
|
+
}
|
|
59540
59570
|
function createFlushOp(sessionId, reason, resultOk) {
|
|
59541
59571
|
return {
|
|
59542
59572
|
type: "flush",
|
|
@@ -59599,6 +59629,9 @@ function createStatusUpdateOp(sessionId, options) {
|
|
|
59599
59629
|
}
|
|
59600
59630
|
// src/operations/transformer.ts
|
|
59601
59631
|
init_cli();
|
|
59632
|
+
function toolsInline(ctx) {
|
|
59633
|
+
return (ctx.toolActivity ?? "full") === "full";
|
|
59634
|
+
}
|
|
59602
59635
|
function transformEvent(event, ctx) {
|
|
59603
59636
|
if (event.parent_tool_use_id) {
|
|
59604
59637
|
return [];
|
|
@@ -59648,7 +59681,16 @@ function transformAssistant(event, ctx) {
|
|
|
59648
59681
|
});
|
|
59649
59682
|
if (result.display && !result.hidden) {
|
|
59650
59683
|
flushTextBuffer();
|
|
59651
|
-
|
|
59684
|
+
if (toolsInline(ctx)) {
|
|
59685
|
+
operations.push(createAppendContentOp(ctx.sessionId, result.display, true));
|
|
59686
|
+
} else {
|
|
59687
|
+
operations.push(createToolActivityOp(ctx.sessionId, {
|
|
59688
|
+
kind: "start",
|
|
59689
|
+
toolUseId: block.id || "",
|
|
59690
|
+
name: block.name,
|
|
59691
|
+
display: result.display
|
|
59692
|
+
}));
|
|
59693
|
+
}
|
|
59652
59694
|
if (block.id) {
|
|
59653
59695
|
ctx.toolStartTimes.set(block.id, Date.now());
|
|
59654
59696
|
}
|
|
@@ -59662,7 +59704,14 @@ function transformAssistant(event, ctx) {
|
|
|
59662
59704
|
} else if (block.type === "server_tool_use" && block.name) {
|
|
59663
59705
|
flushTextBuffer();
|
|
59664
59706
|
const inputStr = block.input ? JSON.stringify(block.input).substring(0, 50) : "";
|
|
59665
|
-
|
|
59707
|
+
const display = `\uD83C\uDF10 ${ctx.formatter.formatBold(block.name)} ${inputStr}`;
|
|
59708
|
+
if (toolsInline(ctx)) {
|
|
59709
|
+
operations.push(createAppendContentOp(ctx.sessionId, display, true));
|
|
59710
|
+
} else {
|
|
59711
|
+
const toolUseId = block.id || "";
|
|
59712
|
+
operations.push(createToolActivityOp(ctx.sessionId, { kind: "start", toolUseId, name: block.name, display }));
|
|
59713
|
+
operations.push(createToolActivityOp(ctx.sessionId, { kind: "end", toolUseId, ok: true, elapsedMs: 0, display: " ↳ ✓" }));
|
|
59714
|
+
}
|
|
59666
59715
|
}
|
|
59667
59716
|
}
|
|
59668
59717
|
flushTextBuffer();
|
|
@@ -59691,7 +59740,7 @@ function transformUser(event, ctx) {
|
|
|
59691
59740
|
}
|
|
59692
59741
|
}
|
|
59693
59742
|
if (ctx.toolStartTimes.has(block.tool_use_id)) {
|
|
59694
|
-
operations.push(createResultIndicatorOp(block.tool_use_id, block.is_error === true, ctx));
|
|
59743
|
+
operations.push(toolsInline(ctx) ? createResultIndicatorOp(block.tool_use_id, block.is_error === true, ctx) : createToolEndOp(block.tool_use_id, block.is_error === true, ctx));
|
|
59695
59744
|
indicatorCount++;
|
|
59696
59745
|
}
|
|
59697
59746
|
}
|
|
@@ -59730,6 +59779,12 @@ function createResultIndicatorOp(toolUseId, isError, ctx) {
|
|
|
59730
59779
|
const errorNote = isError ? " Error" : "";
|
|
59731
59780
|
return createAppendContentOp(ctx.sessionId, ` ↳ ${icon}${errorNote}${elapsed}`, true);
|
|
59732
59781
|
}
|
|
59782
|
+
function createToolEndOp(toolUseId, isError, ctx) {
|
|
59783
|
+
const startTime = ctx.toolStartTimes.get(toolUseId);
|
|
59784
|
+
const elapsedMs = startTime ? Date.now() - startTime : 0;
|
|
59785
|
+
const indicator = createResultIndicatorOp(toolUseId, isError, ctx);
|
|
59786
|
+
return createToolActivityOp(ctx.sessionId, { kind: "end", toolUseId, ok: !isError, elapsedMs, display: indicator.content });
|
|
59787
|
+
}
|
|
59733
59788
|
function transformToolUse(event, ctx) {
|
|
59734
59789
|
const tool = event.tool_use;
|
|
59735
59790
|
if (tool.id) {
|
|
@@ -59745,7 +59800,9 @@ function transformToolUse(event, ctx) {
|
|
|
59745
59800
|
worktreeInfo: ctx.worktreeInfo
|
|
59746
59801
|
});
|
|
59747
59802
|
if (result.display && !result.hidden) {
|
|
59748
|
-
return [
|
|
59803
|
+
return [
|
|
59804
|
+
toolsInline(ctx) ? createAppendContentOp(ctx.sessionId, result.display, true) : createToolActivityOp(ctx.sessionId, { kind: "start", toolUseId: tool.id || "", name: tool.name, display: result.display })
|
|
59805
|
+
];
|
|
59749
59806
|
}
|
|
59750
59807
|
return [];
|
|
59751
59808
|
}
|
|
@@ -59754,13 +59811,18 @@ function transformToolResult(event, ctx) {
|
|
|
59754
59811
|
return [];
|
|
59755
59812
|
}
|
|
59756
59813
|
const result = event.tool_result;
|
|
59814
|
+
const toolUseId = result.tool_use_id || "";
|
|
59815
|
+
const isError = result.is_error === true;
|
|
59757
59816
|
return [
|
|
59758
|
-
|
|
59817
|
+
toolsInline(ctx) ? createResultIndicatorOp(toolUseId, isError, ctx) : createToolEndOp(toolUseId, isError, ctx),
|
|
59759
59818
|
createFlushOp(ctx.sessionId, "tool_complete")
|
|
59760
59819
|
];
|
|
59761
59820
|
}
|
|
59762
59821
|
function transformResult(event, ctx) {
|
|
59763
59822
|
const operations = [];
|
|
59823
|
+
if (!toolsInline(ctx)) {
|
|
59824
|
+
operations.push(createToolActivityOp(ctx.sessionId, { kind: "turn_end" }));
|
|
59825
|
+
}
|
|
59764
59826
|
operations.push(createFlushOp(ctx.sessionId, "result", !isErrorResultEvent(event)));
|
|
59765
59827
|
const result = event;
|
|
59766
59828
|
const r = result.result;
|
|
@@ -59844,6 +59906,210 @@ function truncateAtWord2(text, maxLength) {
|
|
|
59844
59906
|
}
|
|
59845
59907
|
return truncated + "...";
|
|
59846
59908
|
}
|
|
59909
|
+
// src/operations/executors/tool-activity.ts
|
|
59910
|
+
var fresh = () => ({ started: 0, finished: 0, failed: 0, firstStartAt: null, lastEndAt: null, lastTool: null });
|
|
59911
|
+
function shortToolName(name) {
|
|
59912
|
+
return parseMcpToolName(name)?.tool || name;
|
|
59913
|
+
}
|
|
59914
|
+
function renderToolSummary(stats, now, link, formatter) {
|
|
59915
|
+
const running = stats.started > stats.finished;
|
|
59916
|
+
const until = running || stats.lastEndAt === null ? now : stats.lastEndAt;
|
|
59917
|
+
const seconds = stats.firstStartAt === null ? 0 : Math.max(0, Math.round((until - stats.firstStartAt) / 1000));
|
|
59918
|
+
const parts = [`\uD83D\uDD27 ${stats.started} ${stats.started === 1 ? "tool" : "tools"}`, `${seconds} s${running ? "…" : ""}`];
|
|
59919
|
+
if (stats.lastTool)
|
|
59920
|
+
parts.push(shortToolName(stats.lastTool));
|
|
59921
|
+
if (stats.failed > 0)
|
|
59922
|
+
parts.push(`${stats.failed} ❌`);
|
|
59923
|
+
if (link)
|
|
59924
|
+
parts.push(formatter.formatLink("details", link));
|
|
59925
|
+
return parts.join(" · ");
|
|
59926
|
+
}
|
|
59927
|
+
|
|
59928
|
+
class ToolActivityExecutor {
|
|
59929
|
+
options;
|
|
59930
|
+
stats = fresh();
|
|
59931
|
+
open = new Set;
|
|
59932
|
+
constructor(options) {
|
|
59933
|
+
this.options = options;
|
|
59934
|
+
}
|
|
59935
|
+
getStats() {
|
|
59936
|
+
return this.stats;
|
|
59937
|
+
}
|
|
59938
|
+
async execute(op, ctx) {
|
|
59939
|
+
const now = this.options.now?.() ?? Date.now();
|
|
59940
|
+
if (op.kind === "start") {
|
|
59941
|
+
this.stats.started++;
|
|
59942
|
+
this.open.add(op.toolUseId);
|
|
59943
|
+
this.stats.firstStartAt ??= now;
|
|
59944
|
+
this.stats.lastTool = op.name;
|
|
59945
|
+
this.renderHeader(now, ctx);
|
|
59946
|
+
await this.options.sink.append(op, ctx);
|
|
59947
|
+
} else if (op.kind === "end") {
|
|
59948
|
+
if (!this.open.delete(op.toolUseId))
|
|
59949
|
+
return;
|
|
59950
|
+
this.stats.finished++;
|
|
59951
|
+
if (!op.ok)
|
|
59952
|
+
this.stats.failed++;
|
|
59953
|
+
this.stats.lastEndAt = now;
|
|
59954
|
+
this.renderHeader(now, ctx);
|
|
59955
|
+
await this.options.sink.append(op, ctx);
|
|
59956
|
+
} else if (this.stats.started > 0) {
|
|
59957
|
+
if (this.stats.started > this.stats.finished) {
|
|
59958
|
+
this.stats.finished = this.stats.started;
|
|
59959
|
+
this.stats.lastEndAt = now;
|
|
59960
|
+
}
|
|
59961
|
+
this.open.clear();
|
|
59962
|
+
this.renderHeader(now, ctx);
|
|
59963
|
+
}
|
|
59964
|
+
}
|
|
59965
|
+
async afterResultFlush(ctx) {
|
|
59966
|
+
if (this.stats.started === 0)
|
|
59967
|
+
return;
|
|
59968
|
+
this.stats = fresh();
|
|
59969
|
+
this.open.clear();
|
|
59970
|
+
try {
|
|
59971
|
+
await this.options.sink.turnEnded(ctx);
|
|
59972
|
+
} catch (err) {
|
|
59973
|
+
ctx.logger.warn(`tool details: delivering the turn failed: ${err.message ?? err}`);
|
|
59974
|
+
}
|
|
59975
|
+
}
|
|
59976
|
+
reset() {
|
|
59977
|
+
if (this.stats.started === 0)
|
|
59978
|
+
return;
|
|
59979
|
+
this.stats = fresh();
|
|
59980
|
+
this.open.clear();
|
|
59981
|
+
this.options.sink.reset();
|
|
59982
|
+
}
|
|
59983
|
+
wake() {
|
|
59984
|
+
this.options.sink.wake?.();
|
|
59985
|
+
}
|
|
59986
|
+
renderHeader(now, ctx) {
|
|
59987
|
+
if (this.options.mode !== "summary")
|
|
59988
|
+
return;
|
|
59989
|
+
this.options.onHeader(renderToolSummary(this.stats, now, this.options.sink.link(), ctx.formatter));
|
|
59990
|
+
}
|
|
59991
|
+
}
|
|
59992
|
+
|
|
59993
|
+
// src/operations/tool-details/thread.ts
|
|
59994
|
+
function createThreadSink(deps) {
|
|
59995
|
+
const flushDelayMs = deps.flushDelayMs ?? 500;
|
|
59996
|
+
const newTurn = () => ({ queued: [], executor: null, ctx: null, timer: null, chain: Promise.resolve(), dead: false });
|
|
59997
|
+
let turn = newTurn();
|
|
59998
|
+
function enqueue(t, work) {
|
|
59999
|
+
const run = t.chain.then(() => t.dead ? undefined : work());
|
|
60000
|
+
t.chain = run.catch(() => {
|
|
60001
|
+
return;
|
|
60002
|
+
});
|
|
60003
|
+
return run;
|
|
60004
|
+
}
|
|
60005
|
+
async function drain(t) {
|
|
60006
|
+
if (t.queued.length === 0)
|
|
60007
|
+
return;
|
|
60008
|
+
t.ctx ??= deps.contextFor();
|
|
60009
|
+
if (!t.ctx)
|
|
60010
|
+
return;
|
|
60011
|
+
t.executor ??= deps.makeExecutor();
|
|
60012
|
+
const lines = t.queued;
|
|
60013
|
+
t.queued = [];
|
|
60014
|
+
const chunk = Math.floor(t.ctx.platform.getMessageLimits().hardThreshold / 2);
|
|
60015
|
+
let nextFlushAt = chunk;
|
|
60016
|
+
for (const line of lines) {
|
|
60017
|
+
await t.executor.executeAppend(createAppendContentOp(t.ctx.sessionId, line, true), t.ctx);
|
|
60018
|
+
if (t.dead)
|
|
60019
|
+
return;
|
|
60020
|
+
const pending = t.executor.getState().pendingContent.length;
|
|
60021
|
+
if (pending >= nextFlushAt) {
|
|
60022
|
+
await t.executor.executeFlush(createFlushOp(t.ctx.sessionId, "soft_threshold"), t.ctx);
|
|
60023
|
+
if (t.dead)
|
|
60024
|
+
return;
|
|
60025
|
+
const left = t.executor.getState().pendingContent.length;
|
|
60026
|
+
nextFlushAt = left >= pending ? pending * 2 : chunk;
|
|
60027
|
+
}
|
|
60028
|
+
}
|
|
60029
|
+
}
|
|
60030
|
+
function scheduleFlush(t) {
|
|
60031
|
+
if (t.timer || t.dead)
|
|
60032
|
+
return;
|
|
60033
|
+
t.timer = setTimeout(() => {
|
|
60034
|
+
t.timer = null;
|
|
60035
|
+
enqueue(t, async () => {
|
|
60036
|
+
if (t.executor && t.ctx)
|
|
60037
|
+
await t.executor.executeFlush(createFlushOp(t.ctx.sessionId, "soft_threshold"), t.ctx);
|
|
60038
|
+
});
|
|
60039
|
+
}, flushDelayMs);
|
|
60040
|
+
}
|
|
60041
|
+
function stopTimer(t) {
|
|
60042
|
+
if (t.timer) {
|
|
60043
|
+
clearTimeout(t.timer);
|
|
60044
|
+
t.timer = null;
|
|
60045
|
+
}
|
|
60046
|
+
}
|
|
60047
|
+
return {
|
|
60048
|
+
async append(op) {
|
|
60049
|
+
const t = turn;
|
|
60050
|
+
t.queued.push(op.display);
|
|
60051
|
+
if (deps.holdUntilTurnEnd)
|
|
60052
|
+
return;
|
|
60053
|
+
await enqueue(t, async () => {
|
|
60054
|
+
await drain(t);
|
|
60055
|
+
if (t.executor)
|
|
60056
|
+
scheduleFlush(t);
|
|
60057
|
+
});
|
|
60058
|
+
},
|
|
60059
|
+
async turnEnded(ctx) {
|
|
60060
|
+
const t = turn;
|
|
60061
|
+
turn = newTurn();
|
|
60062
|
+
stopTimer(t);
|
|
60063
|
+
t.ctx ??= deps.contextFor();
|
|
60064
|
+
await enqueue(t, async () => {
|
|
60065
|
+
await drain(t);
|
|
60066
|
+
if (t.executor && t.ctx) {
|
|
60067
|
+
await t.executor.executeFlush(createFlushOp(t.ctx.sessionId, "result"), t.ctx);
|
|
60068
|
+
t.executor.closeCurrentPost(t.ctx);
|
|
60069
|
+
}
|
|
60070
|
+
if (t.queued.length > 0) {
|
|
60071
|
+
ctx.logger.warn(`tool details: ${t.queued.length} line(s) dropped, the turn has no post to thread under`);
|
|
60072
|
+
t.queued = [];
|
|
60073
|
+
}
|
|
60074
|
+
});
|
|
60075
|
+
},
|
|
60076
|
+
link: () => null,
|
|
60077
|
+
wake() {
|
|
60078
|
+
const t = turn;
|
|
60079
|
+
if (deps.holdUntilTurnEnd || t.ctx || t.queued.length === 0)
|
|
60080
|
+
return;
|
|
60081
|
+
enqueue(t, async () => {
|
|
60082
|
+
await drain(t);
|
|
60083
|
+
if (t.executor)
|
|
60084
|
+
scheduleFlush(t);
|
|
60085
|
+
}).catch(() => {
|
|
60086
|
+
return;
|
|
60087
|
+
});
|
|
60088
|
+
},
|
|
60089
|
+
reset() {
|
|
60090
|
+
const t = turn;
|
|
60091
|
+
t.dead = true;
|
|
60092
|
+
stopTimer(t);
|
|
60093
|
+
t.executor?.reset();
|
|
60094
|
+
turn = newTurn();
|
|
60095
|
+
}
|
|
60096
|
+
};
|
|
60097
|
+
}
|
|
60098
|
+
|
|
60099
|
+
// src/operations/tool-details/types.ts
|
|
60100
|
+
var noneSink = {
|
|
60101
|
+
append: async () => {
|
|
60102
|
+
return;
|
|
60103
|
+
},
|
|
60104
|
+
turnEnded: async () => {
|
|
60105
|
+
return;
|
|
60106
|
+
},
|
|
60107
|
+
link: () => null,
|
|
60108
|
+
reset: () => {
|
|
60109
|
+
return;
|
|
60110
|
+
}
|
|
60111
|
+
};
|
|
60112
|
+
|
|
59847
60113
|
// src/operations/message-manager.ts
|
|
59848
60114
|
init_types();
|
|
59849
60115
|
|
|
@@ -60009,9 +60275,66 @@ class ContentExecutor extends BaseExecutor {
|
|
|
60009
60275
|
currentPostId: null,
|
|
60010
60276
|
currentPostContent: "",
|
|
60011
60277
|
pendingContent: "",
|
|
60012
|
-
updateTimer: null
|
|
60278
|
+
updateTimer: null,
|
|
60279
|
+
header: null,
|
|
60280
|
+
headerDirty: false,
|
|
60281
|
+
headerPostId: null,
|
|
60282
|
+
headerBody: "",
|
|
60283
|
+
turnOpen: false,
|
|
60284
|
+
paragraphBreak: false
|
|
60013
60285
|
};
|
|
60014
60286
|
}
|
|
60287
|
+
setHeader(line) {
|
|
60288
|
+
if (!this.state.turnOpen) {
|
|
60289
|
+
this.state.turnOpen = true;
|
|
60290
|
+
this.state.headerPostId = this.state.currentPostId;
|
|
60291
|
+
this.state.headerBody = this.state.currentPostContent;
|
|
60292
|
+
}
|
|
60293
|
+
this.state.header = line;
|
|
60294
|
+
this.state.headerDirty = true;
|
|
60295
|
+
}
|
|
60296
|
+
getHeaderPostId() {
|
|
60297
|
+
return this.state.headerPostId;
|
|
60298
|
+
}
|
|
60299
|
+
abandonHeaderTurn() {
|
|
60300
|
+
this.state.header = null;
|
|
60301
|
+
this.state.headerDirty = false;
|
|
60302
|
+
this.state.headerPostId = null;
|
|
60303
|
+
this.state.headerBody = "";
|
|
60304
|
+
this.state.turnOpen = false;
|
|
60305
|
+
}
|
|
60306
|
+
getRenderedCurrentPost() {
|
|
60307
|
+
const postId = this.state.currentPostId;
|
|
60308
|
+
if (!postId)
|
|
60309
|
+
return null;
|
|
60310
|
+
return { postId, text: this.renderFor(postId, this.state.currentPostContent) };
|
|
60311
|
+
}
|
|
60312
|
+
renderFor(postId, body) {
|
|
60313
|
+
if (this.state.header && postId !== null && postId === this.state.headerPostId) {
|
|
60314
|
+
return body ? `${this.state.header}
|
|
60315
|
+
|
|
60316
|
+
${body}` : this.state.header;
|
|
60317
|
+
}
|
|
60318
|
+
return body;
|
|
60319
|
+
}
|
|
60320
|
+
headerReserve(postId) {
|
|
60321
|
+
const header = this.state.header;
|
|
60322
|
+
if (!header)
|
|
60323
|
+
return 0;
|
|
60324
|
+
const applies = postId === this.state.headerPostId || postId === null && this.state.headerPostId === null && this.state.turnOpen;
|
|
60325
|
+
return applies ? header.length + 2 : 0;
|
|
60326
|
+
}
|
|
60327
|
+
async renderHeaderOnly(ctx) {
|
|
60328
|
+
this.state.headerDirty = false;
|
|
60329
|
+
if (this.state.headerPostId) {
|
|
60330
|
+
const postId = this.state.headerPostId;
|
|
60331
|
+
await this.tryUpdatePost(ctx, postId, this.state.headerBody, "header", { reason: "header_update", headerLength: this.state.header?.length ?? 0 }, { reason: "header_update_failed" }, () => {}, () => {});
|
|
60332
|
+
return;
|
|
60333
|
+
}
|
|
60334
|
+
if (this.state.header && this.state.turnOpen) {
|
|
60335
|
+
await this.createNewPost(ctx, "", "");
|
|
60336
|
+
}
|
|
60337
|
+
}
|
|
60015
60338
|
getInitialState() {
|
|
60016
60339
|
return ContentExecutor.createInitialState();
|
|
60017
60340
|
}
|
|
@@ -60023,7 +60346,11 @@ class ContentExecutor extends BaseExecutor {
|
|
|
60023
60346
|
}
|
|
60024
60347
|
async tryUpdatePost(ctx, postId, content, logTag, successDetails, failureDetails, onSuccess, onFailure) {
|
|
60025
60348
|
try {
|
|
60026
|
-
await ctx.platform.updatePost(postId, content);
|
|
60349
|
+
await ctx.platform.updatePost(postId, this.renderFor(postId, content));
|
|
60350
|
+
if (postId === this.state.headerPostId) {
|
|
60351
|
+
this.state.headerBody = content;
|
|
60352
|
+
this.state.headerDirty = false;
|
|
60353
|
+
}
|
|
60027
60354
|
onSuccess();
|
|
60028
60355
|
ctx.threadLogger?.logExecutor("content", "update", postId, successDetails, logTag);
|
|
60029
60356
|
} catch (err) {
|
|
@@ -60045,8 +60372,14 @@ class ContentExecutor extends BaseExecutor {
|
|
|
60045
60372
|
}, "closeCurrentPost");
|
|
60046
60373
|
}
|
|
60047
60374
|
}
|
|
60375
|
+
markParagraphBreak() {
|
|
60376
|
+
this.state.paragraphBreak = true;
|
|
60377
|
+
}
|
|
60048
60378
|
async executeAppend(op, _ctx) {
|
|
60049
|
-
|
|
60379
|
+
const breakBefore = this.state.paragraphBreak && !op.isToolOutput;
|
|
60380
|
+
if (!op.isToolOutput)
|
|
60381
|
+
this.state.paragraphBreak = false;
|
|
60382
|
+
if ((op.isToolOutput || breakBefore) && this.state.pendingContent.length > 0) {
|
|
60050
60383
|
if (!this.state.pendingContent.endsWith(`
|
|
60051
60384
|
|
|
60052
60385
|
`)) {
|
|
@@ -60079,10 +60412,21 @@ class ContentExecutor extends BaseExecutor {
|
|
|
60079
60412
|
this.flush(ctx, "soft_threshold");
|
|
60080
60413
|
}, delayMs);
|
|
60081
60414
|
}
|
|
60082
|
-
async flush(ctx,
|
|
60415
|
+
async flush(ctx, reason) {
|
|
60083
60416
|
if (!this.state.pendingContent.trim()) {
|
|
60417
|
+
if (this.state.headerDirty)
|
|
60418
|
+
await this.renderHeaderOnly(ctx);
|
|
60419
|
+
if (reason === "result")
|
|
60420
|
+
this.state.turnOpen = false;
|
|
60084
60421
|
return;
|
|
60085
60422
|
}
|
|
60423
|
+
await this.flushPending(ctx);
|
|
60424
|
+
if (this.state.headerDirty)
|
|
60425
|
+
await this.renderHeaderOnly(ctx);
|
|
60426
|
+
if (reason === "result")
|
|
60427
|
+
this.state.turnOpen = false;
|
|
60428
|
+
}
|
|
60429
|
+
async flushPending(ctx) {
|
|
60086
60430
|
const pendingAtFlushStart = this.state.pendingContent;
|
|
60087
60431
|
let content = ctx.formatter.formatMarkdown(pendingAtFlushStart).trim();
|
|
60088
60432
|
const { maxLength: MAX_POST_LENGTH, hardThreshold: HARD_CONTINUATION_THRESHOLD } = ctx.platform.getMessageLimits();
|
|
@@ -60098,13 +60442,14 @@ class ContentExecutor extends BaseExecutor {
|
|
|
60098
60442
|
combinedContent = content;
|
|
60099
60443
|
}
|
|
60100
60444
|
const shouldBreakEarly = this.state.currentPostId && combinedContent.length > MIN_BREAK_THRESHOLD && ctx.contentBreaker.shouldFlushEarly(combinedContent);
|
|
60101
|
-
|
|
60445
|
+
const reserve = this.headerReserve(this.state.currentPostId);
|
|
60446
|
+
if (this.state.currentPostId && (combinedContent.length + reserve > HARD_CONTINUATION_THRESHOLD || shouldBreakEarly)) {
|
|
60102
60447
|
await this.handleSplit(ctx, combinedContent, pendingAtFlushStart, HARD_CONTINUATION_THRESHOLD);
|
|
60103
60448
|
return;
|
|
60104
60449
|
}
|
|
60105
|
-
if (content.length > MAX_POST_LENGTH) {
|
|
60450
|
+
if (this.state.currentPostId && content.length + reserve > MAX_POST_LENGTH) {
|
|
60106
60451
|
ctx.logger.warn(`Content too long (${content.length}), truncating`);
|
|
60107
|
-
content = truncateMessageSafely(content, MAX_POST_LENGTH, ctx.formatter.formatItalic("... (truncated)"));
|
|
60452
|
+
content = truncateMessageSafely(content, MAX_POST_LENGTH - reserve, ctx.formatter.formatItalic("... (truncated)"));
|
|
60108
60453
|
}
|
|
60109
60454
|
if (this.state.currentPostId) {
|
|
60110
60455
|
const postId = this.state.currentPostId;
|
|
@@ -60119,7 +60464,7 @@ class ContentExecutor extends BaseExecutor {
|
|
|
60119
60464
|
} else {
|
|
60120
60465
|
combinedContent = content;
|
|
60121
60466
|
}
|
|
60122
|
-
if (combinedContent.length > MAX_POST_LENGTH) {
|
|
60467
|
+
if (combinedContent.length + reserve > MAX_POST_LENGTH) {
|
|
60123
60468
|
ctx.logger.debug(`Combined content (${combinedContent.length}) would exceed max (${MAX_POST_LENGTH}), creating continuation post`);
|
|
60124
60469
|
ctx.threadLogger?.logExecutor("content", "create_start", "none", {
|
|
60125
60470
|
contentLength: content.length,
|
|
@@ -60139,14 +60484,28 @@ class ContentExecutor extends BaseExecutor {
|
|
|
60139
60484
|
this.state.currentPostContent = "";
|
|
60140
60485
|
});
|
|
60141
60486
|
} else {
|
|
60142
|
-
const chunks = splitContentForHeight(content, ctx.contentBreaker);
|
|
60487
|
+
const chunks = splitContentForHeight(content, ctx.contentBreaker).flatMap((chunk) => chunk.length + reserve > MAX_POST_LENGTH ? splitByLength(chunk, HARD_CONTINUATION_THRESHOLD - reserve) : [chunk]);
|
|
60143
60488
|
ctx.threadLogger?.logExecutor("content", "create_start", "none", {
|
|
60144
60489
|
contentLength: content.length,
|
|
60145
60490
|
chunkCount: chunks.length,
|
|
60146
60491
|
reason: "no_currentPostId"
|
|
60147
60492
|
}, "flush");
|
|
60493
|
+
let lastPosted = null;
|
|
60148
60494
|
for (let i = 0;i < chunks.length; i++) {
|
|
60149
|
-
await this.createNewPost(ctx, chunks[i], pendingAtFlushStart);
|
|
60495
|
+
const created = await this.createNewPost(ctx, chunks[i], lastPosted ? "" : pendingAtFlushStart);
|
|
60496
|
+
if (!created) {
|
|
60497
|
+
if (lastPosted) {
|
|
60498
|
+
this.state.pendingContent = chunks.slice(i).join(`
|
|
60499
|
+
|
|
60500
|
+
`) + (this.state.pendingContent ? `
|
|
60501
|
+
|
|
60502
|
+
${this.state.pendingContent}` : "");
|
|
60503
|
+
}
|
|
60504
|
+
this.state.currentPostId = null;
|
|
60505
|
+
this.state.currentPostContent = "";
|
|
60506
|
+
break;
|
|
60507
|
+
}
|
|
60508
|
+
lastPosted = { id: this.state.currentPostId, content: this.state.currentPostContent };
|
|
60150
60509
|
if (i < chunks.length - 1) {
|
|
60151
60510
|
this.state.currentPostId = null;
|
|
60152
60511
|
this.state.currentPostContent = "";
|
|
@@ -60245,7 +60604,11 @@ class ContentExecutor extends BaseExecutor {
|
|
|
60245
60604
|
const remainder = content.substring(breakPoint).trim();
|
|
60246
60605
|
if (this.state.currentPostId) {
|
|
60247
60606
|
const postId = this.state.currentPostId;
|
|
60248
|
-
await this.tryUpdatePost(ctx, postId, firstPart, "handleSplit", { reason: "split_first_part", firstPartLength: firstPart.length, remainderLength: remainder.length }, { reason: "split_first_part_failed" }, () => {}, () => {
|
|
60607
|
+
await this.tryUpdatePost(ctx, postId, firstPart, "handleSplit", { reason: "split_first_part", firstPartLength: firstPart.length, remainderLength: remainder.length }, { reason: "split_first_part_failed" }, () => {}, () => {
|
|
60608
|
+
if (postId === this.state.headerPostId) {
|
|
60609
|
+
this.state.headerBody = firstPart;
|
|
60610
|
+
}
|
|
60611
|
+
});
|
|
60249
60612
|
}
|
|
60250
60613
|
this.state.currentPostId = null;
|
|
60251
60614
|
this.state.currentPostContent = "";
|
|
@@ -60254,9 +60617,22 @@ class ContentExecutor extends BaseExecutor {
|
|
|
60254
60617
|
}
|
|
60255
60618
|
}
|
|
60256
60619
|
async createNewPost(ctx, content, pendingAtFlushStart) {
|
|
60620
|
+
const header = this.state.header;
|
|
60621
|
+
const becomesHeaderPost = this.state.turnOpen && this.state.headerPostId === null && header !== null;
|
|
60622
|
+
const rendered = becomesHeaderPost && header !== null ? content ? `${header}
|
|
60623
|
+
|
|
60624
|
+
${content}` : header : content;
|
|
60625
|
+
const adoptAsHeaderPost = (postId) => {
|
|
60626
|
+
if (becomesHeaderPost) {
|
|
60627
|
+
this.state.headerPostId = postId;
|
|
60628
|
+
this.state.headerBody = content;
|
|
60629
|
+
this.state.headerDirty = false;
|
|
60630
|
+
}
|
|
60631
|
+
};
|
|
60257
60632
|
if (this.onBumpTaskList) {
|
|
60258
|
-
const bumpedPostId = await this.onBumpTaskList(
|
|
60633
|
+
const bumpedPostId = await this.onBumpTaskList(rendered, ctx);
|
|
60259
60634
|
if (bumpedPostId) {
|
|
60635
|
+
adoptAsHeaderPost(bumpedPostId);
|
|
60260
60636
|
this.state.currentPostId = bumpedPostId;
|
|
60261
60637
|
this.state.currentPostContent = content;
|
|
60262
60638
|
this.clearFlushedContent(pendingAtFlushStart);
|
|
@@ -60267,11 +60643,12 @@ class ContentExecutor extends BaseExecutor {
|
|
|
60267
60643
|
if (this.onBumpTaskListToBottom) {
|
|
60268
60644
|
await this.onBumpTaskListToBottom();
|
|
60269
60645
|
}
|
|
60270
|
-
return;
|
|
60646
|
+
return true;
|
|
60271
60647
|
}
|
|
60272
60648
|
}
|
|
60273
60649
|
try {
|
|
60274
|
-
const post = await ctx.createPost(
|
|
60650
|
+
const post = await ctx.createPost(rendered, { type: "content" });
|
|
60651
|
+
adoptAsHeaderPost(post.id);
|
|
60275
60652
|
this.state.currentPostId = post.id;
|
|
60276
60653
|
this.state.currentPostContent = content;
|
|
60277
60654
|
this.clearFlushedContent(pendingAtFlushStart);
|
|
@@ -60283,8 +60660,10 @@ class ContentExecutor extends BaseExecutor {
|
|
|
60283
60660
|
if (this.onBumpTaskListToBottom) {
|
|
60284
60661
|
await this.onBumpTaskListToBottom();
|
|
60285
60662
|
}
|
|
60663
|
+
return true;
|
|
60286
60664
|
} catch (err) {
|
|
60287
60665
|
ctx.logger.error(`Failed to create post: ${err}`);
|
|
60666
|
+
return false;
|
|
60288
60667
|
}
|
|
60289
60668
|
}
|
|
60290
60669
|
clearFlushedContent(flushedContent) {
|
|
@@ -60295,6 +60674,40 @@ class ContentExecutor extends BaseExecutor {
|
|
|
60295
60674
|
}
|
|
60296
60675
|
}
|
|
60297
60676
|
}
|
|
60677
|
+
var FENCE_LINE = /^```.*$/gm;
|
|
60678
|
+
var FENCE_CLOSE = "\n```";
|
|
60679
|
+
function splitByLength(text, max) {
|
|
60680
|
+
const room = max - FENCE_CLOSE.length;
|
|
60681
|
+
if (room < 1)
|
|
60682
|
+
return [text];
|
|
60683
|
+
const pieces = [];
|
|
60684
|
+
let rest = text;
|
|
60685
|
+
while (rest.length > max) {
|
|
60686
|
+
let cut = rest.lastIndexOf(`
|
|
60687
|
+
`, room);
|
|
60688
|
+
if (cut < room * 0.5)
|
|
60689
|
+
cut = room;
|
|
60690
|
+
let piece = rest.slice(0, cut);
|
|
60691
|
+
let next = rest.slice(cut).replace(/^\n+/, "");
|
|
60692
|
+
const fences = piece.match(FENCE_LINE) ?? [];
|
|
60693
|
+
const opener = fences[fences.length - 1];
|
|
60694
|
+
if (fences.length % 2 === 1 && opener !== undefined && `${opener}
|
|
60695
|
+
${next}`.length < rest.length) {
|
|
60696
|
+
piece += FENCE_CLOSE;
|
|
60697
|
+
next = `${opener}
|
|
60698
|
+
${next}`;
|
|
60699
|
+
}
|
|
60700
|
+
pieces.push(piece);
|
|
60701
|
+
if (next.length >= rest.length) {
|
|
60702
|
+
rest = next;
|
|
60703
|
+
break;
|
|
60704
|
+
}
|
|
60705
|
+
rest = next;
|
|
60706
|
+
}
|
|
60707
|
+
if (rest)
|
|
60708
|
+
pieces.push(rest);
|
|
60709
|
+
return pieces;
|
|
60710
|
+
}
|
|
60298
60711
|
// src/operations/executors/task-list.ts
|
|
60299
60712
|
init_emoji();
|
|
60300
60713
|
class TaskListExecutor extends BaseExecutor {
|
|
@@ -61806,6 +62219,8 @@ class MessageManager {
|
|
|
61806
62219
|
platform;
|
|
61807
62220
|
postTracker;
|
|
61808
62221
|
contentBreaker;
|
|
62222
|
+
toolActivity;
|
|
62223
|
+
toolActivityExecutor = null;
|
|
61809
62224
|
turnMarker;
|
|
61810
62225
|
flushInFlight = null;
|
|
61811
62226
|
turn = 0;
|
|
@@ -61897,6 +62312,39 @@ class MessageManager {
|
|
|
61897
62312
|
updateLastMessage: options.updateLastMessage,
|
|
61898
62313
|
events: this.events
|
|
61899
62314
|
});
|
|
62315
|
+
this.toolActivity = options.toolActivity ?? { activity: "full", details: "none" };
|
|
62316
|
+
if (this.toolActivity.activity !== "full") {
|
|
62317
|
+
const sink = this.toolActivity.details === "thread" ? createThreadSink({
|
|
62318
|
+
contextFor: () => this.toolDetailsContext(),
|
|
62319
|
+
holdUntilTurnEnd: !isDcmThreadId(this.threadId),
|
|
62320
|
+
makeExecutor: () => new ContentExecutor({ registerPost: options.registerPost, updateLastMessage: () => {
|
|
62321
|
+
return;
|
|
62322
|
+
} })
|
|
62323
|
+
}) : noneSink;
|
|
62324
|
+
this.toolActivityExecutor = new ToolActivityExecutor({
|
|
62325
|
+
mode: this.toolActivity.activity,
|
|
62326
|
+
sink,
|
|
62327
|
+
onHeader: (line) => {
|
|
62328
|
+
this.contentExecutor.setHeader(line);
|
|
62329
|
+
this.scheduleFlush(this.getExecutorContext());
|
|
62330
|
+
}
|
|
62331
|
+
});
|
|
62332
|
+
}
|
|
62333
|
+
}
|
|
62334
|
+
toolDetailsContext() {
|
|
62335
|
+
const replyPost = this.contentExecutor.getHeaderPostId();
|
|
62336
|
+
if (!replyPost)
|
|
62337
|
+
return null;
|
|
62338
|
+
const root = isDcmThreadId(this.threadId) ? replyPost : this.threadId;
|
|
62339
|
+
const base = this.getExecutorContext();
|
|
62340
|
+
return {
|
|
62341
|
+
...base,
|
|
62342
|
+
createPost: async (content, options) => {
|
|
62343
|
+
const post = await this.platform.createPost(content, root);
|
|
62344
|
+
this.registerPost(post.id, { ...options, type: "tool_details" });
|
|
62345
|
+
return post;
|
|
62346
|
+
}
|
|
62347
|
+
};
|
|
61900
62348
|
}
|
|
61901
62349
|
async handleEvent(event) {
|
|
61902
62350
|
const logger = log16.forSession(this.sessionId);
|
|
@@ -61906,6 +62354,7 @@ class MessageManager {
|
|
|
61906
62354
|
toolStartTimes: this.toolStartTimes,
|
|
61907
62355
|
taskTracker: this.taskTracker,
|
|
61908
62356
|
detailed: true,
|
|
62357
|
+
toolActivity: this.toolActivity.activity,
|
|
61909
62358
|
worktreeInfo: this.worktreePath && this.worktreeBranch ? { path: this.worktreePath, branch: this.worktreeBranch } : undefined
|
|
61910
62359
|
};
|
|
61911
62360
|
const ops = transformEvent(event, transformCtx);
|
|
@@ -61958,6 +62407,10 @@ class MessageManager {
|
|
|
61958
62407
|
try {
|
|
61959
62408
|
if (isContentOp(op)) {
|
|
61960
62409
|
await this.handleContentOp(op, ctx);
|
|
62410
|
+
} else if (isToolActivityOp(op)) {
|
|
62411
|
+
if (op.kind === "start")
|
|
62412
|
+
this.contentExecutor.markParagraphBreak();
|
|
62413
|
+
await this.toolActivityExecutor?.execute(op, ctx);
|
|
61961
62414
|
} else if (isFlushOp(op)) {
|
|
61962
62415
|
await this.handleFlushOp(op, ctx);
|
|
61963
62416
|
} else if (isTaskListOp(op)) {
|
|
@@ -61997,25 +62450,26 @@ class MessageManager {
|
|
|
61997
62450
|
await this.flushInFlight.catch(() => {
|
|
61998
62451
|
return;
|
|
61999
62452
|
});
|
|
62000
|
-
|
|
62001
|
-
this.turn++;
|
|
62453
|
+
const turn = op.reason === "result" ? ++this.turn : this.turn;
|
|
62002
62454
|
await this.runTrackedFlush(op, ctx);
|
|
62003
62455
|
if (op.reason === "result") {
|
|
62004
|
-
await this.
|
|
62456
|
+
await this.toolActivityExecutor?.afterResultFlush(ctx);
|
|
62457
|
+
await this.markTurnComplete(ctx, op.resultOk !== false, turn);
|
|
62005
62458
|
}
|
|
62006
62459
|
}
|
|
62007
|
-
async markTurnComplete(ctx, ok) {
|
|
62460
|
+
async markTurnComplete(ctx, ok, turn) {
|
|
62008
62461
|
if (this.turnMarker.mode === "off")
|
|
62009
62462
|
return;
|
|
62010
|
-
const
|
|
62011
|
-
if (!
|
|
62463
|
+
const current = this.contentExecutor.getRenderedCurrentPost();
|
|
62464
|
+
if (!current)
|
|
62012
62465
|
return;
|
|
62466
|
+
const { postId: currentPostId, text: currentPostText } = current;
|
|
62013
62467
|
try {
|
|
62014
62468
|
if (this.turnMarker.mode === "metadata") {
|
|
62015
|
-
await this.platform.updatePost(currentPostId,
|
|
62469
|
+
await this.platform.updatePost(currentPostId, currentPostText, {
|
|
62016
62470
|
metadata: {
|
|
62017
62471
|
event_type: TURN_COMPLETE_EVENT_TYPE,
|
|
62018
|
-
event_payload: { v: TURN_COMPLETE_PAYLOAD_VERSION, session: this.sessionId, turn
|
|
62472
|
+
event_payload: { v: TURN_COMPLETE_PAYLOAD_VERSION, session: this.sessionId, turn, ok }
|
|
62019
62473
|
}
|
|
62020
62474
|
});
|
|
62021
62475
|
} else {
|
|
@@ -62035,6 +62489,7 @@ class MessageManager {
|
|
|
62035
62489
|
});
|
|
62036
62490
|
this.flushInFlight = running;
|
|
62037
62491
|
await running;
|
|
62492
|
+
this.toolActivityExecutor?.wake();
|
|
62038
62493
|
}
|
|
62039
62494
|
scheduleFlush(ctx) {
|
|
62040
62495
|
if (this.flushTimer)
|
|
@@ -62431,15 +62886,21 @@ class MessageManager {
|
|
|
62431
62886
|
this.pendingBridgeQuestion = null;
|
|
62432
62887
|
}
|
|
62433
62888
|
clearClaudeSessionState() {
|
|
62889
|
+
this.clearTurnState();
|
|
62434
62890
|
this.toolStartTimes.clear();
|
|
62435
62891
|
this.taskTracker.clear();
|
|
62436
62892
|
this.denyPendingBridgeRequests("Claude was restarted before a decision was made");
|
|
62437
62893
|
}
|
|
62894
|
+
clearTurnState() {
|
|
62895
|
+
this.toolActivityExecutor?.reset();
|
|
62896
|
+
this.contentExecutor.abandonHeaderTurn();
|
|
62897
|
+
}
|
|
62438
62898
|
reset() {
|
|
62439
62899
|
this.cancelScheduledFlush();
|
|
62440
62900
|
this.toolStartTimes.clear();
|
|
62441
62901
|
this.taskTracker.clear();
|
|
62442
62902
|
this.contentExecutor.reset();
|
|
62903
|
+
this.toolActivityExecutor?.reset();
|
|
62443
62904
|
this.taskListExecutor.reset();
|
|
62444
62905
|
this.questionApprovalExecutor.reset();
|
|
62445
62906
|
this.messageApprovalExecutor.reset();
|
|
@@ -65782,6 +66243,7 @@ async function restartClaudeSession(session, cliOptions, ctx, actionName) {
|
|
|
65782
66243
|
await session.claude.kill();
|
|
65783
66244
|
await ctx.ops.flush(session);
|
|
65784
66245
|
session.messageManager?.denyPendingBridgeRequests("Claude was restarted before a decision was made");
|
|
66246
|
+
session.messageManager?.clearTurnState();
|
|
65785
66247
|
if (!cliOptions.resume) {
|
|
65786
66248
|
session.messageManager?.clearClaudeSessionState();
|
|
65787
66249
|
}
|
|
@@ -67301,6 +67763,7 @@ function createMessageManager(session, ctx) {
|
|
|
67301
67763
|
sessionId: session.sessionId,
|
|
67302
67764
|
worktreePath: session.worktreeInfo?.worktreePath,
|
|
67303
67765
|
worktreeBranch: session.worktreeInfo?.branch,
|
|
67766
|
+
toolActivity: ctx.ops.getPlatformOverhead(session.platformId).tools,
|
|
67304
67767
|
turnMarker: ctx.ops.getPlatformOverhead(session.platformId).turnMarker,
|
|
67305
67768
|
registerPost: (postId, options) => {
|
|
67306
67769
|
ctx.ops.registerPost(postId, session.threadId);
|
|
@@ -72954,7 +73417,8 @@ class SessionManager extends EventEmitter4 {
|
|
|
72954
73417
|
sessionHeader: options?.overhead?.sessionHeader ?? DEFAULT_OVERHEAD_VISIBILITY,
|
|
72955
73418
|
stickyMessage: options?.overhead?.stickyMessage ?? DEFAULT_OVERHEAD_VISIBILITY,
|
|
72956
73419
|
lifecycle: options?.overhead?.lifecycle ?? DEFAULT_OVERHEAD_VISIBILITY,
|
|
72957
|
-
turnMarker: options?.overhead?.turnMarker ?? DEFAULT_TURN_MARKER
|
|
73420
|
+
turnMarker: options?.overhead?.turnMarker ?? DEFAULT_TURN_MARKER,
|
|
73421
|
+
tools: options?.overhead?.tools ?? DEFAULT_TOOL_ACTIVITY
|
|
72958
73422
|
});
|
|
72959
73423
|
this.platformMemory.set(platformId, options?.memory ?? DEFAULT_MEMORY_CONFIG);
|
|
72960
73424
|
this.platformRoutines.set(platformId, options?.routinesEnabled ?? true);
|
|
@@ -73094,7 +73558,8 @@ class SessionManager extends EventEmitter4 {
|
|
|
73094
73558
|
sessionHeader: DEFAULT_OVERHEAD_VISIBILITY,
|
|
73095
73559
|
stickyMessage: DEFAULT_OVERHEAD_VISIBILITY,
|
|
73096
73560
|
lifecycle: DEFAULT_OVERHEAD_VISIBILITY,
|
|
73097
|
-
turnMarker: DEFAULT_TURN_MARKER
|
|
73561
|
+
turnMarker: DEFAULT_TURN_MARKER,
|
|
73562
|
+
tools: DEFAULT_TOOL_ACTIVITY
|
|
73098
73563
|
},
|
|
73099
73564
|
getPlatformMemoryConfig: (pid) => this.platformMemory.get(pid) ?? DEFAULT_MEMORY_CONFIG,
|
|
73100
73565
|
isRoutinesEnabled: (pid) => this.platformRoutines.get(pid) ?? true,
|
|
@@ -87519,7 +87984,8 @@ async function startWithoutDaemon() {
|
|
|
87519
87984
|
session.addPlatform(platformConfig.id, client, {
|
|
87520
87985
|
overhead: {
|
|
87521
87986
|
...resolvePresentationOverhead(platformConfig, `platforms[${platformConfig.id}]`),
|
|
87522
|
-
turnMarker: resolveTurnMarker(platformConfig.turnMarker, platformConfig.turnMarkerEmoji, platformConfig.type, `platforms[${platformConfig.id}]`)
|
|
87987
|
+
turnMarker: resolveTurnMarker(platformConfig.turnMarker, platformConfig.turnMarkerEmoji, platformConfig.type, `platforms[${platformConfig.id}]`),
|
|
87988
|
+
tools: resolveToolActivity(platformConfig.toolActivity, platformConfig.toolDetails, `platforms[${platformConfig.id}]`)
|
|
87523
87989
|
},
|
|
87524
87990
|
memory: resolveMemoryConfig(platformConfig.memory, `platforms[${platformConfig.id}].memory`),
|
|
87525
87991
|
routinesEnabled: resolveRoutinesEnabled(platformConfig.routines, `platforms[${platformConfig.id}].routines`),
|
|
@@ -87547,6 +88013,7 @@ async function startWithoutDaemon() {
|
|
|
87547
88013
|
overhead: {
|
|
87548
88014
|
...resolvePresentationOverhead(dmConfig, `dm[${dmConfig.id}]`),
|
|
87549
88015
|
stickyMessage: "hidden",
|
|
88016
|
+
tools: resolveToolActivity(dmConfig.toolActivity, dmConfig.toolDetails, `dm[${dmConfig.id}]`),
|
|
87550
88017
|
turnMarker: resolveTurnMarker(dmConfig.turnMarker, dmConfig.turnMarkerEmoji, dmConfig.type, `dm[${dmConfig.id}]`)
|
|
87551
88018
|
},
|
|
87552
88019
|
memory: resolveMemoryConfig(dmConfig.memory, `dm[${dmConfig.id}].memory`),
|
package/dist/mcp/mcp-server.js
CHANGED
|
@@ -34639,11 +34639,6 @@ var METADATA_STORE_PATH = path.join(stateHome(), ".claude-threads", "worktree-me
|
|
|
34639
34639
|
var log5 = createLogger("helpers");
|
|
34640
34640
|
var sessionLog = createSessionLog(log5);
|
|
34641
34641
|
|
|
34642
|
-
// src/operations/message-manager.ts
|
|
34643
|
-
init_types();
|
|
34644
|
-
|
|
34645
|
-
// src/operations/task-tracker.ts
|
|
34646
|
-
var VALID_STATUSES = new Set(["pending", "in_progress", "completed"]);
|
|
34647
34642
|
// src/platform/utils.ts
|
|
34648
34643
|
function formatWebSocketError(err) {
|
|
34649
34644
|
if (err instanceof Error)
|
|
@@ -34764,6 +34759,12 @@ function isDcmThreadId(threadId) {
|
|
|
34764
34759
|
function resolvePostThreadId(threadId) {
|
|
34765
34760
|
return isDcmThreadId(threadId) ? undefined : threadId;
|
|
34766
34761
|
}
|
|
34762
|
+
|
|
34763
|
+
// src/operations/message-manager.ts
|
|
34764
|
+
init_types();
|
|
34765
|
+
|
|
34766
|
+
// src/operations/task-tracker.ts
|
|
34767
|
+
var VALID_STATUSES = new Set(["pending", "in_progress", "completed"]);
|
|
34767
34768
|
// src/operations/executors/task-list.ts
|
|
34768
34769
|
init_emoji();
|
|
34769
34770
|
// src/operations/executors/subagent.ts
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -423,6 +423,32 @@ platforms:
|
|
|
423
423
|
|
|
424
424
|
Note: the per-platform `stickyMessage: <mode>` field is distinct from the top-level `Config.stickyMessage: { description, footer }` block, which still customizes the full sticky for platforms not in `hidden` mode.
|
|
425
425
|
|
|
426
|
+
#### Tool activity: a summary line instead of the stream
|
|
427
|
+
|
|
428
|
+
By default every tool Claude uses streams into the reply (`Bash …`, `Read …`, `↳ ✓`). For an assistant-style channel where you want the answer, not the shopping, two per-platform fields change that:
|
|
429
|
+
|
|
430
|
+
```yaml
|
|
431
|
+
platforms:
|
|
432
|
+
- id: slack-assistant
|
|
433
|
+
type: slack
|
|
434
|
+
# ... credentials ...
|
|
435
|
+
toolActivity: summary # full (default) | summary | hidden
|
|
436
|
+
toolDetails: thread # thread (default for summary) | none
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
| `toolActivity` | The reply |
|
|
440
|
+
|---|---|
|
|
441
|
+
| `full` | unchanged: every tool inline with its completion indicator |
|
|
442
|
+
| `summary` | one live line at the top of the turn's post, `🔧 12 tools · 40 s · Bash`, updated as tools run; the name is the tool most recently started, so the line shows what the bot is doing; `· 1 ❌` when a tool failed. Claude's text follows |
|
|
443
|
+
| `hidden` | nothing about tools at all |
|
|
444
|
+
|
|
445
|
+
| `toolDetails` | Where the full tool stream goes when `toolActivity` is not `full` |
|
|
446
|
+
|---|---|
|
|
447
|
+
| `thread` | posted as replies in a thread under the turn's post (in a thread-mode session, after the reply in the same thread, once the turn ends: Slack has no nested threads) |
|
|
448
|
+
| `none` | dropped |
|
|
449
|
+
|
|
450
|
+
Permission prompts, plan approvals, questions, task lists and errors post as before in every mode. `toolDetails` with `full`, or `thread` with `hidden`, is a startup config error.
|
|
451
|
+
|
|
426
452
|
#### End-of-turn marker
|
|
427
453
|
|
|
428
454
|
Integrations that read the channel (a voice front end, a phone bridge, a dashboard) need to know when Claude's answer is complete. The daemon streams by editing one post, so without help they can only guess from the text going quiet. With a marker, the daemon stamps the turn's last reply post the moment the turn ends:
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# Tool activity: summary line instead of the stream, details one click away
|
|
2
|
+
|
|
3
|
+
Upstream discussion: anneschuth/claude-threads#505 (shape proposed in the
|
|
4
|
+
2026-09-02 comment; `hidden` was agreed by the maintainer on 2026-08-25).
|
|
5
|
+
|
|
6
|
+
## What it does
|
|
7
|
+
|
|
8
|
+
Today every tool Claude uses renders into the reply post as it happens
|
|
9
|
+
(`Bash …`, `Read …`, then `↳ ✓ (5s)`), and there is no switch. For a coding
|
|
10
|
+
session that is the point; for an assistant-style channel the answer is
|
|
11
|
+
buried under the commands that produced it.
|
|
12
|
+
|
|
13
|
+
Two per-platform settings, beside `sessionHeader` / `stickyMessage` /
|
|
14
|
+
`lifecycle`:
|
|
15
|
+
|
|
16
|
+
```yaml
|
|
17
|
+
platforms:
|
|
18
|
+
- id: assistant
|
|
19
|
+
type: slack
|
|
20
|
+
toolActivity: summary # full (default) | summary | hidden
|
|
21
|
+
toolDetails: thread # thread (default with summary) | none
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`toolDetails: file`, with `toolDetailsDir` / `toolDetailsUrl`, arrives in
|
|
25
|
+
PR 2 (see **Delivery**); this PR rejects those three at startup.
|
|
26
|
+
|
|
27
|
+
| `toolActivity` | The reply post |
|
|
28
|
+
|---|---|
|
|
29
|
+
| `full` | unchanged: every tool inline with its `↳` indicator |
|
|
30
|
+
| `summary` | one **live line at the top of the turn's post**, `🔧 12 tools · 40 s · Bash`, updated as tools start and finish; the name is the tool most recently started, so the line says what the bot is doing and not only how much it has done (@thejdubb02 in #505); `· 1 ❌` appended when a tool failed; with `toolDetails: file` and a URL (PR 2), the line links to the details page. Claude's text follows as today |
|
|
31
|
+
| `hidden` | nothing about tools at all, and no `↳` orphans. What the maintainer agreed to in #505 |
|
|
32
|
+
|
|
33
|
+
| `toolDetails` | Where the full rendering goes when `toolActivity` is not `full` |
|
|
34
|
+
|---|---|
|
|
35
|
+
| `thread` | posted as replies **in a thread under the turn's post**, streamed the same way the reply is (edit-in-place, split on length). In a thread-mode session the turn's post is already a thread reply and Slack has no nested threads, so the details land in the same thread after the reply; that is `full` with the tools moved below the answer, and documented as such |
|
|
36
|
+
| `file` (PR 2) | appended to `<toolDetailsDir>/<platformId>/<sessionId>/<turn>.html`, one file per turn, plus `index.html` per session listing turns. Serving the directory is the operator's job. With `toolDetailsUrl` the summary line links to `<url>/<platformId>/<sessionId>/<turn>.html` |
|
|
37
|
+
| `none` | the summary only |
|
|
38
|
+
|
|
39
|
+
Default: `full` / `none`, so an existing config behaves exactly as before.
|
|
40
|
+
`summary` without `toolDetails` means `thread`. Config errors, thrown with
|
|
41
|
+
the field path at startup like the other per-platform fields: `toolDetails`
|
|
42
|
+
with `full`; `hidden` with `thread` (hidden has no post of its own to hang
|
|
43
|
+
a thread on; use `summary`, or — from PR 2 — `file`); `toolDetailsDir` /
|
|
44
|
+
`toolDetailsUrl` with anything but `file`.
|
|
45
|
+
|
|
46
|
+
Untouched in every mode: permission prompts, plan approvals, questions, task
|
|
47
|
+
lists, `send_file`, the bug button, session errors. Those are not tool
|
|
48
|
+
rendering; they are interactive or they are failures. A *tool* that fails is
|
|
49
|
+
Claude's business and shows only as the `❌` count.
|
|
50
|
+
|
|
51
|
+
## Why
|
|
52
|
+
|
|
53
|
+
- Assistant use (#505): replies only, but the receipt stays one click away.
|
|
54
|
+
- Anything that consumes the reply post as an answer — text-to-speech, a
|
|
55
|
+
digest, a downstream bot — has to skip forty tool lines to find it.
|
|
56
|
+
`summary` is what makes the post the answer.
|
|
57
|
+
- `file` (PR 2) over a web server outside the daemon: the daemon writes, the
|
|
58
|
+
operator's existing server serves, nothing new listens. ⚠️ Tool details
|
|
59
|
+
contain command lines and outputs. The directory must be served **behind
|
|
60
|
+
auth**; the spec says so, the README says so, and the default is no URL.
|
|
61
|
+
|
|
62
|
+
Considered and dropped: a git commit per tool call (a `Read` has no diff;
|
|
63
|
+
forty commits a turn would wreck the repo the agent works in) and a
|
|
64
|
+
`!tools` runtime toggle (round 2 if anyone asks; the config is enough to
|
|
65
|
+
start).
|
|
66
|
+
|
|
67
|
+
## How
|
|
68
|
+
|
|
69
|
+
### Transformer (`src/operations/transformer.ts`)
|
|
70
|
+
|
|
71
|
+
Tool rendering already produces ops tagged `isToolOutput` (tool_use display,
|
|
72
|
+
`server_tool_use`, and the `↳` result indicator). With `toolActivity !== 'full'`
|
|
73
|
+
the transformer emits, instead of those ops, a `tool_activity` op:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
{ type: 'tool_activity', kind: 'start', toolUseId, name, display } // display: the same rendered line
|
|
77
|
+
{ type: 'tool_activity', kind: 'end', toolUseId, ok, elapsedMs, display } // display: the ↳ line
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Nothing else in the transformer changes; special tools keep their own ops.
|
|
81
|
+
A `TransformContext.toolActivity` field carries the mode (default `full`).
|
|
82
|
+
|
|
83
|
+
### Executors
|
|
84
|
+
|
|
85
|
+
- **`ToolActivityExecutor`** (new, `src/operations/executors/tool-activity.ts`)
|
|
86
|
+
owns the per-turn counter
|
|
87
|
+
`{ started, finished, failed, firstStartAt, lastEndAt, lastTool }`
|
|
88
|
+
and renders the summary line. On each op it asks the content executor to
|
|
89
|
+
re-render the post header (debounced through the existing 500 ms flush).
|
|
90
|
+
The transformer emits an explicit `{ kind: 'turn_end' }` op from the
|
|
91
|
+
`result` event (inferring it from a flush reason would couple the executor
|
|
92
|
+
to flush internals); the last render is the final line, the sink's
|
|
93
|
+
`turnEnded()` runs, and the counter resets.
|
|
94
|
+
- **Content executor**: every write to a post goes through one
|
|
95
|
+
`renderPost(postId, body)` path (today `flush`, `handleSplit` and the
|
|
96
|
+
task-list repurpose branch each write directly; Codex plan review), which
|
|
97
|
+
prepends the header when `postId` is the turn's first post. A header
|
|
98
|
+
update with nothing pending still renders: it edits the first post, or
|
|
99
|
+
creates it header-only when the turn has no post yet. Length and height
|
|
100
|
+
decisions for the first post use `header + body`. Gains an optional
|
|
101
|
+
`header` (one line) that belongs to the **first post of the turn only**. The executor remembers
|
|
102
|
+
`turnFirstPostId` and that post's body; a header update re-renders that
|
|
103
|
+
post as `header + body` even after streaming has moved on to a
|
|
104
|
+
continuation post. Continuation posts never carry the header, so a split
|
|
105
|
+
cannot bake a stale receipt into one post and a fresh one into the next
|
|
106
|
+
(Gemini plan review). The header is not part of `pendingContent`, so
|
|
107
|
+
splitting and length checks are unchanged apart from counting its length
|
|
108
|
+
on the first post.
|
|
109
|
+
- **Details sinks** (`src/operations/tool-details/`), one interface:
|
|
110
|
+
`ToolDetailsSink { append(op): void; turnEnded(): Promise<void>; linkFor(turn): string | null }`.
|
|
111
|
+
- `thread`: a second `ContentExecutor` **without** the task-list bump
|
|
112
|
+
callbacks (they would let a details post repurpose the task-list post)
|
|
113
|
+
and with its own `createPost` that registers posts as `tool_details`
|
|
114
|
+
and does **not** touch `updateLastMessage` (a details post must never
|
|
115
|
+
become the session's "latest reply"). Its context posts with
|
|
116
|
+
`rootId = turnFirstPostId` of the main executor in direct-channel mode
|
|
117
|
+
(the reply is a channel post, the details thread hangs under it), and
|
|
118
|
+
`rootId = the session's threadId` in thread mode (Slack has no nested
|
|
119
|
+
threads; `thread_ts` of a reply is rejected with `invalid_thread_ts`), so
|
|
120
|
+
there the details land as peers after the reply. In that mode they are
|
|
121
|
+
held until the turn ends and then posted in one go, because a details
|
|
122
|
+
post made mid-turn would sit above reply posts that are still to come.
|
|
123
|
+
In direct-channel mode they have a thread of their own and stream the
|
|
124
|
+
`display` strings exactly like the reply, starting lazily on the first
|
|
125
|
+
tool of a turn; if the main post does not exist yet, the first header
|
|
126
|
+
render creates it. Each turn's details are written on a serial chain of
|
|
127
|
+
their own, so a streaming flush and the turn-end flush never overlap,
|
|
128
|
+
and the next turn's first tool never lands in the turn being closed.
|
|
129
|
+
- `file`: appends escaped `<pre>` blocks to the turn file (ANSI escape
|
|
130
|
+
sequences stripped first; tool output is full of them) and rewrites
|
|
131
|
+
`index.html`; deterministic path so the link exists from the first tool.
|
|
132
|
+
Writes are sequential per session. A write error is **reported once in
|
|
133
|
+
the channel as a system error and the sink stops for the session**; the
|
|
134
|
+
reply itself keeps streaming. Not silent, not fatal to the answer.
|
|
135
|
+
- `none`: no-op.
|
|
136
|
+
|
|
137
|
+
### Config (`src/config/types.ts`)
|
|
138
|
+
|
|
139
|
+
`PlatformInstanceConfig.toolActivity?`, `toolDetails?`, `toolDetailsDir?`,
|
|
140
|
+
`toolDetailsUrl?`; resolved next to the overhead fields into
|
|
141
|
+
`PlatformOverhead.tools: { activity, details, dir, url }` with the same
|
|
142
|
+
`resolve…` validation and the same wiring through `src/index.ts` →
|
|
143
|
+
`SessionManager` → `TransformContext` / executor options as `lifecycle`.
|
|
144
|
+
|
|
145
|
+
### Turn boundaries
|
|
146
|
+
|
|
147
|
+
A turn's counter and its details sink describe **one CLI process**. Every
|
|
148
|
+
respawn — `!cd`, a worktree switch, `!permissions interactive` — kills that
|
|
149
|
+
process mid-turn, so both must start over. `MessageManager.clearTurnState()`
|
|
150
|
+
does exactly that and `restartClaudeSession` calls it unconditionally:
|
|
151
|
+
`clearClaudeSessionState()` is not enough, because a resume restart
|
|
152
|
+
deliberately skips it to keep its task numbering.
|
|
153
|
+
|
|
154
|
+
### Delivery
|
|
155
|
+
|
|
156
|
+
Two PRs so each stands alone:
|
|
157
|
+
|
|
158
|
+
1. `pr/quiet-tools`: `full | summary | hidden`, `thread | none`, transformer
|
|
159
|
+
op, executors, config, tests. (#505's tool half.)
|
|
160
|
+
2. `pr/quiet-tools-file` stacked on it: the `file` sink and `toolDetailsUrl`.
|
|
161
|
+
|
|
162
|
+
## Tests (first)
|
|
163
|
+
|
|
164
|
+
- transformer: each mode × (tool_use, server_tool_use, tool_result ok/error,
|
|
165
|
+
special tool) → which ops come out; in both quiet modes no
|
|
166
|
+
`append_content` op carries tool text, and the start time is consumed by
|
|
167
|
+
the end op (so `elapsedMs` exists and no `↳` line can orphan).
|
|
168
|
+
- ToolActivityExecutor: counter and line text through start/end/error/turn
|
|
169
|
+
end; the line with and without a link.
|
|
170
|
+
- content executor: header stays on the first post of the turn across a split;
|
|
171
|
+
the continuation post has none; a header update after the split edits the
|
|
172
|
+
first post, not the current one; a final header set after a split still
|
|
173
|
+
reaches the header post when the closing text lands on the continuation.
|
|
174
|
+
- respawn: `restartClaudeSession` clears the turn in progress whether or not
|
|
175
|
+
it resumes, and the next turn's counter starts at one.
|
|
176
|
+
- content executor: header rendered above content, survives a split, counted
|
|
177
|
+
in length checks.
|
|
178
|
+
- thread sink: posts under the main post's id, lazy start, thread-mode
|
|
179
|
+
degrade.
|
|
180
|
+
- file sink: path, escaping, index rewrite, write error propagates.
|
|
181
|
+
- config: defaults, `summary` implies `thread`, `toolDetails` with `full`
|
|
182
|
+
rejected with the config path in the message.
|
|
183
|
+
|
|
184
|
+
## Decisions
|
|
185
|
+
|
|
186
|
+
| Decision | Why |
|
|
187
|
+
|---|---|
|
|
188
|
+
| Two fields, not one enum | what to show and where to keep the rest are independent; `summary+file` and `hidden+file` are both real |
|
|
189
|
+
| Summary line at the top of the post, not the bottom | it is the receipt for the whole turn and must not move as text streams in |
|
|
190
|
+
| Details as a second content executor | the streaming, splitting and rate behaviour already exist there; a new poster would reimplement them badly |
|
|
191
|
+
| `file` writes HTML, not markdown | the point is a browser; markdown in a browser is raw text |
|
|
192
|
+
| Keep an explicit `turn_end` op rather than finalising from the existing `StatusUpdateOp` (Codex suggested the latter) | `turn_end` is emitted *before* the final flush, so the final summary line lands in the same edit as the last text; the status op comes after the flush and would cost one more edit of the first post per turn |
|
|
193
|
+
| The header post is the post being written when the turn's first tool starts, not retroactively the turn's very first post (Codex code review asked for the latter) | if a long preamble was already split into two posts before the first tool, the receipt sits on the post where the work began and the details thread hangs there too; it never moves after adoption, which is what the reader needs. Chasing the earlier post would mean editing a post the executor has already left behind, for no gain |
|
|
194
|
+
| No HTTP server in the daemon | one more listener, auth, TLS; the operator already has a web server |
|
|
195
|
+
| Flat fields, not a nested `tools:` block (Gemini suggested nesting) | the sibling dials `sessionHeader` / `stickyMessage` / `lifecycle` are flat and `toolActivity` is the name agreed in #505; PR 1 adds two fields, the dir/url pair only comes with PR 2 |
|
|
196
|
+
| Resolved settings ride on the same per-platform record as the other dials (Gemini wanted a separate type) | one map, one wiring path, already threaded through `SessionManager`; a second type for two fields is plumbing for its own sake |
|
|
197
|
+
|
|
198
|
+
## Open
|
|
199
|
+
|
|
200
|
+
(none)
|
|
201
|
+
|
|
202
|
+
## Decided during review
|
|
203
|
+
|
|
204
|
+
- Q-001: does the summary line also name the last tool (`· Bash`)? **Yes.**
|
|
205
|
+
@thejdubb02 asked for it in #505 and the maintainer agreed in #534: once
|
|
206
|
+
the stream is hidden this line is the only liveness signal, so it has to
|
|
207
|
+
say what the bot is doing. MCP names are shortened to the tool part —
|
|
208
|
+
`mcp__playwright__browser_navigate` renders as `browser_navigate`.
|
|
209
|
+
|
|
210
|
+
## Lessons learned
|
|
211
|
+
|
|
212
|
+
- **A reset nobody calls is not a reset.** `ToolActivityExecutor.reset()`
|
|
213
|
+
existed from the first commit and had a test, but its only caller,
|
|
214
|
+
`MessageManager.reset()`, has no production caller — the respawn paths use
|
|
215
|
+
`clearClaudeSessionState()`. Tested and wired are different properties.
|
|
216
|
+
- **A one-line header on a post that a split has already left behind needs
|
|
217
|
+
its own render pass.** Every write clears `headerDirty` only when it wrote
|
|
218
|
+
the header post; a flush that writes the continuation post leaves the
|
|
219
|
+
header running forever. Checking the flag after the flush covers every
|
|
220
|
+
path, which comparing post ids at each call site would not.
|
|
221
|
+
- **Half the turn state lived somewhere else.** Resetting the tool counter
|
|
222
|
+
left `turnOpen` / `headerPostId` set on the content executor, which clear
|
|
223
|
+
only on a `result` flush — and a respawn never produces one. The next
|
|
224
|
+
turn's summary then edited the abandoned reply and its details threaded
|
|
225
|
+
under it: the *same* bug, one layer down. `abandonHeaderTurn()` releases
|
|
226
|
+
the other half.
|
|
227
|
+
- **A retry needs to know what was attempted, not what was confirmed.**
|
|
228
|
+
`headerBody` advanced only on a successful update, so a lost response left
|
|
229
|
+
it stale — and the new header re-render then overwrote the post with the
|
|
230
|
+
older body, deleting text the platform had already accepted. Since an
|
|
231
|
+
update replaces the whole post, recording the attempted body is right
|
|
232
|
+
whether or not it arrived.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-threads",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.39.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",
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
"cross-spawn": "^7.0.6",
|
|
79
79
|
"diff": "^8.0.3",
|
|
80
80
|
"express-rate-limit": "^8.3.0",
|
|
81
|
-
"hono": "4.13.
|
|
81
|
+
"hono": "4.13.8",
|
|
82
82
|
"ink": "^6.6.0",
|
|
83
83
|
"ink-scroll-view": "^0.3.5",
|
|
84
84
|
"js-yaml": "^4.3.1",
|
|
@@ -102,7 +102,7 @@
|
|
|
102
102
|
"@types/ws": "^8.18.0",
|
|
103
103
|
"eslint": "^10.7.0",
|
|
104
104
|
"husky": "^9.1.7",
|
|
105
|
-
"knip": "6.
|
|
105
|
+
"knip": "6.37.0",
|
|
106
106
|
"lint-staged": "^17.0.4",
|
|
107
107
|
"prettier": "^3.9.5",
|
|
108
108
|
"react-devtools-core": "^8.0.0",
|