pi-code 1.0.35 → 1.0.36
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.
|
@@ -101,6 +101,7 @@ import { isPlanModeState, PLAN_MODE_CHANNEL } from '../internal/plan-mode-state.
|
|
|
101
101
|
import { installedPlugins } from '../internal/plugins.js'
|
|
102
102
|
import { isProjectApproved } from '../internal/project-approval.js'
|
|
103
103
|
import { repoRoot } from '../internal/project-root.js'
|
|
104
|
+
import { watchSettingsFiles } from '../internal/settings-watch.js'
|
|
104
105
|
import { isSkillHooksEvent, SKILL_HOOKS_CHANNEL } from '../internal/skill-hooks.js'
|
|
105
106
|
import { isSubagentPhaseEvent, SUBAGENT_CHANNEL } from '../internal/subagent-events.js'
|
|
106
107
|
import { setSubagentStartHookRunner } from '../internal/subagent-hooks.js'
|
|
@@ -207,6 +208,12 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
207
208
|
/** Set inside a subagent child that carries agent-frontmatter hooks: the child's
|
|
208
209
|
* own agent end fires their SubagentStop, per Claude's Stop conversion. */
|
|
209
210
|
let agentIdentity: { agent: string; id?: string } | undefined
|
|
211
|
+
/** Claude's allowManagedHooksOnly: only the managed hook set runs. */
|
|
212
|
+
let managedHooksOnly = false
|
|
213
|
+
/** Skill hooks registered this session, re-applied when a settings edit reloads. */
|
|
214
|
+
const registeredSkillHooks: Array<{ skillName: string; hooks: Record<string, unknown> }> = []
|
|
215
|
+
/** Stops the settings watcher of the previous session. */
|
|
216
|
+
let disposeSettingsWatch: () => void = () => {}
|
|
210
217
|
/** Claude's disableAllHooks escape hatch was set somewhere in the honored chain. */
|
|
211
218
|
let hooksDisabled = false
|
|
212
219
|
/** Which settings file each resolved entry came from, for the /hooks viewer. */
|
|
@@ -300,7 +307,10 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
300
307
|
// Claude documents; a session restart reloads config and drops them.
|
|
301
308
|
pi.events.on(SKILL_HOOKS_CHANNEL, (data) => {
|
|
302
309
|
if (!isSkillHooksEvent(data)) return
|
|
303
|
-
|
|
310
|
+
// Blocked under the escape hatch and under allowManagedHooksOnly, which
|
|
311
|
+
// covers every non-managed hook source.
|
|
312
|
+
if (hooksDisabled || managedHooksOnly) return
|
|
313
|
+
registeredSkillHooks.push({ skillName: data.skillName, hooks: data.hooks })
|
|
304
314
|
mergeSkillHooks(config, data.skillName, data.hooks, hookSources)
|
|
305
315
|
})
|
|
306
316
|
|
|
@@ -376,20 +386,11 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
376
386
|
return results.map((result) => promptContext(result.stdout)).filter(Boolean)
|
|
377
387
|
})
|
|
378
388
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
stopHookActive = false
|
|
385
|
-
stopHookBlockCount = 0
|
|
386
|
-
pendingToolContext.clear()
|
|
387
|
-
const trusted = await isProjectApproved(ctx)
|
|
388
|
-
// Claude's CLAUDE_PROJECT_DIR is the project root, not the session cwd; a hook
|
|
389
|
-
// referencing $CLAUDE_PROJECT_DIR/.claude/hooks/helper.sh must resolve from a
|
|
390
|
-
// subdirectory session too.
|
|
391
|
-
projectDir = repoRoot(ctx.cwd) ?? ctx.cwd
|
|
392
|
-
const files = hookFiles(ctx.cwd, os.homedir(), trusted)
|
|
389
|
+
/** Resolve the whole hook configuration from disk. Runs at session start and
|
|
390
|
+
* again when the settings watcher sees an edit, so mid-session changes to
|
|
391
|
+
* hooks, disableAllHooks, or allowedHttpHookUrls apply without a restart. */
|
|
392
|
+
function resolveConfig(cwd: string, trusted: boolean): void {
|
|
393
|
+
const files = hookFiles(cwd, os.homedir(), trusted)
|
|
393
394
|
hookSources.clear()
|
|
394
395
|
allowedHttpHookUrls = readAllowedHttpHookUrls(files)
|
|
395
396
|
// The disableAllHooks escape hatch, checked before any config loads. The tiers
|
|
@@ -400,15 +401,14 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
400
401
|
hooksDisabled = readDisableAllHooks(files, managedSettings)
|
|
401
402
|
if (managedSettings.disableAllHooks === true) {
|
|
402
403
|
config = {}
|
|
403
|
-
pendingSessionContext = []
|
|
404
404
|
return
|
|
405
405
|
}
|
|
406
406
|
config = loadManagedHooks(hookSources, managedSettings)
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
for (const [
|
|
407
|
+
// Claude's allowManagedHooksOnly: user, project, local, plugin, and skill
|
|
408
|
+
// hooks are blocked; only the managed set runs.
|
|
409
|
+
managedHooksOnly = managedSettings.allowManagedHooksOnly === true
|
|
410
|
+
if (managedHooksOnly || readSettingsDisableAllHooks(files)) return
|
|
411
|
+
for (const [eventName, matchers] of Object.entries(loadHooks(files, hookSources))) config[eventName] = [...(config[eventName] ?? []), ...matchers]
|
|
412
412
|
// Plugins are user-installed and enabled by user settings (see installedPlugins),
|
|
413
413
|
// so a checked-out repo cannot toggle which code-bearing plugin hooks run.
|
|
414
414
|
loadPluginHooks(config, installedPlugins(os.homedir()), hookSources)
|
|
@@ -416,6 +416,31 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
416
416
|
// env (Stop already converted to SubagentStop, per Claude); they run only for
|
|
417
417
|
// this child process.
|
|
418
418
|
agentIdentity = mergeAgentEnvHooks(config, hookSources)
|
|
419
|
+
// A reload must not drop the skill hooks the session already registered.
|
|
420
|
+
for (const skill of registeredSkillHooks) mergeSkillHooks(config, skill.skillName, skill.hooks, hookSources)
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
pi.on('session_start', async (event, ctx) => {
|
|
424
|
+
sessionCtx = ctx
|
|
425
|
+
// One extension instance serves every session. A mid-turn /new fires session_start on
|
|
426
|
+
// the same instance while a Stop-hook continuation streak is in flight; it must not
|
|
427
|
+
// carry into the next session, so reset before any early return (disableAllHooks below).
|
|
428
|
+
stopHookActive = false
|
|
429
|
+
stopHookBlockCount = 0
|
|
430
|
+
pendingToolContext.clear()
|
|
431
|
+
registeredSkillHooks.length = 0
|
|
432
|
+
const trusted = await isProjectApproved(ctx)
|
|
433
|
+
// Claude's CLAUDE_PROJECT_DIR is the project root, not the session cwd; a hook
|
|
434
|
+
// referencing $CLAUDE_PROJECT_DIR/.claude/hooks/helper.sh must resolve from a
|
|
435
|
+
// subdirectory session too.
|
|
436
|
+
projectDir = repoRoot(ctx.cwd) ?? ctx.cwd
|
|
437
|
+
resolveConfig(ctx.cwd, trusted)
|
|
438
|
+
// Claude picks up direct settings edits mid-session via a file watcher.
|
|
439
|
+
disposeSettingsWatch()
|
|
440
|
+
disposeSettingsWatch = watchSettingsFiles(hookFiles(ctx.cwd, os.homedir(), trusted), () => resolveConfig(ctx.cwd, trusted))
|
|
441
|
+
// A disabled or managed-only resolution leaves config empty (or managed-only),
|
|
442
|
+
// so the SessionStart run below fires exactly what remains active.
|
|
443
|
+
pendingSessionContext = []
|
|
419
444
|
// "reload" re-fires in-process with the same conversation and would double-run hooks;
|
|
420
445
|
// a fork is a genuine session begin, which Claude reports as source "fork".
|
|
421
446
|
if (event.reason === 'reload') return
|
|
@@ -9,16 +9,22 @@
|
|
|
9
9
|
|
|
10
10
|
import * as path from 'node:path'
|
|
11
11
|
import { claudeConfigDir } from './config-dir.js'
|
|
12
|
-
import {
|
|
12
|
+
import { repoRoot } from './project-root.js'
|
|
13
13
|
|
|
14
|
-
/** The user settings.json, then (only when `includeProject`) the
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
/** The user settings.json, then (only when `includeProject`) the project files by
|
|
15
|
+
* Claude's placement rules: the shared `.claude/settings.json` is read from the
|
|
16
|
+
* session's primary working directory (never an ancestor; "to use a file committed
|
|
17
|
+
* at the repository root, start Claude Code there"), while `settings.local.json`
|
|
18
|
+
* lives at the repository root, falling back to the primary directory outside a
|
|
19
|
+
* repository or when the root is the home directory. A legacy local file at the
|
|
20
|
+
* primary directory is still read, with the root's values winning. Later files win. */
|
|
17
21
|
export function claudeSettingsChain(cwd: string, home: string, includeProject: boolean): string[] {
|
|
18
22
|
const files = [path.join(claudeConfigDir(home), 'settings.json')]
|
|
19
23
|
if (!includeProject) return files
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
24
|
+
files.push(path.join(cwd, '.claude', 'settings.json'))
|
|
25
|
+
const root = repoRoot(cwd)
|
|
26
|
+
const localDir = root !== undefined && root !== home ? root : cwd
|
|
27
|
+
if (localDir !== cwd) files.push(path.join(cwd, '.claude', 'settings.local.json'))
|
|
28
|
+
files.push(path.join(localDir, '.claude', 'settings.local.json'))
|
|
23
29
|
return files
|
|
24
30
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mid-session settings watching, Claude's "picked up automatically by the file
|
|
3
|
+
* watcher". Polling stat watchers rather than fs.watch: editors replace files via
|
|
4
|
+
* rename, which event watchers miss on some platforms, and a missing file that
|
|
5
|
+
* appears later must start reporting too, which stat polling handles uniformly.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from 'node:fs'
|
|
9
|
+
|
|
10
|
+
/** Watch the given settings files, calling `reload` when any of them changes.
|
|
11
|
+
* Returns a dispose function. The poll interval is env-tunable for tests. */
|
|
12
|
+
export function watchSettingsFiles(files: string[], reload: () => void): () => void {
|
|
13
|
+
const interval = Number(process.env.PI_CODE_SETTINGS_WATCH_INTERVAL_MS) || 2000
|
|
14
|
+
const listeners: Array<[string, (curr: fs.Stats, prev: fs.Stats) => void]> = []
|
|
15
|
+
for (const file of files) {
|
|
16
|
+
const listener = (curr: fs.Stats, prev: fs.Stats): void => {
|
|
17
|
+
if (curr.mtimeMs !== prev.mtimeMs || curr.size !== prev.size) reload()
|
|
18
|
+
}
|
|
19
|
+
// persistent: false, so a watcher alone never keeps a one-shot run alive.
|
|
20
|
+
fs.watchFile(file, { interval, persistent: false }, listener)
|
|
21
|
+
listeners.push([file, listener])
|
|
22
|
+
}
|
|
23
|
+
return () => {
|
|
24
|
+
for (const [file, listener] of listeners) fs.unwatchFile(file, listener)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -5,10 +5,14 @@
|
|
|
5
5
|
* with the session JSON on stdin (model, workspace, cost, context_window, effort,
|
|
6
6
|
* output_style, session ids) and its first stdout line becomes the footer segment,
|
|
7
7
|
* padded per `padding`. It re-runs, debounced 300ms as Claude does, at session
|
|
8
|
-
* start, after turns, after compaction, on plan-mode
|
|
9
|
-
* analogue, off the shared bus),
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* start, after turns and each assistant message, after compaction, on plan-mode
|
|
9
|
+
* changes (the permission-mode analogue, off the shared bus), when a rate-limit
|
|
10
|
+
* window in the last payload reaches its resets_at time, when the statusLine
|
|
11
|
+
* settings change mid-session (file watcher), and on the optional
|
|
12
|
+
* `refreshInterval` timer (minimum 1s). A new trigger while the script is still
|
|
13
|
+
* running cancels the in-flight run, as Claude does. A project-defined command is
|
|
14
|
+
* arbitrary shell, so project settings count only once the project is already
|
|
15
|
+
* approved, read without prompting.
|
|
12
16
|
* Claude's `disableAllHooks` setting turns the configured command off too, and
|
|
13
17
|
* the built-in segment stands in.
|
|
14
18
|
*
|
|
@@ -29,8 +33,10 @@ import * as path from 'node:path'
|
|
|
29
33
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
30
34
|
|
|
31
35
|
import { hookFiles, readDisableAllHooks, runHookCommand } from './hooks/index.js'
|
|
36
|
+
import { readManagedSettings } from './internal/managed-settings.js'
|
|
32
37
|
import { isPlanModeState, PLAN_MODE_CHANNEL } from './internal/plan-mode-state.js'
|
|
33
38
|
import { isProjectApprovedSilently } from './internal/project-approval.js'
|
|
39
|
+
import { watchSettingsFiles } from './internal/settings-watch.js'
|
|
34
40
|
import { readActiveStyleName, settingsFiles } from './output-styles.js'
|
|
35
41
|
|
|
36
42
|
const COMMAND_TIMEOUT_MS = 5_000
|
|
@@ -149,22 +155,33 @@ export interface StatusLineConfig {
|
|
|
149
155
|
refreshInterval: number | undefined
|
|
150
156
|
}
|
|
151
157
|
|
|
152
|
-
/**
|
|
153
|
-
* `{type: "command", command, padding?, refreshInterval?}
|
|
154
|
-
*
|
|
155
|
-
|
|
158
|
+
/** One settings `statusLine` entry parsed into a config, or undefined when it is
|
|
159
|
+
* not Claude's `{type: "command", command, padding?, refreshInterval?}` shape;
|
|
160
|
+
* refreshInterval has a documented minimum of 1. */
|
|
161
|
+
function parseStatusLineEntry(entry: unknown): StatusLineConfig | undefined {
|
|
162
|
+
if (entry === null || typeof entry !== 'object') return undefined
|
|
163
|
+
const record = entry as { type?: unknown; command?: unknown; padding?: unknown; refreshInterval?: unknown }
|
|
164
|
+
if (typeof record.command !== 'string') return undefined
|
|
165
|
+
if (record.type !== undefined && record.type !== 'command') return undefined
|
|
166
|
+
return {
|
|
167
|
+
command: record.command,
|
|
168
|
+
padding: typeof record.padding === 'number' && record.padding > 0 ? record.padding : 0,
|
|
169
|
+
refreshInterval: typeof record.refreshInterval === 'number' && record.refreshInterval >= 1 ? record.refreshInterval : undefined,
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The `statusLine` recorded in settings, last file winning; a managed policy
|
|
174
|
+
* entry wins over every file, and allowManagedHooksOnly narrows the setting to
|
|
175
|
+
* managed settings entirely, as Claude documents. */
|
|
176
|
+
export function readStatusLineConfig(files: string[], managed: Record<string, unknown> = readManagedSettings()): StatusLineConfig | undefined {
|
|
177
|
+
const managedConfig = parseStatusLineEntry(managed.statusLine)
|
|
178
|
+
if (managedConfig) return managedConfig
|
|
179
|
+
if (managed.allowManagedHooksOnly === true) return undefined
|
|
156
180
|
let found: StatusLineConfig | undefined
|
|
157
181
|
for (const file of files) {
|
|
158
182
|
try {
|
|
159
183
|
const settings = JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
160
|
-
|
|
161
|
-
if (!entry || typeof entry.command !== 'string') continue
|
|
162
|
-
if (entry.type !== undefined && entry.type !== 'command') continue
|
|
163
|
-
found = {
|
|
164
|
-
command: entry.command,
|
|
165
|
-
padding: typeof entry.padding === 'number' && entry.padding > 0 ? entry.padding : 0,
|
|
166
|
-
refreshInterval: typeof entry.refreshInterval === 'number' && entry.refreshInterval >= 1 ? entry.refreshInterval : undefined,
|
|
167
|
-
}
|
|
184
|
+
found = parseStatusLineEntry(settings.statusLine) ?? found
|
|
168
185
|
} catch {
|
|
169
186
|
// missing or invalid file: skip
|
|
170
187
|
}
|
|
@@ -205,6 +222,11 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
205
222
|
let rateLimitWarned = false
|
|
206
223
|
let refreshTimer: ReturnType<typeof setInterval> | undefined
|
|
207
224
|
let debounceTimer: ReturnType<typeof setTimeout> | undefined
|
|
225
|
+
let expiryTimer: ReturnType<typeof setTimeout> | undefined
|
|
226
|
+
/** Kills the script currently in flight; Claude cancels it on a new trigger. */
|
|
227
|
+
let killInflight: (() => void) | undefined
|
|
228
|
+
/** Stops the settings watcher of the previous session. */
|
|
229
|
+
let disposeSettingsWatch: () => void = () => {}
|
|
208
230
|
let running = false
|
|
209
231
|
let rerunQueued = false
|
|
210
232
|
|
|
@@ -293,6 +315,9 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
293
315
|
async function runCommand(ctx: ExtensionContext): Promise<void> {
|
|
294
316
|
if (!config) return
|
|
295
317
|
if (running) {
|
|
318
|
+
// Claude cancels the in-flight script when a new update triggers; the
|
|
319
|
+
// rerun below then runs the fresh one.
|
|
320
|
+
killInflight?.()
|
|
296
321
|
rerunQueued = true
|
|
297
322
|
return
|
|
298
323
|
}
|
|
@@ -301,7 +326,9 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
301
326
|
// Everything below can touch ctx after an await, and every ctx getter throws
|
|
302
327
|
// once the session is disposed. This promise is started from a timer with no
|
|
303
328
|
// awaiter, so an escaping rejection becomes an uncaughtException and exits pi.
|
|
304
|
-
const result = await runHookCommand(config.command, buildPayload(ctx), COMMAND_TIMEOUT_MS)
|
|
329
|
+
const result = await runHookCommand(config.command, buildPayload(ctx), COMMAND_TIMEOUT_MS, undefined, undefined, (kill) => {
|
|
330
|
+
killInflight = kill
|
|
331
|
+
})
|
|
305
332
|
const first = result.stdout.split('\n')[0].trimEnd()
|
|
306
333
|
const pad = ' '.repeat(config.padding)
|
|
307
334
|
commandLine = first ? `${pad}${first}${pad}` : undefined
|
|
@@ -310,6 +337,7 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
310
337
|
// A replaced or reloaded session invalidates ctx while the command is in
|
|
311
338
|
// flight; there is nothing left to update, and the next session starts fresh.
|
|
312
339
|
} finally {
|
|
340
|
+
killInflight = undefined
|
|
313
341
|
running = false
|
|
314
342
|
if (rerunQueued) {
|
|
315
343
|
rerunQueued = false
|
|
@@ -318,6 +346,18 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
318
346
|
}
|
|
319
347
|
}
|
|
320
348
|
|
|
349
|
+
/** Claude re-runs the script when a rate-limit window in the last data reaches
|
|
350
|
+
* its resets_at time, so an expired segment clears without another event. */
|
|
351
|
+
function scheduleExpiryRefresh(snapshot: RateLimitSnapshot): void {
|
|
352
|
+
clearTimeout(expiryTimer)
|
|
353
|
+
const resets = [snapshot.five_hour?.resets_at, snapshot.seven_day?.resets_at].filter((value): value is number => typeof value === 'number')
|
|
354
|
+
if (resets.length === 0) return
|
|
355
|
+
const delayMs = Math.min(...resets) * 1000 - Date.now()
|
|
356
|
+
if (delayMs <= 0) return
|
|
357
|
+
expiryTimer = setTimeout(() => scheduleRefresh(), delayMs)
|
|
358
|
+
expiryTimer.unref?.()
|
|
359
|
+
}
|
|
360
|
+
|
|
321
361
|
/** Claude debounces statusline updates at 300ms so rapid triggers batch. */
|
|
322
362
|
function scheduleRefresh(): void {
|
|
323
363
|
if (!config || !sessionCtx) return
|
|
@@ -361,7 +401,10 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
361
401
|
// names and presence vary, so parse only what is there and never throw.
|
|
362
402
|
const headers = normalizeHeaders(event.headers)
|
|
363
403
|
const snapshot = parseRateLimits(headers)
|
|
364
|
-
if (snapshot)
|
|
404
|
+
if (snapshot) {
|
|
405
|
+
rateLimits = snapshot
|
|
406
|
+
scheduleExpiryRefresh(snapshot)
|
|
407
|
+
}
|
|
365
408
|
if (event.status === 429 && !rateLimitWarned) {
|
|
366
409
|
rateLimitWarned = true
|
|
367
410
|
const retryAfter = headers['retry-after']
|
|
@@ -380,6 +423,8 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
380
423
|
if (!usage) return
|
|
381
424
|
lastUsage = usage
|
|
382
425
|
costTotal += usage.cost?.total ?? 0
|
|
426
|
+
// Claude re-runs the status line after each assistant message.
|
|
427
|
+
scheduleRefresh()
|
|
383
428
|
})
|
|
384
429
|
|
|
385
430
|
pi.on('session_start', async (_event, ctx) => {
|
|
@@ -415,6 +460,13 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
415
460
|
if (config?.refreshInterval) {
|
|
416
461
|
refreshTimer = setInterval(() => scheduleRefresh(), config.refreshInterval * 1000)
|
|
417
462
|
}
|
|
463
|
+
// Claude re-runs the script when the statusLine settings change mid-session; a
|
|
464
|
+
// command change re-resolves and re-runs.
|
|
465
|
+
disposeSettingsWatch()
|
|
466
|
+
disposeSettingsWatch = watchSettingsFiles(files, () => {
|
|
467
|
+
config = readDisableAllHooks(files) ? undefined : readStatusLineConfig(files)
|
|
468
|
+
scheduleRefresh()
|
|
469
|
+
})
|
|
418
470
|
show(ctx, segmentText(ctx, ctx.ui.theme.fg('dim', '○')))
|
|
419
471
|
scheduleRefresh()
|
|
420
472
|
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.36",
|
|
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, and subagents",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|