@phyzess/pappra 0.1.0-beta.11 → 0.1.0-beta.13

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 (37) hide show
  1. package/README.md +8 -2
  2. package/assets/integrations/skills/pappra/references/operations.md +5 -0
  3. package/dist/app/collab-from.d.ts +35 -0
  4. package/dist/app/collab-from.js +84 -0
  5. package/dist/app/host-mcp.d.ts +25 -0
  6. package/dist/app/host-mcp.js +227 -0
  7. package/dist/app/host-probe.d.ts +16 -0
  8. package/dist/app/host-probe.js +125 -0
  9. package/dist/app/init.d.ts +15 -0
  10. package/dist/app/init.js +82 -3
  11. package/dist/app/onboard.d.ts +60 -0
  12. package/dist/app/onboard.js +259 -0
  13. package/dist/app/task-pin.d.ts +23 -0
  14. package/dist/app/task-pin.js +113 -0
  15. package/dist/cli/commands/docs.js +12 -76
  16. package/dist/cli/commands/host.d.ts +3 -1
  17. package/dist/cli/commands/host.js +55 -1
  18. package/dist/cli/commands/init.d.ts +0 -8
  19. package/dist/cli/commands/init.js +5 -126
  20. package/dist/cli/commands/mcp.d.ts +8 -0
  21. package/dist/cli/commands/mcp.js +13 -0
  22. package/dist/cli/commands/onboard.d.ts +14 -0
  23. package/dist/cli/commands/onboard.js +225 -0
  24. package/dist/cli/commands/task.d.ts +10 -0
  25. package/dist/cli/commands/task.js +76 -0
  26. package/dist/cli/run.js +206 -7
  27. package/dist/hosts/integration/hook.js +4 -1
  28. package/dist/hosts/integration/pappra-extension-runtime.mjs +50 -8
  29. package/dist/hosts/integration/pappra-hook.mjs +50 -8
  30. package/dist/knowledge/scan.d.ts +30 -0
  31. package/dist/knowledge/scan.js +91 -0
  32. package/dist/mcp/server.d.ts +11 -0
  33. package/dist/mcp/server.js +518 -0
  34. package/dist/schemas/workspace.d.ts +6 -0
  35. package/dist/schemas/workspace.js +4 -0
  36. package/package.json +2 -1
  37. package/skills/pappra/references/operations.md +5 -0
package/README.md CHANGED
@@ -8,7 +8,7 @@ Pappra is the manager. External coding hosts, code-graph engines and collaborati
8
8
 
9
9
  Requirements: Node.js 24 or later. `pnpm` 12.3.4 is required only for development inside this repository; Git is required only when a command observes repository identity.
10
10
 
11
- The package is published as `@phyzess/pappra`. Run it with `npx` from the target workspace, or install it globally when you use it every day.
11
+ The package is published as `@phyzess/pappra`. Install it once for daily use (`npm install -g @phyzess/pappra@beta`, the command is `pappra`), or run it with `npx` when you want a zero-install, version-pinned invocation.
12
12
 
13
13
  ```sh
14
14
  # In an empty target workspace
@@ -38,6 +38,11 @@ that action pending instead; the result is then `ok:true` with
38
38
  `status:"external-actions-pending"` and a nonzero exit, which is a partial configuration
39
39
  write rather than a claim that a runtime exists.
40
40
 
41
+ For a new workspace or a new machine, `pappra onboard --dry-run --json` shows the plan and
42
+ the host version matrix; `pappra onboard --yes --apply-host --json` applies config, runtime
43
+ and verified host wiring (host trust still needs one approval). See
44
+ [portability](docs/portability.md).
45
+
41
46
  The runtime copy can be managed directly:
42
47
 
43
48
  ```sh
@@ -66,6 +71,7 @@ They are not needed for the core workflow above.
66
71
  - **Continuity:** private task checkpoints and bounded advisory resume packets. Resume never grants execution by itself.
67
72
  - **Code intelligence:** status, explicit preparation and bounded query for selected repositories. Text/structure fallback is available when the optional graph engine is absent. Registrations are stored under the workspace `.pappra/local/code`, never inside a repository checkout, so a multi-repository workspace does not turn its checkouts into nested workspaces.
68
73
  - **Host integrations:** optional Pi, Codex and Claude Code adapters that merge Pappra-owned entries without taking over user configuration. Hooks are copied as a dependency-free bundle; Claude Code uses `${CLAUDE_PROJECT_DIR}` and Pi imports its runtime relatively, while Codex 0.163.0 loads hooks only from `$CODEX_HOME/hooks.json` (`host install --host codex --scope user`). `host bundle --marketplace` exports a marketplace that Claude Code and Codex accept without registering it. Plugin hooks are verified for Pi 1.3.0 and Claude Code 2.1.292 (startup) and unsupported for Codex, so `host install --mode plugin` fails closed with the per-host registration commands. See [host plugin validation](docs/host-plugin-validation.md).
74
+ - **Agent-native tools:** `pappra mcp` exposes read-only and gated write tools over stdio MCP; `pappra host mcp install` registers it for Claude Code or Pi, and Codex uses `codex mcp add`. `pappra task pin <id> --auto-resume` lets hooks inject a bounded resume packet. See [agent-native usage](docs/agent-native.md).
69
75
  - **Bounded collaboration:** an optional, explicitly controlled one-request workflow. It is not a scheduler or daemon.
70
76
 
71
77
  ## Optional integrations and support
@@ -89,7 +95,7 @@ The check runs the source policy, type checks, formatting, deterministic tests a
89
95
 
90
96
  ## Status and limits
91
97
 
92
- Pappra is development software (`0.1.0-beta.11`) published as `@phyzess/pappra` under the `beta` dist-tag. Releases are published from GitHub Actions with npm trusted publishing (OIDC), so no npm token is stored in this repository. Pin either the moving `beta` tag or an exact version such as `@phyzess/pappra@0.1.0-beta.11`, because pre-release versions are never republished.
98
+ Pappra is development software (`0.1.0-beta.13`) published as `@phyzess/pappra` under the `beta` dist-tag. Releases are published from GitHub Actions with npm trusted publishing (OIDC), so no npm token is stored in this repository. Pin either the moving `beta` tag or an exact version such as `@phyzess/pappra@0.1.0-beta.13`, because pre-release versions are never republished.
93
99
 
94
100
  The deterministic suite covers synthetic workspaces, fixtures and local process boundaries. It does not establish real-model behavior, native host callability on every platform, CodeGraph completeness, Windows/Linux parity or production performance. A host adapter version pin is an observed support claim, not proof that a particular native installation will load or run correctly.
95
101
 
@@ -1,5 +1,8 @@
1
1
  # Operations reference
2
2
 
3
+ - `pappra onboard` plans or applies a whole workspace setup (config, durable runtime,
4
+ verified host wiring). `--dry-run` writes nothing; `--apply-host` still leaves host trust
5
+ to the person.
3
6
  - `pappra init` plans or applies workspace configuration. `--yes` derives
4
7
  selections from discovery, `--print-choices` prints that document without
5
8
  writing, `--reconfigure` rebuilds an existing valid `workspace.json`, and
@@ -18,6 +21,8 @@
18
21
  host. Codex 0.163.0 loads hooks only from `$CODEX_HOME/hooks.json`, so use
19
22
  `install --host codex --scope user`; `bundle --marketplace` exports a plugin
20
23
  directory without registering it, and `install --mode plugin` fails closed.
24
+ - `pappra mcp` starts the stdio MCP server; `pappra host mcp install --host <name> --apply`
25
+ registers it. `pappra task pin <id> --auto-resume` controls hook resume injection.
21
26
  - `pappra collab <prepare|run|query>` runs one bounded collaboration request.
22
27
  `run` requires `--controller fixture|pi-rpc`; `pi-rpc` launches a real Pi RPC
23
28
  session.
@@ -0,0 +1,35 @@
1
+ import type { HostName } from '../schemas/host.js';
2
+ import type { Diagnostic } from '../schemas/diagnostics.js';
3
+ import { type PreparedBundle } from './collaboration.js';
4
+ export interface CollabResource {
5
+ readonly path: string;
6
+ readonly access: 'read' | 'write';
7
+ }
8
+ export interface PrepareFromObjectiveInput {
9
+ readonly domainRoot: string;
10
+ readonly objective: string;
11
+ readonly taskId: string;
12
+ readonly subtaskId?: string;
13
+ readonly attempt?: number;
14
+ readonly phase?: 'discovery' | 'implementation' | 'review' | 'handoff' | 'fix';
15
+ readonly acceptance: readonly string[];
16
+ readonly prompt?: string;
17
+ readonly coordinatorSession: string;
18
+ readonly assigneeSession: string;
19
+ readonly harness?: HostName;
20
+ readonly resources: readonly CollabResource[];
21
+ }
22
+ export type PrepareFromObjectiveResult = {
23
+ readonly ok: true;
24
+ readonly prepared: PreparedBundle['prepared'];
25
+ readonly goal: PreparedBundle['goal'];
26
+ readonly cardPath: string;
27
+ readonly card: Record<string, unknown>;
28
+ readonly notes: readonly string[];
29
+ } | {
30
+ readonly ok: false;
31
+ readonly code: string;
32
+ readonly message: string;
33
+ readonly diagnostics: readonly Diagnostic[];
34
+ };
35
+ export declare function prepareFromObjective(input: PrepareFromObjectiveInput): Promise<PrepareFromObjectiveResult>;
@@ -0,0 +1,84 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { prepareCollaboration } from './collaboration.js';
4
+ export async function prepareFromObjective(input) {
5
+ const harness = input.harness ?? 'pi';
6
+ if (input.resources.length === 0) {
7
+ return {
8
+ ok: false,
9
+ code: 'collab_resource_required',
10
+ message: 'A collaboration goal needs at least one explicit resource (file or directory).',
11
+ diagnostics: [],
12
+ };
13
+ }
14
+ const goal = {
15
+ schema: 'pappra/collaboration-goal/1',
16
+ task_id: input.taskId,
17
+ subtask_id: input.subtaskId ?? 'work',
18
+ attempt: input.attempt ?? 1,
19
+ phase: input.phase ?? 'implementation',
20
+ objective: input.objective,
21
+ acceptance: [...input.acceptance],
22
+ prompt: input.prompt ?? input.objective,
23
+ coordinator: {
24
+ agent_id: 'coordinator',
25
+ session_id: input.coordinatorSession,
26
+ harness: 'codex',
27
+ owner_epoch: 1,
28
+ },
29
+ assignee: {
30
+ agent_id: 'worker',
31
+ session_id: input.assigneeSession,
32
+ harness,
33
+ surface: 'rpc',
34
+ configuration_id: 'daily',
35
+ cwd: input.resources[0].path,
36
+ },
37
+ resources: input.resources.map((resource) => ({
38
+ path: resource.path,
39
+ access: resource.access,
40
+ })),
41
+ required: { skills: [], tools: [] },
42
+ preferred: { skills: [], tools: [] },
43
+ };
44
+ const prepared = await prepareCollaboration(goal, {
45
+ domainRoot: input.domainRoot,
46
+ });
47
+ if (!prepared.ok) {
48
+ return {
49
+ ok: false,
50
+ code: prepared.code,
51
+ message: prepared.message,
52
+ diagnostics: prepared.diagnostics ?? [],
53
+ };
54
+ }
55
+ const card = {
56
+ schema: 'harness-collab/capabilities/1',
57
+ agent_id: goal.assignee.agent_id,
58
+ session_id: goal.assignee.session_id,
59
+ harness: goal.assignee.harness,
60
+ surface: goal.assignee.surface,
61
+ configuration_id: goal.assignee.configuration_id,
62
+ cwd: goal.assignee.cwd,
63
+ observed_at: new Date().toISOString(),
64
+ evidence_level: 'declared',
65
+ skills: [],
66
+ tools: [],
67
+ };
68
+ const cardPath = join(prepared.value.prepared.prepared_dir, 'card.declared.json');
69
+ await mkdir(prepared.value.prepared.prepared_dir, { recursive: true });
70
+ await writeFile(cardPath, `${JSON.stringify(card, null, 2)}\n`, {
71
+ mode: 0o600,
72
+ });
73
+ return {
74
+ ok: true,
75
+ prepared: prepared.value.prepared,
76
+ goal: prepared.value.goal,
77
+ cardPath,
78
+ card,
79
+ notes: [
80
+ 'The generated capability card is declared, not runtime evidence: a work launch will refuse it.',
81
+ 'For a real launch, provide a runtime capability card observed from an admitted session.',
82
+ ],
83
+ };
84
+ }
@@ -0,0 +1,25 @@
1
+ import type { HostName } from '../schemas/host.js';
2
+ declare const MCP_ENTRY: {
3
+ readonly command: 'pappra';
4
+ readonly args: readonly ['mcp'];
5
+ };
6
+ export type HostMcpState = 'create' | 'unchanged' | 'conflict' | 'manual';
7
+ export interface HostMcpPlan {
8
+ readonly host: HostName;
9
+ readonly target: string | null;
10
+ readonly state: HostMcpState;
11
+ readonly entry: typeof MCP_ENTRY;
12
+ readonly command: readonly string[] | null;
13
+ readonly message: string;
14
+ }
15
+ export interface HostMcpResult extends HostMcpPlan {
16
+ readonly applied: boolean;
17
+ readonly verified: boolean;
18
+ readonly detail: string | null;
19
+ }
20
+ export declare function planHostMcp(host: HostName, root: string): Promise<HostMcpPlan>;
21
+ export declare function applyHostMcp(plan: HostMcpPlan, options: {
22
+ readonly force: boolean;
23
+ }): Promise<HostMcpResult>;
24
+ export declare function verifyHostMcp(plan: HostMcpPlan): Promise<HostMcpResult>;
25
+ export {};
@@ -0,0 +1,227 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { dirname } from 'node:path';
4
+ const MCP_ENTRY = { command: 'pappra', args: ['mcp'] };
5
+ function mcpConfigPath(host, root) {
6
+ if (host === 'claude')
7
+ return `${root}/.mcp.json`;
8
+ if (host === 'pi')
9
+ return `${root}/.pi/mcp.json`;
10
+ return null;
11
+ }
12
+ function sameEntry(value) {
13
+ if (typeof value !== 'object' || value === null)
14
+ return false;
15
+ const record = value;
16
+ const args = record['args'];
17
+ return (record['command'] === MCP_ENTRY.command &&
18
+ Array.isArray(args) &&
19
+ args.length === MCP_ENTRY.args.length &&
20
+ args.every((arg, index) => arg === MCP_ENTRY.args[index]));
21
+ }
22
+ async function readConfig(path) {
23
+ let text;
24
+ try {
25
+ text = await readFile(path, 'utf8');
26
+ }
27
+ catch (error) {
28
+ if (typeof error === 'object' &&
29
+ error !== null &&
30
+ 'code' in error &&
31
+ error.code === 'ENOENT') {
32
+ return { ok: true, value: {}, exists: false };
33
+ }
34
+ return {
35
+ ok: false,
36
+ message: error instanceof Error ? error.message : String(error),
37
+ };
38
+ }
39
+ try {
40
+ const value = JSON.parse(text);
41
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
42
+ return { ok: false, message: 'MCP config is not a JSON object.' };
43
+ }
44
+ return { ok: true, value: value, exists: true };
45
+ }
46
+ catch (error) {
47
+ return {
48
+ ok: false,
49
+ message: `MCP config is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
50
+ };
51
+ }
52
+ }
53
+ export async function planHostMcp(host, root) {
54
+ if (host === 'codex') {
55
+ return {
56
+ host,
57
+ target: null,
58
+ state: 'manual',
59
+ entry: MCP_ENTRY,
60
+ command: ['codex', 'mcp', 'add', 'pappra', '--', 'pappra', 'mcp'],
61
+ message: 'Codex MCP servers are user-scoped; pappra prints the host command instead of merging TOML.',
62
+ };
63
+ }
64
+ const target = mcpConfigPath(host, root);
65
+ if (target === null) {
66
+ return {
67
+ host,
68
+ target: null,
69
+ state: 'manual',
70
+ entry: MCP_ENTRY,
71
+ command: null,
72
+ message: `No MCP registration is defined for ${host}.`,
73
+ };
74
+ }
75
+ const config = await readConfig(target);
76
+ if (!config.ok) {
77
+ return {
78
+ host,
79
+ target,
80
+ state: 'conflict',
81
+ entry: MCP_ENTRY,
82
+ command: null,
83
+ message: config.message,
84
+ };
85
+ }
86
+ const servers = config.value['mcpServers'];
87
+ if (servers !== undefined &&
88
+ (typeof servers !== 'object' || servers === null || Array.isArray(servers))) {
89
+ return {
90
+ host,
91
+ target,
92
+ state: 'conflict',
93
+ entry: MCP_ENTRY,
94
+ command: null,
95
+ message: 'mcpServers must be a JSON object.',
96
+ };
97
+ }
98
+ const existing = typeof servers === 'object' && servers !== null
99
+ ? servers['pappra']
100
+ : undefined;
101
+ const state = existing === undefined
102
+ ? 'create'
103
+ : sameEntry(existing)
104
+ ? 'unchanged'
105
+ : 'conflict';
106
+ return {
107
+ host,
108
+ target,
109
+ state,
110
+ entry: MCP_ENTRY,
111
+ command: null,
112
+ message: state === 'create'
113
+ ? `Register pappra in ${target}.`
114
+ : state === 'unchanged'
115
+ ? 'pappra is already registered.'
116
+ : 'A different pappra MCP entry exists; use --force to replace it.',
117
+ };
118
+ }
119
+ export async function applyHostMcp(plan, options) {
120
+ if (plan.state === 'manual') {
121
+ if (plan.host === 'codex' && plan.command !== null) {
122
+ const result = spawnSync(plan.command[0], plan.command.slice(1), {
123
+ encoding: 'utf8',
124
+ shell: false,
125
+ timeout: 60_000,
126
+ });
127
+ const verified = await verifyHostMcp(plan);
128
+ return {
129
+ ...plan,
130
+ applied: result.status === 0,
131
+ verified: verified.verified,
132
+ detail: result.status === 0
133
+ ? null
134
+ : `${result.stdout ?? ''}${result.stderr ?? ''}`
135
+ .trim()
136
+ .slice(0, 1000),
137
+ };
138
+ }
139
+ return {
140
+ ...plan,
141
+ applied: false,
142
+ verified: false,
143
+ detail: plan.message,
144
+ };
145
+ }
146
+ if (plan.state === 'conflict' && !options.force) {
147
+ return { ...plan, applied: false, verified: false, detail: plan.message };
148
+ }
149
+ const target = plan.target;
150
+ if (target === null) {
151
+ return { ...plan, applied: false, verified: false, detail: plan.message };
152
+ }
153
+ const config = await readConfig(target);
154
+ if (!config.ok) {
155
+ return { ...plan, applied: false, verified: false, detail: config.message };
156
+ }
157
+ const servers = config.value['mcpServers'];
158
+ const nextServers = {
159
+ ...(typeof servers === 'object' && servers !== null
160
+ ? servers
161
+ : {}),
162
+ pappra: MCP_ENTRY,
163
+ };
164
+ await mkdir(dirname(target), { recursive: true });
165
+ await writeFile(target, `${JSON.stringify({ ...config.value, mcpServers: nextServers }, null, 2)}\n`);
166
+ const verified = await verifyHostMcp(plan);
167
+ return {
168
+ ...plan,
169
+ state: 'unchanged',
170
+ applied: true,
171
+ verified: verified.verified,
172
+ detail: null,
173
+ };
174
+ }
175
+ export async function verifyHostMcp(plan) {
176
+ if (plan.host === 'codex') {
177
+ const listed = spawnSync('codex', ['mcp', 'list', '--json'], {
178
+ encoding: 'utf8',
179
+ shell: false,
180
+ timeout: 60_000,
181
+ });
182
+ if (listed.status !== 0) {
183
+ return {
184
+ ...plan,
185
+ applied: false,
186
+ verified: false,
187
+ detail: `${listed.stdout ?? ''}${listed.stderr ?? ''}`
188
+ .trim()
189
+ .slice(0, 1000),
190
+ };
191
+ }
192
+ try {
193
+ const value = JSON.parse(listed.stdout);
194
+ const found = Array.isArray(value) &&
195
+ value.some((item) => typeof item === 'object' &&
196
+ item !== null &&
197
+ 'name' in item &&
198
+ item.name === 'pappra');
199
+ return { ...plan, applied: false, verified: found, detail: null };
200
+ }
201
+ catch (error) {
202
+ return {
203
+ ...plan,
204
+ applied: false,
205
+ verified: false,
206
+ detail: error instanceof Error ? error.message : String(error),
207
+ };
208
+ }
209
+ }
210
+ if (plan.target === null) {
211
+ return { ...plan, applied: false, verified: false, detail: plan.message };
212
+ }
213
+ const config = await readConfig(plan.target);
214
+ if (!config.ok) {
215
+ return { ...plan, applied: false, verified: false, detail: config.message };
216
+ }
217
+ const servers = config.value['mcpServers'];
218
+ const entry = typeof servers === 'object' && servers !== null
219
+ ? servers['pappra']
220
+ : undefined;
221
+ return {
222
+ ...plan,
223
+ applied: false,
224
+ verified: sameEntry(entry),
225
+ detail: null,
226
+ };
227
+ }
@@ -0,0 +1,16 @@
1
+ import type { HostName } from '../schemas/host.js';
2
+ export interface HostProbeResult {
3
+ readonly host: HostName;
4
+ readonly command: string;
5
+ readonly version: string | null;
6
+ readonly pinnedVersion: string;
7
+ readonly protocol: 'pi-rpc' | 'version-only';
8
+ readonly ok: boolean;
9
+ readonly details: Readonly<Record<string, unknown>>;
10
+ readonly message: string;
11
+ }
12
+ /**
13
+ * Protocol-level probe. It never sends a prompt, starts a model, or writes workspace
14
+ * state; the Pi path only asks for `get_state` in a throwaway session directory.
15
+ */
16
+ export declare function probeHost(host: HostName): HostProbeResult;
@@ -0,0 +1,125 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { HOST_SUPPORT } from '../hosts/integration/support.js';
7
+ function commandVersion(command) {
8
+ const result = spawnSync(command, ['--version'], {
9
+ encoding: 'utf8',
10
+ shell: false,
11
+ timeout: 10_000,
12
+ });
13
+ if (result.status !== 0)
14
+ return null;
15
+ const match = /(\d+\.\d+\.\d+)/.exec(`${result.stdout ?? ''}\n${result.stderr ?? ''}`);
16
+ return match?.[1] ?? null;
17
+ }
18
+ /**
19
+ * Protocol-level probe. It never sends a prompt, starts a model, or writes workspace
20
+ * state; the Pi path only asks for `get_state` in a throwaway session directory.
21
+ */
22
+ export function probeHost(host) {
23
+ const pinnedVersion = HOST_SUPPORT[host].version;
24
+ const command = host;
25
+ const version = commandVersion(command);
26
+ if (version === null) {
27
+ return {
28
+ host,
29
+ command,
30
+ version: null,
31
+ pinnedVersion,
32
+ protocol: 'version-only',
33
+ ok: false,
34
+ details: {},
35
+ message: `${command} is not callable on PATH.`,
36
+ };
37
+ }
38
+ if (host !== 'pi') {
39
+ return {
40
+ host,
41
+ command,
42
+ version,
43
+ pinnedVersion,
44
+ protocol: 'version-only',
45
+ ok: version === pinnedVersion,
46
+ details: { matchesPinnedVersion: version === pinnedVersion },
47
+ message: version === pinnedVersion
48
+ ? 'Version matches the pinned support matrix; lifecycle callability still needs a real session.'
49
+ : `Version ${version} does not match the pinned ${pinnedVersion}.`,
50
+ };
51
+ }
52
+ const directory = mkdtempSync(join(tmpdir(), 'pappra-pi-probe-'));
53
+ const sessionId = randomUUID();
54
+ try {
55
+ const probe = spawnSync(command, ['--mode', 'rpc', '--session-dir', directory, '--session-id', sessionId], {
56
+ encoding: 'utf8',
57
+ shell: false,
58
+ timeout: 15_000,
59
+ input: `${JSON.stringify({ id: 'probe-1', type: 'get_state' })}\n`,
60
+ maxBuffer: 8 * 1024 * 1024,
61
+ });
62
+ const lines = `${probe.stdout ?? ''}`
63
+ .split('\n')
64
+ .map((line) => line.trim())
65
+ .filter((line) => line !== '');
66
+ let state = null;
67
+ for (const line of lines) {
68
+ try {
69
+ const value = JSON.parse(line);
70
+ if (typeof value === 'object' &&
71
+ value !== null &&
72
+ 'id' in value &&
73
+ value.id === 'probe-1' &&
74
+ 'data' in value &&
75
+ typeof value.data === 'object' &&
76
+ value.data !== null) {
77
+ state = value.data;
78
+ break;
79
+ }
80
+ }
81
+ catch {
82
+ // Non-JSON extension UI lines are expected; keep scanning.
83
+ }
84
+ }
85
+ if (state === null) {
86
+ return {
87
+ host,
88
+ command,
89
+ version,
90
+ pinnedVersion,
91
+ protocol: 'pi-rpc',
92
+ ok: false,
93
+ details: {
94
+ exitCode: probe.status,
95
+ stderr: `${probe.stderr ?? ''}`.slice(0, 500),
96
+ },
97
+ message: 'Pi RPC did not answer get_state within the probe deadline.',
98
+ };
99
+ }
100
+ const reportedSessionId = state['sessionId'];
101
+ const reportedSessionFile = state['sessionFile'];
102
+ const sessionIdMatches = reportedSessionId === sessionId;
103
+ const sessionFileReported = typeof reportedSessionFile === 'string' && reportedSessionFile !== '';
104
+ return {
105
+ host,
106
+ command,
107
+ version,
108
+ pinnedVersion,
109
+ protocol: 'pi-rpc',
110
+ ok: sessionIdMatches && sessionFileReported,
111
+ details: {
112
+ sessionIdMatches,
113
+ sessionFileReported,
114
+ isStreaming: state['isStreaming'] ?? null,
115
+ pendingMessageCount: state['pendingMessageCount'] ?? null,
116
+ },
117
+ message: sessionIdMatches && sessionFileReported
118
+ ? 'Pi RPC answered get_state with the contracted session identity; no prompt was sent.'
119
+ : 'Pi RPC answered, but the session identity or session file did not match.',
120
+ };
121
+ }
122
+ finally {
123
+ rmSync(directory, { recursive: true, force: true });
124
+ }
125
+ }
@@ -6,6 +6,7 @@ import { type WorkspaceConfig, type WorkspaceValidation } from '../schemas/works
6
6
  import type { MachineConfigInspection, WorkspaceConfigInspection } from '../workspace/config.js';
7
7
  import { type WorkspaceDiscovery } from '../workspace/discover.js';
8
8
  import { type PlannedFileContent } from '../install/plan.js';
9
+ import { type RuntimeInstallResult } from '../install/runtime.js';
9
10
  export type InitFailureStatus = 'needs_input' | 'invalid' | 'conflict' | 'unavailable';
10
11
  export interface InitFailure {
11
12
  readonly ok: false;
@@ -150,6 +151,7 @@ export interface DerivedInitChoices {
150
151
  readonly rules: readonly InitChoiceRule[];
151
152
  readonly candidates: InitChoiceCandidates;
152
153
  }
154
+ export declare function detectedHostVersion(command: string, pathEnv: string): string | null;
153
155
  /**
154
156
  * Derives deterministic init selections from discovery alone. Every value is
155
157
  * reported with the rule that produced it so a caller can audit the choice
@@ -169,3 +171,16 @@ export declare function planWorkspaceInit(options: PlanInitOptions): Promise<Ini
169
171
  * reported as pending and never executed here.
170
172
  */
171
173
  export declare function applyInitPlan(bundle: InitPlanBundle, options?: InitApplyOptions): Promise<InitApplyResult>;
174
+ export interface RuntimeFlowResult {
175
+ readonly ok: boolean;
176
+ readonly completion: boolean;
177
+ readonly runtime: RuntimeInstallResult | null;
178
+ readonly diagnostics: readonly Diagnostic[];
179
+ }
180
+ export declare function bindAndRecordRuntime(root: string, installed: RuntimeInstallResult): Promise<RuntimeFlowResult>;
181
+ /**
182
+ * Converges the runtime binding by installing a durable copy of the package that
183
+ * is currently executing. Reusing an already verified copy is cheap: the install
184
+ * machinery short-circuits before packing.
185
+ */
186
+ export declare function ensureDefaultRuntime(root: string, targetVersion: string): Promise<RuntimeFlowResult>;