@shakudo/opencode-mattermost-control 0.3.45

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.
Files changed (69) hide show
  1. package/.opencode/command/mattermost-connect.md +5 -0
  2. package/.opencode/command/mattermost-disconnect.md +5 -0
  3. package/.opencode/command/mattermost-monitor.md +12 -0
  4. package/.opencode/command/mattermost-status.md +5 -0
  5. package/.opencode/command/speckit.analyze.md +184 -0
  6. package/.opencode/command/speckit.checklist.md +294 -0
  7. package/.opencode/command/speckit.clarify.md +181 -0
  8. package/.opencode/command/speckit.constitution.md +82 -0
  9. package/.opencode/command/speckit.implement.md +135 -0
  10. package/.opencode/command/speckit.plan.md +89 -0
  11. package/.opencode/command/speckit.specify.md +258 -0
  12. package/.opencode/command/speckit.tasks.md +137 -0
  13. package/.opencode/command/speckit.taskstoissues.md +30 -0
  14. package/.opencode/plugin/mattermost-control/event-handlers/compaction.ts +61 -0
  15. package/.opencode/plugin/mattermost-control/event-handlers/file.ts +36 -0
  16. package/.opencode/plugin/mattermost-control/event-handlers/index.ts +14 -0
  17. package/.opencode/plugin/mattermost-control/event-handlers/message.ts +124 -0
  18. package/.opencode/plugin/mattermost-control/event-handlers/permission.ts +34 -0
  19. package/.opencode/plugin/mattermost-control/event-handlers/question.ts +92 -0
  20. package/.opencode/plugin/mattermost-control/event-handlers/session.ts +100 -0
  21. package/.opencode/plugin/mattermost-control/event-handlers/todo.ts +33 -0
  22. package/.opencode/plugin/mattermost-control/event-handlers/tool.ts +76 -0
  23. package/.opencode/plugin/mattermost-control/formatters.ts +202 -0
  24. package/.opencode/plugin/mattermost-control/index.ts +964 -0
  25. package/.opencode/plugin/mattermost-control/package.json +12 -0
  26. package/.opencode/plugin/mattermost-control/state.ts +180 -0
  27. package/.opencode/plugin/mattermost-control/timers.ts +96 -0
  28. package/.opencode/plugin/mattermost-control/tools/connect.ts +563 -0
  29. package/.opencode/plugin/mattermost-control/tools/file.ts +41 -0
  30. package/.opencode/plugin/mattermost-control/tools/index.ts +12 -0
  31. package/.opencode/plugin/mattermost-control/tools/monitor.ts +183 -0
  32. package/.opencode/plugin/mattermost-control/tools/schedule.ts +253 -0
  33. package/.opencode/plugin/mattermost-control/tools/session.ts +120 -0
  34. package/.opencode/plugin/mattermost-control/types.ts +107 -0
  35. package/LICENSE +21 -0
  36. package/README.md +1280 -0
  37. package/opencode-shared +359 -0
  38. package/opencode-shared-restart +495 -0
  39. package/opencode-shared-stop +90 -0
  40. package/package.json +65 -0
  41. package/src/clients/mattermost-client.ts +221 -0
  42. package/src/clients/websocket-client.ts +199 -0
  43. package/src/command-handler.ts +1035 -0
  44. package/src/config.ts +170 -0
  45. package/src/context-builder.ts +309 -0
  46. package/src/file-completion-handler.ts +521 -0
  47. package/src/file-handler.ts +242 -0
  48. package/src/guest-approval-handler.ts +223 -0
  49. package/src/logger.ts +73 -0
  50. package/src/merge-handler.ts +335 -0
  51. package/src/message-router.ts +151 -0
  52. package/src/models/index.ts +197 -0
  53. package/src/models/routing.ts +50 -0
  54. package/src/models/thread-mapping.ts +40 -0
  55. package/src/monitor-service.ts +222 -0
  56. package/src/notification-service.ts +118 -0
  57. package/src/opencode-session-registry.ts +370 -0
  58. package/src/persistence/team-store.ts +396 -0
  59. package/src/persistence/thread-mapping-store.ts +258 -0
  60. package/src/question-handler.ts +401 -0
  61. package/src/reaction-handler.ts +111 -0
  62. package/src/response-streamer.ts +364 -0
  63. package/src/scheduler/schedule-store.ts +261 -0
  64. package/src/scheduler/scheduler-service.ts +349 -0
  65. package/src/session-manager.ts +142 -0
  66. package/src/session-ownership-handler.ts +253 -0
  67. package/src/status-indicator.ts +279 -0
  68. package/src/thread-manager.ts +231 -0
  69. package/src/todo-manager.ts +162 -0
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Permission event handler
3
+ */
4
+
5
+ import { PluginState } from "../state.js";
6
+ import { handleMonitorAlert } from "../../../../src/monitor-service.js";
7
+ import { log } from "../../../../src/logger.js";
8
+
9
+ function isScheduledTaskSession(sessionId: string): boolean {
10
+ const scheduler = PluginState.schedulerService;
11
+ return scheduler?.isRunningScheduledTask(sessionId) ?? false;
12
+ }
13
+
14
+ export async function handlePermissionAsked(event: any): Promise<void> {
15
+ const eventSessionId = event.properties?.sessionID;
16
+
17
+ // Skip permission handling for scheduled task sessions - they can't ask for permissions via DM
18
+ if (eventSessionId && isScheduledTaskSession(eventSessionId)) {
19
+ log.debug(`[ScheduledTask] Suppressing permission.asked for scheduled task session ${eventSessionId.substring(0, 8)}`);
20
+ return;
21
+ }
22
+
23
+ log.debug(`[Monitor] permission.asked event: sessionId=${eventSessionId}`);
24
+ const description = event.properties?.description || "Permission requested";
25
+ const activeSessionIds = Array.from(PluginState.activeResponseContexts.keys());
26
+ await handleMonitorAlert(eventSessionId, "permission.asked", description, activeSessionIds[0]);
27
+
28
+ if (eventSessionId && PluginState.isConnected) {
29
+ const ctx = PluginState.activeResponseContexts.get(eventSessionId);
30
+ if (ctx?.streamCtx.statusIndicator) {
31
+ await ctx.streamCtx.statusIndicator.setWaiting("permission", description);
32
+ }
33
+ }
34
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Question event handler - handles question.asked events from OpenCode
3
+ */
4
+
5
+ import { PluginState } from "../state.js";
6
+ import { formatFullResponse } from "../formatters.js";
7
+ import type { QuestionRequest } from "../../../../src/question-handler.js";
8
+ import { log } from "../../../../src/logger.js";
9
+
10
+ function isScheduledTaskSession(sessionId: string): boolean {
11
+ const scheduler = PluginState.schedulerService;
12
+ return scheduler?.isRunningScheduledTask(sessionId) ?? false;
13
+ }
14
+
15
+ export async function handleQuestionAsked(event: any): Promise<void> {
16
+ const { questionHandler, threadMappingStore, streamer, isConnected } = PluginState;
17
+
18
+ if (!questionHandler || !isConnected) return;
19
+
20
+ const props = event.properties;
21
+ const eventSessionId = props?.sessionID;
22
+
23
+ // Skip question handling for scheduled task sessions - they can't ask questions via DM
24
+ if (eventSessionId && isScheduledTaskSession(eventSessionId)) {
25
+ log.debug(`[ScheduledTask] Suppressing question.asked for scheduled task session ${eventSessionId.substring(0, 8)}`);
26
+ return;
27
+ }
28
+
29
+ log.info(`[QuestionHandler] question.asked event: sessionId=${eventSessionId}, requestId=${props?.id}`);
30
+
31
+ if (!eventSessionId || !props?.id || !props?.questions) return;
32
+
33
+ // First try thread mapping (per-session thread mode)
34
+ const mapping = threadMappingStore?.getBySessionId(eventSessionId);
35
+ let targetChannelId: string | undefined;
36
+ let targetThreadId: string | undefined;
37
+
38
+ if (mapping && mapping.status === "active") {
39
+ targetChannelId = mapping.channelId || mapping.dmChannelId;
40
+ targetThreadId = mapping.threadRootPostId;
41
+ log.debug(`[QuestionHandler] Using thread mapping: channel=${targetChannelId}, thread=${targetThreadId}`);
42
+ } else {
43
+ // Fall back to active response context (main DM thread mode)
44
+ const ctx = PluginState.activeResponseContexts.get(eventSessionId);
45
+ if (ctx && ctx.threadRootPostId) {
46
+ targetChannelId = ctx.streamCtx?.channelId || ctx.mmSession?.dmChannelId;
47
+ targetThreadId = ctx.threadRootPostId;
48
+ log.debug(`[QuestionHandler] Using active response context: channel=${targetChannelId}, thread=${targetThreadId}`);
49
+ }
50
+ }
51
+
52
+ if (!targetChannelId || !targetThreadId) {
53
+ log.debug(`[QuestionHandler] No active thread context for session ${eventSessionId}`);
54
+ return;
55
+ }
56
+
57
+ const questionRequest: QuestionRequest = {
58
+ id: props.id,
59
+ sessionID: eventSessionId,
60
+ questions: props.questions,
61
+ };
62
+
63
+ try {
64
+ const ctx = PluginState.activeResponseContexts.get(eventSessionId);
65
+ if (ctx && streamer) {
66
+ const oldContent = formatFullResponse(ctx);
67
+ const newStreamCtx = await streamer.recreateStreamAtBottom(ctx.streamCtx, oldContent);
68
+ ctx.streamCtx = newStreamCtx;
69
+ log.debug(`[QuestionHandler] Recreated stream before question, new postId=${newStreamCtx.postId}`);
70
+ }
71
+
72
+ await questionHandler.handleQuestionAsked(
73
+ questionRequest,
74
+ targetChannelId,
75
+ targetThreadId
76
+ );
77
+ log.info(`[QuestionHandler] Posted question ${props.id} to thread ${targetThreadId}`);
78
+
79
+ // Don't recreate stream after question - let the question be the last visible post
80
+ // The user needs to see and answer the question without it being buried
81
+ // The stream will resume when the question is answered
82
+ if (ctx && ctx.streamCtx.statusIndicator) {
83
+ const firstQuestion = props.questions[0];
84
+ await ctx.streamCtx.statusIndicator.setWaiting(
85
+ "question",
86
+ firstQuestion?.question || "Waiting for your answer..."
87
+ );
88
+ }
89
+ } catch (e) {
90
+ log.error(`[QuestionHandler] Failed to post question:`, e);
91
+ }
92
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Session event handlers - handles session.idle and session.status events
3
+ */
4
+
5
+ import { PluginState } from "../state.js";
6
+ import { formatFullResponse } from "../formatters.js";
7
+ import { stopActiveToolTimer, stopResponseTimer } from "../timers.js";
8
+ import { handleMonitorAlert } from "../../../../src/monitor-service.js";
9
+ import { log } from "../../../../src/logger.js";
10
+
11
+ function isScheduledTaskSession(sessionId: string): boolean {
12
+ const scheduler = PluginState.schedulerService;
13
+ return scheduler?.isRunningScheduledTask(sessionId) ?? false;
14
+ }
15
+
16
+ export async function handleSessionIdle(event: any): Promise<void> {
17
+ const eventSessionId = event.properties?.sessionID;
18
+
19
+ // Handle monitor alerts (skip for scheduled tasks - they don't need idle alerts)
20
+ log.debug(`[Monitor] session.idle event: sessionId=${eventSessionId}`);
21
+ if (eventSessionId && !isScheduledTaskSession(eventSessionId)) {
22
+ const activeSessionIds = Array.from(PluginState.activeResponseContexts.keys());
23
+ await handleMonitorAlert(eventSessionId, "session.idle", undefined, activeSessionIds[0]);
24
+ }
25
+
26
+ // Handle stream finalization
27
+ const { streamer, notifications, isConnected } = PluginState;
28
+ if (!isConnected || !streamer || !notifications) return;
29
+
30
+ if (!eventSessionId) return;
31
+
32
+ // Skip stream finalization for scheduled task sessions
33
+ if (isScheduledTaskSession(eventSessionId)) {
34
+ log.debug(`[ScheduledTask] Suppressing session.idle finalization for scheduled task session ${eventSessionId.substring(0, 8)}`);
35
+ return;
36
+ }
37
+
38
+ const ctx = PluginState.activeResponseContexts.get(eventSessionId);
39
+ if (!ctx) return;
40
+
41
+ // v0.2.70 fix: Don't finalize if we're awaiting continuation after compaction
42
+ if (ctx.awaitingContinuation) {
43
+ log.info(`[Compaction] Session ${eventSessionId.substring(0, 8)} idle but awaitingContinuation=true, skipping finalization`);
44
+ return;
45
+ }
46
+ if (ctx.inCompactionSummary) {
47
+ log.info(`[Compaction] Session ${eventSessionId.substring(0, 8)} idle but inCompactionSummary=true, skipping finalization`);
48
+ return;
49
+ }
50
+
51
+ log.info(`[MessageParts] Session ${eventSessionId.substring(0, 8)} completed: textParts=${ctx.textPartCount || 0}, reasoningParts=${ctx.reasoningPartCount || 0}, responseLen=${ctx.responseBuffer.length}, thinkingLen=${ctx.thinkingBuffer.length}, tools=${ctx.toolCalls.length}, compactions=${ctx.compactionCount}, todos=${ctx.todos.length}, cost=$${(ctx.cost.sessionTotal + ctx.cost.currentMessage).toFixed(4)}`);
52
+
53
+ try {
54
+ stopActiveToolTimer(eventSessionId);
55
+ stopResponseTimer(eventSessionId);
56
+
57
+ ctx.streamCtx.buffer = formatFullResponse(ctx);
58
+ await streamer.endStream(ctx.streamCtx);
59
+ await notifications.notifyCompletion(ctx.mmSession, "Response complete", ctx.streamCtx.threadRootPostId);
60
+ ctx.mmSession.isProcessing = false;
61
+ } catch (e) {
62
+ log.error("Error finalizing stream:", e);
63
+ }
64
+ PluginState.activeResponseContexts.delete(eventSessionId);
65
+ }
66
+
67
+ export async function handleSessionStatus(event: any): Promise<void> {
68
+ const eventSessionId = event.properties?.sessionID;
69
+ if (!eventSessionId || !PluginState.isConnected) return;
70
+
71
+ // Skip status updates for scheduled task sessions
72
+ if (isScheduledTaskSession(eventSessionId)) {
73
+ log.debug(`[ScheduledTask] Suppressing session.status for scheduled task session ${eventSessionId.substring(0, 8)}`);
74
+ return;
75
+ }
76
+
77
+ const status = event.properties?.status as { type: string; attempt?: number; maxAttempts?: number; error?: string } | undefined;
78
+ const ctx = PluginState.activeResponseContexts.get(eventSessionId);
79
+
80
+ if (!ctx?.streamCtx.statusIndicator || !status) return;
81
+
82
+ log.debug(`[StatusEvent] Session ${eventSessionId.substring(0, 8)} status: ${status.type}`);
83
+
84
+ switch (status.type) {
85
+ case "busy":
86
+ await ctx.streamCtx.statusIndicator.setProcessing();
87
+ break;
88
+ case "retry":
89
+ await ctx.streamCtx.statusIndicator.setRetrying(
90
+ status.attempt || 1,
91
+ status.maxAttempts || 3,
92
+ status.error || "Transient error",
93
+ 5000
94
+ );
95
+ break;
96
+ case "idle":
97
+ // No action needed
98
+ break;
99
+ }
100
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Todo event handler - handles todo.updated events
3
+ */
4
+
5
+ import { PluginState } from "../state.js";
6
+ import { updateResponseStream } from "../timers.js";
7
+ import { log } from "../../../../src/logger.js";
8
+
9
+ function isScheduledTaskSession(sessionId: string): boolean {
10
+ const scheduler = PluginState.schedulerService;
11
+ return scheduler?.isRunningScheduledTask(sessionId) ?? false;
12
+ }
13
+
14
+ export async function handleTodoUpdated(event: any): Promise<void> {
15
+ const sessionId = event.properties?.sessionID;
16
+ const todos = event.properties?.todos;
17
+
18
+ if (!sessionId || !todos) return;
19
+
20
+ if (isScheduledTaskSession(sessionId)) {
21
+ log.debug(`[ScheduledTask] Suppressing todo.updated for scheduled task session ${sessionId.substring(0, 8)}`);
22
+ return;
23
+ }
24
+
25
+ const completed = todos.filter((t: any) => t.status === "completed").length;
26
+ log.info(`[TodoEvent] Session ${sessionId.substring(0, 8)}: ${completed}/${todos.length} complete`);
27
+
28
+ const ctx = PluginState.activeResponseContexts.get(sessionId);
29
+ if (ctx) {
30
+ ctx.todos = todos;
31
+ await updateResponseStream(sessionId);
32
+ }
33
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Tool execution event handlers - handles tool.execute.before and tool.execute.after
3
+ */
4
+
5
+ import { PluginState } from "../state.js";
6
+ import { startActiveToolTimer, stopActiveToolTimer, updateResponseStream } from "../timers.js";
7
+ import { handleMonitorAlert } from "../../../../src/monitor-service.js";
8
+ import { log } from "../../../../src/logger.js";
9
+
10
+ function isScheduledTaskSession(sessionId: string): boolean {
11
+ const scheduler = PluginState.schedulerService;
12
+ return scheduler?.isRunningScheduledTask(sessionId) ?? false;
13
+ }
14
+
15
+ export async function handleToolExecuteBefore(input: any): Promise<void> {
16
+ if (!PluginState.isConnected) return;
17
+
18
+ const toolSessionId = input.sessionID || input.session?.id;
19
+ if (!toolSessionId) return;
20
+
21
+ if (isScheduledTaskSession(toolSessionId)) {
22
+ log.debug(`[ScheduledTask] Suppressing tool.execute.before for scheduled task session ${toolSessionId.substring(0, 8)}`);
23
+ return;
24
+ }
25
+
26
+ const ctx = PluginState.activeResponseContexts.get(toolSessionId);
27
+ if (!ctx) return;
28
+
29
+ ctx.activeTool = {
30
+ name: input.tool,
31
+ startTime: Date.now(),
32
+ };
33
+
34
+ startActiveToolTimer(toolSessionId);
35
+ await updateResponseStream(toolSessionId);
36
+ }
37
+
38
+ export async function handleToolExecuteAfter(input: any): Promise<void> {
39
+ const toolSessionId = input.sessionID || input.session?.id;
40
+
41
+ // Skip all tool.execute.after processing for scheduled task sessions
42
+ if (toolSessionId && isScheduledTaskSession(toolSessionId)) {
43
+ log.debug(`[ScheduledTask] Suppressing tool.execute.after for scheduled task session ${toolSessionId.substring(0, 8)}`);
44
+ return;
45
+ }
46
+
47
+ // Handle question tool specially for monitoring
48
+ if (input.tool === "question" && toolSessionId) {
49
+ const questionText = input.args?.questions?.[0]?.question || "Question awaiting answer";
50
+ const activeSessionIds = Array.from(PluginState.activeResponseContexts.keys());
51
+ await handleMonitorAlert(toolSessionId, "question", questionText, activeSessionIds[0]);
52
+
53
+ if (PluginState.isConnected) {
54
+ const ctx = PluginState.activeResponseContexts.get(toolSessionId);
55
+ if (ctx?.streamCtx.statusIndicator) {
56
+ await ctx.streamCtx.statusIndicator.setWaiting("question", questionText);
57
+ }
58
+ }
59
+ }
60
+
61
+ if (!PluginState.isConnected || !toolSessionId) return;
62
+
63
+ const ctx = PluginState.activeResponseContexts.get(toolSessionId);
64
+ if (ctx) {
65
+ if (ctx.activeTool) {
66
+ ctx.toolCalls.push(ctx.activeTool.name);
67
+ if (ctx.activeTool.name === "bash") {
68
+ ctx.shellOutput = "";
69
+ ctx.shellOutputLastUpdate = 0;
70
+ }
71
+ ctx.activeTool = null;
72
+ stopActiveToolTimer(toolSessionId);
73
+ }
74
+ await updateResponseStream(toolSessionId);
75
+ }
76
+ }
@@ -0,0 +1,202 @@
1
+ import type { ResponseContext, TodoItem, ActiveTool, CostInfo } from "./types.js";
2
+
3
+ export const RESPONSE_UPDATE_INTERVAL_MS = 1000;
4
+ export const MAX_SHELL_OUTPUT_LINES = 15;
5
+ export const BASH_HEARTBEAT_THRESHOLD_MS = 10_000;
6
+ export const THINKING_LINE_LIMIT = 500;
7
+
8
+ const TODO_STATUS_ICONS: Record<string, string> = {
9
+ completed: "āœ…",
10
+ in_progress: "šŸ”„",
11
+ pending: "ā³",
12
+ cancelled: "āŒ",
13
+ };
14
+
15
+ export function formatElapsedTime(ms: number): string {
16
+ const seconds = Math.floor(ms / 1000);
17
+ if (seconds < 60) return `${seconds}s`;
18
+ const minutes = Math.floor(seconds / 60);
19
+ const remainingSeconds = seconds % 60;
20
+ return `${minutes}m ${remainingSeconds}s`;
21
+ }
22
+
23
+ export function formatTokenCount(tokens: number): string {
24
+ if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
25
+ if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(0)}K`;
26
+ return `${tokens}`;
27
+ }
28
+
29
+ export function formatCost(cost: number): string {
30
+ if (cost >= 1) return `$${cost.toFixed(2)}`;
31
+ if (cost >= 0.01) return `$${cost.toFixed(2)}`;
32
+ if (cost >= 0.001) return `$${cost.toFixed(3)}`;
33
+ return `$${cost.toFixed(4)}`;
34
+ }
35
+
36
+ export function formatCostStatus(cost: CostInfo): string {
37
+ const totalTokens = cost.tokens.input + cost.tokens.output + cost.tokens.reasoning;
38
+ if (cost.sessionTotal === 0 && cost.currentMessage === 0 && totalTokens === 0) return "";
39
+
40
+ const sessionCost = formatCost(cost.sessionTotal + cost.currentMessage);
41
+ const msgCost = cost.currentMessage > 0 ? ` (+${formatCost(cost.currentMessage)})` : "";
42
+ const tokenStr = totalTokens > 0 ? ` | ${formatTokenCount(totalTokens)} tok` : "";
43
+
44
+ return `šŸ’° ${sessionCost}${msgCost}${tokenStr}`;
45
+ }
46
+
47
+ export function formatToolStatus(
48
+ toolCalls: string[],
49
+ activeTool: ActiveTool | null,
50
+ compactionCount: number = 0,
51
+ cost?: CostInfo,
52
+ responseStartTime?: number,
53
+ awaitingContinuation?: boolean
54
+ ): string {
55
+ const parts: string[] = [];
56
+
57
+ if (responseStartTime) {
58
+ const elapsed = formatElapsedTime(Date.now() - responseStartTime);
59
+ parts.push(`šŸ’» Processing (${elapsed})`);
60
+ }
61
+
62
+ if (toolCalls.length > 0) {
63
+ const toolCounts = toolCalls.reduce((acc, tool) => {
64
+ acc[tool] = (acc[tool] || 0) + 1;
65
+ return acc;
66
+ }, {} as Record<string, number>);
67
+
68
+ const summary = Object.entries(toolCounts)
69
+ .map(([tool, count]) => count > 1 ? `\`${tool}\` Ɨ${count}` : `\`${tool}\``)
70
+ .join(", ");
71
+ parts.push(`āœ… ${summary}`);
72
+ }
73
+
74
+ if (compactionCount > 0) {
75
+ parts.push(compactionCount > 1 ? `šŸ“¦ Compacted Ɨ${compactionCount}` : `šŸ“¦ Compacted`);
76
+ }
77
+
78
+ if (awaitingContinuation) {
79
+ parts.push(`ā³ Continuing...`);
80
+ }
81
+
82
+ if (cost && (cost.sessionTotal > 0 || cost.currentMessage > 0 || cost.tokens.input > 0)) {
83
+ parts.push(formatCostStatus(cost));
84
+ }
85
+
86
+ if (activeTool) {
87
+ const elapsed = formatElapsedTime(Date.now() - activeTool.startTime);
88
+ parts.push(`šŸ”§ \`${activeTool.name}\` (${elapsed})...`);
89
+ }
90
+
91
+ return parts.join(" | ");
92
+ }
93
+
94
+ export function formatShellOutput(
95
+ shellOutput: string,
96
+ lastOutputTime?: number,
97
+ toolStartTime?: number
98
+ ): string {
99
+ if (!shellOutput) return "";
100
+
101
+ const lines = shellOutput.trim().split('\n');
102
+ const totalLines = lines.length;
103
+
104
+ let output: string;
105
+ if (totalLines <= MAX_SHELL_OUTPUT_LINES) {
106
+ output = shellOutput.trim();
107
+ } else {
108
+ const tailLines = lines.slice(-MAX_SHELL_OUTPUT_LINES);
109
+ output = `... (${totalLines - MAX_SHELL_OUTPUT_LINES} lines hidden)\n${tailLines.join('\n')}`;
110
+ }
111
+
112
+ if (lastOutputTime && toolStartTime) {
113
+ const timeSinceLastOutput = Date.now() - lastOutputTime;
114
+ const totalRunTime = Date.now() - toolStartTime;
115
+
116
+ if (timeSinceLastOutput >= BASH_HEARTBEAT_THRESHOLD_MS) {
117
+ const lastOutputAgo = formatElapsedTime(timeSinceLastOutput);
118
+ const runningFor = formatElapsedTime(totalRunTime);
119
+ output += `\n\nā³ Still running (${runningFor} total, last output ${lastOutputAgo} ago)`;
120
+ }
121
+ }
122
+
123
+ return output;
124
+ }
125
+
126
+ export function formatTodoStatus(todos: TodoItem[]): string {
127
+ if (!todos || todos.length === 0) return "";
128
+
129
+ const completed = todos.filter(t => t.status === "completed").length;
130
+ const total = todos.length;
131
+
132
+ const sortedTodos = [...todos].sort((a, b) => {
133
+ const statusOrder: Record<string, number> = {
134
+ in_progress: 0,
135
+ pending: 1,
136
+ completed: 2,
137
+ cancelled: 3,
138
+ };
139
+ return (statusOrder[a.status] ?? 99) - (statusOrder[b.status] ?? 99);
140
+ });
141
+
142
+ let output = `šŸ“‹ **Tasks** (${completed}/${total})\n`;
143
+
144
+ for (const todo of sortedTodos) {
145
+ const icon = TODO_STATUS_ICONS[todo.status] || "ā“";
146
+ if (todo.status === "completed" || todo.status === "cancelled") {
147
+ output += `${icon} ~~${todo.content}~~\n`;
148
+ } else {
149
+ output += `${icon} ${todo.content}\n`;
150
+ }
151
+ }
152
+
153
+ return output;
154
+ }
155
+
156
+ export function formatFullResponse(ctx: ResponseContext): string {
157
+ const toolStatus = formatToolStatus(
158
+ ctx.toolCalls,
159
+ ctx.activeTool,
160
+ ctx.compactionCount,
161
+ ctx.cost,
162
+ ctx.responseStartTime,
163
+ ctx.awaitingContinuation
164
+ );
165
+ const todoStatus = formatTodoStatus(ctx.todos);
166
+ const thinkingPreview = ctx.thinkingBuffer.length > THINKING_LINE_LIMIT
167
+ ? ctx.thinkingBuffer.slice(-THINKING_LINE_LIMIT) + "..."
168
+ : ctx.thinkingBuffer;
169
+
170
+ let output = "";
171
+
172
+ if (toolStatus) {
173
+ output += toolStatus + "\n\n";
174
+ }
175
+
176
+ if (todoStatus) {
177
+ output += todoStatus + "\n";
178
+ }
179
+
180
+ if (ctx.shellOutput && ctx.activeTool?.name === "bash") {
181
+ const formattedShell = formatShellOutput(
182
+ ctx.shellOutput,
183
+ ctx.shellOutputLastUpdate,
184
+ ctx.activeTool.startTime
185
+ );
186
+ output += "```\n" + formattedShell + "\n```\n\n";
187
+ }
188
+
189
+ if (ctx.responseBuffer) {
190
+ const needsSeparator = toolStatus || todoStatus || ctx.shellOutput;
191
+ if (needsSeparator) {
192
+ output += "---\n\n";
193
+ }
194
+ output += ctx.responseBuffer;
195
+ }
196
+
197
+ if (ctx.thinkingBuffer) {
198
+ output += `\n\n---\n:brain: **Thinking:**\n> ${thinkingPreview.split('\n').join('\n> ')}`;
199
+ }
200
+
201
+ return output;
202
+ }