pi-code 1.0.76 → 1.1.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/extensions/commands.ts +3 -1
- package/extensions/env-settings.ts +14 -2
- package/extensions/hooks/matcher.ts +8 -2
- package/extensions/internal/managed-settings.ts +20 -3
- package/extensions/internal/plugins.ts +16 -12
- package/extensions/internal/tool-target.ts +4 -2
- package/extensions/mcp/policy.ts +14 -2
- package/extensions/memory.ts +12 -5
- package/extensions/question.ts +161 -11
- package/extensions/subagent/index.ts +6 -5
- package/package.json +1 -1
package/extensions/commands.ts
CHANGED
|
@@ -413,7 +413,9 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
413
413
|
const rules = pendingPathRules?.[event.toolName as PathRuleTool]
|
|
414
414
|
if (!rules) return
|
|
415
415
|
// pi's read/edit/write accept `file_path` as an alias for `path`; the shared reader
|
|
416
|
-
// handles both
|
|
416
|
+
// handles both and normalises the value as pi resolves it. The paired guard in
|
|
417
|
+
// hooks/matcher.ts judges the same paths against the same rules, so it normalises
|
|
418
|
+
// through the same helper rather than only aliasing the two keys.
|
|
417
419
|
const filePath = fileToolTarget(event) ?? ''
|
|
418
420
|
const anchors = { cwd: ctx.cwd, projectRoot: checkoutRoot(ctx.cwd), home: os.homedir() }
|
|
419
421
|
if (filePath && matchesPathRules(filePath, rules, anchors)) return
|
|
@@ -103,7 +103,8 @@ function userEnv(home: string): Record<string, string> {
|
|
|
103
103
|
* documented drop list: variables that choose where config and files are written
|
|
104
104
|
* (redirecting later home-scope reads and every subprocess), variables that export
|
|
105
105
|
* session content, and variables that change how the agent starts or syncs.
|
|
106
|
-
* PI_CODING_AGENT_DIR is pi's own config-dir analogue of CLAUDE_CONFIG_DIR
|
|
106
|
+
* PI_CODING_AGENT_DIR is pi's own config-dir analogue of CLAUDE_CONFIG_DIR, and the
|
|
107
|
+
* PI_CODE_ prefix covers pi-code's own control variables (see isRepoHostileEnvKey). */
|
|
107
108
|
const REPO_HOSTILE_ENV_KEYS = new Set([
|
|
108
109
|
'CLAUDE_CONFIG_DIR',
|
|
109
110
|
'CLAUDE_CODE_TMPDIR',
|
|
@@ -122,13 +123,24 @@ const REPO_HOSTILE_ENV_KEYS = new Set([
|
|
|
122
123
|
'PI_CODING_AGENT_DIR',
|
|
123
124
|
])
|
|
124
125
|
|
|
126
|
+
/** Whether a repository's settings must not set this key: Claude's documented drop list,
|
|
127
|
+
* the XDG_ family, and pi-code's own PI_CODE_ control variables. The last matter because
|
|
128
|
+
* they are read as instructions rather than data: PI_CODE_SUBAGENT makes a session believe
|
|
129
|
+
* it is a subagent child, which suppresses the USER's own SessionStart, UserPromptSubmit,
|
|
130
|
+
* Stop and SessionEnd hooks and their auto memory, and PI_CODE_AGENT_HOOKS is then parsed
|
|
131
|
+
* into hook definitions, which are shell commands. Approving a repository means running the
|
|
132
|
+
* config it ships, never silently disabling the user's own guardrails. */
|
|
133
|
+
function isRepoHostileEnvKey(key: string): boolean {
|
|
134
|
+
return REPO_HOSTILE_ENV_KEYS.has(key) || key.startsWith('XDG_') || key.startsWith('PI_CODE_')
|
|
135
|
+
}
|
|
136
|
+
|
|
125
137
|
/** Drop the keys a repository's settings must not set, warning each, as Claude
|
|
126
138
|
* documents ("Claude Code drops each one and logs a warning"). Set them in the
|
|
127
139
|
* shell, user settings, or managed settings instead. */
|
|
128
140
|
export function sanitizeProjectEnv(env: Record<string, string>, warn: (key: string) => void = (key) => console.warn(`pi-code-env: dropping ${key} from project settings env (a checked-out repository must not control it; set it in user or managed settings)`)): Record<string, string> {
|
|
129
141
|
const kept: Record<string, string> = {}
|
|
130
142
|
for (const [key, value] of Object.entries(env)) {
|
|
131
|
-
if (
|
|
143
|
+
if (isRepoHostileEnvKey(key)) warn(key)
|
|
132
144
|
else kept[key] = value
|
|
133
145
|
}
|
|
134
146
|
return kept
|
|
@@ -8,6 +8,7 @@ import { matchesBashIfFilter } from '../internal/bash-rules.js'
|
|
|
8
8
|
import { CLAUDE_TOOL_MAP } from '../internal/claude-tool-names.js'
|
|
9
9
|
import { matchesPathRules, type PathAnchors } from '../internal/path-rules.js'
|
|
10
10
|
import { agentNamesIn, matchesAgentRules, matchesDomainRules, matchesSkillRules } from '../internal/scope-rules.js'
|
|
11
|
+
import { asPiReadsIt } from '../internal/tool-target.js'
|
|
11
12
|
import { errorMessage } from '../internal/values.js'
|
|
12
13
|
import type { HookCommand, HookMatcher } from './config.js'
|
|
13
14
|
|
|
@@ -219,8 +220,13 @@ function matchesToolPattern(piName: string, input: Record<string, unknown> | nul
|
|
|
219
220
|
case 'slash_command':
|
|
220
221
|
return matchesSkillRules(str(input?.command), [pattern])
|
|
221
222
|
default: {
|
|
222
|
-
|
|
223
|
-
|
|
223
|
+
// Normalised as pi's file tools resolve it (~, a leading @, a file:// URL), because
|
|
224
|
+
// the rule side expands ~ against home too: comparing the raw string let an
|
|
225
|
+
// `if: Edit(~/.ssh/*)` guard miss the very call it names. The value is normalised
|
|
226
|
+
// rather than routed through fileToolTarget, which would drop every tool outside
|
|
227
|
+
// read/edit/write and so stop matching rules that match today.
|
|
228
|
+
const named = str(input?.path) || str(input?.file_path)
|
|
229
|
+
return named.length > 0 && matchesPathRules(asPiReadsIt(named), [pattern], anchors)
|
|
224
230
|
}
|
|
225
231
|
}
|
|
226
232
|
}
|
|
@@ -34,12 +34,29 @@ export function managedSettingsFile(): string {
|
|
|
34
34
|
return managedSettingsFileOverride ?? managedSettingsPath()
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/** Files already reported as unparsable. Managed settings are read from sixteen call
|
|
38
|
+
* sites, several of them once per turn, so one warning per broken file is the whole
|
|
39
|
+
* budget; the same warn-once shape project-approval.ts uses for its runtime notice. */
|
|
40
|
+
const warnedUnparsable = new Set<string>()
|
|
41
|
+
|
|
37
42
|
function readOneSettingsFile(file: string): Record<string, unknown> {
|
|
43
|
+
let raw: string
|
|
44
|
+
try {
|
|
45
|
+
raw = fs.readFileSync(file, 'utf-8')
|
|
46
|
+
} catch {
|
|
47
|
+
return {} // no such file: genuinely no managed policy on this machine
|
|
48
|
+
}
|
|
38
49
|
try {
|
|
39
|
-
const parsed = JSON.parse(
|
|
40
|
-
if (
|
|
50
|
+
const parsed: unknown = JSON.parse(raw)
|
|
51
|
+
if (isRecord(parsed)) return parsed
|
|
41
52
|
} catch {
|
|
42
|
-
//
|
|
53
|
+
// Present but unparsable is an administrator's typo, not an absent policy. Silence
|
|
54
|
+
// there disabled every policy the file carries (exclusion lists, MCP allow and deny,
|
|
55
|
+
// enabledPlugins, disableAllHooks) on every session, with nothing said anywhere.
|
|
56
|
+
if (!warnedUnparsable.has(file)) {
|
|
57
|
+
warnedUnparsable.add(file)
|
|
58
|
+
console.warn(`pi-code: ignoring ${file}: not valid JSON; the managed policy it carries is not applied`)
|
|
59
|
+
}
|
|
43
60
|
}
|
|
44
61
|
return {}
|
|
45
62
|
}
|
|
@@ -19,7 +19,8 @@ import * as fs from 'node:fs'
|
|
|
19
19
|
import * as path from 'node:path'
|
|
20
20
|
import { claudeConfigDir } from './config-dir.js'
|
|
21
21
|
import { readManagedSettings } from './managed-settings.js'
|
|
22
|
-
import {
|
|
22
|
+
import { statToken } from './stat-token.js'
|
|
23
|
+
import { errorMessage, isRecord } from './values.js'
|
|
23
24
|
|
|
24
25
|
export interface InstalledPlugin {
|
|
25
26
|
name: string
|
|
@@ -40,8 +41,10 @@ function readJson(file: string): Record<string, unknown> {
|
|
|
40
41
|
return {} // no such file: nothing to read, and most callers expect that
|
|
41
42
|
}
|
|
42
43
|
try {
|
|
43
|
-
const parsed = JSON.parse(raw)
|
|
44
|
-
|
|
44
|
+
const parsed: unknown = JSON.parse(raw)
|
|
45
|
+
// isRecord, not a hand-written object check: a manifest or settings file that is a
|
|
46
|
+
// JSON array is not a config object, and every caller here reads it by key.
|
|
47
|
+
return isRecord(parsed) ? parsed : {}
|
|
45
48
|
} catch (error) {
|
|
46
49
|
// A manifest that does not parse leaves the plugin with no components at all, and
|
|
47
50
|
// settings that do not parse drop the enablement or configuration they carried.
|
|
@@ -176,12 +179,13 @@ export function resetInstalledPluginsCache(): void {
|
|
|
176
179
|
pluginCache.clear()
|
|
177
180
|
}
|
|
178
181
|
|
|
179
|
-
/** mtime plus size; cheap, but blind to a same-size rewrite within one
|
|
180
|
-
*
|
|
181
|
-
|
|
182
|
+
/** mtime plus size; cheap, but blind to a same-size rewrite within one timestamp tick, so
|
|
183
|
+
* only directory-tree entries use it. The shared token is the one that owns the format
|
|
184
|
+
* (internal/stat-token.ts); it throws where this caller wants a value for a path that is
|
|
185
|
+
* simply not there, which is the catch below rather than a second copy of the format. */
|
|
186
|
+
function statTokenOrMissing(target: string): string {
|
|
182
187
|
try {
|
|
183
|
-
|
|
184
|
-
return `${stat.mtimeMs}:${stat.size}`
|
|
188
|
+
return statToken(target)
|
|
185
189
|
} catch {
|
|
186
190
|
return 'missing'
|
|
187
191
|
}
|
|
@@ -208,18 +212,18 @@ function pluginFingerprint(cacheDir: string, settingsFiles: string[], index: Map
|
|
|
208
212
|
const parts = settingsFiles.map(contentToken)
|
|
209
213
|
for (const marketplace of listDirs(cacheDir)) {
|
|
210
214
|
const marketplaceDir = path.join(cacheDir, marketplace)
|
|
211
|
-
parts.push(`${marketplace}:${
|
|
215
|
+
parts.push(`${marketplace}:${statTokenOrMissing(marketplaceDir)}`)
|
|
212
216
|
for (const pluginDir of listPluginDirs(marketplaceDir)) {
|
|
213
217
|
const pluginPath = path.join(marketplaceDir, pluginDir)
|
|
214
|
-
parts.push(`${marketplace}/${pluginDir}:${
|
|
218
|
+
parts.push(`${marketplace}/${pluginDir}:${statTokenOrMissing(pluginPath)}`)
|
|
215
219
|
const versions = listDirs(pluginPath)
|
|
216
220
|
for (const version of versions) {
|
|
217
|
-
parts.push(`${marketplace}/${pluginDir}/${version}:${
|
|
221
|
+
parts.push(`${marketplace}/${pluginDir}/${version}:${statTokenOrMissing(path.join(pluginPath, version))}`)
|
|
218
222
|
}
|
|
219
223
|
// resolvePlugin reads only the resolved version's manifest, so its stat token is
|
|
220
224
|
// what an in-place edit (no directory entry changing) must move.
|
|
221
225
|
const resolved = versionDir(pluginPath, index?.get(`${pluginDir}@${marketplace}`))
|
|
222
|
-
if (resolved) parts.push(`${marketplace}/${pluginDir}/${path.basename(resolved)}/manifest:${
|
|
226
|
+
if (resolved) parts.push(`${marketplace}/${pluginDir}/${path.basename(resolved)}/manifest:${statTokenOrMissing(path.join(resolved, '.claude-plugin', 'plugin.json'))}`)
|
|
223
227
|
}
|
|
224
228
|
}
|
|
225
229
|
return parts.join('\n')
|
|
@@ -21,8 +21,10 @@ const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g
|
|
|
21
21
|
* some models add) stripped, ~ expanded, a file:// URL decoded, unicode spaces folded.
|
|
22
22
|
* pi does this in resolveToCwd (dist/core/tools/path-utils), which the package does not
|
|
23
23
|
* export. A reader that skips it judges a different file than the one pi opens: a
|
|
24
|
-
* command scoped to `Read(*.md)` allowed `~/secret/notes.md`, read as <cwd>/~/secret.
|
|
25
|
-
|
|
24
|
+
* command scoped to `Read(*.md)` allowed `~/secret/notes.md`, read as <cwd>/~/secret.
|
|
25
|
+
* Exported for the hook `if` filter, which judges the same paths against the same rules
|
|
26
|
+
* but reaches them through tool names this module's FILE_TOOLS set does not cover. */
|
|
27
|
+
export function asPiReadsIt(target: string): string {
|
|
26
28
|
const folded = target.replace(UNICODE_SPACES, ' ')
|
|
27
29
|
const bare = folded.startsWith('@') ? folded.slice(1) : folded
|
|
28
30
|
if (bare === '~') return os.homedir()
|
package/extensions/mcp/policy.ts
CHANGED
|
@@ -8,7 +8,7 @@ import * as fs from 'node:fs'
|
|
|
8
8
|
import * as path from 'node:path'
|
|
9
9
|
import { managedSettingsFile } from '../internal/managed-settings.js'
|
|
10
10
|
import { claudeSettingsChain } from '../internal/settings-chain.js'
|
|
11
|
-
import { errorMessage, escapeRegExp } from '../internal/values.js'
|
|
11
|
+
import { errorMessage, escapeRegExp, isRecord } from '../internal/values.js'
|
|
12
12
|
import { interpolateEnv, type ServerConfig } from './config.js'
|
|
13
13
|
|
|
14
14
|
export interface ProjectServerPolicy {
|
|
@@ -29,10 +29,22 @@ export interface ProjectServerPolicy {
|
|
|
29
29
|
* disabledMcpjsonServers counts from every file, including the repo's own, and wins
|
|
30
30
|
* over consent: a repo may always restrict itself further, never less. */
|
|
31
31
|
export function projectServerPolicy(cwd: string, home: string, projectApproved: boolean): ProjectServerPolicy {
|
|
32
|
+
// Tells a missing file from an unparsable one, like mcpAllowDeny below: silence for the
|
|
33
|
+
// second silently emptied disabledMcpjsonServers, so a server the repository had
|
|
34
|
+
// explicitly disabled connected. That direction fails open, against this function's own
|
|
35
|
+
// rule that a repo may always restrict itself further, never less.
|
|
32
36
|
const read = (file: string): Record<string, unknown> => {
|
|
37
|
+
let raw: string
|
|
38
|
+
try {
|
|
39
|
+
raw = fs.readFileSync(file, 'utf-8')
|
|
40
|
+
} catch {
|
|
41
|
+
return {} // no such file: genuinely no policy
|
|
42
|
+
}
|
|
33
43
|
try {
|
|
34
|
-
|
|
44
|
+
const parsed: unknown = JSON.parse(raw)
|
|
45
|
+
return isRecord(parsed) ? parsed : {}
|
|
35
46
|
} catch {
|
|
47
|
+
console.warn(`pi-code-mcp: ignoring ${file}: not valid JSON; its MCP server consent and disable lists are not applied`)
|
|
36
48
|
return {}
|
|
37
49
|
}
|
|
38
50
|
}
|
package/extensions/memory.ts
CHANGED
|
@@ -243,18 +243,25 @@ function readMemory(dir: string, name: string): MemoryToolResult {
|
|
|
243
243
|
}
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
-
/** The delete action: remove a memory
|
|
247
|
-
* like save (single key, no deadlock). The index
|
|
248
|
-
* and
|
|
249
|
-
*
|
|
246
|
+
/** The delete action: remove a memory's index line and then its file, queued on the index
|
|
247
|
+
* like save (single key, no deadlock). The index moves first because it is what the prompt
|
|
248
|
+
* and /memory show: a failure before it is written leaves both the file and the index as
|
|
249
|
+
* they were, and a failure after it leaves an orphan file rather than an index line
|
|
250
|
+
* pointing at a file that is gone. Each outcome is reported as what actually happened. */
|
|
250
251
|
async function deleteMemory(dir: string, indexPath: string, name: string): Promise<MemoryToolResult> {
|
|
251
252
|
try {
|
|
252
253
|
return await withFileMutationQueue(indexPath, async (): Promise<MemoryToolResult> => {
|
|
253
254
|
const index = readIndex(dir)
|
|
254
|
-
fs.rmSync(path.join(dir, `${name}.md`), { force: true })
|
|
255
255
|
const remaining = removeIndexLine(index, name)
|
|
256
256
|
if (remaining) writeIndex(indexPath, remaining)
|
|
257
257
|
else fs.rmSync(indexPath, { force: true })
|
|
258
|
+
try {
|
|
259
|
+
fs.rmSync(path.join(dir, `${name}.md`), { force: true })
|
|
260
|
+
} catch (error) {
|
|
261
|
+
// Out of the index, so it is gone from every surface the user sees; saying the
|
|
262
|
+
// delete failed would be false, and silence would leave the stray file unexplained.
|
|
263
|
+
return { content: [{ type: 'text', text: `Deleted memory ${name} from the index, but its file could not be removed: ${errorMessage(error)}.` }], details: {} }
|
|
264
|
+
}
|
|
258
265
|
return { content: [{ type: 'text', text: `Deleted memory ${name}.` }], details: {} }
|
|
259
266
|
})
|
|
260
267
|
} catch (error) {
|
package/extensions/question.ts
CHANGED
|
@@ -4,8 +4,11 @@
|
|
|
4
4
|
* only), or space-toggled checkboxes when `multiSelect` is set. An optional `header`
|
|
5
5
|
* labels the question. Escape in the editor returns to options; Escape in options cancels.
|
|
6
6
|
* Multiple questions per call are not batched; ask sequentially.
|
|
7
|
+
* On the TUI each question is also offered to a remote responder over pi.events
|
|
8
|
+
* (REMOTE_QUESTION_CHANNEL); the first answer, local or remote, wins.
|
|
7
9
|
*/
|
|
8
10
|
|
|
11
|
+
import { randomUUID } from 'node:crypto'
|
|
9
12
|
import * as os from 'node:os'
|
|
10
13
|
import * as path from 'node:path'
|
|
11
14
|
import type { ExtensionAPI, ExtensionContext, Theme } from '@earendil-works/pi-coding-agent'
|
|
@@ -64,6 +67,26 @@ export interface QuestionSpec {
|
|
|
64
67
|
multiSelect?: boolean
|
|
65
68
|
}
|
|
66
69
|
|
|
70
|
+
/** In-process, question-owned offer; arbitrary custom TUI components are not sent. */
|
|
71
|
+
export const REMOTE_QUESTION_CHANNEL = 'pi-code:question:v1'
|
|
72
|
+
export type RemoteQuestionOutcome = { action: 'answer'; indices: number[] } | { action: 'text'; text: string } | { action: 'cancel' } | { action: 'pass' }
|
|
73
|
+
|
|
74
|
+
export interface RemoteQuestionOffer {
|
|
75
|
+
version: 1
|
|
76
|
+
requestId: string
|
|
77
|
+
sessionId: string
|
|
78
|
+
question: string
|
|
79
|
+
header?: string
|
|
80
|
+
options: OptionWithDesc[]
|
|
81
|
+
multiSelect: boolean
|
|
82
|
+
allowFreeText: boolean
|
|
83
|
+
signal: AbortSignal
|
|
84
|
+
/** Must be called during event emission; only one listener receives a settle handle. */
|
|
85
|
+
claim: () => ((outcome: RemoteQuestionOutcome) => boolean) | undefined
|
|
86
|
+
/** The claimed responder reports input activity to reset a configured idle timer. */
|
|
87
|
+
touch: () => boolean
|
|
88
|
+
}
|
|
89
|
+
|
|
67
90
|
/** Normalize either accepted shape into the list of questions to ask. */
|
|
68
91
|
function questionList(params: Partial<QuestionSpec> & { questions?: QuestionSpec[] }): QuestionSpec[] {
|
|
69
92
|
if (params.questions && params.questions.length > 0) return params.questions
|
|
@@ -194,19 +217,19 @@ export default function question(pi: ExtensionAPI) {
|
|
|
194
217
|
// parallel leave the first unanswerable and the run unable to finish.
|
|
195
218
|
executionMode: 'sequential',
|
|
196
219
|
|
|
197
|
-
async execute(_toolCallId, rawParams,
|
|
220
|
+
async execute(_toolCallId, rawParams, signal, _onUpdate, ctx) {
|
|
198
221
|
const specs = questionList(rawParams as Partial<QuestionSpec> & { questions?: QuestionSpec[] })
|
|
199
222
|
if (specs.length === 0) {
|
|
200
223
|
return { content: [{ type: 'text', text: 'Error: No question provided' }], details: { question: '', options: [], answer: null } as QuestionDetails }
|
|
201
224
|
}
|
|
202
|
-
if (specs.length === 1) return await askOne(specs[0], ctx)
|
|
225
|
+
if (specs.length === 1) return await askOne(specs[0], ctx, pi.events, signal)
|
|
203
226
|
|
|
204
227
|
// Several questions are asked in sequence; a cancel ends the run, since the
|
|
205
228
|
// remaining answers would be guesses about a flow the user just declined.
|
|
206
229
|
const texts: string[] = []
|
|
207
230
|
const collected: QuestionDetails[] = []
|
|
208
231
|
for (const spec of specs) {
|
|
209
|
-
const result = await askOne(spec, ctx)
|
|
232
|
+
const result = await askOne(spec, ctx, pi.events, signal)
|
|
210
233
|
const detail = result.details as QuestionDetails
|
|
211
234
|
collected.push(detail)
|
|
212
235
|
texts.push(`${spec.question}\n${result.content[0].text}`)
|
|
@@ -258,7 +281,135 @@ export default function question(pi: ExtensionAPI) {
|
|
|
258
281
|
})
|
|
259
282
|
}
|
|
260
283
|
|
|
261
|
-
|
|
284
|
+
type QuestionAnswer = Awaited<ReturnType<typeof askViaOverlay>>
|
|
285
|
+
|
|
286
|
+
/** A claimed offer raced against the open overlay: whichever answers first wins. */
|
|
287
|
+
interface RemoteQuestion {
|
|
288
|
+
/** Routes remote answers into the overlay and remote activity into its idle timer. */
|
|
289
|
+
attach: (answer: (value: QuestionAnswer) => void, touch: () => void) => void
|
|
290
|
+
/** Ends the offer; the responder's settle handle and touch are rejected from then on. */
|
|
291
|
+
close: () => void
|
|
292
|
+
}
|
|
293
|
+
type RemoteOffer = { kind: 'settled'; value: QuestionAnswer } | { kind: 'claimed'; remote: RemoteQuestion }
|
|
294
|
+
|
|
295
|
+
function validIndices(indices: unknown, count: number, multiSelect: boolean): indices is number[] {
|
|
296
|
+
if (!Array.isArray(indices) || (!multiSelect && indices.length !== 1)) return false
|
|
297
|
+
return new Set(indices).size === indices.length && indices.every((index) => Number.isInteger(index) && index >= 1 && index <= count)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** A remote outcome as the answer it stands for: null is a cancel, undefined is a
|
|
301
|
+
* pass or anything this question cannot accept, which must never become an answer. */
|
|
302
|
+
function remoteAnswer(outcome: RemoteQuestionOutcome, options: DisplayOption[], multiSelect: boolean): QuestionAnswer | undefined {
|
|
303
|
+
if (outcome === null || typeof outcome !== 'object') return undefined
|
|
304
|
+
if (outcome.action === 'cancel') return null
|
|
305
|
+
if (outcome.action === 'text') {
|
|
306
|
+
const text = typeof outcome.text === 'string' ? outcome.text.trim() : ''
|
|
307
|
+
return !multiSelect && text ? { answer: text, wasCustom: true } : undefined
|
|
308
|
+
}
|
|
309
|
+
if (outcome.action !== 'answer') return undefined
|
|
310
|
+
// Read once and copied, so a getter or Proxy cannot answer with other indices
|
|
311
|
+
// than the ones that passed validation.
|
|
312
|
+
const raw: unknown = outcome.indices
|
|
313
|
+
const indices: unknown = Array.isArray(raw) ? [...raw] : undefined
|
|
314
|
+
if (!validIndices(indices, options.length, multiSelect)) return undefined
|
|
315
|
+
const checked = options.map((_, index) => indices.includes(index + 1))
|
|
316
|
+
return { answer: selectedLabels(options, checked), wasCustom: false, ...(multiSelect ? {} : { index: indices[0] }) }
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Undefined when nobody claims synchronously, so the overlay opens with no async gap. */
|
|
320
|
+
function offerRemoteQuestion(params: QuestionSpec, allOptions: DisplayOption[], ctx: ExtensionContext, events: ExtensionAPI['events'] | undefined, signal: AbortSignal | undefined): RemoteOffer | undefined {
|
|
321
|
+
if (!events) return undefined
|
|
322
|
+
if (signal?.aborted) return { kind: 'settled', value: null }
|
|
323
|
+
|
|
324
|
+
const multiSelect = params.multiSelect === true
|
|
325
|
+
const controller = new AbortController()
|
|
326
|
+
let claimed = false
|
|
327
|
+
let early: { value: QuestionAnswer } | undefined
|
|
328
|
+
let deliver: ((value: QuestionAnswer) => void) | undefined
|
|
329
|
+
let touchOverlay: (() => void) | undefined
|
|
330
|
+
const close = (): void => {
|
|
331
|
+
controller.abort()
|
|
332
|
+
signal?.removeEventListener('abort', abort)
|
|
333
|
+
}
|
|
334
|
+
const settle = (value: QuestionAnswer): void => {
|
|
335
|
+
close()
|
|
336
|
+
if (deliver) deliver(value)
|
|
337
|
+
else early = { value }
|
|
338
|
+
}
|
|
339
|
+
// Pi aborts the turn before a session replacement invalidates ctx, so this is
|
|
340
|
+
// also the session-replacement path; ctx is never read after emission.
|
|
341
|
+
const abort = (): void => settle(null)
|
|
342
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
343
|
+
|
|
344
|
+
const offer: RemoteQuestionOffer = {
|
|
345
|
+
version: 1,
|
|
346
|
+
requestId: randomUUID(),
|
|
347
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
348
|
+
question: params.question,
|
|
349
|
+
header: shortHeader(params.header),
|
|
350
|
+
options: params.options.map(({ label, description }) => ({ label, ...(description === undefined ? {} : { description }) })),
|
|
351
|
+
multiSelect,
|
|
352
|
+
allowFreeText: allOptions.some((option) => option.isOther === true),
|
|
353
|
+
signal: controller.signal,
|
|
354
|
+
claim: () => {
|
|
355
|
+
if (claimed || controller.signal.aborted) return undefined
|
|
356
|
+
claimed = true
|
|
357
|
+
return (outcome) => {
|
|
358
|
+
if (controller.signal.aborted) return false
|
|
359
|
+
const value = remoteAnswer(outcome, params.options, multiSelect)
|
|
360
|
+
if (value !== undefined) {
|
|
361
|
+
settle(value)
|
|
362
|
+
return true
|
|
363
|
+
}
|
|
364
|
+
// A pass or invalid reply only withdraws the remote side; the overlay stays
|
|
365
|
+
// open, and a turn abort must still cancel it.
|
|
366
|
+
controller.abort()
|
|
367
|
+
return outcome?.action === 'pass'
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
touch: () => {
|
|
371
|
+
if (!claimed || controller.signal.aborted) return false
|
|
372
|
+
touchOverlay?.()
|
|
373
|
+
return true
|
|
374
|
+
},
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
events.emit(REMOTE_QUESTION_CHANNEL, offer)
|
|
378
|
+
if (early) return { kind: 'settled', value: early.value }
|
|
379
|
+
if (!claimed || controller.signal.aborted) {
|
|
380
|
+
close()
|
|
381
|
+
return undefined
|
|
382
|
+
}
|
|
383
|
+
return {
|
|
384
|
+
kind: 'claimed',
|
|
385
|
+
remote: {
|
|
386
|
+
attach: (answer, touch) => {
|
|
387
|
+
deliver = answer
|
|
388
|
+
touchOverlay = touch
|
|
389
|
+
},
|
|
390
|
+
close,
|
|
391
|
+
},
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** ui.custom() is terminal-only: with a UI but no terminal (RPC mode) it resolves
|
|
396
|
+
* undefined immediately, which would read as a cancel without ever asking. Ask
|
|
397
|
+
* through the dialog primitives there instead. askUserQuestionTimeout is a TUI
|
|
398
|
+
* concept (a countdown, a keypress resetting it): the dialog-primitive fallback
|
|
399
|
+
* has no keyboard or visible countdown to drive it, so it is not applied there. */
|
|
400
|
+
async function collectAnswer(params: QuestionSpec, ctx: ExtensionContext, allOptions: DisplayOption[], multiSelect: boolean, events?: ExtensionAPI['events'], signal?: AbortSignal): Promise<QuestionAnswer> {
|
|
401
|
+
if (ctx.mode !== 'tui') return askViaDialogs(params, ctx, allOptions, multiSelect)
|
|
402
|
+
const offer = offerRemoteQuestion(params, allOptions, ctx, events, signal)
|
|
403
|
+
if (offer?.kind === 'settled') return offer.value
|
|
404
|
+
const remote = offer?.remote
|
|
405
|
+
try {
|
|
406
|
+
return await askViaOverlay(params, ctx, allOptions, multiSelect, askUserQuestionTimeoutMs(), remote)
|
|
407
|
+
} finally {
|
|
408
|
+
remote?.close()
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function askOne(params: QuestionSpec, ctx: ExtensionContext, events?: ExtensionAPI['events'], signal?: AbortSignal): Promise<{ content: Array<{ type: 'text'; text: string }>; details: QuestionDetails }> {
|
|
262
413
|
if (!ctx.hasUI) {
|
|
263
414
|
return {
|
|
264
415
|
content: [{ type: 'text', text: 'Error: UI not available (running in non-interactive mode)' }],
|
|
@@ -281,12 +432,7 @@ async function askOne(params: QuestionSpec, ctx: ExtensionContext): Promise<{ co
|
|
|
281
432
|
// The free-text option does not compose with checkbox selection, so it is single-select only.
|
|
282
433
|
const allOptions: DisplayOption[] = multiSelect ? [...params.options] : [...params.options, { label: 'Type something.', isOther: true }]
|
|
283
434
|
|
|
284
|
-
|
|
285
|
-
// undefined immediately, which would read as a cancel without ever asking. Ask
|
|
286
|
-
// through the dialog primitives there instead. askUserQuestionTimeout is a TUI
|
|
287
|
-
// concept (a countdown, a keypress resetting it): the dialog-primitive fallback
|
|
288
|
-
// has no keyboard or visible countdown to drive it, so it is not applied there.
|
|
289
|
-
const result = ctx.mode === 'tui' ? await askViaOverlay(params, ctx, allOptions, multiSelect, askUserQuestionTimeoutMs()) : await askViaDialogs(params, ctx, allOptions, multiSelect)
|
|
435
|
+
const result = await collectAnswer(params, ctx, allOptions, multiSelect, events, signal)
|
|
290
436
|
|
|
291
437
|
// Build simple options list for details; header/multiSelect appear only when set,
|
|
292
438
|
// so single-select details are unchanged.
|
|
@@ -345,7 +491,7 @@ const IDLE_TICK_MS = 250
|
|
|
345
491
|
* mechanics are testable directly, independent of where timeoutMs itself is read
|
|
346
492
|
* from (askUserQuestionTimeoutMs, tested separately).
|
|
347
493
|
*/
|
|
348
|
-
export function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOptions: DisplayOption[], multiSelect: boolean, timeoutMs?: number): Promise<{ answer: string; wasCustom: boolean; index?: number; timedOut?: boolean } | null> {
|
|
494
|
+
export function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOptions: DisplayOption[], multiSelect: boolean, timeoutMs?: number, remote?: RemoteQuestion): Promise<{ answer: string; wasCustom: boolean; index?: number; timedOut?: boolean } | null> {
|
|
349
495
|
return ctx.ui.custom<{ answer: string; wasCustom: boolean; index?: number; timedOut?: boolean } | null>((tui: Parameters<Parameters<ExtensionContext['ui']['custom']>[0]>[0], theme: Theme, _kb: unknown, done: (value: { answer: string; wasCustom: boolean; index?: number; timedOut?: boolean } | null) => void) => {
|
|
350
496
|
let optionIndex = 0
|
|
351
497
|
let editMode = false
|
|
@@ -368,6 +514,8 @@ export function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOp
|
|
|
368
514
|
* here, so the interval can never outlive the overlay it belongs to. */
|
|
369
515
|
function finish(value: { answer: string; wasCustom: boolean; index?: number; timedOut?: boolean } | null): void {
|
|
370
516
|
stopIdleTimer()
|
|
517
|
+
// Closed before done, so a remote reply racing a local answer is rejected.
|
|
518
|
+
remote?.close()
|
|
371
519
|
done(value)
|
|
372
520
|
}
|
|
373
521
|
|
|
@@ -376,6 +524,8 @@ export function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOp
|
|
|
376
524
|
deadline = Date.now() + timeoutMs
|
|
377
525
|
}
|
|
378
526
|
|
|
527
|
+
remote?.attach(finish, resetIdleTimer)
|
|
528
|
+
|
|
379
529
|
function fireTimeout(): void {
|
|
380
530
|
// Claude: "submits any options you'd already selected". Single-select has
|
|
381
531
|
// nothing pre-committed (a selection only exists once Enter confirms it), so
|
|
@@ -250,11 +250,12 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
250
250
|
if (params.tasks?.length) return runParallelMode(params.tasks, mode)
|
|
251
251
|
if (params.agent && params.task) return runSingleMode(params.agent, params.task, params.cwd, mode)
|
|
252
252
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
253
|
+
// Unreachable: the modeCount guard above returns unless exactly one of these three
|
|
254
|
+
// is set, and nothing between it and here touches params. It was a second copy of
|
|
255
|
+
// that guard's message, which no input could ever produce, so a reader had to work
|
|
256
|
+
// out for themselves that it was dead. Stated as the invariant it actually is, so a
|
|
257
|
+
// future edit that breaks it says so instead of printing a confusing refusal.
|
|
258
|
+
throw new Error('subagent: exactly one mode must be set here; the mode guard should have returned')
|
|
258
259
|
},
|
|
259
260
|
|
|
260
261
|
renderCall(args, theme, _context) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
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",
|