@mjasnikovs/pi-task 0.38.1 → 0.38.3

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 (46) hide show
  1. package/dist/config/config.d.ts +7 -0
  2. package/dist/config/config.js +10 -4
  3. package/dist/config/register.d.ts +37 -0
  4. package/dist/config/register.js +89 -114
  5. package/dist/remote/events.js +0 -3
  6. package/dist/remote/register.js +12 -3
  7. package/dist/task/auto-orchestrator.js +119 -94
  8. package/dist/task/command-run.d.ts +104 -0
  9. package/dist/task/command-run.js +138 -0
  10. package/dist/task/coverage-loop.d.ts +45 -0
  11. package/dist/task/critique-probes.d.ts +82 -0
  12. package/dist/task/critique-probes.js +156 -0
  13. package/dist/task/enforce-guidelines.d.ts +14 -17
  14. package/dist/task/enforce-guidelines.js +44 -31
  15. package/dist/task/final-gate.d.ts +8 -10
  16. package/dist/task/final-gate.js +36 -74
  17. package/dist/task/gate-child.d.ts +104 -0
  18. package/dist/task/gate-child.js +177 -0
  19. package/dist/task/gate-deps.js +57 -205
  20. package/dist/task/orchestrator.js +13 -22
  21. package/dist/task/phases.js +109 -182
  22. package/dist/task/plan-session.d.ts +4 -22
  23. package/dist/task/plan-session.js +4 -33
  24. package/dist/task/question-dialog.d.ts +71 -0
  25. package/dist/task/question-dialog.js +89 -0
  26. package/dist/task/terminal-outcome.d.ts +67 -0
  27. package/dist/task/terminal-outcome.js +76 -0
  28. package/dist/task/type-only-answer.js +2 -3
  29. package/dist/workers/abstention.d.ts +71 -0
  30. package/dist/workers/abstention.js +108 -0
  31. package/dist/workers/docs-chunk.d.ts +74 -0
  32. package/dist/workers/docs-chunk.js +143 -0
  33. package/dist/workers/docs-core.d.ts +10 -1
  34. package/dist/workers/docs-core.js +22 -19
  35. package/dist/workers/docs-index.js +2 -69
  36. package/dist/workers/docs-project.d.ts +15 -1
  37. package/dist/workers/docs-project.js +27 -66
  38. package/dist/workers/fetch-core.d.ts +1 -1
  39. package/dist/workers/fetch-core.js +2 -1
  40. package/dist/workers/pi-worker-core.js +157 -86
  41. package/dist/workers/pi-worker-docs.js +5 -10
  42. package/dist/workers/pi-worker-fetch.js +8 -1
  43. package/dist/workers/typeonly-log.js +2 -10
  44. package/dist/workers/worker-failure.d.ts +91 -0
  45. package/dist/workers/worker-failure.js +82 -0
  46. package/package.json +1 -1
@@ -178,6 +178,13 @@ export declare const STREAM_INACTIVITY_OPTIONS: ReadonlyArray<{
178
178
  /** Same pinning as {@link sanitizeRequestTimeoutMs}: a hand-edited value that is
179
179
  * not one of the offered choices falls back to the default. */
180
180
  export declare function sanitizeStreamInactivityMs(value: unknown): number;
181
+ /**
182
+ * The shipped defaults. Exported so tests can start from a KNOWN config instead
183
+ * of `getConfig()`, which reads whatever this machine happens to have on disk —
184
+ * a test that passes or fails depending on the developer's own settings is not
185
+ * testing the code.
186
+ */
187
+ export declare const DEFAULT_CONFIG: PiTaskConfig;
181
188
  /**
182
189
  * A hand-edited config can hold anything; keep only string entries so a stray
183
190
  * object/number can't reach the child argv as `-e [object Object]`.
@@ -79,7 +79,13 @@ export function sanitizeStreamInactivityMs(value) {
79
79
  value
80
80
  : DEFAULT_STREAM_INACTIVITY_MS;
81
81
  }
82
- const DEFAULTS = {
82
+ /**
83
+ * The shipped defaults. Exported so tests can start from a KNOWN config instead
84
+ * of `getConfig()`, which reads whatever this machine happens to have on disk —
85
+ * a test that passes or fails depending on the developer's own settings is not
86
+ * testing the code.
87
+ */
88
+ export const DEFAULT_CONFIG = {
83
89
  remote: true,
84
90
  compressReasoning: true,
85
91
  autoCommit: true,
@@ -113,7 +119,7 @@ export function sanitizeExtensionWhitelist(value) {
113
119
  const CONFIG_PATH = path.join(os.homedir(), '.config', 'pi-task', 'config.json');
114
120
  const _g = globalThis;
115
121
  if (!_g.__piTaskConfig) {
116
- _g.__piTaskConfig = { config: { ...DEFAULTS }, loaded: false };
122
+ _g.__piTaskConfig = { config: { ...DEFAULT_CONFIG }, loaded: false };
117
123
  }
118
124
  const G = _g.__piTaskConfig;
119
125
  // Load synchronously on module evaluation so getConfig() is always ready
@@ -136,10 +142,10 @@ if (!G.loaded) {
136
142
  if (typeof parsed.yoloMode !== 'boolean')
137
143
  delete parsed.yoloMode;
138
144
  parsed.debugLogs = sanitizeDebugLogs(parsed.debugLogs);
139
- G.config = { ...DEFAULTS, ...parsed };
145
+ G.config = { ...DEFAULT_CONFIG, ...parsed };
140
146
  }
141
147
  catch {
142
- G.config = { ...DEFAULTS };
148
+ G.config = { ...DEFAULT_CONFIG };
143
149
  }
144
150
  G.loaded = true;
145
151
  }
@@ -38,6 +38,43 @@ declare class BorderedBox implements Component {
38
38
  invalidate(): void;
39
39
  handleInput(data: string): void;
40
40
  }
41
+ /**
42
+ * One /task-config setting, and BOTH directions of its value.
43
+ *
44
+ * `format` renders the stored value for the panel; `apply` parses the chosen
45
+ * label back into it. They are per-row because the two used to be hand-written
46
+ * ladders — a `displayValue` arm to format, a matching `onChange` arm to parse,
47
+ * and three different idioms for the same parse across the four enum settings.
48
+ * Adding one enum setting meant four coordinated edits (row, format arm, parse
49
+ * arm, sanitizer) and NONE of them failed to compile if you forgot it: a missed
50
+ * format arm rendered `String(cfg[id])`, and a missed parse arm let the generic
51
+ * `else` write the boolean `newValue === 'on'` into an enum field. The comment
52
+ * that used to sit in that `else` — explaining why `debugLogs` must not fall
53
+ * into it — was the interface saying it was too shallow.
54
+ *
55
+ * With both directions on the row, `format(apply(cfg, v)) === v` is a property
56
+ * over the whole table, and the panel and the non-TUI listing cannot disagree
57
+ * because both read `format`.
58
+ */
59
+ export interface ConfigItem {
60
+ id: keyof PiTaskConfig;
61
+ label: string;
62
+ description: string;
63
+ /** Offered values. Omitted for a boolean, which is always on/off. */
64
+ values?: string[];
65
+ /** What the panel shows for the current value. */
66
+ format: (cfg: PiTaskConfig) => string;
67
+ /** Write the chosen label back. A value it does not recognise is ignored. */
68
+ apply: (cfg: PiTaskConfig, chosen: string) => void;
69
+ }
70
+ /**
71
+ * Every setting rendered by /task-config, in display order.
72
+ *
73
+ * Exported so the round-trip property below can be asserted over the WHOLE table
74
+ * rather than per setting — the check that would have caught a forgotten arm in
75
+ * either of the two ladders this replaced.
76
+ */
77
+ export declare const ITEMS: ConfigItem[];
41
78
  export declare function extensionItems(extensions: InstalledExtension[], whitelist: readonly string[]): {
42
79
  id: string;
43
80
  label: string;
@@ -68,70 +68,64 @@ class BorderedBox {
68
68
  }
69
69
  }
70
70
  /**
71
- * Every setting rendered by /task-config. Boolean settings omit `values` and
72
- * toggle on/off; enum settings list their values and cycle through them.
71
+ * The shared pair for a boolean setting: shown as on/off, stored as a boolean.
72
+ * Every non-enum row uses this, so a boolean cannot be given a bespoke parser by
73
+ * accident.
73
74
  */
74
- const ITEMS = [
75
- {
76
- id: 'remote',
77
- label: 'remote control',
78
- description: 'Serve the task UI on your local network so you can follow and steer a run from '
79
- + 'your phone. Prints a QR code to scan when it starts'
80
- },
81
- {
82
- id: 'compressReasoning',
83
- label: 'compress thinking',
84
- description: "Shrink the model's thinking blocks once it has moved on, so a long run keeps more "
85
- + 'room for the work itself'
86
- },
87
- {
88
- id: 'autoCommit',
89
- label: 'auto-commit',
90
- description: 'Make a git commit before and after every sub-task, so each step is a checkpoint '
91
- + 'you can read back or roll back to'
92
- },
93
- {
94
- id: 'verifyWork',
95
- label: 'verify work',
96
- description: 'When a task says it is done, actually run the checks its spec asks for and report '
97
- + "PASS or FAIL instead of taking the model's word for it. This is also what lets "
98
- + '"enforce guidelines" fix things safely. /task waits for the work to finish'
99
- },
100
- {
101
- id: 'enforceGuidelines',
102
- label: 'enforce guidelines',
103
- description: 'Check what each task committed against your AGENTS.md / CLAUDE.md rules. With '
104
- + '"verify work" on it also fixes what it finds, undoing any fix that breaks the '
105
- + 'checks; on its own it only reports. /task waits for the work to finish'
106
- },
107
- {
108
- id: 'orientation',
109
- label: 'project tour',
110
- description: 'Show the research workers the shape of the project firstpackage manifest, '
111
- + 'types, schema so they spend their steps on the question instead of on finding '
112
- + 'their way around'
113
- },
114
- {
115
- id: 'parallelResearchWorkers',
116
- label: 'parallel research',
117
- description: 'Run the 4 research workers at once instead of one after another. Only faster if '
118
- + 'your model backend can answer several requests at the same time — on a single '
119
- + 'local GPU it is measurably slower, so leave it off there'
120
- },
121
- {
122
- id: 'researchCache',
123
- label: 'research cache',
124
- description: 'Remember docs and web pages for the length of one run, so later tasks reuse what '
125
- + 'the first one already fetched instead of downloading it again. Only external '
126
- + 'sources, only successful fetches, and it is dropped when the run ends'
127
- },
75
+ function booleanItem(id, label, description) {
76
+ return {
77
+ id,
78
+ label,
79
+ description,
80
+ format: cfg => (cfg[id] ? 'on' : 'off'),
81
+ apply: (cfg, chosen) => {
82
+ ;
83
+ cfg[id] = chosen === 'on';
84
+ }
85
+ };
86
+ }
87
+ /**
88
+ * Every setting rendered by /task-config, in display order.
89
+ *
90
+ * Exported so the round-trip property below can be asserted over the WHOLE table
91
+ * rather than per setting the check that would have caught a forgotten arm in
92
+ * either of the two ladders this replaced.
93
+ */
94
+ export const ITEMS = [
95
+ booleanItem('remote', 'remote control', 'Serve the task UI on your local network so you can follow and steer a run from '
96
+ + 'your phone. Prints a QR code to scan when it starts'),
97
+ booleanItem('compressReasoning', 'compress thinking', "Shrink the model's thinking blocks once it has moved on, so a long run keeps more "
98
+ + 'room for the work itself'),
99
+ booleanItem('autoCommit', 'auto-commit', 'Make a git commit before and after every sub-task, so each step is a checkpoint '
100
+ + 'you can read back or roll back to'),
101
+ booleanItem('verifyWork', 'verify work', 'When a task says it is done, actually run the checks its spec asks for and report '
102
+ + "PASS or FAIL instead of taking the model's word for it. This is also what lets "
103
+ + '"enforce guidelines" fix things safely. /task waits for the work to finish'),
104
+ booleanItem('enforceGuidelines', 'enforce guidelines', 'Check what each task committed against your AGENTS.md / CLAUDE.md rules. With '
105
+ + '"verify work" on it also fixes what it finds, undoing any fix that breaks the '
106
+ + 'checks; on its own it only reports. /task waits for the work to finish'),
107
+ booleanItem('orientation', 'project tour', 'Show the research workers the shape of the project first — package manifest, '
108
+ + 'types, schema — so they spend their steps on the question instead of on finding '
109
+ + 'their way around'),
110
+ booleanItem('parallelResearchWorkers', 'parallel research', 'Run the 4 research workers at once instead of one after another. Only faster if '
111
+ + 'your model backend can answer several requests at the same timeon a single '
112
+ + 'local GPU it is measurably slower, so leave it off there'),
113
+ booleanItem('researchCache', 'research cache', 'Remember docs and web pages for the length of one run, so later tasks reuse what '
114
+ + 'the first one already fetched instead of downloading it again. Only external '
115
+ + 'sources, only successful fetches, and it is dropped when the run ends'),
128
116
  {
129
117
  id: 'searchProvider',
130
118
  label: 'search engine',
131
119
  description: 'Which engine backs web search. Exa and DuckDuckGo work with no setup; Brave needs '
132
120
  + 'a BRAVE_SEARCH_API_KEY in your environment',
133
121
  // Display full engine names; the stored config value stays the short id.
134
- values: SEARCH_PROVIDERS.map(p => SEARCH_PROVIDER_LABELS[p])
122
+ values: SEARCH_PROVIDERS.map(p => SEARCH_PROVIDER_LABELS[p]),
123
+ format: cfg => SEARCH_PROVIDER_LABELS[cfg.searchProvider],
124
+ apply: (cfg, chosen) => {
125
+ const provider = providerForLabel(chosen);
126
+ if (provider)
127
+ cfg.searchProvider = provider;
128
+ }
135
129
  },
136
130
  {
137
131
  id: 'requestTimeoutMs',
@@ -141,7 +135,14 @@ const ITEMS = [
141
135
  + 'hung build. Covers the main session and the checking steps; "off" lets a stuck '
142
136
  + 'command hang the run until you notice',
143
137
  // Display human labels; the stored config value stays the ms number.
144
- values: COMMAND_TIMEOUT_OPTIONS.map(o => o.label)
138
+ values: COMMAND_TIMEOUT_OPTIONS.map(o => o.label),
139
+ format: cfg => COMMAND_TIMEOUT_OPTIONS.find(o => o.ms === cfg.requestTimeoutMs)?.label
140
+ ?? `${cfg.requestTimeoutMs}ms`,
141
+ apply: (cfg, chosen) => {
142
+ const opt = COMMAND_TIMEOUT_OPTIONS.find(o => o.label === chosen);
143
+ if (opt)
144
+ cfg.requestTimeoutMs = opt.ms;
145
+ }
145
146
  },
146
147
  {
147
148
  id: 'streamInactivityMs',
@@ -152,16 +153,19 @@ const ITEMS = [
152
153
  + 'pauses while a command runs — local models go quiet for minutes on long prompts, '
153
154
  + 'so leave room',
154
155
  // Display human labels; the stored config value stays the ms number.
155
- values: STREAM_INACTIVITY_OPTIONS.map(o => o.label)
156
- },
157
- {
158
- id: 'yoloMode',
159
- label: 'yolo mode',
160
- description: 'Stop asking you anything: every question takes the option pi recommends, a failed '
161
- + 'check is accepted and written down as debt, and a failed final check is retried '
162
- + 'until the budget runs out. Each auto-answer is marked (YOLO) in the task file. '
163
- + 'For throwaway projects you are not watching'
156
+ values: STREAM_INACTIVITY_OPTIONS.map(o => o.label),
157
+ format: cfg => STREAM_INACTIVITY_OPTIONS.find(o => o.ms === cfg.streamInactivityMs)?.label
158
+ ?? `${cfg.streamInactivityMs}ms`,
159
+ apply: (cfg, chosen) => {
160
+ const opt = STREAM_INACTIVITY_OPTIONS.find(o => o.label === chosen);
161
+ if (opt)
162
+ cfg.streamInactivityMs = opt.ms;
163
+ }
164
164
  },
165
+ booleanItem('yoloMode', 'yolo mode', 'Stop asking you anything: every question takes the option pi recommends, a failed '
166
+ + 'check is accepted and written down as debt, and a failed final check is retried '
167
+ + 'until the budget runs out. Each auto-answer is marked (YOLO) in the task file. '
168
+ + 'For throwaway projects you are not watching'),
165
169
  {
166
170
  id: 'debugLogs',
167
171
  label: 'debug logs',
@@ -170,29 +174,15 @@ const ITEMS = [
170
174
  + 'failed — a few lines per task. "full" adds everything the model said and every '
171
175
  + 'command it ran, which is most of the size and only useful while you are digging '
172
176
  + 'into a problem. "off" writes nothing, and nothing can be reconstructed later',
173
- values: [...DEBUG_LOG_OPTIONS]
177
+ values: [...DEBUG_LOG_OPTIONS],
178
+ // The stored value IS the label here, but it still goes through the
179
+ // sanitizer rather than being written raw.
180
+ format: cfg => String(cfg.debugLogs),
181
+ apply: (cfg, chosen) => {
182
+ cfg.debugLogs = sanitizeDebugLogs(chosen);
183
+ }
174
184
  }
175
185
  ];
176
- /** Human label for the stored command-timeout ms (falls back to the raw ms). */
177
- function timeoutLabel(ms) {
178
- return COMMAND_TIMEOUT_OPTIONS.find(o => o.ms === ms)?.label ?? `${ms}ms`;
179
- }
180
- /** Human label for the stored stream-inactivity ms (falls back to the raw ms). */
181
- function streamTimeoutLabel(ms) {
182
- return STREAM_INACTIVITY_OPTIONS.find(o => o.ms === ms)?.label ?? `${ms}ms`;
183
- }
184
- /** What /task-config shows for a setting's current value. */
185
- function displayValue(cfg, id, isEnum) {
186
- if (id === 'searchProvider')
187
- return SEARCH_PROVIDER_LABELS[cfg.searchProvider];
188
- if (id === 'requestTimeoutMs')
189
- return timeoutLabel(cfg.requestTimeoutMs);
190
- if (id === 'streamInactivityMs')
191
- return streamTimeoutLabel(cfg.streamInactivityMs);
192
- if (isEnum)
193
- return String(cfg[id]);
194
- return cfg[id] ? 'on' : 'off';
195
- }
196
186
  /**
197
187
  * One /task-config toggle per installed host extension (GitHub issue #4).
198
188
  * The id carries the entry path behind a prefix so the shared onChange handler
@@ -291,11 +281,11 @@ export function createSettingsPanel(items, theme, onChange, onCancel) {
291
281
  /** The full settings row list for the current config, in menu order. */
292
282
  export function panelItems(cfg, installed, tools = []) {
293
283
  return [
294
- ...ITEMS.map(({ id, label, description, values }) => ({
284
+ ...ITEMS.map(({ id, label, description, values, format }) => ({
295
285
  id: id,
296
286
  label,
297
287
  description,
298
- currentValue: displayValue(cfg, id, Boolean(values)),
288
+ currentValue: format(cfg),
299
289
  values: values ?? ['on', 'off']
300
290
  })),
301
291
  ...toolItems(tools, cfg.commandTimeoutExemptTools),
@@ -317,7 +307,9 @@ async function handleTaskConfig(_args, ctx, getTools = () => []) {
317
307
  // it can only be read here, when the menu opens, never at registration.
318
308
  const tools = getTools();
319
309
  if (ctx.mode !== 'tui') {
320
- const lines = ITEMS.map(({ id, label, values }) => `${label.padEnd(22)} ${displayValue(cfg, id, Boolean(values))}`);
310
+ // Reads the SAME `format` the panel does, so the two renderings cannot
311
+ // disagree about what a setting currently says.
312
+ const lines = ITEMS.map(({ label, format }) => `${label.padEnd(22)} ${format(cfg)}`);
321
313
  for (const t of tools) {
322
314
  const state = cfg.commandTimeoutExemptTools.includes(t.name) ? 'off' : 'on';
323
315
  lines.push(`${('watch: ' + t.name).padEnd(22)} ${state}`);
@@ -336,30 +328,13 @@ async function handleTaskConfig(_args, ctx, getTools = () => []) {
336
328
  else if (id.startsWith(TOOL_ID_PREFIX)) {
337
329
  cfg.commandTimeoutExemptTools = applyToolToggle(cfg.commandTimeoutExemptTools, id.slice(TOOL_ID_PREFIX.length), newValue === 'on');
338
330
  }
339
- else if (id === 'searchProvider') {
340
- const provider = providerForLabel(newValue);
341
- if (provider)
342
- cfg.searchProvider = provider;
343
- }
344
- else if (id === 'requestTimeoutMs') {
345
- const opt = COMMAND_TIMEOUT_OPTIONS.find(o => o.label === newValue);
346
- if (opt)
347
- cfg.requestTimeoutMs = opt.ms;
348
- }
349
- else if (id === 'streamInactivityMs') {
350
- const opt = STREAM_INACTIVITY_OPTIONS.find(o => o.label === newValue);
351
- if (opt)
352
- cfg.streamInactivityMs = opt.ms;
353
- }
354
- else if (id === 'debugLogs') {
355
- // The stored value IS the label here, but it must still go
356
- // through the sanitizer — the generic `else` below would
357
- // write the boolean `newValue === 'on'` into an enum field.
358
- cfg.debugLogs = sanitizeDebugLogs(newValue);
359
- }
360
331
  else {
361
- ;
362
- cfg[id] = newValue === 'on';
332
+ // Every setting parses its own value. There is no generic
333
+ // fallback: the ladder this replaces ended in one that
334
+ // wrote `newValue === 'on'` into whatever field it was
335
+ // handed, so a new enum setting silently became a boolean
336
+ // until someone noticed.
337
+ ITEMS.find(item => item.id === id)?.apply(cfg, newValue);
363
338
  }
364
339
  saveConfig(cfg).catch(() => { });
365
340
  }, () => done(undefined)), { overlay: true, overlayOptions: { width: OVERLAY_WIDTH } });
@@ -1,11 +1,9 @@
1
- import { setAgentIdle } from './state.js';
2
1
  import { publishNotify } from './bridge.js';
3
2
  import { agentStart, appendText, textEnd, appendThinking, thinkingEnd, startTool, updateTool, endTool, agentEnd, addUserTurn, addError, addSystemNote } from './session-state.js';
4
3
  /** Mirror pi agent events into the authoritative SessionState. Each handler
5
4
  * drives a mutator, which updates the snapshot AND broadcasts the live delta. */
6
5
  export function setupEvents(pi) {
7
6
  pi.on('agent_start', (_event, ctx) => {
8
- setAgentIdle(false);
9
7
  agentStart(ctx.model?.name);
10
8
  });
11
9
  pi.on('message_update', (event, _ctx) => {
@@ -48,7 +46,6 @@ export function setupEvents(pi) {
48
46
  endTool(event.toolCallId, event.toolName, event.result, event.isError);
49
47
  });
50
48
  pi.on('agent_end', (_event, ctx) => {
51
- setAgentIdle(true);
52
49
  agentEnd(ctx.getContextUsage(), ctx.model?.name);
53
50
  // Deliberately no push: agent_end fires on EVERY host turn — every chat
54
51
  // reply, every internal phase turn inside /task, and every internal /task
@@ -1,12 +1,11 @@
1
1
  import { getConfig } from '../config/config.js';
2
2
  import { getBridge, dispatchRemoteLine, dispatchRemoteNewSession, makeShimmedCtx, interruptAgent, registerBridgeCommand, registerRemoteOnlyCommand, publishNotify } from './bridge.js';
3
3
  import { setupEvents } from './events.js';
4
- import { reset, addUserTurn, setHeld } from './session-state.js';
4
+ import { reset, addUserTurn, setHeld, getState } from './session-state.js';
5
5
  import { html } from './ui.js';
6
6
  import { qrLines } from './qr.js';
7
7
  import { startServer, formatAddresses } from './server.js';
8
8
  import { ensureTailscaleServe, teardownTailscaleServe, planRemoteUrls, hostFromResult } from './tailscale.js';
9
- import { isAgentIdle } from './state.js';
10
9
  import { holdInput, isRunActive, clearHeldInput, heldInput, setHeldInputListener } from '../task/mid-run-input.js';
11
10
  const _g = globalThis;
12
11
  if (!_g.__piRemote)
@@ -19,7 +18,17 @@ const S = _g.__piRemote;
19
18
  */
20
19
  export function routePlainLine(plain, send) {
21
20
  addUserTurn(plain);
22
- if (!isAgentIdle()) {
21
+ // Read the run flag from SessionState, which is the only thing that owns it.
22
+ // There used to be a mirror of it here (remote/state.ts: a module-level `let`
23
+ // plus a getter/setter, set alongside agentStart/agentEnd in events.ts). Two
24
+ // sources of truth for one boolean, and they disagreed twice over: SessionState
25
+ // also clears agentRunning in addError and reset, neither of which touched the
26
+ // mirror — so an errored turn or a /new left this branch steering into a turn
27
+ // SessionState already considered finished. And the mirror was a plain module
28
+ // `let` while every other piece of remote state deliberately lives on
29
+ // globalThis to survive jiti re-evaluation, so it silently reset on session
30
+ // switch. Deleting it removed complexity rather than moving it.
31
+ if (getState().agentRunning) {
23
32
  // A live turn: steer it (inject into the current generation) so the
24
33
  // nudge lands immediately.
25
34
  send(plain, { deliverAs: 'steer' });