@rmrdeveloper/sideroom-pi 8.9.0 → 8.10.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
@@ -193,15 +193,19 @@ Reading the guide covers the mechanical rules. `jev` covers the other kind: one
193
193
  optional call to TypeSafe's Jev decision model about the six guide rules a
194
194
  single file can answer (guard clauses, fail fast, command/query separation,
195
195
  null handling, immutability, validate once) on the file a `write` or `edit` just
196
- changed. The answer arrives as a probability, and a note is appended to the tool
197
- result when it clears the cutoff.
196
+ changed. The answer arrives as a probability, and when it clears the cutoff the
197
+ note travels with the guidelines review at the end of the run instead of
198
+ interrupting each edit. READMEs, JSON, lockfiles, deletion-only edits, and files
199
+ outside the project are never sent.
198
200
 
199
201
  It never blocks and never enters the system prompt. The state is the file and
200
202
  the added lines and nothing else — no conversation, no session, no neighbours.
201
203
  The footer counts the calls the session made, and `F10` shows the same count
202
204
  beside the key source. Without a key nothing is called, and when Jev runs out of
203
205
  quota, rejects the key,
204
- or stops answering, the extension goes quiet without touching the turn. The key
206
+ or stops answering, the extension goes quiet without touching the turn. A rate
207
+ limit only pauses it until your next prompt, and Escape cuts a request in
208
+ flight. The key
205
209
  lives in `TYPESAFE_API_KEY` or in your agent directory, and `F10` captures it
206
210
  without ever writing it to the session.
207
211
 
@@ -230,11 +234,12 @@ nothing is detectable, the gate does nothing.
230
234
 
231
235
  When an implementation settles, `explain` steers the agent to offer a
232
236
  walkthrough through `sideroom_ask`: explain the changes only, how to test them
233
- only, both, or nothing. It fires on `agent_settled`, the point where
234
- no retry, compaction, or queued message is left, so the offer never lands on
235
- work that is about to be redone. It waits one settle so the guidelines review
236
- runs first; both fire on `agent_settled`, and Pi would otherwise order them by
237
- extension load. Once per turn, only when the turn touched at least five distinct
237
+ only, both, or nothing. It fires on `agent_before_settle`, after every retry
238
+ and compaction recovery, so the offer never lands on work that is about to be
239
+ redone, and it stays inside the same run. It waits one boundary so the
240
+ guidelines review runs first; both use the same event, and Pi would otherwise
241
+ order them by extension load. It stays quiet after Escape, after an error, and
242
+ while a message you typed is waiting. Once per turn, only when the turn touched at least five distinct
238
243
  files, and only in the TUI, because `sideroom_ask` cannot run anywhere else. No
239
244
  tool, no widget, no persisted state.
240
245
 
@@ -312,10 +317,12 @@ also fully read the packaged guide matching the target path. Reads with
312
317
  The extension blocks the mutation until the required reads succeed; unsupported
313
318
  languages use the shared table. Do not route around
314
319
  the gate through Bash or another file-mutation path. After any successful
315
- `write`/`edit`, one hidden review steer fires on `agent_settled` (at most once
316
- per turn) asking the agent to re-check every changed file against the loaded
317
- guide and run the relevant project checks. Interactive input clears the steer
318
- flags; `before_agent_start` does not, so a steer continuation never re-triggers
320
+ `write`/`edit`, one hidden review steer fires on `agent_before_settle` (at most
321
+ once per turn) asking the agent to re-check every changed file against the
322
+ loaded guide and run the relevant project checks. It is skipped after Escape,
323
+ after an error, and while a user message is pending. Interactive input sent
324
+ while the agent is idle clears the steer flags; a message typed during a run
325
+ and `before_agent_start` do not, so a steer continuation never re-triggers
319
326
  itself.
320
327
 
321
328
  ### Monorepo-skills contract
@@ -347,15 +354,21 @@ itself.
347
354
 
348
355
  - Off without a key: no request, and behavior identical to a package without
349
356
  this extension.
350
- - Warn only. A finding is text appended to the tool result; nothing blocks and
351
- nothing reaches the system prompt.
352
- - One request per successful `write`/`edit`, with six `noul` questions on one
353
- shared state of the file body and the added lines.
354
- - A body over 60k characters is skipped rather than truncated. An answer at or
355
- above `0.8` probability becomes a note.
356
- - Quota (`402`) and a rejected key (`401`, `403`) stop the calls immediately;
357
- network errors, `429` and `5xx` stop after three consecutive failures. A
358
- success rearms the breaker, and `F10` rearms it by hand.
357
+ - Warn only. A finding goes to the guidelines review on `sideroom:review-note`
358
+ and rides its end-of-run steer, with one follow-up per turn for notes that
359
+ arrive after the review fired; nothing blocks and nothing reaches the system
360
+ prompt.
361
+ - One request per successful `write`/`edit` of a project file in a supported
362
+ language that added lines, with six `noul` questions on one shared state of
363
+ the project-relative path, the file body, and the added lines.
364
+ - A serialized state over 60k characters is skipped rather than truncated. An
365
+ answer at or above `0.8` probability becomes a note.
366
+ - The request follows the run's abort signal; a cancelled request is not a
367
+ failure.
368
+ - Quota (`402`) and a rejected key (`401`, `403`) stop the calls immediately. A
369
+ rate limit (`429`) pauses them until the next idle prompt. Network errors and
370
+ other `4xx`/`5xx` stop after three consecutive failures. A success rearms the
371
+ breaker, and `F10` rearms it by hand.
359
372
  - Every request increments a session call counter, shown in the footer and on
360
373
  `F10`; it resets on `session_start` and is never persisted.
361
374
  - The key comes from `TYPESAFE_API_KEY` or from
@@ -390,12 +403,13 @@ itself.
390
403
 
391
404
  ### Explain contract
392
405
 
393
- - Trigger: `agent_settled` with at least five distinct files mutated since the
394
- last user prompt, in TUI mode. File identity follows Pi's path aliases and
395
- canonicalizes existing paths.
396
- - One settle of delay: the guidelines review steers on the same event, and Pi
397
- runs both deferred steers in extension load order, so `explain` offers at the
398
- next settle.
406
+ - Trigger: `agent_before_settle` with at least five distinct files mutated
407
+ since the last idle user prompt, in TUI mode, on a completed run with no user
408
+ message pending. File identity follows Pi's path aliases and canonicalizes
409
+ existing paths.
410
+ - One boundary of delay: the guidelines review steers on the same event, and Pi
411
+ runs both handlers in extension load order, so `explain` offers at the next
412
+ boundary.
399
413
  - The steer names the intent; the agent writes the question in the user's
400
414
  language with four options, one of them a decline, and one recommendation.
401
415
  `sideroom_ask` still adds *Out of scope* and the custom answer.
@@ -3,6 +3,11 @@ import {
3
3
  isEditToolResult,
4
4
  isWriteToolResult,
5
5
  } from '@earendil-works/pi-coding-agent';
6
+ import {
7
+ acceptsSettleSteer,
8
+ startsUserTurn,
9
+ withHiddenSteer,
10
+ } from '../shared/settle.ts';
6
11
  import {
7
12
  applyOfferDecision,
8
13
  decideOffer,
@@ -24,7 +29,7 @@ export function registerExplainGuard(
24
29
  state: ExplainState,
25
30
  ): void {
26
31
  pi.on('input', (event) => {
27
- if (event.source !== 'interactive' && event.source !== 'rpc') {
32
+ if (!startsUserTurn(event)) {
28
33
  return;
29
34
  }
30
35
  resetExplainTurn(state);
@@ -46,21 +51,18 @@ export function registerExplainGuard(
46
51
  recordMutation(state, pathKey);
47
52
  });
48
53
 
49
- // agent_settled is the only point with no retry, compaction, or queued
50
- // continuation left, so the walkthrough is never offered over reworked work.
51
- pi.on('agent_settled', (_event, ctx) => {
54
+ // agent_before_settle runs after retries and compaction recovery, so the
55
+ // walkthrough is never offered over reworked work, and its continuation
56
+ // stays inside the same run instead of starting a second one.
57
+ pi.on('agent_before_settle', (event, ctx) => {
58
+ if (!acceptsSettleSteer(event)) {
59
+ return;
60
+ }
52
61
  const decision = decideOffer(state, ctx.mode);
53
62
  applyOfferDecision(state, decision);
54
63
  if (decision !== EXPLAIN_DECISION.send) {
55
64
  return;
56
65
  }
57
- pi.sendMessage(
58
- {
59
- customType: EXPLAIN_OFFER_TYPE,
60
- content: EXPLAIN_OFFER,
61
- display: false,
62
- },
63
- { triggerTurn: true, deliverAs: 'steer' },
64
- );
66
+ return withHiddenSteer(event, EXPLAIN_OFFER_TYPE, EXPLAIN_OFFER);
65
67
  });
66
68
  }
@@ -54,9 +54,10 @@ export function recordMutation(state: ExplainState, pathKey: string): void {
54
54
  state.mutatedSinceSettle = true;
55
55
  }
56
56
 
57
- // The guidelines review steers at the first settle after a mutation, and
58
- // agent_settled runs both deferred steers in extension load order. Explain
59
- // holds its own steer back one settle so the review always runs first.
57
+ // The guidelines review steers at the first settle boundary after a mutation,
58
+ // and both handlers run in an extension load order Sideroom does not control.
59
+ // Explain holds its steer back one boundary so the review always runs first;
60
+ // the review's continuation is what brings the next boundary.
60
61
  export function decideOffer(
61
62
  state: ExplainState,
62
63
  mode: string,
@@ -3,36 +3,82 @@ import {
3
3
  isEditToolResult,
4
4
  isWriteToolResult,
5
5
  } from '@earendil-works/pi-coding-agent';
6
+ import { isReviewNote, REVIEW_NOTE_EVENT } from '../shared/review-note.ts';
7
+ import {
8
+ acceptsSettleSteer,
9
+ startsUserTurn,
10
+ withHiddenSteer,
11
+ } from '../shared/settle.ts';
6
12
 
7
13
  export const REVIEW_STEER_TYPE = 'sideroom-guidelines-review';
8
14
 
9
15
  export const REVIEW_STEER =
10
16
  'Review every file this run changed against the loaded Sideroom language guide: fix any mechanical, structural, or architectural rule violations in those files, then run the relevant formatter, linter, type checks, and tests. If the guide is no longer in context, re-read it in full first.';
11
17
 
18
+ export const REVIEW_NOTES_STEER =
19
+ 'Files changed during the review drew the semantic notes below. Verify each against the loaded Sideroom language guide, fix only real violations, then rerun the relevant checks.';
20
+
12
21
  export interface ReviewState {
13
22
  mutated: boolean;
14
23
  steeredThisTurn: boolean;
24
+ notesSteeredThisTurn: boolean;
25
+ notes: string[];
15
26
  }
16
27
 
17
28
  export function createReviewState(): ReviewState {
18
- return { mutated: false, steeredThisTurn: false };
29
+ return {
30
+ mutated: false,
31
+ steeredThisTurn: false,
32
+ notesSteeredThisTurn: false,
33
+ notes: [],
34
+ };
19
35
  }
20
36
 
21
37
  export function resetReviewTurn(state: ReviewState): void {
22
38
  state.mutated = false;
23
39
  state.steeredThisTurn = false;
40
+ state.notesSteeredThisTurn = false;
41
+ state.notes = [];
24
42
  }
25
43
 
26
44
  export function shouldReview(state: ReviewState): boolean {
27
45
  return state.mutated && !state.steeredThisTurn;
28
46
  }
29
47
 
48
+ // Notes that arrive after the review fired get one follow-up per turn, so a
49
+ // fix that draws new notes cannot keep the run going.
50
+ export function pendingReviewSteer(state: ReviewState): string | undefined {
51
+ if (shouldReview(state)) {
52
+ return withNotes(REVIEW_STEER, state.notes);
53
+ }
54
+ if (
55
+ state.steeredThisTurn &&
56
+ !state.notesSteeredThisTurn &&
57
+ state.notes.length > 0
58
+ ) {
59
+ return withNotes(REVIEW_NOTES_STEER, state.notes);
60
+ }
61
+ return undefined;
62
+ }
63
+
64
+ export function markReviewSteered(state: ReviewState): void {
65
+ if (state.steeredThisTurn) {
66
+ state.notesSteeredThisTurn = true;
67
+ }
68
+ state.steeredThisTurn = true;
69
+ state.notes = [];
70
+ }
71
+
72
+ function withNotes(lead: string, notes: readonly string[]): string {
73
+ return [lead, ...notes].join('\n\n');
74
+ }
75
+
30
76
  export function registerReviewGuard(
31
77
  pi: ExtensionAPI,
32
78
  state: ReviewState,
33
79
  ): void {
34
80
  pi.on('input', (event) => {
35
- if (event.source !== 'interactive' && event.source !== 'rpc') {
81
+ if (!startsUserTurn(event)) {
36
82
  return;
37
83
  }
38
84
  resetReviewTurn(state);
@@ -48,23 +94,22 @@ export function registerReviewGuard(
48
94
  state.mutated = true;
49
95
  });
50
96
 
51
- // agent_settled is the only point with no retry, compaction, or queued
52
- // continuation left, so the review never lands on work a retry is about to
53
- // redo. Mark the flag before sending so the continuation this steer starts
54
- // cannot re-trigger it; sendMessage is intentionally not awaited, matching
55
- // explain, so the settle handler returns immediately.
56
- pi.on('agent_settled', () => {
57
- if (!shouldReview(state)) {
97
+ pi.events.on(REVIEW_NOTE_EVENT, (payload) => {
98
+ if (isReviewNote(payload)) {
99
+ state.notes.push(payload.text);
100
+ }
101
+ });
102
+
103
+ // agent_before_settle runs after retries and compaction recovery, and its
104
+ // continuation stays inside the same run. agent_settled is notification-only:
105
+ // a steer sent there starts a second run after the user already saw the
106
+ // agent stop, and it fires even when the user pressed Escape.
107
+ pi.on('agent_before_settle', (event) => {
108
+ const steer = pendingReviewSteer(state);
109
+ if (steer === undefined || !acceptsSettleSteer(event)) {
58
110
  return;
59
111
  }
60
- state.steeredThisTurn = true;
61
- pi.sendMessage(
62
- {
63
- customType: REVIEW_STEER_TYPE,
64
- content: REVIEW_STEER,
65
- display: false,
66
- },
67
- { triggerTurn: true, deliverAs: 'steer' },
68
- );
112
+ markReviewSteered(state);
113
+ return withHiddenSteer(event, REVIEW_STEER_TYPE, steer);
69
114
  });
70
115
  }
@@ -12,7 +12,17 @@ export const JEV_REQUEST_TIMEOUT_MS = 3_000;
12
12
  export const JEV_FAILURE = {
13
13
  quota: 'quota',
14
14
  auth: 'auth',
15
+ rateLimited: 'rateLimited',
15
16
  transient: 'transient',
17
+ cancelled: 'cancelled',
18
+ } as const;
19
+
20
+ const HTTP_STATUS = {
21
+ unauthorized: 401,
22
+ paymentRequired: 402,
23
+ forbidden: 403,
24
+ tooManyRequests: 429,
25
+ firstClientError: 400,
16
26
  } as const;
17
27
 
18
28
  export type JevFailure = (typeof JEV_FAILURE)[keyof typeof JEV_FAILURE];
@@ -20,6 +30,7 @@ export type JevFailure = (typeof JEV_FAILURE)[keyof typeof JEV_FAILURE];
20
30
  export interface JevHttpRequest {
21
31
  readonly apiKey: string;
22
32
  readonly body: string;
33
+ readonly signal: AbortSignal | undefined;
23
34
  }
24
35
 
25
36
  export interface JevHttpResponse {
@@ -50,20 +61,36 @@ export function createFetchTransport(
50
61
  'Content-Type': 'application/json',
51
62
  },
52
63
  body: request.body,
53
- signal: AbortSignal.timeout(timeoutMs),
64
+ signal: requestSignal(timeoutMs, request.signal),
54
65
  });
55
66
  return { status: response.status, text: await response.text() };
56
67
  };
57
68
  }
58
69
 
70
+ // Pi waits for every tool_result handler before the result reaches the model,
71
+ // so the user's Escape has to cut the request, not only the timeout.
72
+ function requestSignal(
73
+ timeoutMs: number,
74
+ runSignal: AbortSignal | undefined,
75
+ ): AbortSignal {
76
+ const timeout = AbortSignal.timeout(timeoutMs);
77
+ if (runSignal === undefined) {
78
+ return timeout;
79
+ }
80
+ return AbortSignal.any([timeout, runSignal]);
81
+ }
82
+
59
83
  export function classifyStatus(status: number): JevFailure | undefined {
60
- if (status === 401 || status === 403) {
84
+ if (status === HTTP_STATUS.unauthorized || status === HTTP_STATUS.forbidden) {
61
85
  return JEV_FAILURE.auth;
62
86
  }
63
- if (status === 402) {
87
+ if (status === HTTP_STATUS.paymentRequired) {
64
88
  return JEV_FAILURE.quota;
65
89
  }
66
- if (status >= 400) {
90
+ if (status === HTTP_STATUS.tooManyRequests) {
91
+ return JEV_FAILURE.rateLimited;
92
+ }
93
+ if (status >= HTTP_STATUS.firstClientError) {
67
94
  return JEV_FAILURE.transient;
68
95
  }
69
96
  return undefined;
@@ -76,16 +103,20 @@ export async function askJev(
76
103
  transport: JevTransport,
77
104
  apiKey: string,
78
105
  file: JevFileState,
106
+ signal: AbortSignal | undefined,
79
107
  ): Promise<JevAttempt> {
80
108
  const body = JSON.stringify(buildRequestBody(file));
81
109
  let response: JevHttpResponse;
82
110
  try {
83
- response = await transport({ apiKey, body });
111
+ response = await transport({ apiKey, body, signal });
84
112
  } catch {
85
113
  return {
86
114
  findings: undefined,
87
115
  model: undefined,
88
- failure: JEV_FAILURE.transient,
116
+ failure:
117
+ signal?.aborted === true
118
+ ? JEV_FAILURE.cancelled
119
+ : JEV_FAILURE.transient,
89
120
  };
90
121
  }
91
122
 
@@ -6,8 +6,13 @@ import {
6
6
  isWriteToolResult,
7
7
  type ToolCallEvent,
8
8
  } from '@earendil-works/pi-coding-agent';
9
- import { resolveFileToolPath } from '../shared/file-path.ts';
9
+ import {
10
+ projectRelativePath,
11
+ resolveFileToolPath,
12
+ } from '../shared/file-path.ts';
10
13
  import { readFileIfExists } from '../shared/read-file.ts';
14
+ import { REVIEW_NOTE_EVENT, type ReviewNote } from '../shared/review-note.ts';
15
+ import { startsUserTurn } from '../shared/settle.ts';
11
16
  import {
12
17
  askJev,
13
18
  JEV_FAILURE,
@@ -21,6 +26,7 @@ import {
21
26
  buildFileState,
22
27
  findingKey,
23
28
  formatFindings,
29
+ isReviewablePath,
24
30
  type JevFinding,
25
31
  stateFitsBudget,
26
32
  } from './model.ts';
@@ -37,13 +43,18 @@ export const JEV_STATUS = {
37
43
  noKey: 'noKey',
38
44
  quota: 'quota',
39
45
  auth: 'auth',
46
+ rateLimited: 'rateLimited',
40
47
  unavailable: 'unavailable',
41
48
  } as const;
42
49
 
43
50
  export type JevStatus = (typeof JEV_STATUS)[keyof typeof JEV_STATUS];
44
51
 
45
- interface PendingMutation {
46
- readonly path: string;
52
+ interface MutationTarget {
53
+ readonly absolutePath: string;
54
+ readonly relativePath: string;
55
+ }
56
+
57
+ interface PendingMutation extends MutationTarget {
47
58
  readonly kind: string;
48
59
  readonly lines: readonly AddedLine[];
49
60
  }
@@ -51,6 +62,7 @@ interface PendingMutation {
51
62
  export interface JevGuardState {
52
63
  halted: JevStatus | undefined;
53
64
  transientFailures: number;
65
+ rateLimitedThisTurn: boolean;
54
66
  lastModel: string | undefined;
55
67
  calls: number;
56
68
  readonly pending: Map<string, PendingMutation>;
@@ -67,6 +79,7 @@ export function createJevGuardState(): JevGuardState {
67
79
  return {
68
80
  halted: undefined,
69
81
  transientFailures: 0,
82
+ rateLimitedThisTurn: false,
70
83
  lastModel: undefined,
71
84
  calls: 0,
72
85
  pending: new Map(),
@@ -78,17 +91,25 @@ export function createJevGuardState(): JevGuardState {
78
91
  export function resetJevGuard(state: JevGuardState): void {
79
92
  state.halted = undefined;
80
93
  state.transientFailures = 0;
94
+ state.rateLimitedThisTurn = false;
81
95
  }
82
96
 
83
- // Usage belongs to the active session: a new session starts counting from zero.
84
- export function resetJevUsage(state: JevGuardState): void {
97
+ // Usage and per-turn memory belong to the active session: a new session starts
98
+ // counting from zero. The breaker stays, because it tracks the key.
99
+ export function resetJevSession(state: JevGuardState): void {
85
100
  state.calls = 0;
101
+ state.rateLimitedThisTurn = false;
102
+ state.pending.clear();
103
+ state.notedThisTurn.clear();
86
104
  }
87
105
 
88
106
  export function guardStatus(state: JevGuardState): JevStatus {
89
107
  if (state.halted !== undefined) {
90
108
  return state.halted;
91
109
  }
110
+ if (state.rateLimitedThisTurn) {
111
+ return JEV_STATUS.rateLimited;
112
+ }
92
113
  return state.transientFailures > 0
93
114
  ? JEV_STATUS.unavailable
94
115
  : JEV_STATUS.ready;
@@ -97,6 +118,7 @@ export function guardStatus(state: JevGuardState): JevStatus {
97
118
  export function shouldCallJev(state: JevGuardState): boolean {
98
119
  return (
99
120
  state.halted === undefined &&
121
+ !state.rateLimitedThisTurn &&
100
122
  state.transientFailures < JEV_TRANSIENT_FAILURE_LIMIT
101
123
  );
102
124
  }
@@ -107,17 +129,24 @@ export function registerJevGuard(
107
129
  dependencies: JevGuardDependencies,
108
130
  ): void {
109
131
  pi.on('input', (event) => {
110
- if (event.source !== 'interactive' && event.source !== 'rpc') {
132
+ if (!startsUserTurn(event)) {
111
133
  return;
112
134
  }
113
135
  state.notedThisTurn.clear();
136
+ state.rateLimitedThisTurn = false;
137
+ });
138
+
139
+ // A call another extension blocks in tool_call never reaches tool_result,
140
+ // and every result of a turn lands before its turn_end.
141
+ pi.on('turn_end', () => {
142
+ state.pending.clear();
114
143
  });
115
144
 
116
145
  // The added lines are read before the mutation lands, because after a write
117
146
  // the previous content is gone. The body is read after, from disk.
118
147
  pi.on('tool_call', (event, ctx) => {
119
148
  const mutation = extractMutation(event, ctx.cwd);
120
- if (mutation === undefined) {
149
+ if (mutation === undefined || !isWorthAsking(mutation)) {
121
150
  return undefined;
122
151
  }
123
152
  state.pending.set(event.toolCallId, mutation);
@@ -144,8 +173,17 @@ export function registerJevGuard(
144
173
  return undefined;
145
174
  }
146
175
 
147
- const body = readMutationBody(ctx.cwd, pending.path);
148
- if (body === undefined || !stateFitsBudget(body)) {
176
+ const body = readFileIfExists(pending.absolutePath);
177
+ if (body === undefined) {
178
+ return undefined;
179
+ }
180
+ const fileState = buildFileState(
181
+ pending.relativePath,
182
+ body,
183
+ pending.kind,
184
+ pending.lines,
185
+ );
186
+ if (!stateFitsBudget(fileState) || ctx.signal?.aborted === true) {
149
187
  return undefined;
150
188
  }
151
189
 
@@ -153,7 +191,8 @@ export function registerJevGuard(
153
191
  const attempt = await askJev(
154
192
  dependencies.transport,
155
193
  apiKey,
156
- buildFileState(pending.path, body, pending.kind, pending.lines),
194
+ fileState,
195
+ ctx.signal,
157
196
  );
158
197
  if (attempt.failure !== undefined) {
159
198
  registerFailure(state, attempt.failure);
@@ -169,18 +208,17 @@ export function registerJevGuard(
169
208
 
170
209
  const findings = attempt.findings ?? [];
171
210
  const fresh = findings.filter((finding) =>
172
- isUnnoted(state, pending.path, finding),
211
+ isUnnoted(state, pending.relativePath, finding),
173
212
  );
174
213
  if (fresh.length === 0) {
175
214
  return undefined;
176
215
  }
177
- markNoted(state, pending.path, fresh);
178
- return {
179
- content: [
180
- ...event.content,
181
- { type: 'text' as const, text: formatFindings(pending.path, fresh) },
182
- ],
216
+ markNoted(state, pending.relativePath, fresh);
217
+ const note: ReviewNote = {
218
+ text: formatFindings(pending.relativePath, fresh),
183
219
  };
220
+ pi.events.emit(REVIEW_NOTE_EVENT, note);
221
+ return undefined;
184
222
  });
185
223
  }
186
224
 
@@ -202,7 +240,16 @@ function markNoted(
202
240
  }
203
241
  }
204
242
 
243
+ // A user abort says nothing about the service, and a rate limit clears on its
244
+ // own, so neither may count toward switching Jev off.
205
245
  function registerFailure(state: JevGuardState, failure: JevFailure): void {
246
+ if (failure === JEV_FAILURE.cancelled) {
247
+ return;
248
+ }
249
+ if (failure === JEV_FAILURE.rateLimited) {
250
+ state.rateLimitedThisTurn = true;
251
+ return;
252
+ }
206
253
  if (failure === JEV_FAILURE.quota) {
207
254
  state.halted = JEV_STATUS.quota;
208
255
  return;
@@ -222,24 +269,37 @@ function registerSuccess(state: JevGuardState): void {
222
269
  state.transientFailures = 0;
223
270
  }
224
271
 
272
+ // A deletion-only edit leaves nothing for the six questions to read, and only
273
+ // the project-relative path of a supported language may leave the machine.
274
+ function isWorthAsking(mutation: PendingMutation): boolean {
275
+ return mutation.lines.length > 0 && isReviewablePath(mutation.relativePath);
276
+ }
277
+
225
278
  function extractMutation(
226
279
  event: ToolCallEvent,
227
280
  cwd: string,
228
281
  ): PendingMutation | undefined {
229
282
  if (isToolCallEventType('write', event)) {
230
- const absolutePath = resolveFileToolPath(cwd, event.input.path);
283
+ const target = resolveTarget(cwd, event.input.path);
284
+ if (target === undefined) {
285
+ return undefined;
286
+ }
231
287
  return {
232
- path: event.input.path,
288
+ ...target,
233
289
  kind: MUTATION_KIND.write,
234
290
  lines: addedLinesForWrite(
235
- absolutePath === undefined ? undefined : readFileIfExists(absolutePath),
291
+ readFileIfExists(target.absolutePath),
236
292
  event.input.content,
237
293
  ),
238
294
  };
239
295
  }
240
296
  if (isToolCallEventType('edit', event)) {
297
+ const target = resolveTarget(cwd, event.input.path);
298
+ if (target === undefined) {
299
+ return undefined;
300
+ }
241
301
  return {
242
- path: event.input.path,
302
+ ...target,
243
303
  kind: MUTATION_KIND.edit,
244
304
  lines: addedLinesForEdit(event.input.edits),
245
305
  };
@@ -247,10 +307,14 @@ function extractMutation(
247
307
  return undefined;
248
308
  }
249
309
 
250
- function readMutationBody(cwd: string, path: string): string | undefined {
310
+ function resolveTarget(cwd: string, path: string): MutationTarget | undefined {
251
311
  const absolutePath = resolveFileToolPath(cwd, path);
252
312
  if (absolutePath === undefined) {
253
313
  return undefined;
254
314
  }
255
- return readFileIfExists(absolutePath);
315
+ const relativePath = projectRelativePath(cwd, absolutePath);
316
+ if (relativePath === undefined) {
317
+ return undefined;
318
+ }
319
+ return { absolutePath, relativePath };
256
320
  }
@@ -11,7 +11,7 @@ import {
11
11
  type JevStatus,
12
12
  registerJevGuard,
13
13
  resetJevGuard,
14
- resetJevUsage,
14
+ resetJevSession,
15
15
  } from './guard.ts';
16
16
  import {
17
17
  clearStoredKey,
@@ -75,7 +75,7 @@ export default function registerJev(pi: ExtensionAPI): void {
75
75
  });
76
76
 
77
77
  pi.on('session_start', (_event, ctx) => {
78
- resetJevUsage(guardState);
78
+ resetJevSession(guardState);
79
79
  publish(currentStatus(), ctx);
80
80
  });
81
81
 
@@ -1,4 +1,4 @@
1
- import { languageForPath } from '../rules/catalog.ts';
1
+ import { LANGUAGE, languageForPath } from '../rules/catalog.ts';
2
2
  import { type AddedLine, addedLines } from '../rules/model.ts';
3
3
 
4
4
  export type { AddedLine } from '../rules/model.ts';
@@ -11,8 +11,9 @@ export const JEV_MODEL = 'jev-latest';
11
11
  // noise on every edit.
12
12
  export const VIOLATION_PROBABILITY = 0.8;
13
13
 
14
- // The state must stay well under the two documented budgets (64k tokens for the
15
- // whole request, 32k for the state plus the longest question). A file that does
14
+ // The serialized state must stay well under the two documented budgets (64k
15
+ // tokens for the whole request, 32k for the state plus the longest question). A
16
+ // new file travels twice, as the body and as its added lines. A state that does
16
17
  // not fit is skipped, never truncated: half a file reads as a distorted picture
17
18
  // and Jev answers it with the same confidence.
18
19
  export const MAX_STATE_CHARACTERS = 60_000;
@@ -167,8 +168,14 @@ export function addedLinesForEdit(
167
168
  return edits.flatMap((edit) => addedLines(edit.oldText, edit.newText));
168
169
  }
169
170
 
170
- export function stateFitsBudget(body: string): boolean {
171
- return body.length <= MAX_STATE_CHARACTERS;
171
+ // The six questions are about code structure; a README or a lockfile would
172
+ // spend a call on answers that mean nothing.
173
+ export function isReviewablePath(path: string): boolean {
174
+ return languageForPath(path) !== LANGUAGE.generic;
175
+ }
176
+
177
+ export function stateFitsBudget(state: JevFileState): boolean {
178
+ return JSON.stringify(state).length <= MAX_STATE_CHARACTERS;
172
179
  }
173
180
 
174
181
  export function buildRequestBody(file: JevFileState): JevRequestBody {
@@ -30,6 +30,7 @@ const STATUS_LABEL: Readonly<Record<JevStatus, string>> = {
30
30
  [JEV_STATUS.noKey]: 'jev: no key (F10)',
31
31
  [JEV_STATUS.quota]: 'jev: out of quota (F10)',
32
32
  [JEV_STATUS.auth]: 'jev: key rejected (F10)',
33
+ [JEV_STATUS.rateLimited]: 'jev: rate limited',
33
34
  [JEV_STATUS.unavailable]: 'jev: unreachable',
34
35
  };
35
36
 
@@ -1,6 +1,6 @@
1
1
  import { realpathSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
- import { isAbsolute, join, resolve } from 'node:path';
3
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
 
6
6
  const FILE_URL_PREFIX = 'file://';
@@ -29,6 +29,31 @@ export function resolveFileToolPath(
29
29
  return realpathOr(absolutePath);
30
30
  }
31
31
 
32
+ // A file that does not exist yet keeps its unresolved path, so it is matched
33
+ // against the literal working directory as well as the canonical one.
34
+ export function projectRelativePath(
35
+ cwd: string,
36
+ absolutePath: string,
37
+ ): string | undefined {
38
+ const literalRoot = resolve(cwd);
39
+ for (const root of new Set([realpathOr(literalRoot), literalRoot])) {
40
+ const relativePath = relative(root, absolutePath);
41
+ if (isInsideRoot(relativePath)) {
42
+ return relativePath;
43
+ }
44
+ }
45
+ return undefined;
46
+ }
47
+
48
+ function isInsideRoot(relativePath: string): boolean {
49
+ return (
50
+ relativePath.length > 0 &&
51
+ relativePath !== '..' &&
52
+ !relativePath.startsWith(`..${sep}`) &&
53
+ !isAbsolute(relativePath)
54
+ );
55
+ }
56
+
32
57
  function normalizeFileToolPath(path: string): string {
33
58
  const normalizedSpaces = path.replace(UNICODE_SPACES, ' ');
34
59
  const withoutAtPrefix = normalizedSpaces.startsWith('@')
@@ -0,0 +1,14 @@
1
+ export const REVIEW_NOTE_EVENT = 'sideroom:review-note';
2
+
3
+ export interface ReviewNote {
4
+ readonly text: string;
5
+ }
6
+
7
+ export function isReviewNote(value: unknown): value is ReviewNote {
8
+ return (
9
+ typeof value === 'object' &&
10
+ value !== null &&
11
+ 'text' in value &&
12
+ typeof value.text === 'string'
13
+ );
14
+ }
@@ -0,0 +1,50 @@
1
+ import type {
2
+ AgentBeforeSettleEvent,
3
+ AgentBeforeSettleEventResult,
4
+ InputEvent,
5
+ InputSource,
6
+ } from '@earendil-works/pi-coding-agent';
7
+
8
+ const COMPLETED_OUTCOME = 'completed';
9
+ const USER_ROLE = 'user';
10
+ const CUSTOM_MESSAGE_ENTRY = 'custom_message';
11
+ const USER_INPUT_SOURCES: ReadonlySet<InputSource> = new Set([
12
+ 'interactive',
13
+ 'rpc',
14
+ ]);
15
+
16
+ // A message typed while the agent runs joins the current run as a steer or
17
+ // follow-up; only input that arrives idle opens a new turn.
18
+ export function startsUserTurn(event: InputEvent): boolean {
19
+ return (
20
+ USER_INPUT_SOURCES.has(event.source) &&
21
+ event.streamingBehavior === undefined
22
+ );
23
+ }
24
+
25
+ // An aborted run is the user stopping the agent, an errored run has nothing
26
+ // worth reviewing, and a pending user message already decides what comes next.
27
+ export function acceptsSettleSteer(event: AgentBeforeSettleEvent): boolean {
28
+ if (event.outcome !== COMPLETED_OUTCOME) {
29
+ return false;
30
+ }
31
+ return !event.context.pendingMessages.some(
32
+ (message) => message.role === USER_ROLE,
33
+ );
34
+ }
35
+
36
+ // Handlers chain on agent_before_settle, so keep the entries earlier handlers
37
+ // proposed; replacing them would drop another extension's steer.
38
+ export function withHiddenSteer(
39
+ event: AgentBeforeSettleEvent,
40
+ customType: string,
41
+ content: string,
42
+ ): AgentBeforeSettleEventResult {
43
+ return {
44
+ entries: [
45
+ ...event.entries,
46
+ { type: CUSTOM_MESSAGE_ENTRY, customType, content, display: false },
47
+ ],
48
+ continue: true,
49
+ };
50
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmrdeveloper/sideroom-pi",
3
- "version": "8.9.0",
3
+ "version": "8.10.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": [
@@ -89,8 +89,8 @@
89
89
  "@changesets/cli": "^3.0.2",
90
90
  "@commitlint/cli": "^21.2.2",
91
91
  "@commitlint/config-conventional": "^21.2.2",
92
- "@earendil-works/pi-coding-agent": "0.84.4",
93
- "@earendil-works/pi-tui": "0.84.4",
92
+ "@earendil-works/pi-coding-agent": "0.87.1",
93
+ "@earendil-works/pi-tui": "0.87.1",
94
94
  "@types/node": "^22.20.1",
95
95
  "typebox": "1.3.7",
96
96
  "typescript": "^5.6.0"
@@ -77,9 +77,12 @@ dead-locked.
77
77
  ### What steering means
78
78
 
79
79
  The extension sends a corrective steer after a degraded artifact block or a
80
- prohibited assistant message. Steers use `triggerTurn: true` so they reach the
81
- model even when the run is already idle. At most three steers are sent per
82
- agent run.
80
+ prohibited assistant message. Both moments happen while the agent is working, so
81
+ the steer travels through `pi.sendMessage` with `{ triggerTurn: true, deliverAs:
82
+ 'steer' }` and joins the current run. A steer sent after the run ended belongs
83
+ to the guidelines review and the walkthrough offer, which append a
84
+ `custom_message` entry on `agent_before_settle` instead.
85
+ At most three steers are sent per agent run.
83
86
 
84
87
  ## Boundaries
85
88