pi-code 1.0.69 → 1.0.70

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.
@@ -601,7 +601,8 @@ function nestedContextBlock(file: string, dir: string, load: NestedLoadContext):
601
601
  // a body already in the system prompt.
602
602
  const seen = new Set([...load.launchLoaded, real])
603
603
  const budget = createImportBudget()
604
- const imports = collectImports(content, dir, load.home, rootsForImporter(real, load.home, load.cwd), seen, {
604
+ // The stripped body, as at launch: a commented-out @import never expands.
605
+ const imports = collectImports(body, dir, load.home, rootsForImporter(real, load.home, load.cwd), seen, {
605
606
  importer: real,
606
607
  isExcluded: (absPath) => isExcludedPath(absPath, load.excludeGlobs, load.home),
607
608
  budget,
@@ -6,9 +6,37 @@
6
6
 
7
7
  import * as fs from 'node:fs'
8
8
 
9
- /** The tmp name carries the pid so concurrent processes do not collide. */
9
+ /** The file a path leads to. A rename onto a symlink replaces the link itself: a settings
10
+ * file a dotfiles setup links into a repository would silently become a regular file while
11
+ * the real one kept the old content. The write goes to the real file, in its own directory
12
+ * so the rename stays on one filesystem. A link that leads nowhere yet is written as is. */
13
+ function throughLinks(filePath: string): string {
14
+ try {
15
+ return fs.realpathSync(filePath)
16
+ } catch {
17
+ return filePath
18
+ }
19
+ }
20
+
21
+ /** The permission bits of `filePath`, or undefined when it does not exist yet. */
22
+ function existingMode(filePath: string): number | undefined {
23
+ try {
24
+ return fs.statSync(filePath).mode & 0o777
25
+ } catch {
26
+ return undefined
27
+ }
28
+ }
29
+
30
+ /** The tmp name carries the pid so concurrent processes do not collide. A rename replaces
31
+ * the file's inode, so the replacement is created with the mode of the file it replaces:
32
+ * settings.local.json can hold env secrets, and a fresh temp file is 0644 under the usual
33
+ * umask. Created with that mode, the temp file is never more permissive than the target;
34
+ * the chmod then makes it exact where the umask took bits away. */
10
35
  export function atomicWriteFile(filePath: string, content: string): void {
11
- const tmp = `${filePath}.${process.pid}.tmp`
12
- fs.writeFileSync(tmp, content)
13
- fs.renameSync(tmp, filePath)
36
+ const target = throughLinks(filePath)
37
+ const tmp = `${target}.${process.pid}.tmp`
38
+ const mode = existingMode(target)
39
+ fs.writeFileSync(tmp, content, mode === undefined ? undefined : { mode })
40
+ if (mode !== undefined) fs.chmodSync(tmp, mode)
41
+ fs.renameSync(tmp, target)
14
42
  }
@@ -286,12 +286,20 @@ export function waitForAuthCode(server: http.Server, timeoutMs: number, expected
286
286
  })
287
287
  }
288
288
 
289
- /** Best-effort browser launch; the caller also surfaces the URL as text. */
289
+ /** Best-effort browser launch; the caller also surfaces the URL as text.
290
+ *
291
+ * The URL comes from the server's metadata, so two things are not taken on trust. Only a
292
+ * web page is opened: the SDK rejects javascript:, data: and vbscript: alone, and a file:
293
+ * URL or a custom protocol handed to the platform launcher opens a file or starts an
294
+ * application. And on Windows no shell sees it: `cmd /c start` splits its line at every
295
+ * bare `&`, which every authorization URL has, truncating the URL and running the rest as
296
+ * commands. rundll32 takes the URL as one argument. */
290
297
  export function openBrowser(url: string): void {
298
+ if (!/^https?:\/\//i.test(url)) return
291
299
  let command = 'xdg-open'
292
300
  if (process.platform === 'darwin') command = 'open'
293
- else if (process.platform === 'win32') command = 'cmd'
294
- const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]
301
+ else if (process.platform === 'win32') command = 'rundll32'
302
+ const args = process.platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url]
295
303
  try {
296
304
  const child = spawn(command, args, { stdio: 'ignore', detached: true })
297
305
  // A missing launcher (a container or an SSH session with no xdg-open) reports itself
@@ -17,6 +17,8 @@ function separatorAt(command: string, i: number): number {
17
17
  const pair = command.slice(i, i + 2)
18
18
  if (pair === '&&' || pair === '||' || pair === '|&') return 2
19
19
  const ch = command[i]
20
+ // The & of a redirection (`2>&1`, `&>file`) belongs to the command, not between two.
21
+ if (ch === '&' && (command[i - 1] === '>' || command[i + 1] === '>')) return 0
20
22
  return ch === ';' || ch === '|' || ch === '&' || ch === '\n' ? 1 : 0
21
23
  }
22
24
 
@@ -37,12 +37,17 @@ const DEFAULT_STDIO_CALL_IDLE_TIMEOUT_MS = 1_800_000
37
37
 
38
38
  /** Claude's numeric env vars accept scientific notation and digit-separator spellings
39
39
  * (2e3 as 2000, 64_000 as 64000). A non-numeric value is undefined, not zero. */
40
+ /** The longest delay setTimeout holds: it keeps the delay in 32 bits, and a larger one fires
41
+ * after 1 ms. A user's "never" (3000000000) made every call time out at once, so a timeout
42
+ * is clamped to this, about 24.8 days. */
43
+ const MAX_TIMER_MS = 2_147_483_647
44
+
40
45
  /** A positive-integer env override, or the default when unset or unparseable. */
41
46
  function envTimeout(name: string, fallback: number): number {
42
47
  const raw = process.env[name]
43
48
  if (raw === undefined) return fallback
44
49
  const value = parseNumericEnv(raw)
45
- return value !== undefined && value > 0 ? Math.floor(value) : fallback
50
+ return value !== undefined && value > 0 ? Math.min(Math.floor(value), MAX_TIMER_MS) : fallback
46
51
  }
47
52
 
48
53
  // Claude honors MCP_TIMEOUT (connect) and MCP_TOOL_TIMEOUT (per-call), both in ms.
@@ -97,7 +102,7 @@ export function callRequestOptions(wall: number, tuning: ServerCallTuning = {}):
97
102
  * per-server timeout. Per Claude, timeout values below 1000 are ignored and fall
98
103
  * through to MCP_TOOL_TIMEOUT. */
99
104
  export function serverCallTuning(config: ServerConfig): ServerCallTuning {
100
- const declared = typeof config.timeout === 'number' && config.timeout >= 1000 ? config.timeout : undefined
105
+ const declared = typeof config.timeout === 'number' && config.timeout >= 1000 ? Math.min(config.timeout, MAX_TIMER_MS) : undefined
101
106
  return { stdio: isStdio(config), ...(declared !== undefined ? { serverTimeoutMs: declared } : {}) }
102
107
  }
103
108
 
@@ -5,7 +5,9 @@
5
5
 
6
6
  import { hasSubstitution, splitSegments } from '../internal/shell-split.js'
7
7
 
8
- // Destructive commands blocked in plan mode
8
+ // Destructive commands blocked in plan mode. Tested against the segment's command word,
9
+ // the one word that runs: an allowlisted head never executes its arguments, so `code`
10
+ // in a path, `touch` in a quoted pattern or `cp` in a file name is not a command.
9
11
  const DESTRUCTIVE_PATTERNS = [
10
12
  /\brm\b/i,
11
13
  /\brmdir\b/i,
@@ -21,8 +23,6 @@ const DESTRUCTIVE_PATTERNS = [
21
23
  /\btruncate\b/i,
22
24
  /\bdd\b/i,
23
25
  /\bshred\b/i,
24
- /(^|[^<])>(?!>)/,
25
- />>/,
26
26
  /\bnpm\s+(install|uninstall|update|ci|link|publish)/i,
27
27
  /\byarn\s+(add|remove|install|publish)/i,
28
28
  /\bpnpm\s+(add|remove|install|publish)/i,
@@ -40,7 +40,17 @@ const DESTRUCTIVE_PATTERNS = [
40
40
  /\bsystemctl\s+(start|stop|restart|enable|disable)/i,
41
41
  /\bservice\s+\S+\s+(start|stop|restart)/i,
42
42
  /\b(vim?|nano|emacs|code|subl)\b/i,
43
- ]
43
+ ].map((pattern) => new RegExp(String.raw`^\s*(?:${pattern.source})`, pattern.flags))
44
+
45
+ // A redirect writes wherever it points, from any position in the segment.
46
+ const REDIRECT_PATTERNS = [/(^|[^<])>(?!>)/, />>/]
47
+
48
+ // Redirections that write nothing: onto /dev/null, and a descriptor duplicated onto
49
+ // another (`2>&1`, `>&2`, `>&-`). `>&file` is not one: it writes the file. Every run is
50
+ // bounded: the text is chosen by the model, and an unbounded `\d*` in front of an
51
+ // unanchored match backtracks quadratically (40,000 digits took over a second).
52
+ const DEV_NULL_REDIRECT = /(?:&|\d{0,3})>>?[ \t]{0,8}\/dev\/null(?=\s|$)/g
53
+ const DESCRIPTOR_REDIRECT = /\d{0,3}>&(?:\d{1,3}|-)(?=\s|$)/g
44
54
 
45
55
  // Safe read-only commands allowed in plan mode. Deliberately excludes env/printenv
46
56
  // (secret disclosure, and env is an exec wrapper), curl/wget (fetch plus -o writes),
@@ -55,6 +65,7 @@ const SAFE_PATTERNS = [
55
65
  /^\s*find\b/,
56
66
  /^\s*ls\b/,
57
67
  /^\s*pwd\b/,
68
+ /^\s*cd(\s|$)/,
58
69
  /^\s*echo\b/,
59
70
  /^\s*printf\b/,
60
71
  /^\s*wc\b/,
@@ -92,13 +103,93 @@ const SAFE_PATTERNS = [
92
103
  /^\s*eza\b/,
93
104
  ]
94
105
 
95
- // find is allowlisted for traversal only; these actions run commands or delete.
96
- const FIND_ACTIONS = /\s-(exec|execdir|ok|okdir|delete|fls|fprint|fprintf)\b/
106
+ // find is allowlisted for traversal only; these actions run commands, delete or write.
107
+ const FIND_ACTIONS = /\s-(exec|execdir|ok|okdir|delete|fls|fprint|fprint0|fprintf)\b/
108
+
109
+ // Flags that turn an allowlisted read into a write or an execution, per command.
110
+ const UNSAFE_FLAGS: ReadonlyArray<readonly [head: RegExp, flag: RegExp]> = [
111
+ [/^\s*find\b/, FIND_ACTIONS],
112
+ [/^\s*sort\b/, /\s(-[a-zA-Z]*o|--output\b|--compress-program\b)/],
113
+ [/^\s*tree\b/, /\s-[a-zA-Z]*o/],
114
+ [/^\s*rg\b/, /\s--(pre|hostname-bin)\b/],
115
+ [/^\s*git\s/, /\s--output(=|\s|$)/],
116
+ ]
117
+
118
+ /** `segment` with its quoted spans removed, so a `>` or a flag inside a pattern reads as
119
+ * text. Follows splitSegments: a backslash outside quotes escapes the next character,
120
+ * and inside quotes only the closing quote matters. */
121
+ function withoutQuoted(segment: string): string {
122
+ let bare = ''
123
+ let quote: string | undefined
124
+ for (let i = 0; i < segment.length; i++) {
125
+ const ch = segment[i]
126
+ if (quote !== undefined) {
127
+ if (ch === quote) quote = undefined
128
+ } else if (ch === "'" || ch === '"') {
129
+ quote = ch
130
+ } else if (ch === '\\') {
131
+ i++
132
+ } else {
133
+ bare += ch
134
+ }
135
+ }
136
+ return bare
137
+ }
138
+
139
+ function writesThroughRedirect(segment: string): boolean {
140
+ const bare = withoutQuoted(segment).replace(DEV_NULL_REDIRECT, ' ').replace(DESCRIPTOR_REDIRECT, ' ')
141
+ return REDIRECT_PATTERNS.some((pattern) => pattern.test(bare))
142
+ }
143
+
144
+ /** uniq [INPUT [OUTPUT]] writes its second operand. An option's value (-f N, -s N, -w N)
145
+ * is not an operand. */
146
+ function uniqWrites(args: string[]): boolean {
147
+ let operands = 0
148
+ for (let i = 0; i < args.length; i++) {
149
+ if (['-f', '-s', '-w'].includes(args[i])) i++
150
+ else if (!args[i].startsWith('-')) operands++
151
+ }
152
+ return operands >= 2
153
+ }
154
+
155
+ const BRANCH_LISTS = /^(-l|--list|--contains|--no-contains|--merged|--no-merged|--points-at)$/
156
+ const BRANCH_WRITE_LONG_FLAGS = new Set(['--delete', '--move', '--copy', '--force', '--unset-upstream', '--set-upstream-to', '--edit-description', '--track', '--no-track', '--create-reflog'])
157
+
158
+ /** A `git branch` flag that changes a branch: -d -D -m -M -c -C -u -f -t, alone or in a
159
+ * cluster, or the long form of one. */
160
+ function isBranchWriteFlag(arg: string): boolean {
161
+ if (arg.startsWith('--')) return BRANCH_WRITE_LONG_FLAGS.has(arg.split('=')[0])
162
+ return arg.startsWith('-') && /[dDmMcCuft]/.test(arg)
163
+ }
164
+
165
+ /** `git branch` lists unless it is given a name to create or a flag that changes one. */
166
+ function gitBranchWrites(args: string[]): boolean {
167
+ if (args.some(isBranchWriteFlag)) return true
168
+ return args.some((arg) => !arg.startsWith('-')) && !args.some((arg) => BRANCH_LISTS.test(arg))
169
+ }
170
+
171
+ /** `git remote` lists and shows; every other subcommand edits the configuration. */
172
+ function gitRemoteWrites(args: string[]): boolean {
173
+ const subcommand = args.find((arg) => !arg.startsWith('-'))
174
+ return subcommand !== undefined && !['show', 'get-url'].includes(subcommand)
175
+ }
176
+
177
+ /** Whether an allowlisted command carries a flag or operand that makes it write. */
178
+ function writesThroughOperands(segment: string): boolean {
179
+ const bare = withoutQuoted(segment)
180
+ if (UNSAFE_FLAGS.some(([head, flag]) => head.test(segment) && flag.test(bare))) return true
181
+ const [command = '', subcommand = '', ...rest] = segment.trim().split(/\s+/)
182
+ if (command === 'uniq') return uniqWrites([subcommand, ...rest].filter(Boolean))
183
+ if (command !== 'git') return false
184
+ if (subcommand === 'branch') return gitBranchWrites(rest)
185
+ return subcommand === 'remote' && gitRemoteWrites(rest)
186
+ }
97
187
 
98
188
  function isSafeSegment(segment: string): boolean {
99
- if (DESTRUCTIVE_PATTERNS.some((p) => p.test(segment))) return false
100
- if (!SAFE_PATTERNS.some((p) => p.test(segment))) return false
101
- return !(/^\s*find\b/.test(segment) && FIND_ACTIONS.test(segment))
189
+ if (DESTRUCTIVE_PATTERNS.some((pattern) => pattern.test(segment))) return false
190
+ if (writesThroughRedirect(segment)) return false
191
+ if (!SAFE_PATTERNS.some((pattern) => pattern.test(segment))) return false
192
+ return !writesThroughOperands(segment)
102
193
  }
103
194
 
104
195
  /**
@@ -123,7 +123,9 @@ function findClaudeSkill(name: string, roots: string[]): FoundSkill | undefined
123
123
  continue
124
124
  }
125
125
  for (const entry of entries) {
126
- if (!entry.isDirectory()) continue
126
+ // A link counts: pi's loader follows it, and skillAt already turns a link that
127
+ // leads to no SKILL.md (dangling, or to a plain file) into undefined.
128
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
127
129
  const skill = skillAt(root, entry.name)
128
130
  if (skill?.name === name) return { filePath: skill.filePath, baseDir: path.dirname(skill.filePath) }
129
131
  }
@@ -360,6 +360,10 @@ export default function statusLine(pi: ExtensionAPI) {
360
360
  killInflight = kill
361
361
  },
362
362
  })
363
+ // A newer trigger killed this run and queued its rerun: what a killed script left
364
+ // behind is not its output, and applying it blanked the custom segment until the
365
+ // rerun finished. Claude cancels the in-flight script and shows nothing for it.
366
+ if (rerunQueued) return
363
367
  // Claude: "Your script can output multiple lines to create a richer display."
364
368
  // pi has one row for every extension status and replaces newlines with spaces
365
369
  // before rendering it (footer.js sanitizeStatusText), so the rows are joined
@@ -13,7 +13,7 @@ import * as path from 'node:path'
13
13
  import { killProcessTree } from '../internal/process-tree.js'
14
14
  import { sharedSlot } from '../internal/shared-slot.js'
15
15
  import { errorMessage } from '../internal/values.js'
16
- import { spawnChild } from './run.js'
16
+ import { spawnChild, utf8Chunks } from './run.js'
17
17
 
18
18
  export interface BackgroundRun {
19
19
  id: string
@@ -350,13 +350,15 @@ function driveRun(run: BackgroundRun, invocation: BackgroundSpawn, onComplete: (
350
350
  : undefined,
351
351
  )
352
352
  let stderrTail = ''
353
- proc.stdout.on('data', (data) => parser.push(data.toString()))
353
+ const decodeStdout = utf8Chunks()
354
+ const decodeStderr = utf8Chunks()
355
+ proc.stdout.on('data', (data) => parser.push(decodeStdout(data)))
354
356
  // An 'error' on a stream with no listener is rethrown by EventEmitter, and this one
355
357
  // belongs to a detached child, so a pipe read failure would exit pi the same way an
356
358
  // unguarded completion would. The foreground runner guards its streams the same way.
357
359
  proc.stdout.on('error', () => {})
358
360
  proc.stderr?.on('data', (data) => {
359
- stderrTail = (stderrTail + data.toString()).slice(-STDERR_TAIL_CHARS)
361
+ stderrTail = (stderrTail + decodeStderr(data)).slice(-STDERR_TAIL_CHARS)
360
362
  })
361
363
  proc.stderr?.on('error', () => {})
362
364
  proc.on('close', (code) => {
@@ -12,6 +12,7 @@ import * as fs from 'node:fs'
12
12
  import * as os from 'node:os'
13
13
  import * as path from 'node:path'
14
14
  import type { Readable } from 'node:stream'
15
+ import { StringDecoder } from 'node:string_decoder'
15
16
 
16
17
  import type { AgentToolResult } from '@earendil-works/pi-agent-core'
17
18
  import type { Message } from '@earendil-works/pi-ai'
@@ -125,6 +126,14 @@ function appendPartialNote(result: SingleResult): void {
125
126
  if (result.partial) appendResultNote(result, '[Output is partial: the subagent stopped at its maxTurns limit.]')
126
127
  }
127
128
 
129
+ /** A decoder for one child stream. A pipe delivers whatever bytes are ready, so a read can
130
+ * end inside a multi-byte character: decoded on its own, each half became U+FFFD. This one
131
+ * holds the incomplete bytes until the rest arrive. One per stream, never shared. */
132
+ export function utf8Chunks(): (chunk: Buffer | string) => string {
133
+ const decoder = new StringDecoder('utf8')
134
+ return (chunk) => (typeof chunk === 'string' ? chunk : decoder.write(chunk))
135
+ }
136
+
128
137
  /** The pi child, or the error spawning it threw synchronously. Node normally
129
138
  * reports a spawn failure through the child's async 'error' event, but some
130
139
  * failures (confirmed on Linux: posix_spawn detects E2BIG immediately) throw at
@@ -328,8 +337,10 @@ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResu
328
337
  if (onAbort && signal) signal.removeEventListener('abort', onAbort)
329
338
  }
330
339
 
340
+ const decodeStdout = utf8Chunks()
341
+ const decodeStderr = utf8Chunks()
331
342
  proc.stdout.on('data', (data) => {
332
- buffer += data.toString()
343
+ buffer += decodeStdout(data)
333
344
  const lines = buffer.split('\n')
334
345
  buffer = lines.pop() || ''
335
346
  for (const line of lines) processLine(line)
@@ -337,7 +348,7 @@ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResu
337
348
  proc.stdout.on('error', () => {})
338
349
 
339
350
  proc.stderr.on('data', (data) => {
340
- currentResult.stderr += data.toString()
351
+ currentResult.stderr += decodeStderr(data)
341
352
  })
342
353
  proc.stderr.on('error', () => {})
343
354
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.69",
3
+ "version": "1.0.70",
4
4
  "description": "Claude Code experience for the pi coding agent: reads your .claude config (rules, commands, skills, hooks, output styles, MCP servers, agents) and adds todo, checkpoints, memory, web, subagents, and goals",
5
5
  "keywords": [
6
6
  "pi",