@rmrdeveloper/sideroom-pi 8.5.0 → 8.6.0

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/README.md CHANGED
@@ -121,7 +121,7 @@ chat. It never creates a repository task file or a `/todos` command.
121
121
  - Widget: at most five rows — open items before resolved ones, the
122
122
  `in_progress` row always visible — plus a `…+N more · F9: view all` hint.
123
123
  `F9` opens the full board as a read-only overlay, so the widget stays small
124
- without hiding work from you; the agent still gets every item in its prompt.
124
+ without hiding work from you; the agent still gets every item in its context.
125
125
 
126
126
  ### Edited files — proof, not promises
127
127
 
@@ -157,6 +157,30 @@ function shellOperatorAt(
157
157
  return undefined;
158
158
  }
159
159
 
160
+ export function hasTestSetup(cwd: string): boolean {
161
+ if (hasPackageTestScript(cwd)) {
162
+ return true;
163
+ }
164
+ if (
165
+ existsSync(join(cwd, 'pyproject.toml')) ||
166
+ existsSync(join(cwd, 'setup.py'))
167
+ ) {
168
+ return true;
169
+ }
170
+ if (existsSync(join(cwd, 'go.mod'))) {
171
+ return true;
172
+ }
173
+ return existsSync(join(cwd, 'Cargo.toml'));
174
+ }
175
+
176
+ function hasPackageTestScript(cwd: string): boolean {
177
+ const packagePath = join(cwd, 'package.json');
178
+ if (!existsSync(packagePath)) {
179
+ return false;
180
+ }
181
+ return typeof readScripts(packagePath).test === 'string';
182
+ }
183
+
160
184
  function detectPackageCommand(cwd: string): CheckCommand | undefined {
161
185
  const packagePath = join(cwd, 'package.json');
162
186
  if (!existsSync(packagePath)) {
@@ -11,6 +11,11 @@ import {
11
11
  DONE_GATE_TYPE,
12
12
  type DoneState,
13
13
  formatDoneSteer,
14
+ formatMissingTestNotice,
15
+ isSourcePath,
16
+ isTestPath,
17
+ MISSING_TEST_NOTICE_TYPE,
18
+ shouldNoticeMissingTest,
14
19
  shouldSteerDone,
15
20
  } from './model.ts';
16
21
 
@@ -18,18 +23,29 @@ export type ResolveCommand = (
18
23
  ctx: Pick<ExtensionContext, 'cwd'>,
19
24
  ) => CheckCommand | undefined;
20
25
 
26
+ export type ResolveTestSetup = (ctx: Pick<ExtensionContext, 'cwd'>) => boolean;
27
+
21
28
  export function registerDoneGuard(
22
29
  pi: ExtensionAPI,
23
30
  state: DoneState,
24
31
  resolveCommand: ResolveCommand,
32
+ resolveTestSetup: ResolveTestSetup,
25
33
  ): void {
26
34
  const checkRuns = new Set<string>();
35
+ const mutationPaths = new Map<string, string>();
27
36
 
28
37
  pi.on('turn_start', () => {
29
38
  state.steeredThisTurn = false;
30
39
  });
31
40
 
32
41
  pi.on('tool_call', (event, ctx) => {
42
+ if (
43
+ isToolCallEventType('write', event) ||
44
+ isToolCallEventType('edit', event)
45
+ ) {
46
+ mutationPaths.set(event.toolCallId, event.input.path);
47
+ return;
48
+ }
33
49
  if (!isToolCallEventType('bash', event)) {
34
50
  return;
35
51
  }
@@ -44,10 +60,14 @@ export function registerDoneGuard(
44
60
 
45
61
  pi.on('tool_result', (event) => {
46
62
  if (isWriteToolResult(event) || isEditToolResult(event)) {
47
- if (!event.isError) {
48
- state.mutated = true;
49
- state.checksGreen = false;
63
+ const path = mutationPaths.get(event.toolCallId);
64
+ mutationPaths.delete(event.toolCallId);
65
+ if (event.isError) {
66
+ return;
50
67
  }
68
+ state.mutated = true;
69
+ state.checksGreen = false;
70
+ recordMutationPath(state, path);
51
71
  return;
52
72
  }
53
73
  if (isBashToolResult(event)) {
@@ -60,18 +80,45 @@ export function registerDoneGuard(
60
80
 
61
81
  pi.on('turn_end', (_event, ctx) => {
62
82
  const check = resolveCommand(ctx);
63
- if (check === undefined || !shouldSteerDone(state, true)) {
83
+ if (check !== undefined && shouldSteerDone(state, true)) {
84
+ state.steeredThisTurn = true;
85
+ state.steerCount += 1;
86
+ pi.sendMessage(
87
+ {
88
+ customType: DONE_GATE_TYPE,
89
+ content: formatDoneSteer(check.display),
90
+ display: false,
91
+ },
92
+ { triggerTurn: true, deliverAs: 'steer' },
93
+ );
94
+ return;
95
+ }
96
+
97
+ if (!shouldNoticeMissingTest(state, resolveTestSetup(ctx))) {
64
98
  return;
65
99
  }
66
- state.steeredThisTurn = true;
100
+ state.noticedMissingTest = true;
67
101
  state.steerCount += 1;
68
102
  pi.sendMessage(
69
103
  {
70
- customType: DONE_GATE_TYPE,
71
- content: formatDoneSteer(check.display),
104
+ customType: MISSING_TEST_NOTICE_TYPE,
105
+ content: formatMissingTestNotice(),
72
106
  display: false,
73
107
  },
74
108
  { triggerTurn: true, deliverAs: 'steer' },
75
109
  );
76
110
  });
77
111
  }
112
+
113
+ function recordMutationPath(state: DoneState, path: string | undefined): void {
114
+ if (path === undefined) {
115
+ return;
116
+ }
117
+ if (isTestPath(path)) {
118
+ state.mutatedTest = true;
119
+ return;
120
+ }
121
+ if (isSourcePath(path)) {
122
+ state.mutatedSource = true;
123
+ }
124
+ }
@@ -2,14 +2,23 @@ import type {
2
2
  ExtensionAPI,
3
3
  ExtensionContext,
4
4
  } from '@earendil-works/pi-coding-agent';
5
- import { type CheckCommand, detectCheckCommand } from './detect.ts';
6
- import { type ResolveCommand, registerDoneGuard } from './guard.ts';
5
+ import {
6
+ type CheckCommand,
7
+ detectCheckCommand,
8
+ hasTestSetup,
9
+ } from './detect.ts';
10
+ import {
11
+ type ResolveCommand,
12
+ type ResolveTestSetup,
13
+ registerDoneGuard,
14
+ } from './guard.ts';
7
15
  import { createDoneState, resetDoneRun } from './model.ts';
8
16
 
9
17
  // Pi loads extensions/*/index.ts through export default.
10
18
  export default function registerDone(pi: ExtensionAPI): void {
11
19
  const state = createDoneState();
12
20
  const cache = new Map<string, CheckCommand | undefined>();
21
+ const testCache = new Map<string, boolean>();
13
22
  const resolveCommand: ResolveCommand = (
14
23
  ctx: Pick<ExtensionContext, 'cwd'>,
15
24
  ) => {
@@ -20,8 +29,19 @@ export default function registerDone(pi: ExtensionAPI): void {
20
29
  cache.set(ctx.cwd, command);
21
30
  return command;
22
31
  };
32
+ const resolveTestSetup: ResolveTestSetup = (
33
+ ctx: Pick<ExtensionContext, 'cwd'>,
34
+ ) => {
35
+ const cached = testCache.get(ctx.cwd);
36
+ if (cached !== undefined) {
37
+ return cached;
38
+ }
39
+ const hasTests = hasTestSetup(ctx.cwd);
40
+ testCache.set(ctx.cwd, hasTests);
41
+ return hasTests;
42
+ };
23
43
 
24
- registerDoneGuard(pi, state, resolveCommand);
44
+ registerDoneGuard(pi, state, resolveCommand, resolveTestSetup);
25
45
 
26
46
  pi.on('input', (event) => {
27
47
  if (event.source === 'interactive' || event.source === 'rpc') {
@@ -3,18 +3,35 @@ export interface DoneState {
3
3
  checksGreen: boolean;
4
4
  steeredThisTurn: boolean;
5
5
  steerCount: number;
6
+ mutatedSource: boolean;
7
+ mutatedTest: boolean;
8
+ noticedMissingTest: boolean;
6
9
  }
7
10
 
8
11
  export const MAX_DONE_STEERS = 2;
9
12
 
10
13
  export const DONE_GATE_TYPE = 'sideroom-done-gate';
11
14
 
15
+ export const MISSING_TEST_NOTICE_TYPE = 'sideroom-missing-test-notice';
16
+
17
+ const TEST_DIRECTORY = /(?:^|\/)(?:__tests__|tests?|specs?)(?:\/|$)/;
18
+
19
+ // Test paths are recognised by name only: the notice never claims the right
20
+ // test exists, so a missed convention only costs a nudge, never a false pass.
21
+ const TEST_FILE =
22
+ /(?:^|\/)(?:test_[\w.-]*\.py|[\w.-]*[._](?:test|spec)\.[\w.-]+|[\w.-]*(?:Test|Tests)\.java)$/;
23
+
24
+ const SOURCE_FILE = /\.(?:[cm]?js|jsx|ts|tsx|go|java|php|py|rs)$/i;
25
+
12
26
  export function createDoneState(): DoneState {
13
27
  return {
14
28
  mutated: false,
15
29
  checksGreen: false,
16
30
  steeredThisTurn: false,
17
31
  steerCount: 0,
32
+ mutatedSource: false,
33
+ mutatedTest: false,
34
+ noticedMissingTest: false,
18
35
  };
19
36
  }
20
37
 
@@ -23,6 +40,19 @@ export function resetDoneRun(state: DoneState): void {
23
40
  state.checksGreen = false;
24
41
  state.steeredThisTurn = false;
25
42
  state.steerCount = 0;
43
+ state.mutatedSource = false;
44
+ state.mutatedTest = false;
45
+ state.noticedMissingTest = false;
46
+ }
47
+
48
+ export function isTestPath(path: string): boolean {
49
+ const normalized = path.replaceAll('\\', '/');
50
+ return TEST_DIRECTORY.test(normalized) || TEST_FILE.test(normalized);
51
+ }
52
+
53
+ export function isSourcePath(path: string): boolean {
54
+ const normalized = path.replaceAll('\\', '/');
55
+ return SOURCE_FILE.test(normalized);
26
56
  }
27
57
 
28
58
  export function shouldSteerDone(
@@ -38,6 +68,23 @@ export function shouldSteerDone(
38
68
  );
39
69
  }
40
70
 
71
+ export function shouldNoticeMissingTest(
72
+ state: DoneState,
73
+ hasTests: boolean,
74
+ ): boolean {
75
+ return (
76
+ hasTests &&
77
+ state.mutatedSource &&
78
+ !state.mutatedTest &&
79
+ !state.noticedMissingTest &&
80
+ state.steerCount < MAX_DONE_STEERS
81
+ );
82
+ }
83
+
41
84
  export function formatDoneSteer(display: string): string {
42
85
  return `Run \`${display}\` and make it pass before finishing.`;
43
86
  }
87
+
88
+ export function formatMissingTestNotice(): string {
89
+ return 'This run changed code files and no test file. Add or update the test that covers the change before finishing.';
90
+ }
@@ -18,19 +18,23 @@ export type LanguageId = (typeof LANGUAGE)[keyof typeof LANGUAGE];
18
18
 
19
19
  export type RuleId =
20
20
  | 'braced-conditionals'
21
+ | 'explicit-any'
21
22
  | 'explicit-error-handling'
22
23
  | 'clear-names'
23
24
  | 'comments'
24
- | 'debug-artifacts';
25
+ | 'debug-artifacts'
26
+ | 'suppressed-type-errors';
25
27
 
26
28
  export type RuleSeverity = (typeof SEVERITY)[keyof typeof SEVERITY];
27
29
 
28
30
  export const RULE_SEVERITY: Readonly<Record<RuleId, RuleSeverity>> = {
29
31
  'braced-conditionals': SEVERITY.block,
32
+ 'explicit-any': SEVERITY.block,
30
33
  'explicit-error-handling': SEVERITY.block,
31
34
  'clear-names': SEVERITY.warn,
32
35
  comments: SEVERITY.warn,
33
36
  'debug-artifacts': SEVERITY.warn,
37
+ 'suppressed-type-errors': SEVERITY.warn,
34
38
  };
35
39
 
36
40
  export function languageForPath(path: string): LanguageId {
@@ -69,3 +73,7 @@ export function languageForPath(path: string): LanguageId {
69
73
  export function supportsBracedConditionals(language: LanguageId): boolean {
70
74
  return language !== LANGUAGE.python && language !== LANGUAGE.generic;
71
75
  }
76
+
77
+ export function supportsExplicitAny(language: LanguageId): boolean {
78
+ return language === LANGUAGE.typescript;
79
+ }
@@ -4,6 +4,7 @@ import {
4
4
  RULE_SEVERITY,
5
5
  type RuleId,
6
6
  supportsBracedConditionals,
7
+ supportsExplicitAny,
7
8
  } from './catalog.ts';
8
9
  import { maskNonCode } from './lexer.ts';
9
10
  import type { AddedLine, RuleViolation } from './model.ts';
@@ -38,6 +39,29 @@ const DEBUG_PATTERNS: readonly RegExp[] = [
38
39
  /\bdbg!\s*\(/,
39
40
  ];
40
41
 
42
+ // Keep these patterns on syntax that can only introduce a type. Value-level
43
+ // commas, assignments, and operators may legally surround an identifier named
44
+ // `any`, so they are deliberately absent.
45
+ const EXPLICIT_ANY_PATTERNS: readonly RegExp[] = [
46
+ /\bas\s+any\b/,
47
+ /\b(?:keyof|satisfies)\s+any\b/,
48
+ /\bany\s*\[\s*\]/,
49
+ /\b[$\w]+<[^<>\n]*\bany\b[^<>\n]*>(?!\s*[$\w])/,
50
+ /\binterface\s+[$\w]+(?:\s*<[^;{\n]*>)?\s*\{[^}\n]*:\s*[^;}\n]*\bany\b/,
51
+ /\b(?:const|let|var)\s+[$\w]+\s*:\s*(?:(?:=>)|[^;=\n])*\bany\b/,
52
+ /(?:^|[(,])\s*(?:\.\.\.)?[$\w]+\??\s*:\s*(?:(?:=>)|[^;=\n])*\bany\b/,
53
+ /\)\s*:\s*(?:(?:=>)|[^;={\n])*\bany\b/,
54
+ /^\s*(?:(?:public|private|protected|readonly|static|declare|abstract)\s+)*[$\w]+[?!]?\s*:\s*(?:(?:=>)|[^;,=\n])*\bany\b[^;=\n]*(?:;|=)/,
55
+ ];
56
+
57
+ // A suppression is a comment whose first token is the directive; prose that
58
+ // mentions the directive, or a string that quotes it, is not one.
59
+ const SUPPRESSED_TYPE_ERRORS =
60
+ /(?:^|\s)(?:\/\/|\/\*)\s*@ts-(?:ignore|nocheck)\b/;
61
+
62
+ const TYPE_ALIAS = /\btype\s+[$\w]+(?:\s*<[^;=]*>)?\s*=/g;
63
+ const ANY_TOKEN = /\bany\b/g;
64
+
41
65
  const FUNCTION_PARAMS: readonly RegExp[] = [
42
66
  /\bfunction\s+\w*\s*\(([^()]*)\)/g,
43
67
  /\bdef\s+\w+\s*\(([^()]*)\)/g,
@@ -54,10 +78,17 @@ export function evaluateAddedLines(
54
78
  const code = maskNonCode(joined, language);
55
79
  const codeLines = code.split('\n');
56
80
  const usesBracedConditionals = supportsBracedConditionals(language);
81
+ const usesExplicitAny = supportsExplicitAny(language);
82
+ const commentLines = usesExplicitAny
83
+ ? maskNonCode(joined, language, { keepComments: true }).split('\n')
84
+ : [];
57
85
 
58
86
  if (usesBracedConditionals) {
59
87
  collectBracedConditionals(code, lines, violations);
60
88
  }
89
+ if (usesExplicitAny) {
90
+ collectTypeAliasAny(code, lines, violations);
91
+ }
61
92
 
62
93
  for (let index = 0; index < lines.length; index += 1) {
63
94
  const added = lines[index];
@@ -71,6 +102,13 @@ export function evaluateAddedLines(
71
102
  if (language === LANGUAGE.python) {
72
103
  collectPythonSuite(codeLine, violations);
73
104
  }
105
+ if (usesExplicitAny) {
106
+ collectExplicitAny(codeLine, violations);
107
+ collectSuppressedTypeErrors(
108
+ { line: added.line, text: commentLines[index] ?? '' },
109
+ violations,
110
+ );
111
+ }
74
112
  collectClearNames(codeLine, violations);
75
113
  collectComments(added, violations);
76
114
  collectDebugArtifacts(codeLine, violations);
@@ -274,6 +312,123 @@ function collectDebugArtifacts(
274
312
  }
275
313
  }
276
314
 
315
+ function collectExplicitAny(
316
+ codeLine: AddedLine,
317
+ violations: RuleViolation[],
318
+ ): void {
319
+ const typeCode = codeLine.text
320
+ .replaceAll(/\btypeof\s+any\b/g, (match) => ' '.repeat(match.length))
321
+ .replaceAll(/^\s*(?:import|export)\s*\{[^}]*\}/g, (match) =>
322
+ ' '.repeat(match.length),
323
+ );
324
+ if (EXPLICIT_ANY_PATTERNS.some((pattern) => pattern.test(typeCode))) {
325
+ violations.push(
326
+ violation(
327
+ 'explicit-any',
328
+ "[explicit-any] Replace 'any' with the type the value actually has; use 'unknown' only when it must be narrowed first.",
329
+ codeLine.line,
330
+ ),
331
+ );
332
+ }
333
+ }
334
+
335
+ function collectTypeAliasAny(
336
+ code: string,
337
+ lines: readonly AddedLine[],
338
+ violations: RuleViolation[],
339
+ ): void {
340
+ const aliases = code.matchAll(
341
+ new RegExp(TYPE_ALIAS.source, TYPE_ALIAS.flags),
342
+ );
343
+ for (const alias of aliases) {
344
+ const bodyOffset = (alias.index ?? 0) + alias[0].length;
345
+ const endOffset = typeAliasEndOffset(code, bodyOffset);
346
+ const body = code.slice(bodyOffset, endOffset);
347
+ for (const anyMatch of body.matchAll(
348
+ new RegExp(ANY_TOKEN.source, ANY_TOKEN.flags),
349
+ )) {
350
+ const anyOffset = bodyOffset + (anyMatch.index ?? 0);
351
+ if (isValueLevelAnyInType(code, anyOffset)) {
352
+ continue;
353
+ }
354
+ violations.push(
355
+ violation(
356
+ 'explicit-any',
357
+ "[explicit-any] Replace 'any' with the type the value actually has; use 'unknown' only when it must be narrowed first.",
358
+ lineForOffset(code, lines, anyOffset),
359
+ ),
360
+ );
361
+ }
362
+ }
363
+ }
364
+
365
+ function typeAliasEndOffset(code: string, bodyOffset: number): number {
366
+ let braceDepth = 0;
367
+ let bracketDepth = 0;
368
+ let parenthesisDepth = 0;
369
+ for (let index = bodyOffset; index < code.length; index += 1) {
370
+ const character = code[index];
371
+ if (character === '{') {
372
+ braceDepth += 1;
373
+ continue;
374
+ }
375
+ if (character === '}') {
376
+ braceDepth -= 1;
377
+ continue;
378
+ }
379
+ if (character === '[') {
380
+ bracketDepth += 1;
381
+ continue;
382
+ }
383
+ if (character === ']') {
384
+ bracketDepth -= 1;
385
+ continue;
386
+ }
387
+ if (character === '(') {
388
+ parenthesisDepth += 1;
389
+ continue;
390
+ }
391
+ if (character === ')') {
392
+ parenthesisDepth -= 1;
393
+ continue;
394
+ }
395
+ if (
396
+ character === ';' &&
397
+ braceDepth === 0 &&
398
+ bracketDepth === 0 &&
399
+ parenthesisDepth === 0
400
+ ) {
401
+ return index;
402
+ }
403
+ }
404
+ return code.length;
405
+ }
406
+
407
+ function isValueLevelAnyInType(code: string, anyOffset: number): boolean {
408
+ const prefix = code.slice(0, anyOffset);
409
+ if (/\btypeof\s*$/.test(prefix) || /\.\s*$/.test(prefix)) {
410
+ return true;
411
+ }
412
+ const suffix = code.slice(anyOffset + 'any'.length);
413
+ return /^\s*\??\s*(?::|\()/.test(suffix);
414
+ }
415
+
416
+ function collectSuppressedTypeErrors(
417
+ commentLine: AddedLine,
418
+ violations: RuleViolation[],
419
+ ): void {
420
+ if (!SUPPRESSED_TYPE_ERRORS.test(commentLine.text)) {
421
+ return;
422
+ }
423
+ violations.push(
424
+ violation(
425
+ 'suppressed-type-errors',
426
+ "[suppressed-type-errors] Replace '@ts-ignore' or '@ts-nocheck' with '@ts-expect-error' and an explanation, or fix the type.",
427
+ commentLine.line,
428
+ ),
429
+ );
430
+ }
431
+
277
432
  function closingParenthesisOffset(
278
433
  code: string,
279
434
  openingOffset: number,
@@ -9,8 +9,18 @@ const LEXICAL_MODE = {
9
9
 
10
10
  type LexicalMode = (typeof LEXICAL_MODE)[keyof typeof LEXICAL_MODE];
11
11
 
12
- export function maskNonCode(source: string, language: LanguageId): string {
12
+ export interface MaskOptions {
13
+ /** Keep comment text so a caller can read the directives inside it. */
14
+ readonly keepComments?: boolean;
15
+ }
16
+
17
+ export function maskNonCode(
18
+ source: string,
19
+ language: LanguageId,
20
+ options: MaskOptions = {},
21
+ ): string {
13
22
  const masked = [...source];
23
+ const keepComments = options.keepComments === true;
14
24
  let mode: LexicalMode = LEXICAL_MODE.code;
15
25
  let delimiter = '';
16
26
 
@@ -22,20 +32,20 @@ export function maskNonCode(source: string, language: LanguageId): string {
22
32
  mode = LEXICAL_MODE.code;
23
33
  continue;
24
34
  }
25
- maskCharacter(masked, index);
35
+ maskComment(masked, index, keepComments);
26
36
  continue;
27
37
  }
28
38
 
29
39
  if (mode === LEXICAL_MODE.blockComment) {
30
40
  const closesComment = character === '*' && source[index + 1] === '/';
31
41
  if (closesComment) {
32
- maskCharacter(masked, index);
33
- maskCharacter(masked, index + 1);
42
+ maskComment(masked, index, keepComments);
43
+ maskComment(masked, index + 1, keepComments);
34
44
  index += 1;
35
45
  mode = LEXICAL_MODE.code;
36
46
  continue;
37
47
  }
38
- maskCharacter(masked, index);
48
+ maskComment(masked, index, keepComments);
39
49
  continue;
40
50
  }
41
51
 
@@ -58,7 +68,7 @@ export function maskNonCode(source: string, language: LanguageId): string {
58
68
  mode = LEXICAL_MODE.code;
59
69
  continue;
60
70
  }
61
- if (delimiter.length === 1 && character === '\n') {
71
+ if (delimiter !== '`' && character === '\n') {
62
72
  mode = LEXICAL_MODE.code;
63
73
  continue;
64
74
  }
@@ -68,8 +78,8 @@ export function maskNonCode(source: string, language: LanguageId): string {
68
78
 
69
79
  const startsLineComment = character === '/' && source[index + 1] === '/';
70
80
  if (startsLineComment) {
71
- maskCharacter(masked, index);
72
- maskCharacter(masked, index + 1);
81
+ maskComment(masked, index, keepComments);
82
+ maskComment(masked, index + 1, keepComments);
73
83
  index += 1;
74
84
  mode = LEXICAL_MODE.lineComment;
75
85
  continue;
@@ -77,8 +87,8 @@ export function maskNonCode(source: string, language: LanguageId): string {
77
87
 
78
88
  const startsBlockComment = character === '/' && source[index + 1] === '*';
79
89
  if (startsBlockComment) {
80
- maskCharacter(masked, index);
81
- maskCharacter(masked, index + 1);
90
+ maskComment(masked, index, keepComments);
91
+ maskComment(masked, index + 1, keepComments);
82
92
  index += 1;
83
93
  mode = LEXICAL_MODE.blockComment;
84
94
  continue;
@@ -87,7 +97,7 @@ export function maskNonCode(source: string, language: LanguageId): string {
87
97
  const startsHashComment =
88
98
  character === '#' && supportsHashComments(language);
89
99
  if (startsHashComment) {
90
- maskCharacter(masked, index);
100
+ maskComment(masked, index, keepComments);
91
101
  mode = LEXICAL_MODE.lineComment;
92
102
  continue;
93
103
  }
@@ -134,3 +144,14 @@ function maskCharacter(masked: string[], index: number): void {
134
144
  masked[index] = ' ';
135
145
  }
136
146
  }
147
+
148
+ function maskComment(
149
+ masked: string[],
150
+ index: number,
151
+ keepComments: boolean,
152
+ ): void {
153
+ if (keepComments) {
154
+ return;
155
+ }
156
+ maskCharacter(masked, index);
157
+ }
@@ -50,7 +50,7 @@ export default function registerTodo(pi: ExtensionAPI): void {
50
50
  notifyWidgetRefresh(ctx);
51
51
  };
52
52
 
53
- registerSessionRefreshEvents(pi, restoreAndRefresh);
53
+ registerSessionRefreshEvents(pi, store, restoreAndRefresh);
54
54
  registerGuardEvents(pi, store, restore);
55
55
  registerBoardContext(pi, store, restoreAndRefresh);
56
56
  registerTodoTool(pi, store, notifyWidgetRefresh);
@@ -13,11 +13,13 @@ import {
13
13
  import { renderTodoWidget } from './ui.ts';
14
14
 
15
15
  export const SNAPSHOT_TYPE = 'sideroom-todo';
16
+ export const BOARD_MESSAGE_TYPE = 'sideroom-todo-board';
16
17
  export const WIDGET_KEY = 'sideroom-todo';
17
18
  export const TODO_WIDGET_REFRESH_EVENT = 'sideroom:todo-widget-refreshed';
18
19
 
19
20
  export interface TodoStore {
20
21
  items: readonly TodoItem[];
22
+ lastSentBlock?: string;
21
23
  }
22
24
 
23
25
  export type BoardLifecycle = (ctx: ExtensionContext) => void;
@@ -89,11 +91,29 @@ export function commitBoard(
89
91
 
90
92
  export function registerSessionRefreshEvents(
91
93
  pi: ExtensionAPI,
94
+ store: TodoStore,
92
95
  restoreAndRefresh: BoardLifecycle,
93
96
  ): void {
94
- pi.on('session_start', (_event, ctx) => restoreAndRefresh(ctx));
95
- pi.on('session_tree', (_event, ctx) => restoreAndRefresh(ctx));
96
- pi.on('session_compact', (_event, ctx) => restoreAndRefresh(ctx));
97
+ // A restore can drop the sent block from the model's context, so an unchanged
98
+ // board still has to be announced again.
99
+ const restoreSession = (ctx: ExtensionContext) => {
100
+ restoreAndRefresh(ctx);
101
+ forgetSentBoardBlock(store);
102
+ };
103
+ pi.on('session_start', (_event, ctx) => restoreSession(ctx));
104
+ pi.on('session_tree', (_event, ctx) => restoreSession(ctx));
105
+ pi.on('session_compact', (event, ctx) => {
106
+ restoreSession(ctx);
107
+ if (!event.willRetry) {
108
+ return;
109
+ }
110
+ const block = pendingBoardBlock(store);
111
+ if (block === undefined) {
112
+ return;
113
+ }
114
+ store.lastSentBlock = block;
115
+ pi.sendMessage(boardMessage(block), { deliverAs: 'steer' });
116
+ });
97
117
  }
98
118
 
99
119
  export function registerBoardContext(
@@ -101,17 +121,46 @@ export function registerBoardContext(
101
121
  store: TodoStore,
102
122
  restoreAndRefresh: BoardLifecycle,
103
123
  ): void {
104
- pi.on('before_agent_start', (event, ctx) => {
124
+ pi.on('before_agent_start', (_event, ctx) => {
105
125
  restoreAndRefresh(ctx);
106
- if (store.items.length === 0) {
126
+ const block = pendingBoardBlock(store);
127
+ if (block === undefined) {
107
128
  return;
108
129
  }
109
- return {
110
- systemPrompt: `${event.systemPrompt}\n\n${formatBoardBlock(store.items)}`,
111
- };
130
+ store.lastSentBlock = block;
131
+ return { message: boardMessage(block) };
112
132
  });
113
133
  }
114
134
 
135
+ // The block rides a session message instead of the system prompt: a volatile
136
+ // system prompt invalidates the cached prefix of the whole request.
137
+ function pendingBoardBlock(store: TodoStore): string | undefined {
138
+ if (store.items.length === 0) {
139
+ return undefined;
140
+ }
141
+ const block = formatBoardBlock(store.items);
142
+ if (block === store.lastSentBlock) {
143
+ return undefined;
144
+ }
145
+ return block;
146
+ }
147
+
148
+ function forgetSentBoardBlock(store: TodoStore): void {
149
+ store.lastSentBlock = undefined;
150
+ }
151
+
152
+ function boardMessage(block: string): {
153
+ customType: typeof BOARD_MESSAGE_TYPE;
154
+ content: string;
155
+ display: false;
156
+ } {
157
+ return {
158
+ customType: BOARD_MESSAGE_TYPE,
159
+ content: block,
160
+ display: false,
161
+ };
162
+ }
163
+
115
164
  function findSnapshotItems(
116
165
  branch: SessionBranch,
117
166
  ): readonly TodoItem[] | undefined {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmrdeveloper/sideroom-pi",
3
- "version": "8.5.0",
3
+ "version": "8.6.0",
4
4
  "description": "Pi coding-agent package that asks clarifying questions, shows a live todo board, enforces coding guidelines, and will not call it done while your checks are red.",
5
5
  "type": "module",
6
6
  "files": [
@@ -22,6 +22,7 @@
22
22
  "format": "biome format --write extensions scripts package.json biome.json commitlint.config.mjs .changeset/config.json",
23
23
  "format:check": "biome format extensions scripts package.json biome.json commitlint.config.mjs .changeset/config.json",
24
24
  "preview": "node scripts/render-preview.mjs",
25
+ "cache:report": "node scripts/cache-report.mjs",
25
26
  "check:if-blocks": "node scripts/check-if-blocks.mjs",
26
27
  "check": "npm run types && npm run check:if-blocks && biome check extensions scripts package.json biome.json commitlint.config.mjs .changeset/config.json && npm test"
27
28
  },
@@ -69,7 +70,8 @@
69
70
  },
70
71
  "pi": {
71
72
  "extensions": [
72
- "./extensions"
73
+ "./extensions",
74
+ "!extensions/*.test.ts"
73
75
  ],
74
76
  "skills": [
75
77
  "./skills"