@rmrdeveloper/sideroom-pi 5.0.0 → 6.0.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.
Files changed (33) hide show
  1. package/README.md +108 -79
  2. package/{src/assets → assets}/artifacts/GUIDELINES_TEMPLATE.md +40 -11
  3. package/extensions/ask/execute.ts +56 -0
  4. package/extensions/ask/index.ts +77 -0
  5. package/extensions/ask/model.ts +196 -0
  6. package/extensions/ask/ui.ts +445 -0
  7. package/extensions/modified-files/index.ts +45 -0
  8. package/extensions/modified-files/model.ts +74 -0
  9. package/extensions/modified-files/session.ts +200 -0
  10. package/extensions/modified-files/ui.ts +207 -0
  11. package/extensions/todo/execute.ts +69 -0
  12. package/extensions/todo/guards.ts +107 -0
  13. package/extensions/todo/index.ts +91 -0
  14. package/extensions/todo/model.ts +327 -0
  15. package/extensions/todo/session.ts +166 -0
  16. package/extensions/todo/ui.ts +41 -0
  17. package/package.json +26 -27
  18. package/dist/pi-extension.js +0 -1175
  19. package/skills/sideroom-critic/SKILL.md +0 -32
  20. package/skills/sideroom-grilling/SKILL.md +0 -81
  21. package/skills/sideroom-spec/SKILL.md +0 -38
  22. package/skills/sideroom-transcribe-audio/SKILL.md +0 -38
  23. package/skills/sideroom-transcribe-audio/scripts/transcribe.py +0 -40
  24. package/src/assets/agents/sideroom-code-reviewer.md +0 -30
  25. package/src/assets/agents/sideroom-fixer.md +0 -37
  26. package/src/assets/agents/sideroom-implementer.md +0 -39
  27. package/src/assets/agents/sideroom-planner.md +0 -31
  28. package/src/assets/agents/sideroom-verifier.md +0 -32
  29. package/src/assets/artifacts/guidelines/java.md +0 -211
  30. package/src/assets/artifacts/guidelines/javascript.md +0 -235
  31. package/src/assets/artifacts/guidelines/php-laravel.md +0 -210
  32. package/src/assets/artifacts/guidelines/python.md +0 -227
  33. package/src/assets/artifacts/guidelines/typescript.md +0 -231
@@ -0,0 +1,200 @@
1
+ import { resolve } from 'node:path';
2
+ import type {
3
+ ExtensionAPI,
4
+ ExtensionContext,
5
+ } from '@earendil-works/pi-coding-agent';
6
+ import {
7
+ isEditToolResult,
8
+ isWriteToolResult,
9
+ } from '@earendil-works/pi-coding-agent';
10
+ import {
11
+ type FileToolName,
12
+ MODIFIED_FILES_SNAPSHOT_TYPE,
13
+ MODIFIED_FILES_WIDGET_KEY,
14
+ type ModifiedFile,
15
+ readModifiedFilesSnapshot,
16
+ recordModifiedFile,
17
+ resolveModifiedPath,
18
+ } from './model.ts';
19
+ import { renderModifiedFilesWidget } from './ui.ts';
20
+
21
+ export interface ModifiedFilesStore {
22
+ files: readonly ModifiedFile[];
23
+ }
24
+
25
+ type SessionBranch = ReturnType<
26
+ ExtensionContext['sessionManager']['getBranch']
27
+ >;
28
+
29
+ export function createModifiedFilesStore(
30
+ files: readonly ModifiedFile[] = [],
31
+ ): ModifiedFilesStore {
32
+ return { files };
33
+ }
34
+
35
+ export function reconstructModifiedFiles(
36
+ branch: SessionBranch,
37
+ ): readonly ModifiedFile[] {
38
+ for (let index = branch.length - 1; index >= 0; index -= 1) {
39
+ const entry = branch[index];
40
+ if (
41
+ entry?.type !== 'custom' ||
42
+ entry.customType !== MODIFIED_FILES_SNAPSHOT_TYPE
43
+ ) {
44
+ continue;
45
+ }
46
+ const files = readModifiedFilesSnapshot(entry.data);
47
+ if (files !== undefined) {
48
+ return files;
49
+ }
50
+ }
51
+ return reconstructFromToolResults(branch);
52
+ }
53
+
54
+ export function restoreModifiedFiles(
55
+ store: ModifiedFilesStore,
56
+ ctx: Pick<ExtensionContext, 'sessionManager' | 'cwd'>,
57
+ ): void {
58
+ store.files = reconstructModifiedFiles(ctx.sessionManager.getBranch()).map(
59
+ (file) => ({
60
+ ...file,
61
+ path: resolve(ctx.cwd, file.path),
62
+ }),
63
+ );
64
+ }
65
+
66
+ export function refreshModifiedFiles(
67
+ store: ModifiedFilesStore,
68
+ ctx: Pick<ExtensionContext, 'ui' | 'cwd'>,
69
+ ): void {
70
+ if (store.files.length === 0) {
71
+ ctx.ui.setWidget(MODIFIED_FILES_WIDGET_KEY, undefined);
72
+ return;
73
+ }
74
+ ctx.ui.setWidget(MODIFIED_FILES_WIDGET_KEY, (_tui, theme) => {
75
+ const lines = renderModifiedFilesWidget(store.files, ctx.cwd, theme);
76
+ return {
77
+ render: () => lines ?? [],
78
+ invalidate() {},
79
+ };
80
+ });
81
+ }
82
+
83
+ export function restoreAndRefreshModifiedFiles(
84
+ store: ModifiedFilesStore,
85
+ ctx: Pick<ExtensionContext, 'sessionManager' | 'ui' | 'cwd'>,
86
+ ): void {
87
+ restoreModifiedFiles(store, ctx);
88
+ refreshModifiedFiles(store, ctx);
89
+ }
90
+
91
+ export function commitModifiedFiles(
92
+ store: ModifiedFilesStore,
93
+ pi: Pick<ExtensionAPI, 'appendEntry'>,
94
+ ctx: Pick<ExtensionContext, 'ui' | 'cwd'>,
95
+ files: readonly ModifiedFile[],
96
+ ): void {
97
+ store.files = files;
98
+ pi.appendEntry(MODIFIED_FILES_SNAPSHOT_TYPE, { files });
99
+ refreshModifiedFiles(store, ctx);
100
+ }
101
+
102
+ export function registerModifiedFilesEvents(
103
+ pi: ExtensionAPI,
104
+ store: ModifiedFilesStore,
105
+ ): void {
106
+ pi.on('session_start', (_event, ctx) =>
107
+ restoreAndRefreshModifiedFiles(store, ctx),
108
+ );
109
+ pi.on('session_tree', (_event, ctx) =>
110
+ restoreAndRefreshModifiedFiles(store, ctx),
111
+ );
112
+ pi.on('session_compact', (_event, ctx) =>
113
+ restoreAndRefreshModifiedFiles(store, ctx),
114
+ );
115
+ pi.on('tool_result', (event, ctx) => {
116
+ if (
117
+ event.isError ||
118
+ (!isEditToolResult(event) && !isWriteToolResult(event))
119
+ ) {
120
+ return;
121
+ }
122
+ const path = readToolPath(event.input);
123
+ if (path === undefined) {
124
+ return;
125
+ }
126
+ const resolvedPath = resolveModifiedPath(ctx.cwd, path);
127
+ if (resolvedPath === undefined) {
128
+ return;
129
+ }
130
+ const files = recordModifiedFile(store.files, resolvedPath, event.toolName);
131
+ commitModifiedFiles(store, pi, ctx, files);
132
+ });
133
+ }
134
+
135
+ function reconstructFromToolResults(
136
+ branch: SessionBranch,
137
+ ): readonly ModifiedFile[] {
138
+ const calls = collectFileToolCalls(branch);
139
+ let files: readonly ModifiedFile[] = [];
140
+
141
+ for (const entry of branch) {
142
+ const file = fileFromToolResult(entry, calls);
143
+ if (file !== undefined) {
144
+ files = recordModifiedFile(files, file.path, file.toolName);
145
+ }
146
+ }
147
+ return files;
148
+ }
149
+
150
+ function collectFileToolCalls(
151
+ branch: SessionBranch,
152
+ ): Map<string, ModifiedFile> {
153
+ const calls = new Map<string, ModifiedFile>();
154
+ for (const entry of branch) {
155
+ if (entry?.type !== 'message' || entry.message.role !== 'assistant') {
156
+ continue;
157
+ }
158
+ for (const content of entry.message.content) {
159
+ if (content.type !== 'toolCall') {
160
+ continue;
161
+ }
162
+ const toolName = fileToolName(content.name);
163
+ const path = readToolPath(content.arguments);
164
+ if (toolName !== undefined && path !== undefined) {
165
+ calls.set(content.id, { path, toolName });
166
+ }
167
+ }
168
+ }
169
+ return calls;
170
+ }
171
+
172
+ function fileFromToolResult(
173
+ entry: SessionBranch[number] | undefined,
174
+ calls: ReadonlyMap<string, ModifiedFile>,
175
+ ): ModifiedFile | undefined {
176
+ if (entry?.type !== 'message' || entry.message.role !== 'toolResult') {
177
+ return undefined;
178
+ }
179
+ const message = entry.message;
180
+ if (message.isError) {
181
+ return undefined;
182
+ }
183
+ const toolName = fileToolName(message.toolName);
184
+ const call = calls.get(message.toolCallId);
185
+ if (toolName === undefined || call?.toolName !== toolName) {
186
+ return undefined;
187
+ }
188
+ return call;
189
+ }
190
+
191
+ function fileToolName(value: string): FileToolName | undefined {
192
+ if (value === 'edit' || value === 'write') {
193
+ return value;
194
+ }
195
+ return undefined;
196
+ }
197
+
198
+ function readToolPath(input: Record<string, unknown>): string | undefined {
199
+ return typeof input.path === 'string' ? input.path : undefined;
200
+ }
@@ -0,0 +1,207 @@
1
+ import { pathToFileURL } from 'node:url';
2
+ import type { ExtensionContext, Theme } from '@earendil-works/pi-coding-agent';
3
+ import {
4
+ type Component,
5
+ Key,
6
+ matchesKey,
7
+ type TUI,
8
+ truncateToWidth,
9
+ } from '@earendil-works/pi-tui';
10
+ import { displayModifiedPath, type ModifiedFile } from './model.ts';
11
+
12
+ const COMPACT_FILE_LIMIT = 5;
13
+ const EXTENDED_FILE_LIMIT = 12;
14
+
15
+ export function renderModifiedFilesWidget(
16
+ files: readonly ModifiedFile[],
17
+ cwd: string,
18
+ theme: Theme,
19
+ ): string[] | undefined {
20
+ if (files.length === 0) {
21
+ return undefined;
22
+ }
23
+
24
+ const visibleFiles = files.slice(0, COMPACT_FILE_LIMIT);
25
+ const lines = [
26
+ '',
27
+ theme.fg(
28
+ 'dim',
29
+ `── Edited files this session (${String(files.length)}) ──`,
30
+ ),
31
+ ...visibleFiles.map((file) => renderFileLine(file, cwd, theme)),
32
+ ];
33
+ lines.push(theme.fg('dim', 'F8: view all'));
34
+ return lines;
35
+ }
36
+
37
+ export interface ModifiedFilesOverlayController {
38
+ toggle(ctx: ExtensionContext): void;
39
+ }
40
+
41
+ export function createModifiedFilesOverlayController(
42
+ files: () => readonly ModifiedFile[],
43
+ clearFiles: (ctx: ExtensionContext) => void,
44
+ ): ModifiedFilesOverlayController {
45
+ let close: (() => void) | undefined;
46
+ let opening = false;
47
+
48
+ return {
49
+ toggle(ctx) {
50
+ if (close !== undefined) {
51
+ close();
52
+ return;
53
+ }
54
+ if (opening || ctx.mode !== 'tui') {
55
+ return;
56
+ }
57
+
58
+ opening = true;
59
+ void ctx.ui
60
+ .custom<void>(
61
+ (tui, theme, _keybindings, done) => {
62
+ close = done;
63
+ return new ModifiedFilesOverlay(
64
+ tui,
65
+ files,
66
+ ctx.cwd,
67
+ theme,
68
+ done,
69
+ () => clearFiles(ctx),
70
+ );
71
+ },
72
+ {
73
+ overlay: true,
74
+ overlayOptions: {
75
+ anchor: 'center',
76
+ width: '70%',
77
+ minWidth: 40,
78
+ maxHeight: '80%',
79
+ visible: (terminalWidth) => terminalWidth >= 60,
80
+ },
81
+ },
82
+ )
83
+ .finally(() => {
84
+ close = undefined;
85
+ opening = false;
86
+ });
87
+ },
88
+ };
89
+ }
90
+
91
+ export class ModifiedFilesOverlay implements Component {
92
+ private offset = 0;
93
+ private readonly tui: TUI;
94
+ private readonly files: () => readonly ModifiedFile[];
95
+ private readonly cwd: string;
96
+ private readonly theme: Theme;
97
+ private readonly close: () => void;
98
+ private readonly clear: () => void;
99
+
100
+ constructor(
101
+ tui: TUI,
102
+ files: () => readonly ModifiedFile[],
103
+ cwd: string,
104
+ theme: Theme,
105
+ close: () => void,
106
+ clear: () => void,
107
+ ) {
108
+ this.tui = tui;
109
+ this.files = files;
110
+ this.cwd = cwd;
111
+ this.theme = theme;
112
+ this.close = close;
113
+ this.clear = clear;
114
+ }
115
+
116
+ handleInput(data: string): void {
117
+ const maxOffset = Math.max(0, this.files().length - EXTENDED_FILE_LIMIT);
118
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.f8)) {
119
+ this.close();
120
+ return;
121
+ }
122
+ if (matchesKey(data, 'r')) {
123
+ this.clear();
124
+ this.close();
125
+ return;
126
+ }
127
+ if (matchesKey(data, Key.up)) {
128
+ this.offset = Math.max(0, this.offset - 1);
129
+ this.tui.requestRender();
130
+ return;
131
+ }
132
+ if (matchesKey(data, Key.down)) {
133
+ this.offset = Math.min(maxOffset, this.offset + 1);
134
+ this.tui.requestRender();
135
+ return;
136
+ }
137
+ if (matchesKey(data, Key.pageUp)) {
138
+ this.offset = Math.max(0, this.offset - EXTENDED_FILE_LIMIT);
139
+ this.tui.requestRender();
140
+ return;
141
+ }
142
+ if (matchesKey(data, Key.pageDown)) {
143
+ this.offset = Math.min(maxOffset, this.offset + EXTENDED_FILE_LIMIT);
144
+ this.tui.requestRender();
145
+ }
146
+ }
147
+
148
+ render(width: number): string[] {
149
+ const innerWidth = Math.max(1, width - 2);
150
+ const files = this.files();
151
+ const visibleFiles = files.slice(
152
+ this.offset,
153
+ this.offset + EXTENDED_FILE_LIMIT,
154
+ );
155
+ const border = (text: string) => this.theme.fg('border', text);
156
+ const line = (text: string) => truncateToWidth(text, innerWidth, '…', true);
157
+ const title = line(
158
+ ` Edited files during this session (${String(files.length)}) `,
159
+ );
160
+ const lines = [
161
+ border(`╭${'─'.repeat(innerWidth)}╮`),
162
+ `${border('│')}${line(this.theme.fg('accent', this.theme.bold(title)))}${border('│')}`,
163
+ `${border('├')}${border('─'.repeat(innerWidth))}${border('┤')}`,
164
+ ];
165
+
166
+ for (const file of visibleFiles) {
167
+ lines.push(
168
+ `${border('│')}${line(renderFileLine(file, this.cwd, this.theme))}${border('│')}`,
169
+ );
170
+ }
171
+ if (visibleFiles.length === 0) {
172
+ lines.push(
173
+ `${border('│')}${line(this.theme.fg('muted', ' No files edited yet.'))}${border('│')}`,
174
+ );
175
+ }
176
+
177
+ const scrollHint = files.length > EXTENDED_FILE_LIMIT ? ' ↑↓ scroll ·' : '';
178
+ lines.push(`${border('├')}${border('─'.repeat(innerWidth))}${border('┤')}`);
179
+ lines.push(
180
+ `${border('│')}${line(this.theme.fg('dim', `${scrollHint} R clear · F8 or Esc close`))}${border('│')}`,
181
+ );
182
+ lines.push(border(`╰${'─'.repeat(innerWidth)}╯`));
183
+ return lines;
184
+ }
185
+
186
+ invalidate(): void {}
187
+ }
188
+
189
+ function renderFileLine(file: ModifiedFile, cwd: string, theme: Theme): string {
190
+ const operation = file.toolName === 'write' ? 'write' : 'edit';
191
+ const path = displayModifiedPath(cwd, file.path);
192
+ return ` ${theme.fg('muted', `${operation} `)}${fileHyperlink(file.path, path)}`;
193
+ }
194
+
195
+ function fileHyperlink(path: string, label: string): string {
196
+ const target = pathToFileURL(path).href;
197
+ return `\u001B]8;;${target}\u0007${safeLabel(label)}\u001B]8;;\u0007`;
198
+ }
199
+
200
+ function safeLabel(value: string): string {
201
+ return [...value]
202
+ .map((character) => {
203
+ const codePoint = character.codePointAt(0) ?? 0;
204
+ return codePoint < 32 || codePoint === 127 ? '?' : character;
205
+ })
206
+ .join('');
207
+ }
@@ -0,0 +1,69 @@
1
+ import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
2
+ import {
3
+ applyTodoParams,
4
+ parseTodoParams,
5
+ TODO_ACTION,
6
+ type TodoAction,
7
+ type TodoItem,
8
+ type TodoParams,
9
+ UI_UNAVAILABLE,
10
+ } from './model.ts';
11
+
12
+ export interface TodoToolDetails {
13
+ readonly action?: TodoAction;
14
+ readonly items: readonly TodoItem[];
15
+ readonly error?: string;
16
+ }
17
+
18
+ export interface TodoToolResult {
19
+ readonly content: [{ readonly type: 'text'; readonly text: string }];
20
+ readonly details: TodoToolDetails;
21
+ }
22
+
23
+ export function executeTodo(
24
+ params: TodoParams,
25
+ ctx: Pick<ExtensionContext, 'mode'>,
26
+ currentItems: readonly TodoItem[],
27
+ ): TodoToolResult {
28
+ const parsed = parseTodoParams(params);
29
+ if (!parsed.ok) {
30
+ return errorResult(parsed.message, currentItems);
31
+ }
32
+ if (parsed.params.action === TODO_ACTION.propose && ctx.mode !== 'tui') {
33
+ return errorResult(UI_UNAVAILABLE, currentItems, parsed.params.action);
34
+ }
35
+
36
+ const applied = applyTodoParams(currentItems, parsed.params);
37
+ if (!applied.ok) {
38
+ return errorResult(applied.message, currentItems, parsed.params.action);
39
+ }
40
+
41
+ const itemCount = applied.items.length;
42
+ return {
43
+ content: [
44
+ { type: 'text', text: successMessage(parsed.params.action, itemCount) },
45
+ ],
46
+ details: { action: parsed.params.action, items: applied.items },
47
+ };
48
+ }
49
+
50
+ function successMessage(action: TodoAction, itemCount: number): string {
51
+ if (action === TODO_ACTION.propose) {
52
+ if (itemCount === 0) {
53
+ return 'Board cleared';
54
+ }
55
+ return `Board proposed with ${String(itemCount)} item${itemCount === 1 ? '' : 's'}`;
56
+ }
57
+ return `Board updated (${String(itemCount)} item${itemCount === 1 ? '' : 's'})`;
58
+ }
59
+
60
+ function errorResult(
61
+ error: string,
62
+ items: readonly TodoItem[],
63
+ action?: TodoToolDetails['action'],
64
+ ): TodoToolResult {
65
+ return {
66
+ content: [{ type: 'text', text: error }],
67
+ details: { action, items, error },
68
+ };
69
+ }
@@ -0,0 +1,107 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ } from '@earendil-works/pi-coding-agent';
5
+ import {
6
+ shouldNudgePropose,
7
+ shouldWatchdogUpdate,
8
+ TOOL_NAME,
9
+ } from './model.ts';
10
+ import type { TodoStore } from './session.ts';
11
+
12
+ const NUDGE_TYPE = 'sideroom-todo-nudge';
13
+ const WATCHDOG_TYPE = 'sideroom-todo-watchdog';
14
+ const MUTATING_TOOL_NAMES = new Set(['write', 'edit', 'bash']);
15
+
16
+ interface TodoRunState {
17
+ proposeNudgedThisRun: boolean;
18
+ updateWatchdogThisTurn: boolean;
19
+ turnMutated: boolean;
20
+ turnCalledTodo: boolean;
21
+ steerFromUs: boolean;
22
+ }
23
+
24
+ export function registerGuardEvents(
25
+ pi: ExtensionAPI,
26
+ store: TodoStore,
27
+ restore: (ctx: ExtensionContext) => void,
28
+ ): void {
29
+ const runState = createTodoRunState();
30
+
31
+ pi.on('input', (event) => {
32
+ if (event.source === 'interactive' || event.source === 'rpc') {
33
+ runState.proposeNudgedThisRun = false;
34
+ runState.steerFromUs = false;
35
+ }
36
+ });
37
+
38
+ pi.on('turn_start', () => resetTurnState(runState));
39
+
40
+ pi.on('tool_execution_start', (event, ctx) => {
41
+ if (event.toolName === TOOL_NAME) {
42
+ runState.turnCalledTodo = true;
43
+ return;
44
+ }
45
+ if (!MUTATING_TOOL_NAMES.has(event.toolName)) {
46
+ return;
47
+ }
48
+
49
+ runState.turnMutated = true;
50
+ restore(ctx);
51
+ if (!shouldNudgePropose({ items: store.items, ...runState })) {
52
+ return;
53
+ }
54
+
55
+ runState.proposeNudgedThisRun = true;
56
+ sendSteer(
57
+ pi,
58
+ runState,
59
+ NUDGE_TYPE,
60
+ 'Use sideroom_todo propose now to create the live board before continuing implementation.',
61
+ );
62
+ });
63
+
64
+ pi.on('turn_end', (_event, ctx) => {
65
+ restore(ctx);
66
+ if (!shouldWatchdogUpdate({ items: store.items, ...runState })) {
67
+ return;
68
+ }
69
+
70
+ runState.updateWatchdogThisTurn = true;
71
+ sendSteer(
72
+ pi,
73
+ runState,
74
+ WATCHDOG_TYPE,
75
+ 'Use sideroom_todo update to reflect this work before continuing to the next item.',
76
+ );
77
+ });
78
+ }
79
+
80
+ function createTodoRunState(): TodoRunState {
81
+ return {
82
+ proposeNudgedThisRun: false,
83
+ updateWatchdogThisTurn: false,
84
+ turnMutated: false,
85
+ turnCalledTodo: false,
86
+ steerFromUs: false,
87
+ };
88
+ }
89
+
90
+ function resetTurnState(runState: TodoRunState): void {
91
+ runState.turnMutated = false;
92
+ runState.turnCalledTodo = false;
93
+ runState.updateWatchdogThisTurn = false;
94
+ }
95
+
96
+ function sendSteer(
97
+ pi: ExtensionAPI,
98
+ runState: TodoRunState,
99
+ customType: string,
100
+ content: string,
101
+ ): void {
102
+ runState.steerFromUs = true;
103
+ pi.sendMessage(
104
+ { customType, content, display: false },
105
+ { triggerTurn: true, deliverAs: 'steer' },
106
+ );
107
+ }
@@ -0,0 +1,91 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+ import { Text } from '@earendil-works/pi-tui';
3
+ import { executeTodo, type TodoToolDetails } from './execute.ts';
4
+ import { registerGuardEvents } from './guards.ts';
5
+ import { TOOL_NAME, TodoParamsSchema } from './model.ts';
6
+ import {
7
+ type BoardLifecycle,
8
+ commitBoard,
9
+ createTodoStore,
10
+ registerBoardContext,
11
+ registerSessionRefreshEvents,
12
+ restoreAndRefreshBoard,
13
+ restoreBoard,
14
+ TODO_WIDGET_REFRESH_EVENT,
15
+ type TodoStore,
16
+ } from './session.ts';
17
+
18
+ export const TODO_DESCRIPTION =
19
+ 'Maintain a live, user-visible work board. propose replaces the full board; update patches existing items by id.';
20
+
21
+ export const TODO_PROMPT_SNIPPET =
22
+ 'Maintain the live work board with propose and update.';
23
+
24
+ export const TODO_PROMPT_GUIDELINES = [
25
+ 'Use sideroom_todo to maintain a live work board when implementation has more than one step.',
26
+ 'Use propose to replace the board and update to patch item ids. While any item is pending, exactly one must be in_progress.',
27
+ 'Complete the current item and start the next item in the same update call.',
28
+ 'propose is interactive-TUI-only; update also works in print, JSON, and RPC modes.',
29
+ ];
30
+
31
+ // Pi loads extensions/*/index.ts through export default.
32
+ export default function registerTodo(pi: ExtensionAPI): void {
33
+ const store = createTodoStore();
34
+ const restore: BoardLifecycle = (ctx) => {
35
+ restoreBoard(store, ctx);
36
+ };
37
+ const notifyWidgetRefresh: BoardLifecycle = (ctx) => {
38
+ pi.events.emit(TODO_WIDGET_REFRESH_EVENT, ctx);
39
+ };
40
+ const restoreAndRefresh: BoardLifecycle = (ctx) => {
41
+ restoreAndRefreshBoard(store, ctx);
42
+ notifyWidgetRefresh(ctx);
43
+ };
44
+
45
+ registerSessionRefreshEvents(pi, restoreAndRefresh);
46
+ registerGuardEvents(pi, store, restore);
47
+ registerBoardContext(pi, store, restoreAndRefresh);
48
+ registerTodoTool(pi, store, notifyWidgetRefresh);
49
+ }
50
+
51
+ function registerTodoTool(
52
+ pi: ExtensionAPI,
53
+ store: TodoStore,
54
+ notifyWidgetRefresh: BoardLifecycle,
55
+ ): void {
56
+ pi.registerTool({
57
+ name: TOOL_NAME,
58
+ label: 'Sideroom Todo',
59
+ description: TODO_DESCRIPTION,
60
+ promptSnippet: TODO_PROMPT_SNIPPET,
61
+ promptGuidelines: TODO_PROMPT_GUIDELINES,
62
+ parameters: TodoParamsSchema,
63
+ executionMode: 'sequential',
64
+ execute: async (_toolCallId, params, _signal, _onUpdate, ctx) => {
65
+ const result = executeTodo(params, ctx, store.items);
66
+ if (result.details.error !== undefined) {
67
+ return result;
68
+ }
69
+
70
+ commitBoard(store, pi, ctx, result.details.items);
71
+ notifyWidgetRefresh(ctx);
72
+ return result;
73
+ },
74
+ renderCall(args, theme) {
75
+ const text = `${theme.fg('toolTitle', theme.bold(`${TOOL_NAME} `))}${theme.fg('muted', args.action)}`;
76
+ return new Text(text, 0, 0);
77
+ },
78
+ renderResult(result, _options, theme) {
79
+ const details = result.details as TodoToolDetails | undefined;
80
+ if (details?.error !== undefined) {
81
+ return new Text(theme.fg('error', details.error), 0, 0);
82
+ }
83
+ const text = result.content[0];
84
+ return new Text(
85
+ theme.fg('success', text?.type === 'text' ? text.text : ''),
86
+ 0,
87
+ 0,
88
+ );
89
+ },
90
+ });
91
+ }