killeros 3.0.0 → 3.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,28 @@ All notable changes to KillerOS are documented here. Releases use New Features f
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [3.0.1] - 2026-10-02
8
+
9
+ ### Changed
10
+
11
+ - Expanded Pi peer ranges to `>=0.99.2 <2` and pinned development packages to 1.0.0. Compatibility CI now runs the full quality suite against the exact 0.99.2 minimum and latest stable 1.x with matched host packages, without changing the tracked manifest or lockfile.
12
+ - Documented Pi 1.0's fullscreen default and the regular-mode override for terminal-owned scrollback.
13
+ - Documented the protected PR-based release process and merge-commit requirement for automatic `main`-to-`dev` synchronization. CI now validates release metadata before package smoke testing, so mismatched versions, missing release notes, and stale README tags fail before merge.
14
+
15
+ ### Fixed
16
+
17
+ - Reused bounded cleanup retries in passive Git footer tests so transient Windows directory locks do not fail CI after the security assertions pass.
18
+ - Preserved an authorized goal continuation when a replacement becomes stale during its idle wait, instead of leaving the surviving goal active but idle. Navigation, clear, turn limits, and competing continuation holds remain respected.
19
+ - Rejected recognized credential assignments in Markdown handoffs, including list items, bold labels, and inline-code labels, before creating a destination session.
20
+ - Discarded pending goal mutations after committed navigation or a competing goal mutation during confirmation, idle waiting, or file-baseline inference, including goals set and then cleared. Overlapping commands retain their own continuation holds. Cancelled navigation preserves valid pending commands.
21
+ - Preserved concurrent changes to independent `/auto-compact` fields by merging the effective preference under the settings lock.
22
+ - Removed C1 terminal controls from custom activity tool names.
23
+ - Clipped collapsed and expanded transcript lines to the terminal width, including wide Unicode characters at one column.
24
+ - Treated failed Git HEAD lookups as unavailable instead of inventing added files, while retaining support for repositories without commits. Footer scans and task receipts share the same HEAD resolution.
25
+ - Paused normally stopped goals without an accepted decision even when automatic compaction succeeds or skips a session-too-small request, preventing repeated same-turn requests from bypassing the turn limit. Interrupted-turn recovery and accepted next-turn decisions remain supported.
26
+ - Saved `/handoff` context before reporting success so the linked session survives immediate exit and resume without another prompt or an automatic agent turn.
27
+ - Discarded pending task receipts after committed tree navigation, preventing late scans from attaching the abandoned branch's receipt to the selected branch. Cancelled navigation still preserves receipts.
28
+
7
29
  ## [3.0.0] - 2026-10-01
8
30
 
9
31
  ### Changed
package/README.md CHANGED
@@ -19,7 +19,7 @@ A TypeScript extension for the [Pi coding agent](https://github.com/earendil-wor
19
19
  ## Requirements
20
20
 
21
21
  - Node.js 22.19.0+
22
- - Pi 0.99.2 or later within the 0.x release line
22
+ - Pi 0.99.2 or later below 2.0.0
23
23
  - An interactive TUI session for the custom header, editor, footer, and `question`
24
24
 
25
25
  ## Install
@@ -34,7 +34,7 @@ Or from GitHub:
34
34
  pi install git:github.com/KyrosHendrix/pi-KillerOS
35
35
  ```
36
36
 
37
- Pin a release by appending its tag, for example `@v3.0.0`. Add `-l` to install only for the current project. Restart Pi after installing.
37
+ Pin a release by appending its tag, for example `@v3.0.1`. Add `-l` to install only for the current project. Restart Pi after installing.
38
38
 
39
39
  ## Commands
40
40
 
@@ -61,6 +61,8 @@ The TUI footer's `fast` label means the enabled preference applies to the select
61
61
 
62
62
  ## Behavior by mode
63
63
 
64
+ Pi 1.0 defaults to fullscreen. Use `--tui-mode regular` for one invocation or set `"tuiMode": "regular"` in Pi's [settings](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/settings.md#terminal-and-display) to restore terminal-owned scrollback. KillerOS leaves Pi's `tuiMode` and `quietStartup` preferences unchanged.
65
+
64
66
  | Mode | What works |
65
67
  | --- | --- |
66
68
  | TUI | Everything |
@@ -85,7 +87,9 @@ The packaged `killeros` theme activates on TUI start. Compaction triggers by def
85
87
 
86
88
  Use `/auto-compact status` to inspect the effective KillerOS preference, `/auto-compact on` or `/auto-compact off` to toggle it, and `/auto-compact <percent>` to set an integer threshold from 0 through 100. A threshold of 0 does not disable Pi's own token reserve.
87
89
 
88
- `handoffMaxTokens` caps the `/handoff` summary output at 8192 tokens by default; raise it when long sessions truncate the summary.
90
+ Concurrent `/auto-compact` commands preserve independent changes to the enabled flag and threshold, along with unrelated settings. Malformed settings and failed writes report an error without claiming success or replacing the original file.
91
+
92
+ `handoffMaxTokens` caps the `/handoff` summary output at 8192 tokens by default; raise it when long sessions truncate the summary. The handoff is saved as a visible user-context message before success is reported, so the linked session survives immediate exit and resume without sending another prompt. Creating the handoff does not start an agent turn.
89
93
 
90
94
  State proof in the objective so the agent can verify it with its normal tools:
91
95
 
@@ -99,9 +103,13 @@ A direct quoted path-shaped file target binds silent file proof. For an extensio
99
103
  /goal Fix `killeros/footer.ts`, verified by npm test
100
104
  ```
101
105
 
102
- Bare quoted prose remains model-reported. KillerOS captures the file baseline at goal start and only completes when the file is created or changed. A normal response never continues a goal by itself: the agent must record `continue`, `complete`, or a blocker decision through `killeros_goal_update`. Repeated continuation reports and unavailable goal tools pause the goal. New goals pause after 20 turns without warning. An explicit `/goal resume` on an exhausted goal grants another 20 turns; compaction recovery never grants turns. Restored goals keep their persisted limit.
106
+ Bare quoted prose remains model-reported. KillerOS captures the file baseline at goal start and only completes when the file is created or changed. A normal response never continues a goal by itself: the agent must record `continue`, `complete`, or a blocker decision through `killeros_goal_update`. Repeated continuation reports and unavailable goal tools pause the goal. New goals pause after 20 turns without warning. An explicit `/goal resume` on an exhausted goal grants another 20 turns; compaction recovery never grants turns. Same-turn recovery requires an actual interruption; successful or skipped compaction cannot restart a normally stopped goal without a decision. Restored goals keep their persisted limit.
107
+
108
+ Session replacement, reload, and committed tree navigation discard unfinished task receipts. Late receipt results do not write or notify through the old session context or attach to a different branch. Cancelled navigation preserves the pending receipt.
109
+
110
+ Pending `/goal` commands discard their mutation if committed navigation changes the branch or another mutation changes the goal while confirmation, idle waiting, or file-baseline reading is in progress. Cancelled navigation preserves valid pending commands. Discarding a stale replacement after its idle wait preserves the surviving goal's authorized continuation.
103
111
 
104
- Session replacement and reload discard unfinished task receipts. Late receipt results do not write or notify through the old session context.
112
+ Failed Git metadata reads keep the footer's last successful file counts and mark task changes unavailable. They are not treated as an empty repository.
105
113
 
106
114
  Completion sounds are off by default; change with `/notification` in TUI mode. The tab-title indicator requires a Nerd Font.
107
115
 
@@ -113,12 +121,24 @@ Strict TypeScript throughout. Tests run on Node's built-in test runner:
113
121
  npm ci && npm run check && npm test
114
122
  ```
115
123
 
116
- Releases go through CI on `main`; do not push version tags manually. The prepublish check rejects ordinary direct `npm publish`, but `--ignore-scripts` can bypass it. Configure npm's trusted publisher for `release.yml`, set package publishing access to "Require two-factor authentication and disallow tokens", and revoke unused publish tokens. npm maintainers can still publish interactively with 2FA, so workflow-only publishing also depends on maintainer policy.
124
+ ### Release process
125
+
126
+ 1. Prepare the release on `dev`: update `package.json` and both root versions in `package-lock.json`, update the README's pinned tag, and move completed changelog entries into a dated version section under `[Unreleased]`.
127
+ 2. Run `npm run check` and `npm test`, commit and push to `dev`, then open a pull request into `main`. Push later fixes to the same PR so CI reruns.
128
+ 3. Wait for every required check to pass, including CodeQL and dependency review, and resolve review conversations. The PR must be up to date with `main`.
129
+ 4. Merge using **Create a merge commit**. Squash and rebase merging are disabled because the automated `main`-to-`dev` sync requires shared ancestry. Avoid advancing `dev` until that sync finishes.
130
+ 5. Check the `main` CI run and the following Release run. Successful CI on the current `main` commit triggers npm publication with provenance, creates the GitHub release, and fast-forwards `dev` to the released commit. A green PR alone never publishes.
131
+
132
+ `main` requires pull requests and the CI workflow's required checks, including for administrators. Force pushes and branch deletion are blocked. If CI job names change, update GitHub's required status checks to match.
133
+
134
+ Releases go through CI on `main`; do not push version tags manually. The package smoke job validates release metadata before packing, and the release workflow checks it again before publication. The prepublish check rejects ordinary direct `npm publish`, but `--ignore-scripts` can bypass it. Configure npm's trusted publisher for `release.yml`, set package publishing access to "Require two-factor authentication and disallow tokens", and revoke unused publish tokens. npm maintainers can still publish interactively with 2FA, so workflow-only publishing also depends on maintainer policy.
117
135
 
118
136
  ## Security
119
137
 
120
138
  Pi extensions run with your user permissions. Review the source before installing globally. Hook commands run only for projects Pi marks as trusted; check `.pi/killeros-hooks.json` before enabling project trust. KillerOS accepts that configuration only as a regular, non-linked file no larger than 64 KiB in the project's real `.pi` directory.
121
139
 
140
+ Handoff validation rejects recognized credential assignments in plain text, Markdown lists, bold labels, and inline-code labels before creating a destination session. This pattern check cannot detect every secret or personally identifying value. Rejected values are not included in the error notification.
141
+
122
142
  ## License
123
143
 
124
144
  [MIT](LICENSE) © 2026 KyrosHendrix
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { truncateToWidth } from "@earendil-works/pi-tui";
3
3
  import { modelDisplayName } from "./display.ts";
4
+ import { safeTerminalText } from "./safe-terminal-text.ts";
4
5
 
5
6
  export type ActivityMessage =
6
7
  | { kind: "prompt" }
@@ -9,7 +10,7 @@ export type ActivityMessage =
9
10
  | { kind: "responding" };
10
11
 
11
12
  function safeToolName(toolName: string): string {
12
- const normalized = toolName.replace(/[\u0000-\u001F\u007F]+/gu, " ").replace(/\s+/gu, " ").trim();
13
+ const normalized = safeTerminalText(toolName.replace(/[\r\n\t]+/gu, " ")).replace(/\s+/gu, " ").trim();
13
14
  return truncateToWidth(normalized || "tool", 32, "…");
14
15
  }
15
16
 
@@ -155,11 +155,15 @@ export function registerAutoCompaction(
155
155
  }
156
156
 
157
157
  try {
158
- const preference = readAutoCompactionPreference(settingsStore.load());
159
- const updated = percent === undefined
160
- ? { ...preference, enabled: argument === "on" }
161
- : { ...preference, percentRemaining: percent };
162
- settingsStore.update({ autoCompaction: updated });
158
+ let updated: AutoCompactionPreference | undefined;
159
+ settingsStore.update((current) => {
160
+ const preference = readAutoCompactionPreference(current);
161
+ updated = percent === undefined
162
+ ? { ...preference, enabled: argument === "on" }
163
+ : { ...preference, percentRemaining: percent };
164
+ return { autoCompaction: updated };
165
+ });
166
+ if (!updated) throw new Error("Settings update did not apply the preference");
163
167
  ctx.ui.notify(percent === undefined
164
168
  ? updated.enabled
165
169
  ? `Automatic compaction: on at ${updated.percentRemaining}% remaining`
@@ -11,7 +11,7 @@ export class BoundedText implements Component {
11
11
 
12
12
  render(width: number): string[] {
13
13
  if (width <= 0) return [];
14
- const lines = new Text(this.text, 0, 0).render(width);
14
+ const lines = new Text(this.text, 0, 0).render(width).map((line) => truncateToWidth(line, width, ""));
15
15
  if (this.maxRows === undefined || lines.length <= this.maxRows) return lines;
16
16
  const rowLimit = Math.max(1, this.maxRows);
17
17
  const visible = lines.slice(0, rowLimit);
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createReadStream, watch } from "node:fs";
3
- import { inspectWorktreeWithoutFilters, passiveGitCommand, passiveGitEnv, passiveWorktreeMode, passiveWorktreeSettings, type PassiveWorktreeFile as DirtyFile, type PassiveWorktreeSnapshot } from "./passive-git-status.ts";
3
+ import { inspectWorktreeWithoutFilters, passiveGitCommand, passiveGitEnv, passiveWorktreeMode, passiveWorktreeSettings, resolvePassiveHead, type PassiveWorktreeFile as DirtyFile, type PassiveWorktreeSnapshot } from "./passive-git-status.ts";
4
4
  import { lstat, open, readlink } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { createInflate } from "node:zlib";
@@ -237,19 +237,8 @@ async function snapshotKnownPaths(repo: Repository, baseline: Snapshot, paths: r
237
237
  return { ...baseline, files };
238
238
  }
239
239
 
240
- async function currentHead(repo: Repository): Promise<string> {
241
- let resolved: Buffer;
242
- try {
243
- resolved = await runGit(repo.root, ["rev-parse", "--verify", "HEAD"], undefined, 4_096);
244
- } catch (error) {
245
- if (!(error instanceof GitFailure) || error.reason !== "error") throw error;
246
- const reference = decode(await runGit(repo.root, ["symbolic-ref", "--quiet", "HEAD"], undefined, 4_096)).trim();
247
- if (!/^refs\/[^\0\r\n]+$/u.test(reference)) throw new GitFailure("error");
248
- return "(initial)";
249
- }
250
- const head = decode(resolved).trim();
251
- if (!/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/u.test(head)) throw new GitFailure("error");
252
- return head;
240
+ function currentHead(repo: Repository): Promise<string> {
241
+ return resolvePassiveHead((args) => runGit(repo.root, args, undefined, 4_096));
253
242
  }
254
243
 
255
244
  function observes(filePath: string, observedPaths: readonly string[]): boolean {
@@ -311,6 +311,9 @@ export function registerGoalInterface(
311
311
  ctx.ui.notify("/goal requires a saved session", "error");
312
312
  return;
313
313
  }
314
+ const generation = runtime.lifecycleGeneration;
315
+ const initiatingState = runtime.state;
316
+ const ownsGoal = (): boolean => runtime.lifecycleGeneration === generation && runtime.state === initiatingState;
314
317
 
315
318
  if (command.kind === "status") {
316
319
  if (!runtime.state) {
@@ -323,9 +326,11 @@ export function registerGoalInterface(
323
326
  }
324
327
  const actions = goalPanelActions(runtime.state.status);
325
328
  const selected = await ctx.ui.select(goalStatusSummary(runtime.state, ctx), actions.map((action) => action.label));
329
+ if (!ownsGoal()) return;
326
330
  const action = actions.find((candidate) => candidate.label === selected);
327
331
  if (!action) return;
328
332
  if (action.control === "clear" && !await ctx.ui.confirm("Clear goal?", safeTerminalText(runtime.state.objective))) return;
333
+ if (!ownsGoal()) return;
329
334
  await handleGoalCommand(action.control, ctx);
330
335
  return;
331
336
  }
@@ -487,17 +492,23 @@ export function registerGoalInterface(
487
492
  return;
488
493
  }
489
494
  const replace = await ctx.ui.confirm("Replace active goal", "Replace the current unfinished goal and discard its continuation state?");
490
- if (!replace) return;
495
+ if (!replace || !ownsGoal()) return;
491
496
  }
492
497
 
493
- runtime.continuationHeld = true;
498
+ const hold = Symbol();
499
+ runtime.continuationHeld = hold;
494
500
  let waitError: unknown;
495
501
  try {
496
502
  await ctx.waitForIdle();
497
503
  } catch (error) {
498
504
  waitError = error;
499
505
  } finally {
500
- runtime.continuationHeld = false;
506
+ if (runtime.continuationHeld === hold) runtime.continuationHeld = undefined;
507
+ }
508
+ if (!ownsGoal()) {
509
+ // Settlement may have authorized a turn while this command held continuation.
510
+ if (runtime.lifecycleGeneration === generation) scheduleGoalContinuation(pi, runtime, ctx);
511
+ return;
501
512
  }
502
513
  if (waitError) {
503
514
  reportError(ctx, "Goal could not wait for the active turn", waitError);
@@ -508,6 +519,7 @@ export function registerGoalInterface(
508
519
  try {
509
520
  verification = await inferGoalVerification(objective, ctx.cwd);
510
521
  } catch (error) {
522
+ if (!ownsGoal()) return;
511
523
  if (!unfinished) {
512
524
  reportError(ctx, "Goal could not be started", error);
513
525
  } else {
@@ -516,6 +528,7 @@ export function registerGoalInterface(
516
528
  }
517
529
  return;
518
530
  }
531
+ if (!ownsGoal()) return;
519
532
  try {
520
533
  const state = createNewGoalState(objective, sumGoalTokens(ctx), verification, Date.now(), {
521
534
  maxTurns: DEFAULT_GOAL_MAX_TURNS,
@@ -119,6 +119,10 @@ export function persistGoalState(
119
119
  const data: GoalEntryData = { version: GOAL_VERSION, event, state: state ?? null };
120
120
  pi.appendEntry(GOAL_ENTRY_TYPE, data);
121
121
  runtime.state = state;
122
+ if (event === "clear") {
123
+ runtime.lifecycleGeneration += 1;
124
+ runtime.continuationHeld = undefined;
125
+ }
122
126
  syncGoalUpdateTool(pi, runtime);
123
127
  runtime.persistenceRetryNeeded = false;
124
128
  runtime.requestRender?.();
@@ -404,7 +408,7 @@ export function registerGoalRuntime(
404
408
  runtime.state = isGoalModeSupported(ctx) ? restored.state : undefined;
405
409
  syncGoalUpdateTool(pi, runtime);
406
410
  runtime.continuationScheduled = false;
407
- runtime.continuationHeld = false;
411
+ runtime.continuationHeld = undefined;
408
412
  runtime.goalTurnInFlight = false;
409
413
  runtime.goalTurn = undefined;
410
414
  runtime.agentEndObserved = false;
@@ -441,7 +445,7 @@ export function registerGoalRuntime(
441
445
  runtime.state = undefined;
442
446
  syncGoalUpdateTool(pi, runtime);
443
447
  clearGoalExecutionFlags(runtime);
444
- runtime.continuationHeld = false;
448
+ runtime.continuationHeld = undefined;
445
449
  runtime.persistenceRetryNeeded = false;
446
450
  });
447
451
 
@@ -254,6 +254,10 @@ export function registerGoalSettlement(
254
254
  );
255
255
  return;
256
256
  }
257
+ if (!expectedInterruption && runtime.automaticCompaction.resumeSameTurn) {
258
+ stopAutomaticCompactionRecovery(pi, runtime, ctx, "no turn decision");
259
+ return;
260
+ }
257
261
  if (settledTurn !== undefined && runtime.automaticCompaction.turn === settledTurn) {
258
262
  runtime.automaticCompaction.turnSettled = true;
259
263
  } else {
@@ -300,7 +300,8 @@ export function registerHandoff(pi: ExtensionAPI, goalRuntime: GoalRuntime, hand
300
300
  parentSession: sourceSession,
301
301
  setup: async (sessionManager) => {
302
302
  try {
303
- sessionManager.appendCustomMessageEntry("killeros-handoff", document, true);
303
+ // A regular user message makes Pi persist the otherwise setup-only child session.
304
+ sessionManager.appendMessage({ role: "user", content: document, timestamp: Date.now() });
304
305
  sessionManager.appendSessionInfo(sessionName(sourceName, focus, document));
305
306
  } catch (error) {
306
307
  setupFailure = { error };
@@ -8,6 +8,13 @@ const PASSIVE_METADATA_ARGS = ["-c", "core.fsmonitor=false"] as const;
8
8
 
9
9
  export type PassiveGitRunner = (args: readonly string[]) => Promise<Buffer>;
10
10
 
11
+ /** Revs-only returns empty output for an unborn HEAD; command failures still reject. */
12
+ export async function resolvePassiveHead(runGit: PassiveGitRunner): Promise<string> {
13
+ const head = decode(await runGit(["rev-parse", "--revs-only", "--end-of-options", "HEAD"])).trim();
14
+ if (head && !/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/u.test(head)) throw new Error("invalid HEAD");
15
+ return head || "(initial)";
16
+ }
17
+
11
18
  export type PassiveWorktreeFile = {
12
19
  path: string;
13
20
  headMode?: string;
@@ -343,19 +350,17 @@ function matchesIndex(content: Buffer, entry: IndexEntry, autoCrlf: string): boo
343
350
  * No Git command in this path asks Git to convert worktree content, so clean and process filters cannot start.
344
351
  */
345
352
  export async function inspectWorktreeWithoutFilters(root: string, runGit: PassiveGitRunner): Promise<PassiveWorktreeSnapshot> {
346
- const [indexOutput, untrackedOutput, resolvedHead, autoCrlfOutput, settings] = await Promise.all([
353
+ const [indexOutput, untrackedOutput, head, autoCrlfOutput, settings] = await Promise.all([
347
354
  runGit([...PASSIVE_METADATA_ARGS, "ls-files", "--cached", "--debug", "-z", "--full-name",
348
355
  "--format=%(objectmode) %(objectname) %(stage)%x09%(eolattr)%x09%(path)"]),
349
356
  runGit([...PASSIVE_METADATA_ARGS, "ls-files", "--others", "--exclude-standard", "-z", "--full-name"]),
350
- runGit(["rev-parse", "--verify", "HEAD"]).then(decode, () => ""),
357
+ resolvePassiveHead(runGit),
351
358
  runGit(["config", "--type=bool-or-str", "--default", "false", "--get", "core.autocrlf"]),
352
359
  passiveWorktreeSettings(runGit),
353
360
  ]);
354
361
  const autoCrlf = decode(autoCrlfOutput).trim().toLowerCase();
355
- const head = resolvedHead.trim();
356
- if (head && !/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/u.test(head)) throw new Error("invalid HEAD");
357
362
  const index = parseIndex(indexOutput);
358
- const tree = head ? parseTree(await runGit(["ls-tree", "-r", "-z", "--full-tree", head])) : new Map<string, TreeEntry>();
363
+ const tree = head !== "(initial)" ? parseTree(await runGit(["ls-tree", "-r", "-z", "--full-tree", head])) : new Map<string, TreeEntry>();
359
364
  const files = new Map<string, PassiveWorktreeFile>();
360
365
  let contentBytes = 0;
361
366
 
@@ -398,5 +403,5 @@ export async function inspectWorktreeWithoutFilters(root: string, runGit: Passiv
398
403
  const previous = files.get(filePath);
399
404
  files.set(filePath, { ...previous, path: filePath, mode: state.mode, content: state.content, contentObjectId: undefined });
400
405
  }
401
- return { head: head || "(initial)", files, ...settings };
406
+ return { head, files, ...settings };
402
407
  }
@@ -124,7 +124,7 @@ export interface GoalTurnExecution {
124
124
  export interface GoalRuntime {
125
125
  state?: GoalState;
126
126
  continuationScheduled: boolean;
127
- continuationHeld: boolean;
127
+ continuationHeld?: symbol;
128
128
  goalTurnInFlight: boolean;
129
129
  agentEndObserved: boolean;
130
130
  goalTurn?: GoalTurnExecution;
@@ -139,7 +139,7 @@ export interface GoalRuntime {
139
139
  export function createGoalRuntime(): GoalRuntime {
140
140
  return {
141
141
  continuationScheduled: false,
142
- continuationHeld: false,
142
+ continuationHeld: undefined,
143
143
  goalTurnInFlight: false,
144
144
  agentEndObserved: false,
145
145
  goalTurn: undefined,
@@ -10,7 +10,7 @@ const SECRET_PATTERNS = [
10
10
  /\bsk_live_[A-Za-z0-9]{16,255}\b/u,
11
11
  /\bxox[baprs]-[A-Za-z0-9-]{10,255}\b/u,
12
12
  /\b[a-z][a-z0-9+.-]*:\/\/[^/\s:@]+:[^/\s@]+@/iu,
13
- /^[\t ]*["']?[\w.-]*(?:api_key|apikey|password|passwd|secret|token)[\w.-]*["']?[\t ]*(?:=|:)[\t ]*(?:"[^"\r\n]+"|'[^'\r\n]+'|[^\s#][^\r\n]*)/imu,
13
+ /^[\t ]*(?:(?:[-*+]|\d+[.)])[\t ]+)?(?:\*\*|`)?["']?[\w.-]*(?:api_key|apikey|password|passwd|secret|token)[\w.-]*["']?(?:\*\*|`)?[\t ]*(?:=|:)[\t ]*(?:"[^"\r\n]+"|'[^'\r\n]+'|[^\s#][^\r\n]*)/imu,
14
14
  ] as const;
15
15
 
16
16
  /** Detects high-confidence credential material without returning the matched value. */
@@ -5,10 +5,13 @@ import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
5
  import { hasErrorCode } from "./errors.ts";
6
6
 
7
7
  export type KillerosSettings = Record<string, unknown>;
8
+ export type KillerosSettingsUpdate = Readonly<Record<string, unknown>>
9
+ | ((current: Readonly<KillerosSettings>) => Readonly<Record<string, unknown>>);
8
10
 
9
11
  export interface KillerosSettingsStore {
10
12
  load(): KillerosSettings;
11
- update(patch: Readonly<Record<string, unknown>>): void;
13
+ /** Derive a field update from the latest settings while holding the writer lock. */
14
+ update(patch: KillerosSettingsUpdate): void;
12
15
  }
13
16
 
14
17
  function isSettings(value: unknown): value is KillerosSettings {
@@ -123,9 +126,10 @@ export function createKillerosSettingsStore(
123
126
  const temporaryPath = `${settingsPath}.${process.pid}.${randomUUID()}.tmp`;
124
127
  try {
125
128
  const current = readStoredSettings(settingsPath);
129
+ const changes = typeof patch === "function" ? patch(current) : patch;
126
130
  writeFileSync(
127
131
  temporaryPath,
128
- `${JSON.stringify({ ...current, ...patch }, null, 2)}\n`,
132
+ `${JSON.stringify({ ...current, ...changes }, null, 2)}\n`,
129
133
  { encoding: "utf8", mode: 0o600 },
130
134
  );
131
135
  renameSync(temporaryPath, settingsPath);
@@ -302,6 +302,7 @@ export function workedForOutcome(stopReason: StopReason | undefined): WorkedForO
302
302
  }
303
303
 
304
304
  type ActiveReceipt = {
305
+ leafId: string | null;
305
306
  startedAt: number;
306
307
  startedTokens: number | undefined;
307
308
  stopReason: StopReason | undefined;
@@ -369,17 +370,20 @@ export function registerWorkedFor(
369
370
  return new Text(`${headline}${theme.fg("dim", ` · ${formatWorkedForDuration(data.milliseconds)}${tokens}`)}`, 1, 0);
370
371
  });
371
372
 
372
- pi.on("session_start", async () => {
373
+ const resetForBranch = async (): Promise<void> => {
373
374
  lifecycleGeneration += 1;
374
375
  const stale = active;
375
376
  active = undefined;
376
377
  collectionNoticeShown = false;
377
378
  if (stale) await (await stale.collection).dispose();
378
- });
379
+ };
380
+ pi.on("session_start", resetForBranch);
381
+ pi.on("session_tree", resetForBranch);
379
382
 
380
383
  pi.on("agent_start", async (_event, ctx) => {
381
384
  if (ctx.mode !== "tui" || active || !ctx.isProjectTrusted()) return;
382
385
  const state: ActiveReceipt = {
386
+ leafId: ctx.sessionManager.getLeafId(),
383
387
  startedAt: now(),
384
388
  startedTokens: sessionTokenTotal(ctx),
385
389
  stopReason: undefined,
@@ -440,6 +444,8 @@ export function registerWorkedFor(
440
444
 
441
445
  pi.on("agent_end", (event, ctx) => {
442
446
  if (ctx.mode !== "tui" || !active) return;
447
+ // Pi is not idle yet; later settlement handlers can overlap tree navigation.
448
+ active.leafId = ctx.sessionManager.getLeafId();
443
449
  for (let index = event.messages.length - 1; index >= 0; index -= 1) {
444
450
  const message = event.messages[index];
445
451
  if (message?.role !== "assistant") continue;
@@ -455,8 +461,9 @@ export function registerWorkedFor(
455
461
  active = undefined;
456
462
  const model = receiptModelName(settled, ctx.model);
457
463
  const changes = await (await settled.collection).finish();
458
- // Shutdown can invalidate ctx while the final Git scan is still running.
459
- if (generation !== lifecycleGeneration) return;
464
+ // Earlier async session_tree handlers can delay our reset after Pi switches branches.
465
+ if (generation !== lifecycleGeneration
466
+ || !ctx.sessionManager.getBranch().some((entry) => entry.id === settled.leafId)) return;
460
467
  if (changes.state === "unavailable" && changes.reason !== "not-git" && changes.reason !== "timeout" && !collectionNoticeShown) {
461
468
  collectionNoticeShown = true;
462
469
  ctx.ui.notify(`Change receipt unavailable: ${changes.reason}`, "warning");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "killeros",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "TUI, goals, and workflow automation for the Pi coding agent",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -45,16 +45,16 @@
45
45
  ]
46
46
  },
47
47
  "peerDependencies": {
48
- "@earendil-works/pi-ai": ">=0.99.2 <1",
49
- "@earendil-works/pi-coding-agent": ">=0.99.2 <1",
50
- "@earendil-works/pi-tui": ">=0.99.2 <1",
48
+ "@earendil-works/pi-ai": ">=0.99.2 <2",
49
+ "@earendil-works/pi-coding-agent": ">=0.99.2 <2",
50
+ "@earendil-works/pi-tui": ">=0.99.2 <2",
51
51
  "typebox": ">=1.1.38 <2"
52
52
  },
53
53
  "devDependencies": {
54
- "@earendil-works/pi-ai": "0.99.2",
55
- "@earendil-works/pi-coding-agent": "0.99.2",
56
- "@earendil-works/pi-server": "0.99.2",
57
- "@earendil-works/pi-tui": "0.99.2",
54
+ "@earendil-works/pi-ai": "1.0.0",
55
+ "@earendil-works/pi-coding-agent": "1.0.0",
56
+ "@earendil-works/pi-server": "1.0.0",
57
+ "@earendil-works/pi-tui": "1.0.0",
58
58
  "@types/node": "24.12.4",
59
59
  "eslint": "^10.9.1",
60
60
  "typebox": "1.3.20",