@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.
- package/.opencode/command/mattermost-connect.md +5 -0
- package/.opencode/command/mattermost-disconnect.md +5 -0
- package/.opencode/command/mattermost-monitor.md +12 -0
- package/.opencode/command/mattermost-status.md +5 -0
- package/.opencode/command/speckit.analyze.md +184 -0
- package/.opencode/command/speckit.checklist.md +294 -0
- package/.opencode/command/speckit.clarify.md +181 -0
- package/.opencode/command/speckit.constitution.md +82 -0
- package/.opencode/command/speckit.implement.md +135 -0
- package/.opencode/command/speckit.plan.md +89 -0
- package/.opencode/command/speckit.specify.md +258 -0
- package/.opencode/command/speckit.tasks.md +137 -0
- package/.opencode/command/speckit.taskstoissues.md +30 -0
- package/.opencode/plugin/mattermost-control/event-handlers/compaction.ts +61 -0
- package/.opencode/plugin/mattermost-control/event-handlers/file.ts +36 -0
- package/.opencode/plugin/mattermost-control/event-handlers/index.ts +14 -0
- package/.opencode/plugin/mattermost-control/event-handlers/message.ts +124 -0
- package/.opencode/plugin/mattermost-control/event-handlers/permission.ts +34 -0
- package/.opencode/plugin/mattermost-control/event-handlers/question.ts +92 -0
- package/.opencode/plugin/mattermost-control/event-handlers/session.ts +100 -0
- package/.opencode/plugin/mattermost-control/event-handlers/todo.ts +33 -0
- package/.opencode/plugin/mattermost-control/event-handlers/tool.ts +76 -0
- package/.opencode/plugin/mattermost-control/formatters.ts +202 -0
- package/.opencode/plugin/mattermost-control/index.ts +964 -0
- package/.opencode/plugin/mattermost-control/package.json +12 -0
- package/.opencode/plugin/mattermost-control/state.ts +180 -0
- package/.opencode/plugin/mattermost-control/timers.ts +96 -0
- package/.opencode/plugin/mattermost-control/tools/connect.ts +563 -0
- package/.opencode/plugin/mattermost-control/tools/file.ts +41 -0
- package/.opencode/plugin/mattermost-control/tools/index.ts +12 -0
- package/.opencode/plugin/mattermost-control/tools/monitor.ts +183 -0
- package/.opencode/plugin/mattermost-control/tools/schedule.ts +253 -0
- package/.opencode/plugin/mattermost-control/tools/session.ts +120 -0
- package/.opencode/plugin/mattermost-control/types.ts +107 -0
- package/LICENSE +21 -0
- package/README.md +1280 -0
- package/opencode-shared +359 -0
- package/opencode-shared-restart +495 -0
- package/opencode-shared-stop +90 -0
- package/package.json +65 -0
- package/src/clients/mattermost-client.ts +221 -0
- package/src/clients/websocket-client.ts +199 -0
- package/src/command-handler.ts +1035 -0
- package/src/config.ts +170 -0
- package/src/context-builder.ts +309 -0
- package/src/file-completion-handler.ts +521 -0
- package/src/file-handler.ts +242 -0
- package/src/guest-approval-handler.ts +223 -0
- package/src/logger.ts +73 -0
- package/src/merge-handler.ts +335 -0
- package/src/message-router.ts +151 -0
- package/src/models/index.ts +197 -0
- package/src/models/routing.ts +50 -0
- package/src/models/thread-mapping.ts +40 -0
- package/src/monitor-service.ts +222 -0
- package/src/notification-service.ts +118 -0
- package/src/opencode-session-registry.ts +370 -0
- package/src/persistence/team-store.ts +396 -0
- package/src/persistence/thread-mapping-store.ts +258 -0
- package/src/question-handler.ts +401 -0
- package/src/reaction-handler.ts +111 -0
- package/src/response-streamer.ts +364 -0
- package/src/scheduler/schedule-store.ts +261 -0
- package/src/scheduler/scheduler-service.ts +349 -0
- package/src/session-manager.ts +142 -0
- package/src/session-ownership-handler.ts +253 -0
- package/src/status-indicator.ts +279 -0
- package/src/thread-manager.ts +231 -0
- 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
|
+
}
|