gentle-pi 4.1.0 → 4.2.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.
Files changed (81) hide show
  1. package/README.md +39 -14
  2. package/bin/engram-guard +1 -1
  3. package/bin/gentle-shell.mjs +16 -11
  4. package/docs/auto-permission-answers.md +25 -0
  5. package/docs/auto-permission-evidence.md +39 -0
  6. package/docs/bundled-install.md +8 -6
  7. package/docs/gentle-shell.md +17 -10
  8. package/docs/install-wizard.md +9 -9
  9. package/docs/permission-modes.md +50 -5
  10. package/docs/readme-reference.md +48 -12
  11. package/docs/session-profile-format.md +39 -0
  12. package/docs/yolo-mode.md +2 -1
  13. package/extensions/ask-user-choice.ts +4 -0
  14. package/extensions/ask-user-question.ts +6 -1
  15. package/extensions/gentle-agents.ts +54 -23
  16. package/extensions/gentle-ai.ts +105 -25
  17. package/extensions/gentle-shell.ts +26 -31
  18. package/extensions/gentle-todo.ts +26 -16
  19. package/lib/auto-permission-answers.ts +133 -0
  20. package/lib/auto-permission-classifier.ts +215 -36
  21. package/lib/auto-permission-evidence.ts +88 -0
  22. package/lib/auto-permission-memory.ts +69 -0
  23. package/lib/auto-permission-runtime.ts +251 -35
  24. package/lib/auto-permission-tool-context.ts +61 -0
  25. package/lib/destructive-command-guard.ts +104 -28
  26. package/lib/gentle-shell-bundled.ts +23 -5
  27. package/lib/gentle-shell-launcher.ts +1 -1
  28. package/lib/model-routing-authority.ts +6 -1
  29. package/lib/selection-engine.ts +4 -1
  30. package/lib/session-profile-freeze.ts +10 -15
  31. package/lib/session-profile-persistence.ts +187 -0
  32. package/lib/shell-changes.ts +2 -0
  33. package/lib/shell-prompt.ts +6 -4
  34. package/lib/shell-todo.ts +20 -8
  35. package/lib/vim-editor-adapter.ts +22 -4
  36. package/package.json +5 -5
  37. package/runtime/gentle-shell-bundled.mjs +23 -5
  38. package/runtime/gentle-shell-launcher.mjs +1 -1
  39. package/scripts/install-wizard-preview.mjs +2 -2
  40. package/scripts/installer-downloads.mjs +7 -7
  41. package/scripts/installer-preflight.mjs +1 -1
  42. package/scripts/pi-update.mjs +223 -0
  43. package/tests/append-system-prompt-route.test.ts +18 -1
  44. package/tests/auto-mode-human-answers.test.ts +68 -0
  45. package/tests/auto-mode-invariants.test.ts +23 -5
  46. package/tests/auto-mode-sdk.test.ts +29 -2
  47. package/tests/auto-permission-answers.test.ts +170 -0
  48. package/tests/auto-permission-classifier.test.ts +353 -2
  49. package/tests/auto-permission-compound.test.ts +24 -0
  50. package/tests/auto-permission-evidence.test.ts +88 -0
  51. package/tests/auto-permission-memory.test.ts +46 -0
  52. package/tests/auto-permission-public-regressions.test.ts +111 -0
  53. package/tests/auto-permission-runtime.test.ts +270 -12
  54. package/tests/autonomous-guard.test.ts +86 -16
  55. package/tests/bundled-install.test.ts +3 -3
  56. package/tests/engram-safety.test.ts +34 -6
  57. package/tests/gentle-agents.test.ts +162 -6
  58. package/tests/gentle-shell-bin.test.ts +17 -17
  59. package/tests/gentle-shell-bundled.test.ts +67 -33
  60. package/tests/gentle-shell-launcher.test.ts +18 -13
  61. package/tests/gentle-shell.test.ts +71 -5
  62. package/tests/gentle-todo.test.ts +79 -27
  63. package/tests/install-wizard.test.ts +2 -2
  64. package/tests/installer-posix-bootstrap.test.ts +8 -8
  65. package/tests/installer-preflight.test.ts +3 -3
  66. package/tests/installer-probes.test.ts +8 -8
  67. package/tests/installer-runner.test.ts +14 -13
  68. package/tests/installer-server.test.ts +5 -5
  69. package/tests/main-channel-upgrade.test.ts +7 -1
  70. package/tests/odd-runtime-delegation-gate.test.ts +4 -1
  71. package/tests/package-manifest.test.ts +6 -6
  72. package/tests/pi-update-launcher.test.ts +87 -0
  73. package/tests/pi-update.test.ts +186 -0
  74. package/tests/rdd-status-line.test.ts +52 -0
  75. package/tests/selection-engine.test.ts +35 -5
  76. package/tests/session-profile-freeze.test.ts +14 -1
  77. package/tests/session-profile-persistence.test.ts +334 -0
  78. package/tests/shell-changes.test.ts +18 -0
  79. package/tests/shell-todo.test.ts +15 -12
  80. package/tests/vim-editor-adapter.test.ts +41 -15
  81. package/tests/yolo-mode.test.ts +6 -1
@@ -15,14 +15,18 @@ interface Token {
15
15
  }
16
16
  const separators = new Set([";", "&&", "||", "|", "&", "\n", "(", ")"]);
17
17
 
18
- /** Keep quoted arguments intact so harmless prose is never treated as a command. */
19
- function tokenize(command: string): Token[] {
20
- const tokens: Token[] = [];
21
- // Literal, named heredocs are data, not executable shell segments.
22
- command = command.replace(/<<-?\s*(['"]?)([A-Za-z_]\w*)\1[^\n]*\n[\s\S]*?\n\2(?=\n|$)/g, (raw) => {
18
+ /** Literal, named heredocs are data, not executable shell segments; offsets are preserved. */
19
+ function blankHeredocs(command: string): string {
20
+ return command.replace(/<<-?\s*(['"]?)([A-Za-z_]\w*)\1[^\n]*\n[\s\S]*?\n\2(?=\n|$)/g, (raw) => {
23
21
  const headerLength = raw.indexOf("\n") + 1;
24
22
  return raw.slice(0, headerLength) + " ".repeat(raw.length - headerLength);
25
23
  });
24
+ }
25
+
26
+ /** Keep quoted arguments intact so harmless prose is never treated as a command. */
27
+ function tokenize(command: string, blank = true): Token[] {
28
+ const tokens: Token[] = [];
29
+ if (blank) command = blankHeredocs(command);
26
30
  const pattern = /(?:"(?:\\.|[^"\\])*"|'[^']*'|\\.|[^\s;&|()'"\\])+|&&|\|\||[;&|()\n]/g;
27
31
  for (const match of command.matchAll(pattern)) {
28
32
  const raw = match[0];
@@ -38,6 +42,100 @@ function tokenize(command: string): Token[] {
38
42
  }
39
43
  const executable = (value: string) => value.split("/").at(-1) ?? value;
40
44
 
45
+ const WRAPPER_VALUE_OPTIONS: Record<string, readonly string[]> = {
46
+ env: ["-u", "--unset", "-C", "--chdir"],
47
+ sudo: ["-u", "-g", "-h", "-p", "-C", "--user", "--group", "--host", "--prompt", "--chdir"],
48
+ timeout: ["-s", "--signal", "-k", "--kill-after"],
49
+ xargs: ["-n", "-I", "-P", "--max-args", "--replace", "--max-procs"],
50
+ };
51
+ const WRAPPERS = ["env", "sudo", "command", "exec", "nohup", "timeout", "xargs"];
52
+
53
+ /** Index of the executable token after assignments and familiar wrappers. */
54
+ function headCursor(segment: readonly Token[]): number {
55
+ let cursor = 0;
56
+ while (cursor < segment.length) {
57
+ const value = segment[cursor].value;
58
+ if (/^[A-Za-z_][A-Za-z_0-9]*=/.test(value)) {
59
+ cursor++;
60
+ continue;
61
+ }
62
+ const name = executable(value);
63
+ if (!WRAPPERS.includes(name)) break;
64
+ cursor++;
65
+ while (cursor < segment.length && segment[cursor].value.startsWith("-")) {
66
+ const flag = segment[cursor++].value;
67
+ if (flag === "--") break;
68
+ if (WRAPPER_VALUE_OPTIONS[name]?.includes(flag)) cursor++;
69
+ }
70
+ if (name === "timeout") cursor++;
71
+ }
72
+ return cursor;
73
+ }
74
+
75
+ // gentle-shell#2174: commands whose arguments are searched, printed or viewed
76
+ // text, never executed and never fed to an executor. `sed` is absent on
77
+ // purpose: GNU sed executes scripts through `e` with any delimiter or address.
78
+ // The exemption applies only when EVERY segment head is in DATA_COMMANDS or
79
+ // INERT_COMMANDS, so an unknown consumer (`| at now`, `&& ./x.sh`) keeps the
80
+ // lexical guard: a missing entry costs a dialog, never a bypass.
81
+ const DATA_COMMANDS = new Set([
82
+ "grep", "egrep", "fgrep", "rg", "ag", "ack", "echo", "printf", "cat", "head", "tail", "less", "more",
83
+ "wc", "sort", "uniq", "cut", "tr", "jq", "diff", "ls", "file", "stat",
84
+ ]);
85
+ const INERT_COMMANDS = new Set(["cd", "pwd", "true", "false", "test", "[", "mkdir", "touch"]);
86
+ const HEREDOC_RE = /<<-?\s*(['"]?)([A-Za-z_]\w*)\1[^\n]*\n[\s\S]*?\n\2(?=\n|$)/g;
87
+
88
+ /**
89
+ * Byte ranges of the command that are plain arguments of a known data command
90
+ * (gentle-shell#2174). A guarded or denied pattern matching only inside these
91
+ * ranges is prose, not an operation. Returns no ranges, keeping every lexical
92
+ * match, when the command is opaque: command or process substitution, an
93
+ * interpreter or remote shell anywhere, or quoting the tokenizer cannot
94
+ * account for.
95
+ */
96
+ export function dataArgumentRanges(command: string): Array<readonly [number, number]> {
97
+ if (/\$\(|`|[<>]\(/.test(command)) return [];
98
+ // A heredoc counts only when its `<<` starts an unquoted token of the raw
99
+ // command; quoted text such as `echo '<<x'` must not blank following lines.
100
+ const rawStarts = new Set(tokenize(command, false).filter((token) => token.raw.startsWith("<<")).map((token) => token.index));
101
+ const heredocs = [...command.matchAll(HEREDOC_RE)];
102
+ if (heredocs.some((heredoc) => !rawStarts.has(heredoc.index))) return [];
103
+ const blanked = blankHeredocs(command);
104
+ const tokens = tokenize(command);
105
+ const covered = new Uint8Array(blanked.length);
106
+ for (const token of tokens) covered.fill(1, token.index, token.index + token.raw.length);
107
+ for (let index = 0; index < blanked.length; index++) {
108
+ if (!covered[index] && !/\s/.test(blanked[index])) return [];
109
+ }
110
+ const ranges: Array<readonly [number, number]> = [];
111
+ let start = 0;
112
+ for (let end = 0; end <= tokens.length; end++) {
113
+ if (end < tokens.length && !tokens[end].operator) continue;
114
+ const segment = tokens.slice(start, end);
115
+ start = end + 1;
116
+ if (segment.length === 0) continue;
117
+ // Only plain assignments, optionally after a flagless `env`, may precede
118
+ // the head; every other wrapper or wrapper flag can change what runs.
119
+ let cursor = 0;
120
+ if (segment[0].value === "env") cursor++;
121
+ while (cursor < segment.length && /^[A-Za-z_][A-Za-z_0-9]*=/.test(segment[cursor].value)) cursor++;
122
+ const head = segment[cursor];
123
+ if (head === undefined || head.value !== head.raw) return [];
124
+ const name = head.value;
125
+ if (INERT_COMMANDS.has(name)) continue;
126
+ if (!DATA_COMMANDS.has(name)) return [];
127
+ ranges.push([head.index + head.raw.length, tokens[end]?.index ?? command.length]);
128
+ }
129
+ // A heredoc body belongs to the segment of its `<<` header, which the
130
+ // header's trailing newline would otherwise split off. Only the body is
131
+ // covered: the rest of the header line can hold further commands.
132
+ for (const heredoc of heredocs) {
133
+ const bodyStart = heredoc.index + heredoc[0].indexOf("\n") + 1;
134
+ if (ranges.some(([from, to]) => heredoc.index >= from && heredoc.index < to)) ranges.push([bodyStart, heredoc.index + heredoc[0].length]);
135
+ }
136
+ return ranges;
137
+ }
138
+
41
139
  function sqlDestroys(sql: string): boolean {
42
140
  // Mask quoted identifiers as non-keyword atoms, and remove comments and
43
141
  // strings. A table named "where" is not a predicate or a statement boundary.
@@ -60,30 +158,8 @@ export function recognizeDestructiveCommands(command: string, depth = 0): Destru
60
158
  for (let end = 0; end <= tokens.length; end++) {
61
159
  if (end < tokens.length && !tokens[end].operator) continue;
62
160
  const segment = tokens.slice(start, end);
63
- let cursor = 0;
64
161
  // Assignments and familiar executable wrappers preserve command position.
65
- while (cursor < segment.length) {
66
- const value = segment[cursor].value;
67
- if (/^[A-Za-z_][A-Za-z_0-9]*=/.test(value)) {
68
- cursor++;
69
- continue;
70
- }
71
- const name = executable(value);
72
- if (!["env", "sudo", "command", "exec", "nohup", "timeout", "xargs"].includes(name)) break;
73
- const valueOptions: Record<string, readonly string[]> = {
74
- env: ["-u", "--unset", "-C", "--chdir"],
75
- sudo: ["-u", "-g", "-h", "-p", "-C", "--user", "--group", "--host", "--prompt", "--chdir"],
76
- timeout: ["-s", "--signal", "-k", "--kill-after"],
77
- xargs: ["-n", "-I", "-P", "--max-args", "--replace", "--max-procs"],
78
- };
79
- cursor++;
80
- while (cursor < segment.length && segment[cursor].value.startsWith("-")) {
81
- const flag = segment[cursor++].value;
82
- if (flag === "--") break;
83
- if (valueOptions[name]?.includes(flag)) cursor++;
84
- }
85
- if (name === "timeout") cursor++;
86
- }
162
+ const cursor = headCursor(segment);
87
163
  const head = segment[cursor];
88
164
  const name = executable(head?.value ?? "");
89
165
  const args = segment.slice(cursor + 1).map((token) => token.value);
@@ -213,10 +213,26 @@ export function bundledPi(install: BundledInstall, input: { platform: string; fs
213
213
  // memory is allowed locally; otherwise the Shell-owned guard, so no provider,
214
214
  // whatever loaded it, runs an Engram binary against an unverified store.
215
215
  export const ENGRAM_COMPANION_SOURCE = "npm:gentle-engram@0.4.2";
216
- export const ENGRAM_RECOVERY_NOTICE = "Engram memory disabled: the existing Engram data's compatibility is unknown. Back up your existing Engram data, update Engram and restart its server, then restart Shell to recheck memory safety (it rechecks on every launch). Shell remains usable without memory.";
216
+ export const ENGRAM_RECOVERY_NOTICE = "Engram memory disabled: Shell could not verify safe store or server reuse. Matching versions or updating alone do not prove compatibility. Reuse requires a server with verified /compatibility evidence or matching Shell-provisioned store identity. Back up existing Engram data before manual changes; do not delete it or bypass the safety check. Shell rechecks on every launch and remains usable without memory.";
217
217
  // `gentle-shell setup` publishes the verified core (ensureEngram) on every run.
218
218
  export const ENGRAM_PROVISION_FAILED_NOTICE = "Engram memory disabled for this launch: Shell could not provision a new Engram store identity, so it cannot vouch for that store. Restart Shell to retry. If Shell keeps reporting unknown compatibility and ~/.engram holds only .instance-id files, back it up and remove it, then restart Shell. Shell remains usable without memory.";
219
219
  export const ENGRAM_CORE_MISSING_NOTICE = "Engram memory disabled: the bundled Engram core is not installed yet. Run 'gentle-shell setup' to install the verified core, then restart Shell. Shell remains usable without memory.";
220
+ // The first Engram release whose server answers GET /compatibility (PR #1792).
221
+ export const ENGRAM_COMPATIBILITY_MIN_VERSION = "3.4.0";
222
+ export function engramOutdatedServerNotice(version: string): string {
223
+ return `Engram memory disabled: the running Engram server reports version ${version}, which cannot prove store compatibility. Back up your Engram data, update that server to ${ENGRAM_COMPATIBILITY_MIN_VERSION} or later with the method you installed it with, and restart it; updating Shell alone does not replace a running server. Shell rechecks on every launch and remains usable without memory.`;
224
+ }
225
+ // A canonical numeric x.y.z (optionally v-prefixed, no leading zeros) older than
226
+ // the minimum. The version comes from the server, so anything else is never
227
+ // echoed to the user.
228
+ function outdatedServerVersion(version: string | undefined): string | undefined {
229
+ const match = /^v?(0|[1-9]\d{0,3})\.(0|[1-9]\d{0,3})\.(0|[1-9]\d{0,3})$/.exec(version ?? "");
230
+ if (match === null) return undefined;
231
+ const parts = match.slice(1).map(Number);
232
+ const minimum = ENGRAM_COMPATIBILITY_MIN_VERSION.split(".").map(Number);
233
+ const index = parts.findIndex((part, i) => part !== minimum[i]);
234
+ return index >= 0 && parts[index] < minimum[index] ? parts.join(".") : undefined;
235
+ }
220
236
  // The default endpoint gentle-engram uses without ENGRAM_URL or ENGRAM_PORT.
221
237
  export const ENGRAM_DEFAULT_PORT = 7437;
222
238
  export const ENGRAM_DEFAULT_URL = `http://127.0.0.1:${ENGRAM_DEFAULT_PORT}`;
@@ -238,11 +254,13 @@ export function engramProbeAllowed(env: Env): boolean {
238
254
  // Evidence only: never run an executable, open SQLite or infer compatibility
239
255
  // from versions. `provenance` means Shell's own record matched the store
240
256
  // (directory, .instance-id, core digest) and its header showed no newer schema.
257
+ // A server version only selects the wording of a disabled decision.
241
258
  export function classifyEngramStartup(input: { env: Env; store: "absent" | "present" | "unknown"; endpoint: EngramProbe; core: boolean;
242
259
  coreVersion: string; provenance?: { instanceId: string } }): EngramStartup {
243
- const unknown: EngramStartup = { policy: "disabled", notice: ENGRAM_RECOVERY_NOTICE };
244
- if (!engramProbeAllowed(input.env)) return unknown;
260
+ if (!engramProbeAllowed(input.env)) return { policy: "disabled", notice: ENGRAM_RECOVERY_NOTICE };
245
261
  const endpoint = input.endpoint;
262
+ const outdated = endpoint.kind === "listening" && !endpoint.compatible ? outdatedServerVersion(endpoint.healthVersion) : undefined;
263
+ const unknown: EngramStartup = { policy: "disabled", notice: outdated === undefined ? ENGRAM_RECOVERY_NOTICE : engramOutdatedServerNotice(outdated) };
246
264
  if (endpoint.kind === "listening" && endpoint.compatible) return { policy: "allow", url: ENGRAM_DEFAULT_URL };
247
265
  if (endpoint.kind === "refused" && input.store === "absent") {
248
266
  return input.core ? { policy: "allow", fresh: true } : { policy: "disabled", notice: ENGRAM_CORE_MISSING_NOTICE };
@@ -333,9 +351,9 @@ export function engramProvenanceMatches(record: EngramProvenance | undefined, ob
333
351
  }
334
352
 
335
353
  // The bundled core's own schema (SQLite user_version), verified from the
336
- // tagged source: v3.3.2:internal/store/store.go `const schemaVersion = 1`.
354
+ // tagged sources v3.3.2 and v3.4.0: internal/store/store.go `const schemaVersion = 1`.
337
355
  // A pin without a verified entry leaves the header check unavailable.
338
- const BUNDLED_ENGRAM_SCHEMA: Record<string, number> = { "3.3.2": 1 };
356
+ const BUNDLED_ENGRAM_SCHEMA: Record<string, number> = { "3.3.2": 1, "3.4.0": 1 };
339
357
 
340
358
  export function bundledEngramSchema(coreVersion: string): number | undefined {
341
359
  return Object.hasOwn(BUNDLED_ENGRAM_SCHEMA, coreVersion) ? BUNDLED_ENGRAM_SCHEMA[coreVersion] : undefined;
@@ -392,7 +392,7 @@ export function missingPiMessage(): string {
392
392
 
393
393
  // --- pi version gate ---------------------------------------------------------
394
394
 
395
- export const MIN_PI_VERSION = "0.99.1";
395
+ export const MIN_PI_VERSION = "1.2.0";
396
396
 
397
397
  export type PiVersionCheck = { ok: true; version: string } | { ok: false; message: string; version?: string };
398
398
 
@@ -28,6 +28,11 @@ export type ModelConfigFileResult =
28
28
  export const SAFE_MODEL_ID_PATTERN = /^[A-Za-z0-9._~:@/+%-]+$/;
29
29
  const SAFE_AGENT_NAME_PATTERN = /^[A-Za-z0-9._:@/+%-]+$/;
30
30
 
31
+ /** Shared agent-name policy; the same test normalizeModelConfig applies per key. */
32
+ export function isSafeAgentName(name: string): boolean {
33
+ return SAFE_AGENT_NAME_PATTERN.test(name);
34
+ }
35
+
31
36
  function isRecord(value: unknown): value is Record<string, unknown> {
32
37
  return typeof value === "object" && value !== null && !Array.isArray(value);
33
38
  }
@@ -78,7 +83,7 @@ export function normalizeModelConfig(value: unknown): AgentModelConfig | undefin
78
83
  if (!isRecord(value)) return undefined;
79
84
  const cleaned: AgentModelConfig = {};
80
85
  for (const [name, entryValue] of Object.entries(value)) {
81
- if (!SAFE_AGENT_NAME_PATTERN.test(name)) continue;
86
+ if (!isSafeAgentName(name)) continue;
82
87
  const entry = normalizeRoutingEntry(entryValue);
83
88
  if (entry) cleaned[name] = entry;
84
89
  }
@@ -131,7 +131,10 @@ export function withReverseSpan(row: string, startCu: number, endCu: number): st
131
131
  // A nested SGR reset (e.g. the cursor block's own, when the cursor
132
132
  // sits inside the span) clears reverse for everything after it:
133
133
  // re-arm reverse right after it.
134
- if (opened && cu < endCu && isSgrReset(seq)) out += "\x1b[7m";
134
+ // Pi 1.2 resolves the APC fake-cursor end to SGR 27 later in
135
+ // the host TUI. Preserve the marker for hardware-cursor suppression,
136
+ // but rearm our selection after it just as after a legacy reset.
137
+ if (opened && cu < endCu && (isSgrReset(seq) || seq === "\x1b_pi:/fc\x07")) out += "\x1b[7m";
135
138
  i += seq.length;
136
139
  continue;
137
140
  }
@@ -16,8 +16,8 @@
16
16
  // The live resolution applies only in `follow` mode (GENTLE_PI_PROFILE_FOLLOW=1,
17
17
  // for CI and headless runs), in subagent children (their routing comes from the
18
18
  // parent through --model), and when no session id is available. In `follow`
19
- // mode the orchestrator still comes from the startup profile while children
20
- // follow later defaults: an accepted, documented limitation.
19
+ // mode the orchestrator is still chosen at startup while children follow later
20
+ // defaults: an accepted, documented limitation.
21
21
  //
22
22
  // The frozen state is in-process only, like the explicit binding of slice 2: it
23
23
  // lives on `globalThis` behind a `Symbol.for` key because Pi evaluates this
@@ -114,19 +114,14 @@ function isChildSession(env: NodeJS.ProcessEnv): boolean {
114
114
  * is never part of the routing. `undefined` means no layer has a profile.
115
115
  */
116
116
  export function resolveInheritedProfile(options: InheritedProfileOptions): InheritedProfile | undefined {
117
- // One worktree lookup serves both the pin layers and the non-Git check.
118
- let resolved = false;
119
- let identity: WorktreeIdentity | undefined;
120
- const resolveWorktree: WorktreeResolver | undefined = options.resolveWorktree === undefined ? undefined : (cwd, base) => {
121
- if (!resolved) {
122
- resolved = true;
123
- identity = options.resolveWorktree!(cwd, base);
124
- }
125
- return identity;
126
- };
127
- const pin = resolveProfilePin({ cwd: options.cwd, configHome: options.configHome, resolveWorktree });
128
- if (pin !== undefined) return { name: pin.profile, origin: pin.source, modelProfiles: pin.modelProfiles };
129
- if (readProfilePinStatus(options.cwd, resolveWorktree) === undefined) {
117
+ // One worktree lookup serves both the pin layers and the non-Git check,
118
+ // whether the caller passes a resolver or the pin module's default runs.
119
+ const status = readProfilePinStatus(options.cwd, options.resolveWorktree);
120
+ if (status !== undefined) {
121
+ const identity: WorktreeIdentity = { root: status.root, commonDir: status.commonDir };
122
+ const pin = resolveProfilePin({ cwd: options.cwd, configHome: options.configHome, resolveWorktree: () => identity });
123
+ if (pin !== undefined) return { name: pin.profile, origin: pin.source, modelProfiles: pin.modelProfiles };
124
+ } else {
130
125
  const declaration = resolveUnversionedProjectProfile(options.cwd, options.configHome);
131
126
  if (declaration !== undefined) return { name: declaration.profile, origin: "repo", modelProfiles: declaration.modelProfiles };
132
127
  }
@@ -0,0 +1,187 @@
1
+ // Standalone v1 codec and pure active-branch replay (gentle-shell#1064).
2
+ // No Pi API, disk access, shared binding store, or orchestrator application.
3
+ import { isValidProfileName } from "./agent-profiles.ts";
4
+ import {
5
+ isSafeAgentName,
6
+ isThinkingLevel,
7
+ normalizeModelId,
8
+ normalizeRoutingEntry,
9
+ type AgentModelConfig,
10
+ type AgentRoutingEntry,
11
+ } from "./model-routing-authority.ts";
12
+
13
+ export const SESSION_PROFILE_CUSTOM_TYPE = "gentle-pi.session-profile/v1";
14
+ const SESSION_PROFILE_FAMILY = "gentle-pi.session-profile/";
15
+
16
+ export type SessionProfileOrigin = "user" | "local" | "repo" | "global";
17
+
18
+ export interface SessionProfileBindPayload {
19
+ kind: "bind";
20
+ origin: SessionProfileOrigin;
21
+ name: string;
22
+ modelProfiles: AgentModelConfig;
23
+ }
24
+
25
+ export interface SessionProfileClearPayload {
26
+ kind: "clear";
27
+ }
28
+
29
+ /** Public structural entry shape only; ordinary messages need no custom fields. */
30
+ export interface SessionProfileEntry {
31
+ type: string;
32
+ customType?: string;
33
+ data?: unknown;
34
+ }
35
+
36
+ export type SessionProfileReadResult =
37
+ | { status: "absent" }
38
+ | { status: "bound"; binding: SessionProfileBindPayload }
39
+ | { status: "cleared" }
40
+ | { status: "invalid" }
41
+ | { status: "unsupported" };
42
+
43
+ export type SessionProfileReplayResult =
44
+ | { status: "absent" }
45
+ | (Exclude<SessionProfileReadResult, { status: "absent" }> & {
46
+ entryIndex: number;
47
+ });
48
+
49
+ function isRecord(value: unknown): value is Record<string, unknown> {
50
+ return typeof value === "object" && value !== null && !Array.isArray(value);
51
+ }
52
+
53
+ function hasOwn(value: Record<string, unknown>, key: string): boolean {
54
+ return Object.hasOwn(value, key);
55
+ }
56
+
57
+ function isOrigin(value: unknown): value is SessionProfileOrigin {
58
+ return (
59
+ value === "user" ||
60
+ value === "local" ||
61
+ value === "repo" ||
62
+ value === "global"
63
+ );
64
+ }
65
+
66
+ /** Validate known fields before the permissive routing helper can drop them. */
67
+ function readRoute(
68
+ value: unknown,
69
+ encoderInput = false,
70
+ ): AgentRoutingEntry | undefined {
71
+ if (typeof value === "string") return normalizeRoutingEntry(value);
72
+ if (!isRecord(value)) return undefined;
73
+ // Typed normalized routes can own optional undefined fields. Omit only those
74
+ // encoder fields; persisted fields and legacy effort retain strict validation.
75
+ const route = encoderInput
76
+ ? Object.fromEntries(
77
+ Object.entries(value).filter(
78
+ ([key, field]) =>
79
+ !((key === "model" || key === "thinking") && field === undefined),
80
+ ),
81
+ )
82
+ : value;
83
+ if (hasOwn(route, "model") && normalizeModelId(route.model) === undefined)
84
+ return undefined;
85
+ if (hasOwn(route, "thinking") && !isThinkingLevel(route.thinking))
86
+ return undefined;
87
+ if (hasOwn(route, "effort") && !isThinkingLevel(route.effort))
88
+ return undefined;
89
+ // Project only own known fields. Unknown extras, even an unknown-only route,
90
+ // carry no routing meaning. Thinking wins over legacy effort as elsewhere.
91
+ const known = Object.fromEntries(
92
+ ["model", "thinking", "effort"]
93
+ .filter((key) => hasOwn(route, key))
94
+ .map((key) => [key, route[key]]),
95
+ );
96
+ const normalized = normalizeRoutingEntry(known);
97
+ if (!normalized) return undefined;
98
+ return Object.fromEntries(
99
+ Object.entries(normalized).filter(([, field]) => field !== undefined),
100
+ );
101
+ }
102
+
103
+ function readSnapshot(
104
+ value: unknown,
105
+ encoderInput = false,
106
+ ): AgentModelConfig | undefined {
107
+ if (!isRecord(value)) return undefined;
108
+ const entries: Array<[string, AgentRoutingEntry]> = [];
109
+ for (const [name, rawRoute] of Object.entries(value)) {
110
+ // Shared agent-name policy, applied here without the forgiving whole-config
111
+ // normalization that would silently discard a bad route. Output below uses
112
+ // own data properties, never prototype setters.
113
+ if (!isSafeAgentName(name)) return undefined;
114
+ const route = readRoute(rawRoute, encoderInput);
115
+ if (route === undefined) return undefined;
116
+ entries.push([name, route]);
117
+ }
118
+ return Object.fromEntries(entries);
119
+ }
120
+
121
+ function readBind(
122
+ data: Record<string, unknown>,
123
+ encoderInput = false,
124
+ ): SessionProfileBindPayload | undefined {
125
+ if (!["origin", "name", "modelProfiles"].every((key) => hasOwn(data, key)))
126
+ return undefined;
127
+ if (!isOrigin(data.origin) || !isValidProfileName(data.name)) return undefined;
128
+ const modelProfiles = readSnapshot(data.modelProfiles, encoderInput);
129
+ if (modelProfiles === undefined) return undefined;
130
+ return { kind: "bind", origin: data.origin, name: data.name, modelProfiles };
131
+ }
132
+
133
+ /** Create a detached user selection payload; never appends or claims durability. */
134
+ export function createSessionProfileBind(
135
+ name: string,
136
+ modelProfiles: AgentModelConfig,
137
+ ): SessionProfileBindPayload {
138
+ const binding = readBind({ origin: "user", name, modelProfiles }, true);
139
+ if (!binding) throw new TypeError("Invalid session profile binding");
140
+ return binding;
141
+ }
142
+
143
+ export function createSessionProfileClear(): SessionProfileClearPayload {
144
+ return { kind: "clear" };
145
+ }
146
+
147
+ /** Unknown family identifiers are terminal unsupported states, regardless of data. */
148
+ export function readSessionProfileEntry(
149
+ entry: SessionProfileEntry,
150
+ ): SessionProfileReadResult {
151
+ if (
152
+ entry.type !== "custom" ||
153
+ typeof entry.customType !== "string" ||
154
+ !entry.customType.startsWith(SESSION_PROFILE_FAMILY)
155
+ )
156
+ return { status: "absent" };
157
+ if (entry.customType !== SESSION_PROFILE_CUSTOM_TYPE)
158
+ return { status: "unsupported" };
159
+ const data = entry.data;
160
+ if (!isRecord(data) || !hasOwn(data, "kind")) return { status: "invalid" };
161
+ if (data.kind === "clear") return { status: "cleared" };
162
+ if (data.kind !== "bind") return { status: "invalid" };
163
+ const binding = readBind(data);
164
+ return binding ? { status: "bound", binding } : { status: "invalid" };
165
+ }
166
+
167
+ /**
168
+ * Caller MUST supply ALREADY disk-corroborated active-branch entries, in branch
169
+ * order (oldest to newest). This function cannot establish that precondition:
170
+ * it does not read disk, call Pi, enforce append-failure recovery, establish
171
+ * durability, or update the shared map. In-memory getBranch() alone is NOT
172
+ * corroboration. Sibling and future paths must be excluded by the caller.
173
+ *
174
+ * The newest family entry wins even when invalid or unsupported; never revive
175
+ * an older binding. Cleared permits current fallback at a later consumer,
176
+ * whereas invalid/unsupported must remain distinct from absent. entryIndex is
177
+ * the actual supplied branch offset for a later adapter's location warning.
178
+ */
179
+ export function replaySessionProfileBranch(
180
+ entries: readonly SessionProfileEntry[],
181
+ ): SessionProfileReplayResult {
182
+ for (let entryIndex = entries.length - 1; entryIndex >= 0; entryIndex--) {
183
+ const result = readSessionProfileEntry(entries[entryIndex]);
184
+ if (result.status !== "absent") return { ...result, entryIndex };
185
+ }
186
+ return { status: "absent" };
187
+ }
@@ -110,6 +110,8 @@ export function snapshotChanges(snapshot: ChangesSnapshot): ChangedFile[] {
110
110
  const counts = new Map(parseNumstat(snapshot.numstat).map((entry) => [entry.path, entry]));
111
111
  const files: ChangedFile[] = [];
112
112
  for (const [path, status] of parsePorcelain(snapshot.porcelain)) {
113
+ // Dependency backups are local artifacts; tracked files remain visible.
114
+ if (status === CHANGE_STATUS.UNTRACKED && /(?:^|\/)node_modules\.backup-[^/]+\//.test(path)) continue;
113
115
  const entry = counts.get(path);
114
116
  files.push({ path, added: entry?.added ?? 0, deleted: entry?.deleted ?? 0, status });
115
117
  }
@@ -74,7 +74,9 @@ export const DOUBLE_ESC_CANCEL_HINT = "esc again to cancel";
74
74
  export const IDLE_ESC_CLEAR_HINT = "esc again to clear";
75
75
  const LABEL_ROLE = "muted";
76
76
  const HINT_ROLE = "dim";
77
- const FAKE_CURSOR = "\x1b[7m \x1b[0m";
77
+ // Audited legacy SGR and Pi 1.2 APC cursors. Do not resolve the latter
78
+ // here: the host TUI owns hardware-cursor suppression after composition.
79
+ const EMPTY_FAKE_CURSORS = ["\x1b[7m \x1b[0m", "\x1b_pi:fc\x07 \x1b_pi:/fc\x07"];
78
80
  const SCROLL_INDICATOR = /[↑↓] \d+ more/;
79
81
  const STATE_LABEL: Record<PromptState, string | undefined> = {
80
82
  [PROMPT_STATE.IDLE]: undefined,
@@ -228,9 +230,9 @@ export function framePromptLines(lines: string[], width: number, options: Prompt
228
230
  }
229
231
 
230
232
  export function withPromptHint(line: string, hint: string, fg: PromptFrameOptions["fg"]): string {
231
- const cursorAt = line.indexOf(FAKE_CURSOR);
232
- if (cursorAt === -1) return line;
233
- const afterCursor = cursorAt + FAKE_CURSOR.length;
233
+ const cursor = EMPTY_FAKE_CURSORS.find((marker) => line.includes(marker));
234
+ if (!cursor) return line;
235
+ const afterCursor = line.indexOf(cursor) + cursor.length;
234
236
  const trailing = line.slice(afterCursor);
235
237
  if (trailing.trim() !== "" || trailing.length < hint.length + 1) return line;
236
238
  return `${line.slice(0, afterCursor)} ${fg(HINT_ROLE, hint)}${" ".repeat(trailing.length - hint.length - 1)}`;
package/lib/shell-todo.ts CHANGED
@@ -258,16 +258,28 @@ export function replayTodo(entries: readonly unknown[]): TodoState {
258
258
  return state;
259
259
  }
260
260
 
261
- export function todoPromptBlock(state: TodoState, stale: number): string | undefined {
261
+ // gentle-shell#1367, #1662: the system prompt is a cached prefix for every
262
+ // provider, so the block it carries is byte-stable for the whole session:
263
+ // rules only, no task content and no counters. The current list travels as a
264
+ // request-local message at the end of the conversation (todoStateMessage),
265
+ // where a change costs only the tail of the request, never the prefix.
266
+ const TODO_PROMPT_BLOCK = [
267
+ "## Todo list",
268
+ "Keep it current with the `todo` tool: mark a task in_progress before starting it, done right after finishing it, and rewrite the whole list with `write` whenever the plan changes. Update it before you end the turn.",
269
+ "A blocked task waits on something outside the list: do not work on it until its condition holds. When the plan changes, mark the tasks it made obsolete dropped instead of leaving them pending or marking them done.",
270
+ "While tasks stay open, the current list arrives as the last message of each request; it is not repeated here.",
271
+ ].join("\n");
272
+
273
+ /** The byte-stable rules block appended to every system prompt. */
274
+ export function todoPromptBlock(): string {
275
+ return TODO_PROMPT_BLOCK;
276
+ }
277
+
278
+ /** The open-task snapshot delivered as a request-local message; undefined when nothing is open. */
279
+ export function todoStateMessage(state: TodoState): string | undefined {
262
280
  if (todoSummary(state).open === 0) return undefined;
263
281
  const lines = state.tasks.map((task, index) => `${index + 1}. [${task.status}] ${task.title}${task.note ? ` — ${task.note}` : ""}`);
264
- const staleLine = stale >= STALE_AFTER_TURNS ? `\n(stale: ${stale} turns without an update — bring the list up to date now)` : "";
265
- return [
266
- "## Todo list",
267
- "Keep it current with the `todo` tool: mark a task in_progress before starting it, done right after finishing it, and rewrite the whole list with `write` whenever the plan changes. Update it before you end the turn.",
268
- "A blocked task waits on something outside the list: do not work on it until its condition holds. When the plan changes, mark the tasks it made obsolete dropped instead of leaving them pending or marking them done.",
269
- ...lines,
270
- ].join("\n") + staleLine;
282
+ return ["Current todo list:", ...lines].join("\n");
271
283
  }
272
284
 
273
285
  function taskRow(task: TodoTask, theme: TodoTheme, inner: number): string {
@@ -1,7 +1,7 @@
1
1
  import { CURSOR_MARKER, Editor, visibleWidth } from "@earendil-works/pi-tui";
2
2
  import { createRequire } from "node:module";
3
3
 
4
- // Private shape audited against @earendil-works/pi-tui 0.99.1, 0.99.2, 1.0.0 and 1.1.0:
4
+ // Private shape audited against @earendil-works/pi-tui 0.99.1, 0.99.2, 1.0.0, 1.1.0 and 1.2.0:
5
5
  // editor.js state/pastes/history, sticky cursor reset, layoutText/render geometry,
6
6
  // autocomplete cancellation and { state, pastes, pasteCounter } undo restoration;
7
7
  // undo-stack.js clone-on-push/pop snapshots. The two 0.99 releases ship identical
@@ -13,6 +13,10 @@ import { createRequire } from "node:module";
13
13
  // Actual installed bundled/unbundled tests exercise identity, edit/undo, paste,
14
14
  // selection, wrapping/scroll and autocomplete; fabricated metadata tests only
15
15
  // prove exact-version admission for the older audited releases.
16
+ // 1.2.0 changes only editor cursor rendering to paired pi:fc/pi:/fc APC
17
+ // markers resolved by the host TUI (including hardware-cursor suppression).
18
+ // Keep these markers intact and rearm selection after their reverse-off;
19
+ // state, undo, paste, layout and autocomplete contracts remain unchanged.
16
20
  // Never silently adapt another build: undo also owns the paste registry.
17
21
  interface Position { line: number; col: number }
18
22
  interface State { lines: string[]; cursorLine: number; cursorCol: number }
@@ -51,7 +55,7 @@ interface PrivateEditor {
51
55
 
52
56
  // The single audited-release source for the adapter and Gentle Shell's runtime
53
57
  // identity gates. Add a release only after re-auditing the files named above.
54
- export const AUDITED_PI_EDITOR_VERSIONS = Object.freeze(["0.99.1", "0.99.2", "1.0.0", "1.1.0"] as const);
58
+ export const AUDITED_PI_EDITOR_VERSIONS = Object.freeze(["0.99.1", "0.99.2", "1.0.0", "1.1.0", "1.2.0"] as const);
55
59
  export type AuditedPiEditorVersion = (typeof AUDITED_PI_EDITOR_VERSIONS)[number];
56
60
 
57
61
  export function isAuditedPiEditorVersion(version: unknown): version is AuditedPiEditorVersion {
@@ -83,7 +87,7 @@ function hasEditorIdentity(value: unknown, version: string, editorClass: typeof
83
87
  const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
84
88
  const marker = /\[paste #(\d+)(?: (?:\+\d+ lines|\d+ chars))?\]/g;
85
89
 
86
- // Only CSI SGR and Pi's own zero-width cursor marker are expected in editor
90
+ // Only CSI SGR and Pi's own zero-width cursor markers are expected in editor
87
91
  // text. Unknown escapes leave the unmodified frame intact (fail closed).
88
92
  function highlightRenderedRow(row: string, chunk: LayoutLine, left: number, right: number, padding: number): string | undefined {
89
93
  const cursor = chunk.hasCursor ? chunk.cursorPos : undefined;
@@ -114,8 +118,22 @@ function highlightRenderedRow(row: string, chunk: LayoutLine, left: number, righ
114
118
  result += token;
115
119
  if (selected && !inverse) { result += "\x1b[7m"; inverse = true; }
116
120
  } else if (token.startsWith("\x1b")) {
117
- if (token !== CURSOR_MARKER) return undefined;
121
+ if (token !== CURSOR_MARKER && token !== "\x1b_pi:fc\x07" && token !== "\x1b_pi:/fc\x07") return undefined;
122
+ // Close selection *before* the cursor pair, never inside its cell:
123
+ // otherwise a later host SGR 7 would immediately meet our SGR 27.
124
+ // Closing before CURSOR_MARKER also keeps it adjacent to pi:fc so
125
+ // hardware-cursor suppression can still recognize the focused pair.
126
+ const local = col - padding;
127
+ if ((token === CURSOR_MARKER || token === "\x1b_pi:fc\x07") && selected &&
128
+ !(local >= left && local < right && local < chunk.text.length)) {
129
+ if (!nativeInverse && inverse) result += "\x1b[27m";
130
+ selected = false;
131
+ inverse = nativeInverse;
132
+ }
118
133
  result += token;
134
+ // The host resolves pi:/fc to SGR 27, or drops the pair when the
135
+ // hardware cursor owns this cell. Selection must survive either path.
136
+ if (token === "\x1b_pi:/fc\x07" && selected) result += "\x1b[7m";
119
137
  } else {
120
138
  const local = col - padding;
121
139
  const shouldSelect = local >= left && local < right && local < chunk.text.length;