claude-threads 1.38.1 → 1.39.1
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 +17 -0
- package/dist/index.js +542 -36
- 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,23 @@ 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.1] - 2026-09-25
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **A long burst or a long code block no longer overflows one post** (#617, #619). When a lot of text reached a reply that already had a post, the part that did not fit went into one new post of any size, and a code block Claude was still writing kept growing the last post without limit. Slack cut such a post off; Mattermost refused it, and the text was posted twice later. Both now split over posts that fit, with code blocks closed and reopened with their language. Normal replies below the limit are unchanged.
|
|
12
|
+
|
|
13
|
+
## [1.39.0] - 2026-09-25
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- **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.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
- **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.
|
|
20
|
+
- **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.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
- `hono` 4.13.7 → 4.13.8 (#608).
|
|
24
|
+
|
|
8
25
|
## [1.38.1] - 2026-09-24
|
|
9
26
|
|
|
10
27
|
### 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,18 +60484,57 @@ 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");
|
|
60148
|
-
|
|
60149
|
-
|
|
60150
|
-
|
|
60151
|
-
|
|
60152
|
-
|
|
60493
|
+
await this.postChunks(ctx, chunks, pendingAtFlushStart, false);
|
|
60494
|
+
}
|
|
60495
|
+
}
|
|
60496
|
+
async splitOversized(ctx, content, pendingAtFlushStart, hardThreshold) {
|
|
60497
|
+
const postId = this.state.currentPostId;
|
|
60498
|
+
if (!postId)
|
|
60499
|
+
return false;
|
|
60500
|
+
const reserve = this.headerReserve(postId);
|
|
60501
|
+
if (content.length + reserve <= ctx.platform.getMessageLimits().maxLength)
|
|
60502
|
+
return false;
|
|
60503
|
+
const [firstPiece, ...rest] = splitByLength(content, hardThreshold - reserve);
|
|
60504
|
+
let landed = false;
|
|
60505
|
+
await this.tryUpdatePost(ctx, postId, firstPiece, "handleSplit", { reason: "oversized_code_block", contentLength: content.length, pieces: rest.length + 1 }, { reason: "oversized_code_block_failed" }, () => {
|
|
60506
|
+
landed = true;
|
|
60507
|
+
}, () => {
|
|
60508
|
+
if (postId === this.state.headerPostId)
|
|
60509
|
+
this.state.headerBody = firstPiece;
|
|
60510
|
+
});
|
|
60511
|
+
this.state.currentPostId = null;
|
|
60512
|
+
this.state.currentPostContent = "";
|
|
60513
|
+
if (landed)
|
|
60514
|
+
this.clearFlushedContent(pendingAtFlushStart);
|
|
60515
|
+
await this.postChunks(ctx, rest, pendingAtFlushStart, landed);
|
|
60516
|
+
return true;
|
|
60517
|
+
}
|
|
60518
|
+
async postChunks(ctx, chunks, pendingAtFlushStart, flushCleared) {
|
|
60519
|
+
let cleared = flushCleared;
|
|
60520
|
+
for (let i = 0;i < chunks.length; i++) {
|
|
60521
|
+
const created = await this.createNewPost(ctx, chunks[i], cleared ? "" : pendingAtFlushStart);
|
|
60522
|
+
if (!created) {
|
|
60523
|
+
if (cleared) {
|
|
60524
|
+
this.state.pendingContent = chunks.slice(i).join(`
|
|
60525
|
+
|
|
60526
|
+
`) + (this.state.pendingContent ? `
|
|
60527
|
+
|
|
60528
|
+
${this.state.pendingContent}` : "");
|
|
60153
60529
|
}
|
|
60530
|
+
this.state.currentPostId = null;
|
|
60531
|
+
this.state.currentPostContent = "";
|
|
60532
|
+
return;
|
|
60533
|
+
}
|
|
60534
|
+
cleared = true;
|
|
60535
|
+
if (i < chunks.length - 1) {
|
|
60536
|
+
this.state.currentPostId = null;
|
|
60537
|
+
this.state.currentPostContent = "";
|
|
60154
60538
|
}
|
|
60155
60539
|
}
|
|
60156
60540
|
}
|
|
@@ -60211,6 +60595,8 @@ class ContentExecutor extends BaseExecutor {
|
|
|
60211
60595
|
}
|
|
60212
60596
|
if (codeBlockOpenPosition !== undefined) {
|
|
60213
60597
|
if (codeBlockOpenPosition === 0) {
|
|
60598
|
+
if (await this.splitOversized(ctx, content, pendingAtFlushStart, hardThreshold))
|
|
60599
|
+
return;
|
|
60214
60600
|
if (this.state.currentPostId) {
|
|
60215
60601
|
const postId = this.state.currentPostId;
|
|
60216
60602
|
await this.tryUpdatePost(ctx, postId, content, "handleSplit", { reason: "code_block_at_start", contentLength: content.length }, { reason: "code_block_at_start_failed" }, () => {
|
|
@@ -60228,6 +60614,8 @@ class ContentExecutor extends BaseExecutor {
|
|
|
60228
60614
|
if (breakBeforeCodeBlock > 0) {
|
|
60229
60615
|
breakPoint = breakBeforeCodeBlock;
|
|
60230
60616
|
} else {
|
|
60617
|
+
if (await this.splitOversized(ctx, content, pendingAtFlushStart, hardThreshold))
|
|
60618
|
+
return;
|
|
60231
60619
|
if (this.state.currentPostId) {
|
|
60232
60620
|
const postId = this.state.currentPostId;
|
|
60233
60621
|
await this.tryUpdatePost(ctx, postId, content, "handleSplit", { reason: "no_break_before_code_block", contentLength: content.length }, { reason: "no_break_before_code_block_failed" }, () => {
|
|
@@ -60243,20 +60631,47 @@ class ContentExecutor extends BaseExecutor {
|
|
|
60243
60631
|
}
|
|
60244
60632
|
const firstPart = content.substring(0, breakPoint).trim();
|
|
60245
60633
|
const remainder = content.substring(breakPoint).trim();
|
|
60634
|
+
let firstPartLanded = false;
|
|
60246
60635
|
if (this.state.currentPostId) {
|
|
60247
60636
|
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" }, () => {
|
|
60637
|
+
await this.tryUpdatePost(ctx, postId, firstPart, "handleSplit", { reason: "split_first_part", firstPartLength: firstPart.length, remainderLength: remainder.length }, { reason: "split_first_part_failed" }, () => {
|
|
60638
|
+
firstPartLanded = true;
|
|
60639
|
+
}, () => {
|
|
60640
|
+
if (postId === this.state.headerPostId) {
|
|
60641
|
+
this.state.headerBody = firstPart;
|
|
60642
|
+
}
|
|
60643
|
+
});
|
|
60249
60644
|
}
|
|
60250
60645
|
this.state.currentPostId = null;
|
|
60251
60646
|
this.state.currentPostContent = "";
|
|
60252
60647
|
if (remainder) {
|
|
60253
|
-
|
|
60648
|
+
const MAX_POST_LENGTH = ctx.platform.getMessageLimits().maxLength;
|
|
60649
|
+
const reserve = this.headerReserve(null);
|
|
60650
|
+
const chunks = remainder.length + reserve > MAX_POST_LENGTH ? splitByLength(remainder, hardThreshold - reserve) : [remainder];
|
|
60651
|
+
if (firstPartLanded)
|
|
60652
|
+
this.clearFlushedContent(pendingAtFlushStart);
|
|
60653
|
+
await this.postChunks(ctx, chunks, pendingAtFlushStart, firstPartLanded);
|
|
60654
|
+
} else if (firstPartLanded) {
|
|
60655
|
+
this.clearFlushedContent(pendingAtFlushStart);
|
|
60254
60656
|
}
|
|
60255
60657
|
}
|
|
60256
60658
|
async createNewPost(ctx, content, pendingAtFlushStart) {
|
|
60659
|
+
const header = this.state.header;
|
|
60660
|
+
const becomesHeaderPost = this.state.turnOpen && this.state.headerPostId === null && header !== null;
|
|
60661
|
+
const rendered = becomesHeaderPost && header !== null ? content ? `${header}
|
|
60662
|
+
|
|
60663
|
+
${content}` : header : content;
|
|
60664
|
+
const adoptAsHeaderPost = (postId) => {
|
|
60665
|
+
if (becomesHeaderPost) {
|
|
60666
|
+
this.state.headerPostId = postId;
|
|
60667
|
+
this.state.headerBody = content;
|
|
60668
|
+
this.state.headerDirty = false;
|
|
60669
|
+
}
|
|
60670
|
+
};
|
|
60257
60671
|
if (this.onBumpTaskList) {
|
|
60258
|
-
const bumpedPostId = await this.onBumpTaskList(
|
|
60672
|
+
const bumpedPostId = await this.onBumpTaskList(rendered, ctx);
|
|
60259
60673
|
if (bumpedPostId) {
|
|
60674
|
+
adoptAsHeaderPost(bumpedPostId);
|
|
60260
60675
|
this.state.currentPostId = bumpedPostId;
|
|
60261
60676
|
this.state.currentPostContent = content;
|
|
60262
60677
|
this.clearFlushedContent(pendingAtFlushStart);
|
|
@@ -60267,11 +60682,12 @@ class ContentExecutor extends BaseExecutor {
|
|
|
60267
60682
|
if (this.onBumpTaskListToBottom) {
|
|
60268
60683
|
await this.onBumpTaskListToBottom();
|
|
60269
60684
|
}
|
|
60270
|
-
return;
|
|
60685
|
+
return true;
|
|
60271
60686
|
}
|
|
60272
60687
|
}
|
|
60273
60688
|
try {
|
|
60274
|
-
const post = await ctx.createPost(
|
|
60689
|
+
const post = await ctx.createPost(rendered, { type: "content" });
|
|
60690
|
+
adoptAsHeaderPost(post.id);
|
|
60275
60691
|
this.state.currentPostId = post.id;
|
|
60276
60692
|
this.state.currentPostContent = content;
|
|
60277
60693
|
this.clearFlushedContent(pendingAtFlushStart);
|
|
@@ -60283,8 +60699,10 @@ class ContentExecutor extends BaseExecutor {
|
|
|
60283
60699
|
if (this.onBumpTaskListToBottom) {
|
|
60284
60700
|
await this.onBumpTaskListToBottom();
|
|
60285
60701
|
}
|
|
60702
|
+
return true;
|
|
60286
60703
|
} catch (err) {
|
|
60287
60704
|
ctx.logger.error(`Failed to create post: ${err}`);
|
|
60705
|
+
return false;
|
|
60288
60706
|
}
|
|
60289
60707
|
}
|
|
60290
60708
|
clearFlushedContent(flushedContent) {
|
|
@@ -60295,6 +60713,40 @@ class ContentExecutor extends BaseExecutor {
|
|
|
60295
60713
|
}
|
|
60296
60714
|
}
|
|
60297
60715
|
}
|
|
60716
|
+
var FENCE_LINE = /^```.*$/gm;
|
|
60717
|
+
var FENCE_CLOSE = "\n```";
|
|
60718
|
+
function splitByLength(text, max) {
|
|
60719
|
+
const room = max - FENCE_CLOSE.length;
|
|
60720
|
+
if (room < 1)
|
|
60721
|
+
return [text];
|
|
60722
|
+
const pieces = [];
|
|
60723
|
+
let rest = text;
|
|
60724
|
+
while (rest.length > max) {
|
|
60725
|
+
let cut = rest.lastIndexOf(`
|
|
60726
|
+
`, room);
|
|
60727
|
+
if (cut < room * 0.5)
|
|
60728
|
+
cut = room;
|
|
60729
|
+
let piece = rest.slice(0, cut);
|
|
60730
|
+
let next = rest.slice(cut).replace(/^\n+/, "");
|
|
60731
|
+
const fences = piece.match(FENCE_LINE) ?? [];
|
|
60732
|
+
const opener = fences[fences.length - 1];
|
|
60733
|
+
if (fences.length % 2 === 1 && opener !== undefined && `${opener}
|
|
60734
|
+
${next}`.length < rest.length) {
|
|
60735
|
+
piece += FENCE_CLOSE;
|
|
60736
|
+
next = `${opener}
|
|
60737
|
+
${next}`;
|
|
60738
|
+
}
|
|
60739
|
+
pieces.push(piece);
|
|
60740
|
+
if (next.length >= rest.length) {
|
|
60741
|
+
rest = next;
|
|
60742
|
+
break;
|
|
60743
|
+
}
|
|
60744
|
+
rest = next;
|
|
60745
|
+
}
|
|
60746
|
+
if (rest)
|
|
60747
|
+
pieces.push(rest);
|
|
60748
|
+
return pieces;
|
|
60749
|
+
}
|
|
60298
60750
|
// src/operations/executors/task-list.ts
|
|
60299
60751
|
init_emoji();
|
|
60300
60752
|
class TaskListExecutor extends BaseExecutor {
|
|
@@ -61806,6 +62258,8 @@ class MessageManager {
|
|
|
61806
62258
|
platform;
|
|
61807
62259
|
postTracker;
|
|
61808
62260
|
contentBreaker;
|
|
62261
|
+
toolActivity;
|
|
62262
|
+
toolActivityExecutor = null;
|
|
61809
62263
|
turnMarker;
|
|
61810
62264
|
flushInFlight = null;
|
|
61811
62265
|
turn = 0;
|
|
@@ -61897,6 +62351,39 @@ class MessageManager {
|
|
|
61897
62351
|
updateLastMessage: options.updateLastMessage,
|
|
61898
62352
|
events: this.events
|
|
61899
62353
|
});
|
|
62354
|
+
this.toolActivity = options.toolActivity ?? { activity: "full", details: "none" };
|
|
62355
|
+
if (this.toolActivity.activity !== "full") {
|
|
62356
|
+
const sink = this.toolActivity.details === "thread" ? createThreadSink({
|
|
62357
|
+
contextFor: () => this.toolDetailsContext(),
|
|
62358
|
+
holdUntilTurnEnd: !isDcmThreadId(this.threadId),
|
|
62359
|
+
makeExecutor: () => new ContentExecutor({ registerPost: options.registerPost, updateLastMessage: () => {
|
|
62360
|
+
return;
|
|
62361
|
+
} })
|
|
62362
|
+
}) : noneSink;
|
|
62363
|
+
this.toolActivityExecutor = new ToolActivityExecutor({
|
|
62364
|
+
mode: this.toolActivity.activity,
|
|
62365
|
+
sink,
|
|
62366
|
+
onHeader: (line) => {
|
|
62367
|
+
this.contentExecutor.setHeader(line);
|
|
62368
|
+
this.scheduleFlush(this.getExecutorContext());
|
|
62369
|
+
}
|
|
62370
|
+
});
|
|
62371
|
+
}
|
|
62372
|
+
}
|
|
62373
|
+
toolDetailsContext() {
|
|
62374
|
+
const replyPost = this.contentExecutor.getHeaderPostId();
|
|
62375
|
+
if (!replyPost)
|
|
62376
|
+
return null;
|
|
62377
|
+
const root = isDcmThreadId(this.threadId) ? replyPost : this.threadId;
|
|
62378
|
+
const base = this.getExecutorContext();
|
|
62379
|
+
return {
|
|
62380
|
+
...base,
|
|
62381
|
+
createPost: async (content, options) => {
|
|
62382
|
+
const post = await this.platform.createPost(content, root);
|
|
62383
|
+
this.registerPost(post.id, { ...options, type: "tool_details" });
|
|
62384
|
+
return post;
|
|
62385
|
+
}
|
|
62386
|
+
};
|
|
61900
62387
|
}
|
|
61901
62388
|
async handleEvent(event) {
|
|
61902
62389
|
const logger = log16.forSession(this.sessionId);
|
|
@@ -61906,6 +62393,7 @@ class MessageManager {
|
|
|
61906
62393
|
toolStartTimes: this.toolStartTimes,
|
|
61907
62394
|
taskTracker: this.taskTracker,
|
|
61908
62395
|
detailed: true,
|
|
62396
|
+
toolActivity: this.toolActivity.activity,
|
|
61909
62397
|
worktreeInfo: this.worktreePath && this.worktreeBranch ? { path: this.worktreePath, branch: this.worktreeBranch } : undefined
|
|
61910
62398
|
};
|
|
61911
62399
|
const ops = transformEvent(event, transformCtx);
|
|
@@ -61958,6 +62446,10 @@ class MessageManager {
|
|
|
61958
62446
|
try {
|
|
61959
62447
|
if (isContentOp(op)) {
|
|
61960
62448
|
await this.handleContentOp(op, ctx);
|
|
62449
|
+
} else if (isToolActivityOp(op)) {
|
|
62450
|
+
if (op.kind === "start")
|
|
62451
|
+
this.contentExecutor.markParagraphBreak();
|
|
62452
|
+
await this.toolActivityExecutor?.execute(op, ctx);
|
|
61961
62453
|
} else if (isFlushOp(op)) {
|
|
61962
62454
|
await this.handleFlushOp(op, ctx);
|
|
61963
62455
|
} else if (isTaskListOp(op)) {
|
|
@@ -61997,25 +62489,26 @@ class MessageManager {
|
|
|
61997
62489
|
await this.flushInFlight.catch(() => {
|
|
61998
62490
|
return;
|
|
61999
62491
|
});
|
|
62000
|
-
|
|
62001
|
-
this.turn++;
|
|
62492
|
+
const turn = op.reason === "result" ? ++this.turn : this.turn;
|
|
62002
62493
|
await this.runTrackedFlush(op, ctx);
|
|
62003
62494
|
if (op.reason === "result") {
|
|
62004
|
-
await this.
|
|
62495
|
+
await this.toolActivityExecutor?.afterResultFlush(ctx);
|
|
62496
|
+
await this.markTurnComplete(ctx, op.resultOk !== false, turn);
|
|
62005
62497
|
}
|
|
62006
62498
|
}
|
|
62007
|
-
async markTurnComplete(ctx, ok) {
|
|
62499
|
+
async markTurnComplete(ctx, ok, turn) {
|
|
62008
62500
|
if (this.turnMarker.mode === "off")
|
|
62009
62501
|
return;
|
|
62010
|
-
const
|
|
62011
|
-
if (!
|
|
62502
|
+
const current = this.contentExecutor.getRenderedCurrentPost();
|
|
62503
|
+
if (!current)
|
|
62012
62504
|
return;
|
|
62505
|
+
const { postId: currentPostId, text: currentPostText } = current;
|
|
62013
62506
|
try {
|
|
62014
62507
|
if (this.turnMarker.mode === "metadata") {
|
|
62015
|
-
await this.platform.updatePost(currentPostId,
|
|
62508
|
+
await this.platform.updatePost(currentPostId, currentPostText, {
|
|
62016
62509
|
metadata: {
|
|
62017
62510
|
event_type: TURN_COMPLETE_EVENT_TYPE,
|
|
62018
|
-
event_payload: { v: TURN_COMPLETE_PAYLOAD_VERSION, session: this.sessionId, turn
|
|
62511
|
+
event_payload: { v: TURN_COMPLETE_PAYLOAD_VERSION, session: this.sessionId, turn, ok }
|
|
62019
62512
|
}
|
|
62020
62513
|
});
|
|
62021
62514
|
} else {
|
|
@@ -62035,6 +62528,7 @@ class MessageManager {
|
|
|
62035
62528
|
});
|
|
62036
62529
|
this.flushInFlight = running;
|
|
62037
62530
|
await running;
|
|
62531
|
+
this.toolActivityExecutor?.wake();
|
|
62038
62532
|
}
|
|
62039
62533
|
scheduleFlush(ctx) {
|
|
62040
62534
|
if (this.flushTimer)
|
|
@@ -62431,15 +62925,21 @@ class MessageManager {
|
|
|
62431
62925
|
this.pendingBridgeQuestion = null;
|
|
62432
62926
|
}
|
|
62433
62927
|
clearClaudeSessionState() {
|
|
62928
|
+
this.clearTurnState();
|
|
62434
62929
|
this.toolStartTimes.clear();
|
|
62435
62930
|
this.taskTracker.clear();
|
|
62436
62931
|
this.denyPendingBridgeRequests("Claude was restarted before a decision was made");
|
|
62437
62932
|
}
|
|
62933
|
+
clearTurnState() {
|
|
62934
|
+
this.toolActivityExecutor?.reset();
|
|
62935
|
+
this.contentExecutor.abandonHeaderTurn();
|
|
62936
|
+
}
|
|
62438
62937
|
reset() {
|
|
62439
62938
|
this.cancelScheduledFlush();
|
|
62440
62939
|
this.toolStartTimes.clear();
|
|
62441
62940
|
this.taskTracker.clear();
|
|
62442
62941
|
this.contentExecutor.reset();
|
|
62942
|
+
this.toolActivityExecutor?.reset();
|
|
62443
62943
|
this.taskListExecutor.reset();
|
|
62444
62944
|
this.questionApprovalExecutor.reset();
|
|
62445
62945
|
this.messageApprovalExecutor.reset();
|
|
@@ -65782,6 +66282,7 @@ async function restartClaudeSession(session, cliOptions, ctx, actionName) {
|
|
|
65782
66282
|
await session.claude.kill();
|
|
65783
66283
|
await ctx.ops.flush(session);
|
|
65784
66284
|
session.messageManager?.denyPendingBridgeRequests("Claude was restarted before a decision was made");
|
|
66285
|
+
session.messageManager?.clearTurnState();
|
|
65785
66286
|
if (!cliOptions.resume) {
|
|
65786
66287
|
session.messageManager?.clearClaudeSessionState();
|
|
65787
66288
|
}
|
|
@@ -67301,6 +67802,7 @@ function createMessageManager(session, ctx) {
|
|
|
67301
67802
|
sessionId: session.sessionId,
|
|
67302
67803
|
worktreePath: session.worktreeInfo?.worktreePath,
|
|
67303
67804
|
worktreeBranch: session.worktreeInfo?.branch,
|
|
67805
|
+
toolActivity: ctx.ops.getPlatformOverhead(session.platformId).tools,
|
|
67304
67806
|
turnMarker: ctx.ops.getPlatformOverhead(session.platformId).turnMarker,
|
|
67305
67807
|
registerPost: (postId, options) => {
|
|
67306
67808
|
ctx.ops.registerPost(postId, session.threadId);
|
|
@@ -72954,7 +73456,8 @@ class SessionManager extends EventEmitter4 {
|
|
|
72954
73456
|
sessionHeader: options?.overhead?.sessionHeader ?? DEFAULT_OVERHEAD_VISIBILITY,
|
|
72955
73457
|
stickyMessage: options?.overhead?.stickyMessage ?? DEFAULT_OVERHEAD_VISIBILITY,
|
|
72956
73458
|
lifecycle: options?.overhead?.lifecycle ?? DEFAULT_OVERHEAD_VISIBILITY,
|
|
72957
|
-
turnMarker: options?.overhead?.turnMarker ?? DEFAULT_TURN_MARKER
|
|
73459
|
+
turnMarker: options?.overhead?.turnMarker ?? DEFAULT_TURN_MARKER,
|
|
73460
|
+
tools: options?.overhead?.tools ?? DEFAULT_TOOL_ACTIVITY
|
|
72958
73461
|
});
|
|
72959
73462
|
this.platformMemory.set(platformId, options?.memory ?? DEFAULT_MEMORY_CONFIG);
|
|
72960
73463
|
this.platformRoutines.set(platformId, options?.routinesEnabled ?? true);
|
|
@@ -73094,7 +73597,8 @@ class SessionManager extends EventEmitter4 {
|
|
|
73094
73597
|
sessionHeader: DEFAULT_OVERHEAD_VISIBILITY,
|
|
73095
73598
|
stickyMessage: DEFAULT_OVERHEAD_VISIBILITY,
|
|
73096
73599
|
lifecycle: DEFAULT_OVERHEAD_VISIBILITY,
|
|
73097
|
-
turnMarker: DEFAULT_TURN_MARKER
|
|
73600
|
+
turnMarker: DEFAULT_TURN_MARKER,
|
|
73601
|
+
tools: DEFAULT_TOOL_ACTIVITY
|
|
73098
73602
|
},
|
|
73099
73603
|
getPlatformMemoryConfig: (pid) => this.platformMemory.get(pid) ?? DEFAULT_MEMORY_CONFIG,
|
|
73100
73604
|
isRoutinesEnabled: (pid) => this.platformRoutines.get(pid) ?? true,
|
|
@@ -87519,7 +88023,8 @@ async function startWithoutDaemon() {
|
|
|
87519
88023
|
session.addPlatform(platformConfig.id, client, {
|
|
87520
88024
|
overhead: {
|
|
87521
88025
|
...resolvePresentationOverhead(platformConfig, `platforms[${platformConfig.id}]`),
|
|
87522
|
-
turnMarker: resolveTurnMarker(platformConfig.turnMarker, platformConfig.turnMarkerEmoji, platformConfig.type, `platforms[${platformConfig.id}]`)
|
|
88026
|
+
turnMarker: resolveTurnMarker(platformConfig.turnMarker, platformConfig.turnMarkerEmoji, platformConfig.type, `platforms[${platformConfig.id}]`),
|
|
88027
|
+
tools: resolveToolActivity(platformConfig.toolActivity, platformConfig.toolDetails, `platforms[${platformConfig.id}]`)
|
|
87523
88028
|
},
|
|
87524
88029
|
memory: resolveMemoryConfig(platformConfig.memory, `platforms[${platformConfig.id}].memory`),
|
|
87525
88030
|
routinesEnabled: resolveRoutinesEnabled(platformConfig.routines, `platforms[${platformConfig.id}].routines`),
|
|
@@ -87547,6 +88052,7 @@ async function startWithoutDaemon() {
|
|
|
87547
88052
|
overhead: {
|
|
87548
88053
|
...resolvePresentationOverhead(dmConfig, `dm[${dmConfig.id}]`),
|
|
87549
88054
|
stickyMessage: "hidden",
|
|
88055
|
+
tools: resolveToolActivity(dmConfig.toolActivity, dmConfig.toolDetails, `dm[${dmConfig.id}]`),
|
|
87550
88056
|
turnMarker: resolveTurnMarker(dmConfig.turnMarker, dmConfig.turnMarkerEmoji, dmConfig.type, `dm[${dmConfig.id}]`)
|
|
87551
88057
|
},
|
|
87552
88058
|
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.1",
|
|
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",
|