@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,231 @@
1
+ import type { MattermostClient } from "./clients/mattermost-client.js";
2
+ import type { ThreadSessionMapping, ThreadRootPostContent } from "./models/index.js";
3
+ import type { ThreadMappingStore } from "./persistence/thread-mapping-store.js";
4
+ import type { OpenCodeSessionInfo } from "./opencode-session-registry.js";
5
+ import { log } from "./logger.js";
6
+
7
+ export class ThreadManager {
8
+ private mmClient: MattermostClient;
9
+ private store: ThreadMappingStore;
10
+
11
+ constructor(mmClient: MattermostClient, store: ThreadMappingStore) {
12
+ this.mmClient = mmClient;
13
+ this.store = store;
14
+ }
15
+
16
+ async createThread(
17
+ sessionInfo: OpenCodeSessionInfo,
18
+ mattermostUserId: string,
19
+ dmChannelId: string,
20
+ userPostId?: string,
21
+ channelId?: string,
22
+ ownerUsername?: string
23
+ ): Promise<ThreadSessionMapping> {
24
+ const existing = this.store.getBySessionId(sessionInfo.id);
25
+ if (existing) {
26
+ log.debug(`[ThreadManager] Thread already exists for session ${sessionInfo.shortId}`);
27
+ return existing;
28
+ }
29
+
30
+ const targetChannelId = channelId || dmChannelId;
31
+
32
+ const content: ThreadRootPostContent = {
33
+ projectName: sessionInfo.projectName,
34
+ directory: sessionInfo.directory,
35
+ sessionId: sessionInfo.id,
36
+ shortId: sessionInfo.shortId,
37
+ startedAt: new Date(),
38
+ sessionTitle: sessionInfo.title,
39
+ ownerUsername,
40
+ };
41
+
42
+ const message = this.formatThreadRootPost(content);
43
+
44
+ let threadRootPostId: string;
45
+
46
+ if (userPostId) {
47
+ threadRootPostId = userPostId;
48
+ log.info(`[ThreadManager] Creating reply: channel=${targetChannelId}, root_id=${userPostId}`);
49
+ try {
50
+ await this.mmClient.createPost(targetChannelId, message, userPostId);
51
+ } catch (e) {
52
+ log.warn(`[ThreadManager] First attempt failed (${e}), retrying...`);
53
+ await this.mmClient.createPost(targetChannelId, message, userPostId);
54
+ }
55
+ } else {
56
+ let rootPost;
57
+ try {
58
+ rootPost = await this.mmClient.createPost(targetChannelId, message);
59
+ } catch (e) {
60
+ log.warn(`[ThreadManager] First attempt failed, retrying...`);
61
+ rootPost = await this.mmClient.createPost(targetChannelId, message);
62
+ }
63
+ threadRootPostId = rootPost.id;
64
+ }
65
+
66
+ const mapping: ThreadSessionMapping = {
67
+ sessionId: sessionInfo.id,
68
+ threadRootPostId,
69
+ shortId: sessionInfo.shortId,
70
+ mattermostUserId,
71
+ dmChannelId,
72
+ channelId: targetChannelId,
73
+ projectName: sessionInfo.projectName,
74
+ directory: sessionInfo.directory,
75
+ sessionTitle: sessionInfo.title,
76
+ status: "active",
77
+ createdAt: new Date().toISOString(),
78
+ lastActivityAt: new Date().toISOString(),
79
+ };
80
+
81
+ try {
82
+ this.store.add(mapping);
83
+ } catch (e) {
84
+ log.error(`[ThreadManager] Failed to persist mapping, continuing in-memory:`, e);
85
+ }
86
+
87
+ log.info(`[ThreadManager] Created thread for session ${sessionInfo.shortId} (${sessionInfo.projectName}) in channel ${targetChannelId}`);
88
+ return mapping;
89
+ }
90
+
91
+ async endThread(sessionId: string): Promise<void> {
92
+ const mapping = this.store.getBySessionId(sessionId);
93
+ if (!mapping) {
94
+ log.debug(`[ThreadManager] No mapping found for session ${sessionId}`);
95
+ return;
96
+ }
97
+
98
+ if (mapping.status === "ended") {
99
+ return;
100
+ }
101
+
102
+ const now = new Date();
103
+ const duration = this.formatDuration(new Date(mapping.createdAt), now);
104
+ const message = this.formatSessionEndedPost(duration, now);
105
+
106
+ const targetChannelId = mapping.channelId || mapping.dmChannelId;
107
+ try {
108
+ await this.mmClient.createPost(targetChannelId, message, mapping.threadRootPostId);
109
+ } catch (e) {
110
+ log.error(`[ThreadManager] Failed to post session ended message:`, e);
111
+ }
112
+
113
+ mapping.status = "ended";
114
+ mapping.endedAt = now.toISOString();
115
+ mapping.lastActivityAt = now.toISOString();
116
+ this.store.update(mapping);
117
+
118
+ log.info(`[ThreadManager] Ended thread for session ${mapping.shortId}`);
119
+ }
120
+
121
+ async reconnectThread(sessionId: string): Promise<ThreadSessionMapping | null> {
122
+ const mapping = this.store.getBySessionId(sessionId);
123
+ if (!mapping) {
124
+ return null;
125
+ }
126
+
127
+ if (mapping.status === "disconnected") {
128
+ mapping.status = "active";
129
+ mapping.lastActivityAt = new Date().toISOString();
130
+ this.store.update(mapping);
131
+
132
+ const targetChannelId = mapping.channelId || mapping.dmChannelId;
133
+ try {
134
+ await this.mmClient.createPost(
135
+ targetChannelId,
136
+ ":arrows_counterclockwise: **Session reconnected**",
137
+ mapping.threadRootPostId
138
+ );
139
+ } catch (e) {
140
+ log.warn(`[ThreadManager] Failed to post reconnect message:`, e);
141
+ }
142
+
143
+ log.info(`[ThreadManager] Reconnected thread for session ${mapping.shortId}`);
144
+ }
145
+
146
+ return mapping;
147
+ }
148
+
149
+ markDisconnected(sessionId: string): void {
150
+ const mapping = this.store.getBySessionId(sessionId);
151
+ if (!mapping || mapping.status !== "active") {
152
+ return;
153
+ }
154
+
155
+ mapping.status = "disconnected";
156
+ mapping.lastActivityAt = new Date().toISOString();
157
+ this.store.update(mapping);
158
+
159
+ log.info(`[ThreadManager] Marked session ${mapping.shortId} as disconnected`);
160
+ }
161
+
162
+ getMapping(sessionId: string): ThreadSessionMapping | null {
163
+ return this.store.getBySessionId(sessionId);
164
+ }
165
+
166
+ getMappingByThreadId(threadRootPostId: string): ThreadSessionMapping | null {
167
+ return this.store.getByThreadRootPostId(threadRootPostId);
168
+ }
169
+
170
+ updateActivity(sessionId: string): void {
171
+ const mapping = this.store.getBySessionId(sessionId);
172
+ if (mapping) {
173
+ mapping.lastActivityAt = new Date().toISOString();
174
+ this.store.update(mapping);
175
+ }
176
+ }
177
+
178
+ private formatThreadRootPost(content: ThreadRootPostContent): string {
179
+ const jobId = process.env.MY_JOB_ID || "";
180
+ const jobIdShort = jobId.substring(0, 5);
181
+
182
+ const lines = [
183
+ `:rocket: **OpenCode Session Started**`,
184
+ ``,
185
+ `**Project**: ${content.projectName}`,
186
+ `**Directory**: ${content.directory}`,
187
+ `**Session**: ${content.shortId}`,
188
+ `**My session ID**: ${jobIdShort}`,
189
+ `**Started**: ${content.startedAt.toISOString()}`,
190
+ ];
191
+
192
+ if (content.sessionTitle) {
193
+ lines.splice(2, 0, `**Title**: ${content.sessionTitle}`);
194
+ }
195
+
196
+ if (content.ownerUsername) {
197
+ lines.splice(2, 0, `**Owner**: @${content.ownerUsername}`);
198
+ }
199
+
200
+ lines.push(``, `_Reply in this thread to send prompts to this session._`);
201
+
202
+ return lines.join("\n");
203
+ }
204
+
205
+ private formatSessionEndedPost(duration: string, endedAt: Date): string {
206
+ return [
207
+ `:checkered_flag: **Session Ended**`,
208
+ ``,
209
+ `**Duration**: ${duration}`,
210
+ `**Ended**: ${endedAt.toISOString()}`,
211
+ ``,
212
+ `_This thread is now read-only. Start a new session for a new thread._`,
213
+ ].join("\n");
214
+ }
215
+
216
+ private formatDuration(start: Date, end: Date): string {
217
+ const ms = end.getTime() - start.getTime();
218
+ const minutes = Math.floor(ms / 60000);
219
+ if (minutes < 60) {
220
+ return `${minutes} minute${minutes !== 1 ? "s" : ""}`;
221
+ }
222
+ const hours = Math.floor(minutes / 60);
223
+ const remainingMinutes = minutes % 60;
224
+ if (hours < 24) {
225
+ return `${hours} hour${hours !== 1 ? "s" : ""} ${remainingMinutes} minute${remainingMinutes !== 1 ? "s" : ""}`;
226
+ }
227
+ const days = Math.floor(hours / 24);
228
+ const remainingHours = hours % 24;
229
+ return `${days} day${days !== 1 ? "s" : ""} ${remainingHours} hour${remainingHours !== 1 ? "s" : ""}`;
230
+ }
231
+ }
@@ -0,0 +1,162 @@
1
+ import { z } from "zod";
2
+ import { log } from "./logger";
3
+
4
+ export const TodoItemSchema = z.object({
5
+ id: z.string(),
6
+ content: z.string(),
7
+ status: z.string(),
8
+ priority: z.string(),
9
+ });
10
+
11
+ export type TodoItem = z.infer<typeof TodoItemSchema>;
12
+
13
+ export interface TodoPostInfo {
14
+ postId: string;
15
+ lastUpdated: number;
16
+ todos: TodoItem[];
17
+ }
18
+
19
+ const STATUS_ICONS: Record<string, string> = {
20
+ completed: "✅",
21
+ in_progress: "🔄",
22
+ pending: "⏳",
23
+ cancelled: "❌",
24
+ };
25
+
26
+ const PRIORITY_MARKERS: Record<string, string> = {
27
+ high: "🔴",
28
+ medium: "🟡",
29
+ low: "🟢",
30
+ };
31
+
32
+ export class TodoManager {
33
+ private todoPostsBySession: Map<string, TodoPostInfo> = new Map();
34
+ private mmClient: any;
35
+ private threadRootsBySession: Map<string, string> = new Map();
36
+ private channelIdsBySession: Map<string, string> = new Map();
37
+
38
+ constructor(mmClient: any) {
39
+ this.mmClient = mmClient;
40
+ }
41
+
42
+ setThreadRoot(sessionId: string, threadRootPostId: string, channelId?: string) {
43
+ this.threadRootsBySession.set(sessionId, threadRootPostId);
44
+ if (channelId) {
45
+ this.channelIdsBySession.set(sessionId, channelId);
46
+ }
47
+ }
48
+
49
+ getChannelId(sessionId: string): string | undefined {
50
+ return this.channelIdsBySession.get(sessionId);
51
+ }
52
+
53
+ formatTodoList(todos: TodoItem[]): string {
54
+ if (!todos || todos.length === 0) {
55
+ return "";
56
+ }
57
+
58
+ const completed = todos.filter((t) => t.status === "completed").length;
59
+ const total = todos.length;
60
+
61
+ let output = `📋 **Task List** (${completed}/${total} complete)\n\n`;
62
+
63
+ const sortedTodos = [...todos].sort((a, b) => {
64
+ const statusOrder: Record<string, number> = {
65
+ in_progress: 0,
66
+ pending: 1,
67
+ completed: 2,
68
+ cancelled: 3,
69
+ };
70
+ const priorityOrder: Record<string, number> = {
71
+ high: 0,
72
+ medium: 1,
73
+ low: 2,
74
+ };
75
+
76
+ const statusDiff =
77
+ (statusOrder[a.status] ?? 99) - (statusOrder[b.status] ?? 99);
78
+ if (statusDiff !== 0) return statusDiff;
79
+
80
+ return (
81
+ (priorityOrder[a.priority] ?? 99) - (priorityOrder[b.priority] ?? 99)
82
+ );
83
+ });
84
+
85
+ for (const todo of sortedTodos) {
86
+ const statusIcon = STATUS_ICONS[todo.status] || "❓";
87
+ const priorityMarker =
88
+ todo.priority === "high" ? ` ${PRIORITY_MARKERS.high}` : "";
89
+
90
+ if (todo.status === "completed") {
91
+ output += `${statusIcon} ~~${todo.content}~~${priorityMarker}\n`;
92
+ } else if (todo.status === "cancelled") {
93
+ output += `${statusIcon} ~~${todo.content}~~ _(cancelled)_\n`;
94
+ } else {
95
+ output += `${statusIcon} ${todo.content}${priorityMarker}\n`;
96
+ }
97
+ }
98
+
99
+ return output.trim();
100
+ }
101
+
102
+ async updateTodoPost(
103
+ sessionId: string,
104
+ todos: TodoItem[],
105
+ channelId: string
106
+ ): Promise<void> {
107
+ const threadRootPostId = this.threadRootsBySession.get(sessionId);
108
+ if (!threadRootPostId) {
109
+ log.debug(
110
+ `[TodoManager] No thread root for session ${sessionId.substring(0, 8)}`
111
+ );
112
+ return;
113
+ }
114
+
115
+ const formattedContent = this.formatTodoList(todos);
116
+
117
+ if (!formattedContent) {
118
+ log.debug(`[TodoManager] Empty todo list, skipping update`);
119
+ return;
120
+ }
121
+
122
+ const existingPost = this.todoPostsBySession.get(sessionId);
123
+
124
+ try {
125
+ if (existingPost) {
126
+ try {
127
+ await this.mmClient.deletePost(existingPost.postId);
128
+ } catch (e) {
129
+ log.debug(`[TodoManager] Failed to delete old todo post (may already be deleted)`);
130
+ }
131
+ }
132
+
133
+ const newPost = await this.mmClient.createPost(
134
+ channelId,
135
+ formattedContent,
136
+ threadRootPostId
137
+ );
138
+
139
+ this.todoPostsBySession.set(sessionId, {
140
+ postId: newPost.id,
141
+ lastUpdated: Date.now(),
142
+ todos,
143
+ });
144
+
145
+ log.debug(
146
+ `[TodoManager] Created todo post at end of thread for session ${sessionId.substring(0, 8)}`
147
+ );
148
+ } catch (e) {
149
+ log.error(`[TodoManager] Failed to update todo post:`, e);
150
+ }
151
+ }
152
+
153
+ getTodoPost(sessionId: string): TodoPostInfo | undefined {
154
+ return this.todoPostsBySession.get(sessionId);
155
+ }
156
+
157
+ clearSession(sessionId: string) {
158
+ this.todoPostsBySession.delete(sessionId);
159
+ this.threadRootsBySession.delete(sessionId);
160
+ this.channelIdsBySession.delete(sessionId);
161
+ }
162
+ }