@pi-unipi/unipi 3.0.0-alpha.13 → 3.0.0-alpha.14

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
@@ -6,6 +6,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [3.0.0-alpha.14] — 2026-09-30
10
+
11
+ ### Added
12
+ - `utility` (simple render style): the turn's final reply is painted on its own panel — the darkest possible background (true black) framed by an orange heavy rule reading `summary` above and a matching unlabelled rule below, opening with one blank row of breathing space. Light themes lift the reply text to bright white so it stays readable on black. The panel is cached per component by width + message + colour, so a keystroke re-render is a lookup, not a repaint.
13
+
14
+ ### Fixed
15
+ - `utility` (simple render style): a step with no visible text (thinking and/or tool calls only) no longer renders rows — pi still adds a leading spacer for such a step, so each one left a blank row and the gap above a tool group grew with every thinking-only step.
16
+ - `kanboard` guard: read-only commands that merely mention the binary (`which unipi-kanboard`, `find -name "unipi-kanboard"`, `ls …/bin/unipi-kanboard`, `pip show pi-unipi-kanboard`) are no longer blocked as "credits used up" — a token counts as an invocation only in command position (first word of its segment after `&&`/`||`/`;`/`|`/newline splits with quotes masked, past leading `VAR=value` assignments and `exec`/`command`/`env` wrappers).
17
+ - `kanboard` guard: an invalid subcommand no longer poisons a whole compound call — `start KB-2; done KB-1` passes the guard and the binary itself reports the typo (`done` is not a subcommand). Subcommands the guard does not recognize are never charged or blocked.
18
+ - `kanboard` guard: the refusal text now ends with "(board reads, `start` and `finish` are always free)" so a blocked agent does not conclude the CLI is unusable.
19
+ - `kanboard` reminders (R1): now per-task instead of once-per-turn — starting one task no longer silences the reminder for the other, and when the agent moves to a second task's files (in a later turn) it is nudged again; each task is reminded at most once per turn and twice in total.
20
+ - `kanboard` reminders: shell detection masks quoted spans before splitting segments (`grep -c "a;b" f` is no longer "file-changing"), and harmless node-project arms count as reads (`node`/`npm`/`npx`/`python3` version checks, `sort` without `-o`).
21
+
9
22
  ## [3.0.0-alpha.13] — 2026-09-29
10
23
 
11
24
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/unipi",
3
- "version": "3.0.0-alpha.13",
3
+ "version": "3.0.0-alpha.14",
4
4
  "description": "All-in-one extension suite for Pi coding agent",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -82,26 +82,26 @@
82
82
  "typebox": "^1.1.38"
83
83
  },
84
84
  "dependencies": {
85
- "@pi-unipi/ask-user": "3.0.0-alpha.13",
86
- "@pi-unipi/background-tasks": "3.0.0-alpha.13",
87
- "@pi-unipi/btw": "3.0.0-alpha.13",
88
- "@pi-unipi/command-enchantment": "3.0.0-alpha.13",
89
- "@pi-unipi/compactor": "3.0.0-alpha.13",
90
- "@pi-unipi/core": "3.0.0-alpha.13",
91
- "@pi-unipi/footer": "3.0.0-alpha.13",
92
- "@pi-unipi/fusion": "3.0.0-alpha.13",
93
- "@pi-unipi/info-screen": "3.0.0-alpha.13",
94
- "@pi-unipi/input-shortcuts": "3.0.0-alpha.13",
95
- "@pi-unipi/kanboard": "3.0.0-alpha.13",
96
- "@pi-unipi/mcp": "3.0.0-alpha.13",
97
- "@pi-unipi/memory": "3.0.0-alpha.13",
98
- "@pi-unipi/notify": "3.0.0-alpha.13",
99
- "@pi-unipi/skill-registry": "3.0.0-alpha.13",
100
- "@pi-unipi/subagents": "3.0.0-alpha.13",
101
- "@pi-unipi/updater": "3.0.0-alpha.13",
102
- "@pi-unipi/utility": "3.0.0-alpha.13",
103
- "@pi-unipi/web-api": "3.0.0-alpha.13",
104
- "@pi-unipi/workflow": "3.0.0-alpha.13"
85
+ "@pi-unipi/ask-user": "3.0.0-alpha.14",
86
+ "@pi-unipi/background-tasks": "3.0.0-alpha.14",
87
+ "@pi-unipi/btw": "3.0.0-alpha.14",
88
+ "@pi-unipi/command-enchantment": "3.0.0-alpha.14",
89
+ "@pi-unipi/compactor": "3.0.0-alpha.14",
90
+ "@pi-unipi/core": "3.0.0-alpha.14",
91
+ "@pi-unipi/footer": "3.0.0-alpha.14",
92
+ "@pi-unipi/fusion": "3.0.0-alpha.14",
93
+ "@pi-unipi/info-screen": "3.0.0-alpha.14",
94
+ "@pi-unipi/input-shortcuts": "3.0.0-alpha.14",
95
+ "@pi-unipi/kanboard": "3.0.0-alpha.14",
96
+ "@pi-unipi/mcp": "3.0.0-alpha.14",
97
+ "@pi-unipi/memory": "3.0.0-alpha.14",
98
+ "@pi-unipi/notify": "3.0.0-alpha.14",
99
+ "@pi-unipi/skill-registry": "3.0.0-alpha.14",
100
+ "@pi-unipi/subagents": "3.0.0-alpha.14",
101
+ "@pi-unipi/updater": "3.0.0-alpha.14",
102
+ "@pi-unipi/utility": "3.0.0-alpha.14",
103
+ "@pi-unipi/web-api": "3.0.0-alpha.14",
104
+ "@pi-unipi/workflow": "3.0.0-alpha.14"
105
105
  },
106
106
  "devDependencies": {
107
107
  "@earendil-works/pi-agent-core": "^0.87.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/ask-user",
3
- "version": "3.0.0-alpha.13",
3
+ "version": "3.0.0-alpha.14",
4
4
  "description": "Structured user input tool for Pi coding agent — single-select, multi-select, freeform",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -40,7 +40,7 @@
40
40
  "access": "public"
41
41
  },
42
42
  "dependencies": {
43
- "@pi-unipi/core": "3.0.0-alpha.13"
43
+ "@pi-unipi/core": "3.0.0-alpha.14"
44
44
  },
45
45
  "peerDependencies": {
46
46
  "@earendil-works/pi-coding-agent": "^0.87.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/command-enchantment",
3
- "version": "3.0.0-alpha.13",
3
+ "version": "3.0.0-alpha.14",
4
4
  "description": "Enhanced TUI autocomplete for /unipi:* commands — colored, sorted, and grouped by package",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/background-tasks",
3
- "version": "3.0.0-alpha.13",
3
+ "version": "3.0.0-alpha.14",
4
4
  "description": "Background tasks for UniPi — durable shell jobs and delegated background agents with a footer dock",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "dependencies": {
12
12
  "@earendil-works/pi-ai": "^0.87.1",
13
- "@pi-unipi/core": "3.0.0-alpha.13",
13
+ "@pi-unipi/core": "3.0.0-alpha.14",
14
14
  "turndown": "^7.2.4"
15
15
  },
16
16
  "devDependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/btw",
3
- "version": "3.0.0-alpha.13",
3
+ "version": "3.0.0-alpha.14",
4
4
  "description": "A pi extension for parallel side conversations with /unipi:btw — part of the Unipi suite",
5
5
  "type": "module",
6
6
  "main": "extensions/btw.ts",
@@ -37,7 +37,7 @@
37
37
  "@earendil-works/pi-tui": "^0.87.1"
38
38
  },
39
39
  "dependencies": {
40
- "@pi-unipi/core": "3.0.0-alpha.13"
40
+ "@pi-unipi/core": "3.0.0-alpha.14"
41
41
  },
42
42
  "pi": {
43
43
  "extensions": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/compactor",
3
- "version": "3.0.0-alpha.13",
3
+ "version": "3.0.0-alpha.14",
4
4
  "description": "Compaction for Pi — lossless zero-LLM or model summaries, rebuilt from full history, with session recall",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -33,8 +33,8 @@
33
33
  "access": "public"
34
34
  },
35
35
  "dependencies": {
36
- "@pi-unipi/core": "3.0.0-alpha.13",
37
- "@pi-unipi/info-screen": "3.0.0-alpha.13",
36
+ "@pi-unipi/core": "3.0.0-alpha.14",
37
+ "@pi-unipi/info-screen": "3.0.0-alpha.14",
38
38
  "@earendil-works/pi-agent-core": "^0.87.1"
39
39
  },
40
40
  "peerDependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/core",
3
- "version": "3.0.0-alpha.13",
3
+ "version": "3.0.0-alpha.14",
4
4
  "description": "Shared utilities, event types, and constants for Unipi extension suite",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/footer",
3
- "version": "3.0.0-alpha.13",
3
+ "version": "3.0.0-alpha.14",
4
4
  "description": "Persistent status bar for Unipi — subscribes to UNIPI_EVENTS and renders key stats from all unipi packages",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -32,8 +32,8 @@
32
32
  "access": "public"
33
33
  },
34
34
  "dependencies": {
35
- "@pi-unipi/core": "3.0.0-alpha.13",
36
- "@pi-unipi/background-tasks": "3.0.0-alpha.13"
35
+ "@pi-unipi/core": "3.0.0-alpha.14",
36
+ "@pi-unipi/background-tasks": "3.0.0-alpha.14"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-coding-agent": "^0.87.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/fusion",
3
- "version": "3.0.0-alpha.13",
3
+ "version": "3.0.0-alpha.14",
4
4
  "description": "Devin-style model picker, fusion presets (lead + sidekick), and Local Fusion runtime for UniPi",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -32,8 +32,8 @@
32
32
  "access": "public"
33
33
  },
34
34
  "dependencies": {
35
- "@pi-unipi/core": "3.0.0-alpha.13",
36
- "@pi-unipi/subagents": "3.0.0-alpha.13"
35
+ "@pi-unipi/core": "3.0.0-alpha.14",
36
+ "@pi-unipi/subagents": "3.0.0-alpha.14"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-ai": "^0.87.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/info-screen",
3
- "version": "3.0.0-alpha.13",
3
+ "version": "3.0.0-alpha.14",
4
4
  "description": "Dashboard and module registry for Unipi — configurable info overlay with tabbed groups",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -32,7 +32,7 @@
32
32
  "access": "public"
33
33
  },
34
34
  "dependencies": {
35
- "@pi-unipi/core": "3.0.0-alpha.13"
35
+ "@pi-unipi/core": "3.0.0-alpha.14"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "@earendil-works/pi-coding-agent": "^0.87.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/input-shortcuts",
3
- "version": "3.0.0-alpha.13",
3
+ "version": "3.0.0-alpha.14",
4
4
  "description": "Keyboard shortcuts for stash/restore, undo/redo, clipboard, and thinking toggle — chord-based overlay system",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -33,7 +33,7 @@
33
33
  "access": "public"
34
34
  },
35
35
  "dependencies": {
36
- "@pi-unipi/core": "3.0.0-alpha.13"
36
+ "@pi-unipi/core": "3.0.0-alpha.14"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-coding-agent": "^0.87.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/kanboard",
3
- "version": "3.0.0-alpha.13",
3
+ "version": "3.0.0-alpha.14",
4
4
  "description": "Kanboard v3 — deferred-work board: CLI/daemon bridge, task runner and skill",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -31,7 +31,7 @@
31
31
  "access": "public"
32
32
  },
33
33
  "dependencies": {
34
- "@pi-unipi/core": "3.0.0-alpha.13"
34
+ "@pi-unipi/core": "3.0.0-alpha.14"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "@earendil-works/pi-coding-agent": "^0.87.1",
@@ -48,10 +48,10 @@
48
48
  "themes": []
49
49
  },
50
50
  "optionalDependencies": {
51
- "@pi-unipi/kanboard-linux-x64": "3.0.0-alpha.13",
52
- "@pi-unipi/kanboard-linux-arm64": "3.0.0-alpha.13",
53
- "@pi-unipi/kanboard-darwin-x64": "3.0.0-alpha.13",
54
- "@pi-unipi/kanboard-darwin-arm64": "3.0.0-alpha.13",
55
- "@pi-unipi/kanboard-win32-x64": "3.0.0-alpha.13"
51
+ "@pi-unipi/kanboard-linux-x64": "3.0.0-alpha.14",
52
+ "@pi-unipi/kanboard-linux-arm64": "3.0.0-alpha.14",
53
+ "@pi-unipi/kanboard-darwin-x64": "3.0.0-alpha.14",
54
+ "@pi-unipi/kanboard-darwin-arm64": "3.0.0-alpha.14",
55
+ "@pi-unipi/kanboard-win32-x64": "3.0.0-alpha.14"
56
56
  }
57
57
  }
@@ -10,7 +10,8 @@
10
10
  import { tokenizeArgs } from "./commands.js";
11
11
 
12
12
  export const DEFAULT_DO_CREDITS = 10;
13
- export const WRITE_CREDITS_USED_UP = "kanboard write credits used up — run /unipi:kanboard-do to reload";
13
+ export const WRITE_CREDITS_USED_UP =
14
+ "kanboard write credits used up — run /unipi:kanboard-do to reload (board reads, `start` and `finish` are always free)";
14
15
  export const addCapReason = (limit: number): string => `at most ${limit} new tasks per turn`;
15
16
  /** @deprecated tests should read the limit through the guard's getter instead. */
16
17
  export const ADD_CAP = 20;
@@ -29,6 +30,45 @@ const FREE_WRITES = new Set(["start", "finish"]);
29
30
  /** Global flags that take a value; `--json` is the only valueless one. */
30
31
  const GLOBAL_VALUE_FLAGS = new Set(["--actor", "--project", "--gate", "--session"]);
31
32
 
33
+ /** Quoted spans (single or double quotes), masked before splitting segments. */
34
+ const QUOTED_SPAN = /(["'])(?:\\.|(?!\1).)*\1/g;
35
+
36
+ /**
37
+ * Split a command line into shell segments on `&&`, `||`, `;`, `|` and
38
+ * newlines — ignoring separators inside quoted spans (the mask keeps the
39
+ * original length, so the cut positions map back onto the input exactly).
40
+ */
41
+ export function shellSegments(command: string): string[] {
42
+ const masked = command.replace(QUOTED_SPAN, (span) => " ".repeat(span.length));
43
+ const out: string[] = [];
44
+ let at = 0;
45
+ for (const match of masked.matchAll(/&&|\|\||[;|\n]/g)) {
46
+ out.push(command.slice(at, match.index));
47
+ at = match.index + match[0].length;
48
+ }
49
+ out.push(command.slice(at));
50
+ return out;
51
+ }
52
+
53
+ /** `VAR=value` prefixes that may sit in front of the command word. */
54
+ const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
55
+ /** Wrappers that keep the wrapped word in command position. */
56
+ const COMMAND_PREFIXES = new Set(["exec", "command", "env"]);
57
+
58
+ /**
59
+ * Every subcommand the CLI understands (crates/kanboard/src/cli.rs `Command`,
60
+ * kebab-case as clap spells it). Anything else — a typo like `done`, a bare
61
+ * binary with no subcommand — is the binary's own usage error; the guard
62
+ * neither charges nor blocks it.
63
+ */
64
+ export const KNOWN_SUBCOMMANDS = new Set([
65
+ "project", "add", "list", "show", "move", "note", "attach", "attachments",
66
+ "edit", "link", "unlink", "order", "claim-next", "start", "finish", "next",
67
+ "reap", "queue", "unqueue", "chain", "search", "release", "set-run",
68
+ "duplicate", "archive-sweep", "serve", "settings", "rotate-token", "status",
69
+ "stop", "validate",
70
+ ]);
71
+
32
72
  export interface KanboardInvocation {
33
73
  /** First positional after the binary name ("" when absent). */
34
74
  sub: string;
@@ -36,15 +76,25 @@ export interface KanboardInvocation {
36
76
  args: string[];
37
77
  }
38
78
 
39
- /** Every `unipi-kanboard` invocation inside a shell command line. */
79
+ /**
80
+ * Every `unipi-kanboard` invocation inside a shell command line. A token
81
+ * counts only when it is positioned like a command: the first word of its
82
+ * segment (segments split on `&&`/`||`/`;`/`|`/newlines, quotes masked),
83
+ * past leading `VAR=value` assignments and the `exec`/`command`/`env`
84
+ * wrappers. `which unipi-kanboard` or `find -name "unipi-kanboard"` merely
85
+ * mention the binary and are not invocations.
86
+ */
40
87
  export function kanboardInvocations(command: string): KanboardInvocation[] {
41
- const tokens = tokenizeArgs(command);
42
88
  const out: KanboardInvocation[] = [];
43
- for (let index = 0; index < tokens.length; index += 1) {
44
- const token = tokens[index]!;
89
+ for (const segment of shellSegments(command)) {
90
+ const tokens = tokenizeArgs(segment);
91
+ let head = 0;
92
+ while (head < tokens.length && (ASSIGNMENT.test(tokens[head]!) || COMMAND_PREFIXES.has(tokens[head]!))) {
93
+ head += 1;
94
+ }
45
95
  // The binary may be a bare name or an absolute path (and .exe on Windows).
46
- if (!/unipi-kanboard(\.exe)?$/.test(token)) continue;
47
- const rest = tokens.slice(index + 1);
96
+ if (head >= tokens.length || !/unipi-kanboard(\.exe)?$/.test(tokens[head]!)) continue;
97
+ const rest = tokens.slice(head + 1);
48
98
  let cursor = 0;
49
99
  while (cursor < rest.length) {
50
100
  const arg = rest[cursor]!;
@@ -108,9 +158,12 @@ export interface WriteGuard {
108
158
 
109
159
  /**
110
160
  * Writes cost session credits (one per write subcommand in the command line);
111
- * reads and `start`/`finish` are free and never blocked. A runner task keeps unlimited access
112
- * (still add-capped). Credits persist across turns and follow-up questions
113
- * until spent; /unipi:kanboard-do tops up to N without stacking past N.
161
+ * reads and `start`/`finish` are free and never blocked. Invocations whose
162
+ * subcommand does not exist are skipped: the binary itself rejects them with
163
+ * a usage error, and one typo must not block the rest of a compound call. A
164
+ * runner task keeps unlimited access (still add-capped). Credits persist
165
+ * across turns and follow-up questions until spent; /unipi:kanboard-do tops
166
+ * up to N without stacking past N.
114
167
  */
115
168
  export function createWriteGuard(
116
169
  runnerTask: () => string | null,
@@ -161,6 +214,9 @@ export function createWriteGuard(
161
214
  lastTask = task;
162
215
  }
163
216
  for (const invocation of invocations) {
217
+ // An unknown subcommand (a typo, a bare binary) is the binary's own
218
+ // usage error — never a reason to block or charge the whole call.
219
+ if (!KNOWN_SUBCOMMANDS.has(invocation.sub)) continue;
164
220
  if (isReadonly(invocation) || isFreeWrite(invocation)) continue;
165
221
  if (task !== null) {
166
222
  const cap = countAdd(invocation);
@@ -6,9 +6,9 @@
6
6
  * `finish <ID> --comment` hands it to In Review. Two text-only reminders keep
7
7
  * that honest:
8
8
  *
9
- * R1 the first file-changing tool call of an agent turn, while a mentioned
10
- * task is still Todo and this session has nothing started → a steer is
11
- * appended to that tool result (once per turn, at most twice per task).
9
+ * R1 every file-changing tool call of an agent turn, for each mentioned
10
+ * task that is still Todo and has not been reminded this turn (one
11
+ * steer per task per turn, at most twice per task).
12
12
  * R2 agent_end with a task this session started still In Progress → one
13
13
  * follow-up message (at most twice per task).
14
14
  *
@@ -20,7 +20,7 @@
20
20
 
21
21
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
22
22
 
23
- import { kanboardInvocations } from "./guard.js";
23
+ import { kanboardInvocations, shellSegments } from "./guard.js";
24
24
  import type { KanboardTask } from "./shapes.js";
25
25
 
26
26
  export const REMINDER_CUSTOM_TYPE = "unipi:kanboard-reminder";
@@ -47,6 +47,9 @@ const READ_COMMANDS = new Set([
47
47
  "more", "true", "false", "test", "[", "diff", "cmp", "jq", "realpath", "dirname", "basename", "uname",
48
48
  "hostname", "id", "ps", "cd",
49
49
  ]);
50
+ /** Interpreters/package managers whose version-check arms never write. */
51
+ const VERSION_CHECKED = new Set(["node", "npm", "npx", "python", "python3"]);
52
+ const isVersionFlag = (word: string): boolean => word === "--version" || word === "-v" || word === "-V";
50
53
  const GIT_READS = new Set(["status", "log", "diff", "show", "branch", "remote", "rev-parse", "blame", "ls-files", "describe", "tag"]);
51
54
 
52
55
  export function taskIdsIn(text: string): string[] {
@@ -56,14 +59,16 @@ export function taskIdsIn(text: string): string[] {
56
59
  /**
57
60
  * Whether a shell command may change files. Conservative toward "yes": any
58
61
  * segment that is not a known read (or a kanboard CLI call) counts, and so
59
- * does an output redirection.
62
+ * does an output redirection. Quoted spans are masked before both the
63
+ * redirect test and the segment split, so `grep -c "a;b" f` stays one
64
+ * read-only segment.
60
65
  */
61
66
  export function shellChangesFiles(command: string): boolean {
62
67
  const trimmed = command.trim();
63
68
  if (!trimmed) return false;
64
69
  // `>`/`>>` into a file (but not `2>&1` / `>/dev/null`).
65
70
  if (/(^|[^0-9&>])>{1,2}\s*(?!&|\/dev\/null)[^\s|;&]/.test(trimmed.replace(/(["'])(?:\\.|(?!\1).)*\1/g, '""'))) return true;
66
- const segments = trimmed.split(/&&|\|\||[;|\n]/).map((part) => part.trim()).filter(Boolean);
71
+ const segments = shellSegments(trimmed).map((part) => part.trim()).filter(Boolean);
67
72
  return segments.some((segment) => {
68
73
  const words = segment.split(/\s+/).filter((word) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(word));
69
74
  const first = (words[0] ?? "").replace(/^.*\//, "");
@@ -73,6 +78,11 @@ export function shellChangesFiles(command: string): boolean {
73
78
  if (READ_COMMANDS.has(first)) return false;
74
79
  if (first === "git") return !GIT_READS.has(words[1] ?? "");
75
80
  if (first === "sed") return words.includes("-i") || words.some((word) => word.startsWith("-i"));
81
+ // `sort` writes only through -o/--output (a `>` redirect is caught above).
82
+ if (first === "sort") return words.some((word) => /^-[^-]*o/.test(word) || word.startsWith("--output"));
83
+ // node/npm/npx/python are reads only as version checks (`node --version`,
84
+ // `npx tsx --version`); scripts, installs and builds still count.
85
+ if (VERSION_CHECKED.has(first)) return !words.slice(1).some(isVersionFlag);
76
86
  return true;
77
87
  });
78
88
  }
@@ -160,7 +170,8 @@ export function createProgressTracker(deps: TrackerDeps): ProgressTracker {
160
170
  const started = new Set<string>();
161
171
  const r1Count = new Map<string, number>();
162
172
  const r2Count = new Map<string, number>();
163
- let checkedThisTurn = false;
173
+ /** Task ids already reminded this turn (per-task re-arm, not one shot). */
174
+ const remindedThisTurn = new Set<string>();
164
175
  const debug = (message: string): void => deps.debug?.(`reminders: ${message}`);
165
176
  const silent = (): boolean => !deps.enabled() || deps.runnerOwned() || Boolean(process.env.UNIPI_KANBOARD_CHILD);
166
177
 
@@ -183,7 +194,7 @@ export function createProgressTracker(deps: TrackerDeps): ProgressTracker {
183
194
  },
184
195
 
185
196
  onTurnStart() {
186
- checkedThisTurn = false;
197
+ remindedThisTurn.clear();
187
198
  },
188
199
 
189
200
  async onToolResult(event) {
@@ -196,20 +207,24 @@ export function createProgressTracker(deps: TrackerDeps): ProgressTracker {
196
207
  if (invocation.sub === "start" && !event.isError) started.add(id);
197
208
  }
198
209
  }
199
- if (checkedThisTurn || !isFileChangingCall(toolName, event.input)) return undefined;
200
- // Only the first file-changing call of the turn is considered.
201
- checkedThisTurn = true;
210
+ if (!isFileChangingCall(toolName, event.input)) return undefined;
202
211
  if (silent() || mentioned.size === 0) return undefined;
203
- const candidates = [...mentioned].filter((id) => (r1Count.get(id) ?? 0) < MAX_REMINDERS_PER_TASK);
212
+ // Per task, at most once per turn: candidates are mentioned ids not yet
213
+ // reminded this turn and under the per-task cap (a started task leaves
214
+ // Todo and is never re-reminded).
215
+ const candidates = [...mentioned].filter(
216
+ (id) => !remindedThisTurn.has(id) && (r1Count.get(id) ?? 0) < MAX_REMINDERS_PER_TASK,
217
+ );
204
218
  if (candidates.length === 0) return undefined;
205
219
  const tasks = await safeList();
206
220
  if (!tasks) return undefined;
207
- const session = deps.session();
208
- if (tasks.some((task) => ownedStart(task, session))) return undefined;
209
221
  const byId = new Map(tasks.map((task) => [task.id, task]));
210
222
  const todo = candidates.filter((id) => byId.get(id)?.status === "todo");
211
223
  if (todo.length === 0) return undefined;
212
- for (const id of todo) r1Count.set(id, (r1Count.get(id) ?? 0) + 1);
224
+ for (const id of todo) {
225
+ remindedThisTurn.add(id);
226
+ r1Count.set(id, (r1Count.get(id) ?? 0) + 1);
227
+ }
213
228
  debug(`R1 for ${todo.join(", ")}`);
214
229
  return { content: [...(event.content ?? []), { type: "text", text: `\n\n${r1Text(todo, deps.cliPrefix())}` }] };
215
230
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/long-horizon",
3
- "version": "3.0.0-alpha.13",
3
+ "version": "3.0.0-alpha.14",
4
4
  "description": "Long-horizon execution for Pi — mode-gated /goal /ralph /swarm /graph orchestration with a TypeSafe prompt judge",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -37,7 +37,7 @@
37
37
  "access": "public"
38
38
  },
39
39
  "dependencies": {
40
- "@pi-unipi/core": "3.0.0-alpha.13"
40
+ "@pi-unipi/core": "3.0.0-alpha.14"
41
41
  },
42
42
  "peerDependencies": {
43
43
  "@earendil-works/pi-coding-agent": "^0.87.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/mcp",
3
- "version": "3.0.0-alpha.13",
3
+ "version": "3.0.0-alpha.14",
4
4
  "description": "MCP server management extension for Pi coding agent — browse, add, configure, and use MCP servers",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -30,7 +30,7 @@
30
30
  "test": "npx tsx --test tests/**/*.test.ts"
31
31
  },
32
32
  "dependencies": {
33
- "@pi-unipi/core": "3.0.0-alpha.13"
33
+ "@pi-unipi/core": "3.0.0-alpha.14"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "@earendil-works/pi-coding-agent": "^0.87.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/memory",
3
- "version": "3.0.0-alpha.13",
3
+ "version": "3.0.0-alpha.14",
4
4
  "description": "Persistent cross-session memory on MemPalace (auto-installed): daemon-routed writes, shared palace with other agents, for Pi coding agent",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -42,8 +42,8 @@
42
42
  "README.md"
43
43
  ],
44
44
  "dependencies": {
45
- "@pi-unipi/core": "3.0.0-alpha.13",
46
- "@pi-unipi/info-screen": "3.0.0-alpha.13",
45
+ "@pi-unipi/core": "3.0.0-alpha.14",
46
+ "@pi-unipi/info-screen": "3.0.0-alpha.14",
47
47
  "js-yaml": "^4.1.0"
48
48
  },
49
49
  "peerDependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/notify",
3
- "version": "3.0.0-alpha.13",
3
+ "version": "3.0.0-alpha.14",
4
4
  "description": "Cross-platform notification extension for Pi — native OS, Gotify, and Telegram notifications for agent lifecycle events",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -34,7 +34,7 @@
34
34
  "access": "public"
35
35
  },
36
36
  "dependencies": {
37
- "@pi-unipi/core": "3.0.0-alpha.13",
37
+ "@pi-unipi/core": "3.0.0-alpha.14",
38
38
  "node-notifier": "^10.0.1"
39
39
  },
40
40
  "peerDependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/skill-registry",
3
- "version": "3.0.0-alpha.13",
3
+ "version": "3.0.0-alpha.14",
4
4
  "description": "Skill registry for pi — per-project skill on/off, a skill vault, and jev-judged skill exposure",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,7 +33,7 @@
33
33
  },
34
34
  "main": "index.ts",
35
35
  "dependencies": {
36
- "@pi-unipi/core": "3.0.0-alpha.13"
36
+ "@pi-unipi/core": "3.0.0-alpha.14"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-coding-agent": "^0.87.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/subagents",
3
- "version": "3.0.0-alpha.13",
3
+ "version": "3.0.0-alpha.14",
4
4
  "description": "Subagents for UniPi — Devin-model independent child agents",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -9,7 +9,7 @@
9
9
  "test": "npx tsx --test src/__tests__/*.test.ts"
10
10
  },
11
11
  "dependencies": {
12
- "@pi-unipi/core": "3.0.0-alpha.13",
12
+ "@pi-unipi/core": "3.0.0-alpha.14",
13
13
  "@earendil-works/pi-agent-core": "^0.87.1",
14
14
  "acorn": "8.18.0"
15
15
  },