@oh-my-pi/pi-coding-agent 12.9.0 → 12.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,46 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [12.10.0] - 2026-02-18
6
+
7
+ ### Breaking Changes
8
+
9
+ - Changed keyless provider auth sentinel from `"<no-auth>"` to `kNoAuth` (`"N/A"`) for `ModelRegistry.getApiKey()` and `ModelRegistry.getApiKeyForProvider()`
10
+
11
+ ### Added
12
+
13
+ - Added `--no-rules` CLI flag to disable rules discovery and loading
14
+ - Added `sessionDir` option to RpcClientOptions for specifying agent session directory
15
+ - Added `Symbol.dispose` method to RpcClient for resource cleanup support
16
+ - Added `rules` option to CreateAgentSessionOptions for explicit rule configuration
17
+ - Added `sessionDir` option to RpcClientOptions for specifying agent session directory
18
+ - Added `Symbol.dispose` method to RpcClient for resource cleanup support
19
+ - Added `condition` and `scope` fields to rule frontmatter for advanced TTSR matching and stream filtering
20
+ - Added `ttsr.interruptMode` setting to control when TTSR rules interrupt mid-stream vs inject warnings after completion
21
+ - Added support for loading rules, prompts, commands, context files (AGENTS.md), and system prompts (SYSTEM.md) from ~/.agent/ directory (with fallback to ~/.agents/)
22
+ - Added scoped stream buffering for TTSR matching to isolate prose, thinking, and tool argument streams
23
+ - Added file-path-aware TTSR scope matching for tool calls with glob patterns (e.g., `tool:edit(*.ts)`)
24
+ - Added legacy field support: `ttsr_trigger` and `ttsrTrigger` are accepted as fallback for `condition`
25
+
26
+ ### Changed
27
+
28
+ - Changed TTSR injection tracking to record all turns where rules were injected (instead of only the last turn) to support repeat-after-gap mode across resumed sessions
29
+ - Changed TTSR injection messages to use custom message type with metadata instead of synthetic user messages for better session tracking
30
+ - Changed TTSR rule injection to persist injected rule names in session state for restoration when resuming sessions
31
+ - Changed model discovery to automatically discover built-in provider models (Anthropic, OpenAI, Groq, Cerebras, Xai, Mistral, OpenCode, OpenRouter, Vercel AI Gateway, Kimi Code, GitHub Copilot, Google, Cursor, Google Antigravity, Google Gemini CLI, OpenAI Codex) when credentials are configured
32
+ - Changed `getModel()` and `getModels()` imports to `getBundledModel()` and `getBundledModels()` across test utilities
33
+ - Changed TTSR rule matching from single `ttsrTrigger` regex to multiple `condition` patterns with scope filtering
34
+ - Changed TTSR buffer management to use per-stream-key buffers instead of a single global buffer
35
+ - Changed rule discovery to use unified `buildRuleFromMarkdown` helper across all providers (builtin, cline, cursor, windsurf, agents)
36
+ - Changed TTSR injection to defer warnings until stream completion when `interruptMode` is not `always`
37
+ - Changed `TtsrManager.addRule()` to return boolean indicating successful registration instead of void
38
+
39
+ ### Fixed
40
+
41
+ - Fixed TTSR repeat-after-gap mode to correctly calculate gaps when rules are restored from previous sessions
42
+ - Fixed TTSR matching to respect tool-specific scope filters, preventing cross-tool rule contamination
43
+ - Fixed path normalization in TTSR glob matching to handle both relative and absolute path variants
44
+
5
45
  ## [12.9.0] - 2026-02-17
6
46
 
7
47
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "12.9.0",
3
+ "version": "12.10.0",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "bin": {
@@ -84,12 +84,12 @@
84
84
  },
85
85
  "dependencies": {
86
86
  "@mozilla/readability": "0.6.0",
87
- "@oh-my-pi/omp-stats": "12.9.0",
88
- "@oh-my-pi/pi-agent-core": "12.9.0",
89
- "@oh-my-pi/pi-ai": "12.9.0",
90
- "@oh-my-pi/pi-natives": "12.9.0",
91
- "@oh-my-pi/pi-tui": "12.9.0",
92
- "@oh-my-pi/pi-utils": "12.9.0",
87
+ "@oh-my-pi/omp-stats": "12.10.0",
88
+ "@oh-my-pi/pi-agent-core": "12.10.0",
89
+ "@oh-my-pi/pi-ai": "12.10.0",
90
+ "@oh-my-pi/pi-natives": "12.10.0",
91
+ "@oh-my-pi/pi-tui": "12.10.0",
92
+ "@oh-my-pi/pi-utils": "12.10.0",
93
93
  "@sinclair/typebox": "^0.34.48",
94
94
  "@xterm/headless": "^6.0.0",
95
95
  "ajv": "^8.18.0",
@@ -7,15 +7,23 @@
7
7
  import { defineCapability } from ".";
8
8
  import type { SourceMeta } from "./types";
9
9
 
10
+ const CONDITION_GLOB_SCOPE_TOOLS = ["edit", "write"] as const;
11
+
10
12
  /**
11
- * Parsed frontmatter from MDC rule files (Cursor format).
13
+ * Parsed frontmatter from rule files.
12
14
  */
13
15
  export interface RuleFrontmatter {
14
16
  description?: string;
15
17
  globs?: string[];
16
18
  alwaysApply?: boolean;
17
- /** Regex pattern that triggers time-traveling rule injection */
18
- ttsr_trigger?: string;
19
+ /** New key for TTSR match conditions. */
20
+ condition?: string | string[];
21
+ /** New key for TTSR stream scope. */
22
+ scope?: string | string[];
23
+ /** Legacy key accepted for backward compatibility with existing rules. */
24
+ ttsr_trigger?: string | string[];
25
+ /** Legacy camelCase key accepted for backward compatibility with existing rules. */
26
+ ttsrTrigger?: string | string[];
19
27
  [key: string]: unknown;
20
28
  }
21
29
 
@@ -35,12 +43,172 @@ export interface Rule {
35
43
  alwaysApply?: boolean;
36
44
  /** Description (for agent-requested rules) */
37
45
  description?: string;
38
- /** Regex pattern that triggers time-traveling rule injection */
39
- ttsrTrigger?: string;
46
+ /** Regex condition(s) that can trigger TTSR interruption. */
47
+ condition?: string[];
48
+ /** Optional stream scope tokens (for example: text, thinking, tool:edit(*.ts)). */
49
+ scope?: string[];
40
50
  /** Source metadata */
41
51
  _source: SourceMeta;
42
52
  }
43
53
 
54
+ function normalizeRuleField(value: unknown): string[] | undefined {
55
+ if (typeof value === "string") {
56
+ const token = value.trim();
57
+ return token.length > 0 ? [token] : undefined;
58
+ }
59
+ if (!Array.isArray(value)) {
60
+ return undefined;
61
+ }
62
+
63
+ const tokens = value
64
+ .filter((item): item is string => typeof item === "string")
65
+ .map(item => item.trim())
66
+ .filter(item => item.length > 0);
67
+ if (tokens.length === 0) {
68
+ return undefined;
69
+ }
70
+
71
+ return Array.from(new Set(tokens));
72
+ }
73
+
74
+ function splitScopeTokens(value: string): string[] {
75
+ const tokens: string[] = [];
76
+ let current = "";
77
+ let parenDepth = 0;
78
+ let bracketDepth = 0;
79
+ let braceDepth = 0;
80
+ let quote: '"' | "'" | undefined;
81
+ for (let i = 0; i < value.length; i++) {
82
+ const char = value[i];
83
+ if (quote) {
84
+ current += char;
85
+ if (char === quote && value[i - 1] !== "\\") {
86
+ quote = undefined;
87
+ }
88
+ continue;
89
+ }
90
+ if (char === '"' || char === "'") {
91
+ quote = char;
92
+ current += char;
93
+ continue;
94
+ }
95
+ if (char === "(") {
96
+ parenDepth++;
97
+ current += char;
98
+ continue;
99
+ }
100
+ if (char === ")") {
101
+ parenDepth = Math.max(0, parenDepth - 1);
102
+ current += char;
103
+ continue;
104
+ }
105
+ if (char === "[") {
106
+ bracketDepth++;
107
+ current += char;
108
+ continue;
109
+ }
110
+ if (char === "]") {
111
+ bracketDepth = Math.max(0, bracketDepth - 1);
112
+ current += char;
113
+ continue;
114
+ }
115
+ if (char === "{") {
116
+ braceDepth++;
117
+ current += char;
118
+ continue;
119
+ }
120
+ if (char === "}") {
121
+ braceDepth = Math.max(0, braceDepth - 1);
122
+ current += char;
123
+ continue;
124
+ }
125
+ if (char === "," && parenDepth === 0 && bracketDepth === 0 && braceDepth === 0) {
126
+ const token = current.trim();
127
+ if (token.length > 0) {
128
+ tokens.push(token);
129
+ }
130
+ current = "";
131
+ continue;
132
+ }
133
+ current += char;
134
+ }
135
+
136
+ const tail = current.trim();
137
+ if (tail.length > 0) {
138
+ tokens.push(tail);
139
+ }
140
+
141
+ return tokens;
142
+ }
143
+ function normalizeScopeField(value: unknown): string[] | undefined {
144
+ const normalized = normalizeRuleField(value);
145
+ if (!normalized) {
146
+ return undefined;
147
+ }
148
+
149
+ const tokens = normalized.flatMap(splitScopeTokens).filter(item => item.length > 0);
150
+ if (tokens.length === 0) {
151
+ return undefined;
152
+ }
153
+ return Array.from(new Set(tokens));
154
+ }
155
+ /**
156
+ * Heuristic for condition shorthand that looks like a file glob (for example `*.rs`).
157
+ */
158
+ function isLikelyFileGlob(value: string): boolean {
159
+ const token = value.trim();
160
+ if (token.length === 0) {
161
+ return false;
162
+ }
163
+ if (/[\\^$+|()]/.test(token)) {
164
+ return false;
165
+ }
166
+ if (!/[?*[\]{}]/.test(token)) {
167
+ return false;
168
+ }
169
+ if (token.includes("/")) {
170
+ return true;
171
+ }
172
+ return /^\*\.[^\s/]+$/.test(token);
173
+ }
174
+
175
+ /**
176
+ * Parse `condition` + `scope` from rule frontmatter.
177
+ *
178
+ * - `condition` accepts string or string[]
179
+ * - `scope` accepts string or string[]
180
+ * - legacy `ttsr_trigger` / `ttsrTrigger` are accepted as a `condition` fallback
181
+ * - condition tokens that look like file globs become scope shorthands:
182
+ * `*.rs` => `tool:edit(*.rs)`, `tool:write(*.rs)` and a catch-all condition `.*`
183
+ */
184
+ export function parseRuleConditionAndScope(frontmatter: RuleFrontmatter): Pick<Rule, "condition" | "scope"> {
185
+ const rawCondition = frontmatter.condition ?? frontmatter.ttsr_trigger ?? frontmatter.ttsrTrigger;
186
+ const parsedCondition = normalizeRuleField(rawCondition);
187
+ const parsedScope = normalizeScopeField(frontmatter.scope);
188
+
189
+ const inferredScope: string[] = [];
190
+ const condition: string[] = [];
191
+ for (const token of parsedCondition ?? []) {
192
+ if (isLikelyFileGlob(token)) {
193
+ for (const toolName of CONDITION_GLOB_SCOPE_TOOLS) {
194
+ inferredScope.push(`tool:${toolName}(${token})`);
195
+ }
196
+ continue;
197
+ }
198
+ condition.push(token);
199
+ }
200
+
201
+ if (condition.length === 0 && inferredScope.length > 0) {
202
+ condition.push(".*");
203
+ }
204
+
205
+ const scope = [...(parsedScope ?? []), ...inferredScope];
206
+ return {
207
+ condition: condition.length > 0 ? Array.from(new Set(condition)) : undefined,
208
+ scope: scope.length > 0 ? Array.from(new Set(scope)) : undefined,
209
+ };
210
+ }
211
+
44
212
  export const ruleCapability = defineCapability<Rule>({
45
213
  id: "rules",
46
214
  displayName: "Rules",
package/src/cli/args.ts CHANGED
@@ -40,6 +40,7 @@ export interface Args {
40
40
  export?: string;
41
41
  noSkills?: boolean;
42
42
  skills?: string[];
43
+ noRules?: boolean;
43
44
  listModels?: string | true;
44
45
  noTitle?: boolean;
45
46
  messages: string[];
@@ -150,6 +151,8 @@ export function parseArgs(args: string[], extensionFlags?: Map<string, { type: "
150
151
  result.noExtensions = true;
151
152
  } else if (arg === "--no-skills") {
152
153
  result.noSkills = true;
154
+ } else if (arg === "--no-rules") {
155
+ result.noRules = true;
153
156
  } else if (arg === "--no-title") {
154
157
  result.noTitle = true;
155
158
  } else if (arg === "--skills" && i + 1 < args.length) {
@@ -6,7 +6,6 @@
6
6
  */
7
7
  import { execSync, spawnSync } from "node:child_process";
8
8
  import * as fs from "node:fs";
9
- import * as path from "node:path";
10
9
  import { pipeline } from "node:stream/promises";
11
10
  import { isEnoent } from "@oh-my-pi/pi-utils";
12
11
  import { APP_NAME, VERSION } from "@oh-my-pi/pi-utils/dirs";
@@ -80,7 +79,7 @@ async function getLatestRelease(): Promise<ReleaseInfo> {
80
79
  return {
81
80
  tag,
82
81
  version,
83
- assets: [makeAsset(getBinaryName()), ...getNativeAddonNames().map(makeAsset)],
82
+ assets: [makeAsset(getBinaryName())],
84
83
  };
85
84
  }
86
85
 
@@ -142,27 +141,6 @@ function getBinaryName(): string {
142
141
  return `${APP_NAME}-${os}-${archName}`;
143
142
  }
144
143
 
145
- /**
146
- * Get native addon names for this platform, ordered by preference.
147
- */
148
- function getNativeAddonNames(): string[] {
149
- const platform = process.platform;
150
- const arch = process.arch;
151
- if (!["linux", "darwin", "win32"].includes(platform)) {
152
- throw new Error(`Unsupported platform: ${platform}`);
153
- }
154
- if (!["x64", "arm64"].includes(arch)) {
155
- throw new Error(`Unsupported architecture: ${arch}`);
156
- }
157
-
158
- const baseName = `pi_natives.${platform}-${arch}.node`;
159
- if (arch !== "x64") {
160
- return [baseName];
161
- }
162
-
163
- return [`pi_natives.${platform}-${arch}-modern.node`, `pi_natives.${platform}-${arch}-baseline.node`];
164
- }
165
-
166
144
  /**
167
145
  * Update via bun package manager.
168
146
  */
@@ -200,44 +178,13 @@ async function updateViaBun(expectedVersion: string): Promise<void> {
200
178
  */
201
179
  async function updateViaBinary(release: ReleaseInfo): Promise<void> {
202
180
  const binaryName = getBinaryName();
203
- const nativeAddonNames = getNativeAddonNames();
204
181
  const asset = release.assets.find(a => a.name === binaryName);
205
182
  if (!asset) {
206
183
  throw new Error(`No binary found for ${binaryName}`);
207
184
  }
208
185
  const execPath = process.execPath;
209
- const execDir = path.dirname(execPath);
210
186
  const tempPath = `${execPath}.new`;
211
187
  const backupPath = `${execPath}.bak`;
212
- const nativeDownloads: Array<{ name: string; tempPath: string; finalPath: string }> = [];
213
-
214
- const downloadNativeAsset = async (name: string, required: boolean): Promise<boolean> => {
215
- const nativeAsset = release.assets.find(assetEntry => assetEntry.name === name);
216
- if (!nativeAsset) {
217
- if (required) throw new Error(`No native addon found for ${name}`);
218
- return false;
219
- }
220
-
221
- console.log(chalk.dim(`Downloading ${name}…`));
222
- try {
223
- const nativeResponse = await fetch(nativeAsset.url, { redirect: "follow" });
224
- if (!nativeResponse.ok || !nativeResponse.body) {
225
- if (required) throw new Error(`Native addon download failed for ${name}: ${nativeResponse.statusText}`);
226
- return false;
227
- }
228
-
229
- const nativeFinalPath = path.join(execDir, name);
230
- const nativeTempPath = `${nativeFinalPath}.new`;
231
- const nativeFileStream = fs.createWriteStream(nativeTempPath, { mode: 0o755 });
232
- await pipeline(nativeResponse.body, nativeFileStream);
233
- nativeDownloads.push({ name, tempPath: nativeTempPath, finalPath: nativeFinalPath });
234
- return true;
235
- } catch (err) {
236
- if (required) throw err;
237
- return false;
238
- }
239
- };
240
-
241
188
  console.log(chalk.dim(`Downloading ${binaryName}…`));
242
189
 
243
190
  // Download binary to temp file
@@ -247,9 +194,6 @@ async function updateViaBinary(release: ReleaseInfo): Promise<void> {
247
194
  }
248
195
  const fileStream = fs.createWriteStream(tempPath, { mode: 0o755 });
249
196
  await pipeline(response.body, fileStream);
250
- for (const nativeAddonName of nativeAddonNames) {
251
- await downloadNativeAsset(nativeAddonName, true);
252
- }
253
197
  // Replace current binary
254
198
  console.log(chalk.dim("Installing update..."));
255
199
  try {
@@ -262,11 +206,7 @@ async function updateViaBinary(release: ReleaseInfo): Promise<void> {
262
206
  await fs.promises.rename(tempPath, execPath);
263
207
  await fs.promises.unlink(backupPath);
264
208
 
265
- for (const nativeDownload of nativeDownloads) {
266
- await fs.promises.rename(nativeDownload.tempPath, nativeDownload.finalPath);
267
- }
268
209
  console.log(chalk.green(`\n${theme.status.success} Updated to ${release.version}`));
269
- console.log(chalk.dim(`Installed ${nativeDownloads.length} native addon file(s)`));
270
210
  console.log(chalk.dim(`Restart ${APP_NAME} to use the new version`));
271
211
  } catch (err) {
272
212
  if (fs.existsSync(backupPath) && !fs.existsSync(execPath)) {
@@ -275,11 +215,6 @@ async function updateViaBinary(release: ReleaseInfo): Promise<void> {
275
215
  if (fs.existsSync(tempPath)) {
276
216
  await fs.promises.unlink(tempPath);
277
217
  }
278
- for (const nativeDownload of nativeDownloads) {
279
- if (fs.existsSync(nativeDownload.tempPath)) {
280
- await fs.promises.unlink(nativeDownload.tempPath);
281
- }
282
- }
283
218
  throw err;
284
219
  }
285
220
  }
@@ -105,6 +105,9 @@ export default class Index extends Command {
105
105
  skills: Flags.string({
106
106
  description: "Comma-separated glob patterns to filter skills (e.g., git-*,docker)",
107
107
  }),
108
+ "no-rules": Flags.boolean({
109
+ description: "Disable rules discovery and loading",
110
+ }),
108
111
  export: Flags.string({
109
112
  description: "Export session file to HTML and exit",
110
113
  }),