@phyzess/pappra 0.1.0-beta.12 → 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.
- package/README.md +2 -1
- package/assets/integrations/skills/pappra/references/operations.md +2 -0
- package/dist/app/collab-from.d.ts +35 -0
- package/dist/app/collab-from.js +84 -0
- package/dist/app/host-mcp.d.ts +25 -0
- package/dist/app/host-mcp.js +227 -0
- package/dist/app/host-probe.d.ts +16 -0
- package/dist/app/host-probe.js +125 -0
- package/dist/app/task-pin.d.ts +23 -0
- package/dist/app/task-pin.js +113 -0
- package/dist/cli/commands/docs.js +12 -76
- package/dist/cli/commands/host.d.ts +3 -1
- package/dist/cli/commands/host.js +55 -1
- package/dist/cli/commands/mcp.d.ts +8 -0
- package/dist/cli/commands/mcp.js +13 -0
- package/dist/cli/commands/task.d.ts +10 -0
- package/dist/cli/commands/task.js +76 -0
- package/dist/cli/run.js +134 -4
- package/dist/hosts/integration/hook.js +4 -1
- package/dist/hosts/integration/pappra-extension-runtime.mjs +50 -8
- package/dist/hosts/integration/pappra-hook.mjs +50 -8
- package/dist/hosts/pilot.d.ts +77 -77
- package/dist/knowledge/scan.d.ts +30 -0
- package/dist/knowledge/scan.js +91 -0
- package/dist/mcp/server.d.ts +11 -0
- package/dist/mcp/server.js +518 -0
- package/dist/schemas/workspace.d.ts +6 -0
- package/dist/schemas/workspace.js +4 -0
- package/package.json +2 -1
- package/skills/pappra/references/operations.md +2 -0
package/README.md
CHANGED
|
@@ -71,6 +71,7 @@ They are not needed for the core workflow above.
|
|
|
71
71
|
- **Continuity:** private task checkpoints and bounded advisory resume packets. Resume never grants execution by itself.
|
|
72
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.
|
|
73
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).
|
|
74
75
|
- **Bounded collaboration:** an optional, explicitly controlled one-request workflow. It is not a scheduler or daemon.
|
|
75
76
|
|
|
76
77
|
## Optional integrations and support
|
|
@@ -94,7 +95,7 @@ The check runs the source policy, type checks, formatting, deterministic tests a
|
|
|
94
95
|
|
|
95
96
|
## Status and limits
|
|
96
97
|
|
|
97
|
-
Pappra is development software (`0.1.0-beta.
|
|
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.
|
|
98
99
|
|
|
99
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.
|
|
100
101
|
|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
host. Codex 0.163.0 loads hooks only from `$CODEX_HOME/hooks.json`, so use
|
|
22
22
|
`install --host codex --scope user`; `bundle --marketplace` exports a plugin
|
|
23
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.
|
|
24
26
|
- `pappra collab <prepare|run|query>` runs one bounded collaboration request.
|
|
25
27
|
`run` requires `--controller fixture|pi-rpc`; `pi-rpc` launches a real Pi RPC
|
|
26
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
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export declare const TASK_PIN_SCHEMA = "pappra/task-pin/1";
|
|
2
|
+
export interface TaskPin {
|
|
3
|
+
readonly schema: typeof TASK_PIN_SCHEMA;
|
|
4
|
+
readonly taskId: string;
|
|
5
|
+
readonly pinnedAt: string;
|
|
6
|
+
}
|
|
7
|
+
export interface TaskPinFailure {
|
|
8
|
+
readonly ok: false;
|
|
9
|
+
readonly message: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function readTaskPin(root: string): Promise<TaskPin | null>;
|
|
12
|
+
export declare function pinTask(root: string, taskId: string): Promise<TaskPin | TaskPinFailure>;
|
|
13
|
+
export declare function unpinTask(root: string): Promise<{
|
|
14
|
+
ok: true;
|
|
15
|
+
}>;
|
|
16
|
+
/**
|
|
17
|
+
* Toggles the workspace-level auto-resume switch. The hook only injects a resume
|
|
18
|
+
* packet when this switch is on and a task pin exists; neither is inferred.
|
|
19
|
+
*/
|
|
20
|
+
export declare function setAutoResume(root: string, enabled: boolean): Promise<{
|
|
21
|
+
ok: true;
|
|
22
|
+
} | TaskPinFailure>;
|
|
23
|
+
export declare function readAutoResume(root: string): Promise<boolean>;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { readTextFileBounded } from '../infra/fs.js';
|
|
5
|
+
import { workspaceConfigSchema } from '../schemas/workspace.js';
|
|
6
|
+
export const TASK_PIN_SCHEMA = 'pappra/task-pin/1';
|
|
7
|
+
const TASK_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
8
|
+
function pinPath(root) {
|
|
9
|
+
return join(root, '.pappra', 'local', 'current-task');
|
|
10
|
+
}
|
|
11
|
+
export async function readTaskPin(root) {
|
|
12
|
+
const read = await readTextFileBounded(pinPath(root), 64 * 1024);
|
|
13
|
+
if (!read.ok)
|
|
14
|
+
return null;
|
|
15
|
+
try {
|
|
16
|
+
const value = JSON.parse(read.value.text);
|
|
17
|
+
if (typeof value !== 'object' ||
|
|
18
|
+
value === null ||
|
|
19
|
+
!('schema' in value) ||
|
|
20
|
+
value.schema !== TASK_PIN_SCHEMA ||
|
|
21
|
+
!('taskId' in value) ||
|
|
22
|
+
typeof value.taskId !== 'string' ||
|
|
23
|
+
!TASK_ID.test(value.taskId)) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export async function pinTask(root, taskId) {
|
|
33
|
+
if (!TASK_ID.test(taskId)) {
|
|
34
|
+
return {
|
|
35
|
+
ok: false,
|
|
36
|
+
message: 'task id must be a short identifier (letters, digits, dot, dash, underscore).',
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const pin = {
|
|
40
|
+
schema: TASK_PIN_SCHEMA,
|
|
41
|
+
taskId,
|
|
42
|
+
pinnedAt: new Date().toISOString(),
|
|
43
|
+
};
|
|
44
|
+
const target = pinPath(root);
|
|
45
|
+
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
46
|
+
const temporary = `${target}.tmp-${randomUUID()}`;
|
|
47
|
+
await writeFile(temporary, `${JSON.stringify(pin, null, 2)}\n`, {
|
|
48
|
+
mode: 0o600,
|
|
49
|
+
});
|
|
50
|
+
await rename(temporary, target);
|
|
51
|
+
return pin;
|
|
52
|
+
}
|
|
53
|
+
export async function unpinTask(root) {
|
|
54
|
+
const target = pinPath(root);
|
|
55
|
+
const { rm } = await import('node:fs/promises');
|
|
56
|
+
await rm(target, { force: true });
|
|
57
|
+
return { ok: true };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Toggles the workspace-level auto-resume switch. The hook only injects a resume
|
|
61
|
+
* packet when this switch is on and a task pin exists; neither is inferred.
|
|
62
|
+
*/
|
|
63
|
+
export async function setAutoResume(root, enabled) {
|
|
64
|
+
const configPath = join(root, '.pappra', 'workspace.json');
|
|
65
|
+
const read = await readTextFileBounded(configPath, 1024 * 1024);
|
|
66
|
+
if (!read.ok) {
|
|
67
|
+
return { ok: false, message: read.message };
|
|
68
|
+
}
|
|
69
|
+
let raw;
|
|
70
|
+
try {
|
|
71
|
+
raw = JSON.parse(read.value.text);
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
message: `workspace.json is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const parsed = workspaceConfigSchema.safeParse({
|
|
80
|
+
...(typeof raw === 'object' && raw !== null ? raw : {}),
|
|
81
|
+
continuity: { autoResume: enabled },
|
|
82
|
+
});
|
|
83
|
+
if (!parsed.success) {
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
message: `workspace.json rejected the continuity switch: ${parsed.error.issues[0]?.message ?? 'invalid'}`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const backup = join(root, '.pappra', 'local', 'backups', `task-pin-${randomUUID()}`, 'workspace.json');
|
|
90
|
+
await mkdir(dirname(backup), { recursive: true, mode: 0o700 });
|
|
91
|
+
await writeFile(backup, read.value.text, { mode: 0o600 });
|
|
92
|
+
const temporary = `${configPath}.tmp-${randomUUID()}`;
|
|
93
|
+
await writeFile(temporary, `${JSON.stringify(parsed.data, null, 2)}\n`);
|
|
94
|
+
await rename(temporary, configPath);
|
|
95
|
+
return { ok: true };
|
|
96
|
+
}
|
|
97
|
+
export async function readAutoResume(root) {
|
|
98
|
+
const read = await readFile(join(root, '.pappra', 'workspace.json'), 'utf8');
|
|
99
|
+
try {
|
|
100
|
+
const raw = JSON.parse(read);
|
|
101
|
+
if (typeof raw !== 'object' || raw === null || !('continuity' in raw)) {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
const continuity = raw.continuity;
|
|
105
|
+
return (typeof continuity === 'object' &&
|
|
106
|
+
continuity !== null &&
|
|
107
|
+
'autoResume' in continuity &&
|
|
108
|
+
continuity.autoResume === true);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|