@ludi-uni/ludi-agent-kit 0.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/AGENTS.md +55 -0
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/adapters/codex/README.md +24 -0
- package/adapters/codex/skill-metadata/visual-verification/agents/openai.yaml +7 -0
- package/adapters/pi/README.md +88 -0
- package/adapters/pi/browser/agent-browser.mjs +193 -0
- package/adapters/pi/lib/invoke.mjs +55 -0
- package/adapters/pi/lib/list-models.mjs +29 -0
- package/adapters/pi/lib/settings-proposal.mjs +34 -0
- package/adapters/pi/lib/subagent.mjs +175 -0
- package/adapters/pi/loop-guard/index.js +51 -0
- package/adapters/pi/maintenance-policy.json +36 -0
- package/adapters/pi/mcp.template.json +4 -0
- package/adapters/pi/model-catalog.json +97 -0
- package/adapters/pi/models.json +13 -0
- package/adapters/pi/models.local.example.json +14 -0
- package/adapters/pi/orchestrator-ext/command.mjs +14 -0
- package/adapters/pi/orchestrator-ext/index.js +150 -0
- package/adapters/pi/settings.template.json +7 -0
- package/adapters/pi/shell-gate/index.js +70 -0
- package/adapters/pi/sync-pi.ps1 +137 -0
- package/agents/README.md +26 -0
- package/agents/browser.md +64 -0
- package/agents/coder.md +31 -0
- package/agents/orchestrator.md +37 -0
- package/agents/reviewer.md +32 -0
- package/agents/scout.md +35 -0
- package/agents/tester.md +28 -0
- package/agents/visual.md +28 -0
- package/context-pack/SPEC.md +101 -0
- package/context-pack/context-pack.schema.json +79 -0
- package/context-pack/examples/example-fix.md +44 -0
- package/docs/architecture.md +55 -0
- package/docs/migration-from-codex-setting.md +44 -0
- package/docs/model-maintenance.md +401 -0
- package/docs/orchestrator.md +155 -0
- package/docs/phase2-report.md +39 -0
- package/docs/roadmap.md +27 -0
- package/docs/third-party.md +15 -0
- package/lib/agents.mjs +79 -0
- package/lib/context-pack.mjs +215 -0
- package/lib/job.mjs +312 -0
- package/lib/language-policy.mjs +27 -0
- package/lib/maintenance-exec.mjs +377 -0
- package/lib/maintenance-runner.mjs +266 -0
- package/lib/maintenance.mjs +422 -0
- package/lib/normalize.mjs +101 -0
- package/lib/observe/differ.mjs +185 -0
- package/lib/observe/observation.mjs +147 -0
- package/lib/observe/observers.mjs +134 -0
- package/lib/observe/sources.mjs +154 -0
- package/lib/orchestrator/activity.mjs +249 -0
- package/lib/orchestrator/api.mjs +151 -0
- package/lib/orchestrator/contract.mjs +68 -0
- package/lib/orchestrator/escalation.mjs +84 -0
- package/lib/orchestrator/evaluator.mjs +92 -0
- package/lib/orchestrator/failures.mjs +88 -0
- package/lib/orchestrator/health.mjs +53 -0
- package/lib/orchestrator/orchestrator.mjs +483 -0
- package/lib/orchestrator/permissions.mjs +64 -0
- package/lib/orchestrator/planner.mjs +194 -0
- package/lib/orchestrator/policy.mjs +134 -0
- package/lib/orchestrator/router.mjs +45 -0
- package/lib/orchestrator/runner.mjs +278 -0
- package/lib/orchestrator/shell-policy.mjs +52 -0
- package/lib/orchestrator/store.mjs +581 -0
- package/lib/orchestrator/task-store.mjs +79 -0
- package/lib/orchestrator/turn-budget.mjs +63 -0
- package/lib/orchestrator/worktree.mjs +72 -0
- package/lib/pipeline.mjs +279 -0
- package/lib/registry.mjs +63 -0
- package/lib/resolve.mjs +35 -0
- package/lib/routing.mjs +137 -0
- package/lib/telemetry.mjs +222 -0
- package/mcp/README.md +11 -0
- package/mcp/servers.json +13 -0
- package/orchestration/decision-policy.json +66 -0
- package/package.json +56 -0
- package/routing/README.md +24 -0
- package/routing/routing.json +81 -0
- package/routing/routing.schema.json +66 -0
- package/rules/README.md +10 -0
- package/rules/common.md +52 -0
- package/rules/loop-prevention.md +15 -0
- package/rules/repo-local.md +6 -0
- package/scripts/check-environment.ps1 +22 -0
- package/scripts/context-pack.mjs +17 -0
- package/scripts/e2e-investigate-repro.mjs +66 -0
- package/scripts/model-maintenance-job.mjs +59 -0
- package/scripts/observe-models.mjs +97 -0
- package/scripts/orchestrate.mjs +137 -0
- package/scripts/reevaluate-models.mjs +95 -0
- package/scripts/report-model-maintenance.mjs +70 -0
- package/scripts/resolve-capabilities.mjs +39 -0
- package/scripts/run-pipeline.mjs +56 -0
- package/scripts/sync-agents-md.ps1 +10 -0
- package/scripts/validate.mjs +71 -0
- package/skills/README.md +14 -0
- package/skills/pi-workflow/SKILL.md +26 -0
- package/skills/pi-workflow/references/code-investigation-and-fix.md +16 -0
- package/skills/pi-workflow/references/research.md +14 -0
- package/skills/pi-workflow/references/review.md +11 -0
- package/skills/pi-workflow/references/visual-work.md +14 -0
- package/skills/project-management/SKILL.md +106 -0
- package/skills/project-management/references/operations.md +52 -0
- package/skills/visual-verification/SKILL.md +88 -0
- package/skills/visual-verification/scripts/analyze-speech.ps1 +346 -0
- package/skills/visual-verification/scripts/backends/whisperx_backend.py +234 -0
- package/skills/visual-verification/scripts/common.ps1 +387 -0
- package/skills/visual-verification/scripts/contact-sheet.ps1 +121 -0
- package/skills/visual-verification/scripts/desktop-discover.ps1 +45 -0
- package/skills/visual-verification/scripts/desktop-inspect.ps1 +67 -0
- package/skills/visual-verification/scripts/desktop-record.ps1 +97 -0
- package/skills/visual-verification/scripts/desktop-screenshot.ps1 +65 -0
- package/skills/visual-verification/scripts/evaluate-sync.ps1 +249 -0
- package/skills/visual-verification/scripts/extract-frames.ps1 +79 -0
- package/skills/visual-verification/scripts/inspect-media.ps1 +138 -0
- package/skills/visual-verification/scripts/record-av.ps1 +102 -0
- package/skills/visual-verification/scripts/record.ps1 +72 -0
- package/skills/visual-verification/scripts/screenshot.ps1 +44 -0
- package/skills/visual-verification/scripts/waveform.ps1 +450 -0
- package/skills/visual-verification/scripts/winapp-common.ps1 +465 -0
- package/tests/activity.test.mjs +252 -0
- package/tests/attempt-budget.test.mjs +102 -0
- package/tests/browser.test.mjs +121 -0
- package/tests/context-pack.test.mjs +98 -0
- package/tests/dirty-gate.test.mjs +211 -0
- package/tests/e2e-browser.mjs +66 -0
- package/tests/e2e-real-orchestrator-resume.mjs +101 -0
- package/tests/e2e-real-orchestrator.mjs +41 -0
- package/tests/e2e-real-pi.mjs +27 -0
- package/tests/e2e-real-tool-orchestrator.mjs +66 -0
- package/tests/fixtures/browser-page/index.html +20 -0
- package/tests/fixtures/maintenance/availability.txt +5 -0
- package/tests/fixtures/maintenance/catalog.json +74 -0
- package/tests/fixtures/maintenance/events.json +13 -0
- package/tests/fixtures/math-repo/README.md +3 -0
- package/tests/fixtures/math-repo/package.json +7 -0
- package/tests/fixtures/math-repo/src/math.js +11 -0
- package/tests/fixtures/math-repo/test/math.test.js +7 -0
- package/tests/fixtures/observe/announcements.json +8 -0
- package/tests/fixtures/orch-concurrent-child.mjs +44 -0
- package/tests/fixtures/orch-persist-child.mjs +61 -0
- package/tests/job.test.mjs +230 -0
- package/tests/kit.test.mjs +79 -0
- package/tests/language-policy.test.mjs +93 -0
- package/tests/loop-guard.test.mjs +60 -0
- package/tests/maintenance-exec.test.mjs +218 -0
- package/tests/maintenance-runner.test.mjs +222 -0
- package/tests/maintenance.test.mjs +195 -0
- package/tests/observe.test.mjs +283 -0
- package/tests/observer-registry.test.mjs +157 -0
- package/tests/orchestrator-cleanup.test.mjs +358 -0
- package/tests/orchestrator-command.test.mjs +14 -0
- package/tests/orchestrator-persist.test.mjs +375 -0
- package/tests/orchestrator-tools.test.mjs +215 -0
- package/tests/orchestrator.test.mjs +396 -0
- package/tests/package.test.mjs +37 -0
- package/tests/pipeline.test.mjs +239 -0
- package/tests/planner-classification.test.mjs +81 -0
- package/tests/planner-split.test.mjs +67 -0
- package/tests/qoder-observer.test.mjs +266 -0
- package/tests/reassign-progression.test.mjs +104 -0
- package/tests/retry-escalation.test.mjs +120 -0
- package/tests/routing.test.mjs +110 -0
- package/tests/sqlite-concurrency.test.mjs +178 -0
- package/tests/task-global-e2e.test.mjs +63 -0
- package/tests/task-global-failed.test.mjs +134 -0
- package/tests/telemetry.test.mjs +173 -0
- package/tests/test-sync-pi.ps1 +56 -0
- package/tests/turn-budget.test.mjs +106 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Tool-capable child: `pi --mode json -p` with an explicit tool allowlist and the shell gate extension.
|
|
2
|
+
// Sessions stay out of ~/.pi (`--no-session`). The child id is ours and is recorded on the task trace.
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join, dirname } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { randomBytes } from 'node:crypto';
|
|
9
|
+
import { locatePiEntry } from './invoke.mjs';
|
|
10
|
+
import { classifyRun } from '../../../lib/orchestrator/failures.mjs';
|
|
11
|
+
import { progressScore } from '../../../lib/orchestrator/turn-budget.mjs';
|
|
12
|
+
|
|
13
|
+
const shellGate = join(dirname(fileURLToPath(import.meta.url)), '..', 'shell-gate', 'index.js');
|
|
14
|
+
|
|
15
|
+
export function inspectPiEvents(events) {
|
|
16
|
+
let text = '';
|
|
17
|
+
let error = '';
|
|
18
|
+
let toolCalls = 0;
|
|
19
|
+
let turns = 0;
|
|
20
|
+
const commands = [];
|
|
21
|
+
const toolNames = {};
|
|
22
|
+
const uniqueFiles = new Set();
|
|
23
|
+
for (const event of events) {
|
|
24
|
+
const message = event?.message;
|
|
25
|
+
if (event?.type === 'message_end' && message?.role === 'assistant') {
|
|
26
|
+
turns++;
|
|
27
|
+
if (message.errorMessage) error = String(message.errorMessage);
|
|
28
|
+
for (const part of message.content ?? []) {
|
|
29
|
+
if (part.type === 'text' && part.text) text = part.text;
|
|
30
|
+
if (part.type === 'toolCall') {
|
|
31
|
+
toolCalls++;
|
|
32
|
+
const name = part.name ?? part.toolName ?? 'tool';
|
|
33
|
+
toolNames[name] = (toolNames[name] ?? 0) + 1;
|
|
34
|
+
const command = part.arguments?.command ?? part.args?.command;
|
|
35
|
+
if (command) commands.push(String(command));
|
|
36
|
+
// Track files the tool touched (read/edit/write/grep path args) for progress scoring.
|
|
37
|
+
const fileArg = part.arguments?.path ?? part.arguments?.file ?? part.args?.path ?? part.args?.file;
|
|
38
|
+
if (fileArg) uniqueFiles.add(String(fileArg));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return { text, error, toolCalls, turns, commands, toolNames, uniqueFilesInspected: uniqueFiles.size, uniqueFiles, successfulToolCalls: toolCalls };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createPiSubagentRunner(options = {}) {
|
|
47
|
+
return req => runPiSubagent(req, options);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function runPiSubagent(req, { piEntry = locatePiEntry(), spawnImpl = spawn, timeoutMs = null } = {}) {
|
|
51
|
+
if (!piEntry) return { ok: false, error: 'pi CLI entry not found on PATH', failureClass: 'MODEL_FAILURE' };
|
|
52
|
+
const limits = req.limits ?? {};
|
|
53
|
+
const runtimeMs = timeoutMs ?? limits.max_runtime_ms ?? 600000;
|
|
54
|
+
const childSessionId = `child-${randomBytes(4).toString('hex')}`;
|
|
55
|
+
const startedAt = new Date().toISOString();
|
|
56
|
+
const dir = mkdtempSync(join(tmpdir(), 'ludi-sub-'));
|
|
57
|
+
const promptPath = join(dir, 'task.md');
|
|
58
|
+
writeFileSync(promptPath, req.prompt ?? '');
|
|
59
|
+
const tools = req.toolNames ?? [];
|
|
60
|
+
// Extension discovery stays ON: providers that are registered by pi extensions
|
|
61
|
+
// (e.g. qoder, devin — absent from the static model store) only resolve when their
|
|
62
|
+
// extension has loaded; `--no-extensions` makes `--model` fail with "not found".
|
|
63
|
+
// Kit extensions the child needs are passed explicitly with `-e`.
|
|
64
|
+
const args = [piEntry, '--mode', 'json', '-p', '--no-session', '--no-approve', '--no-skills', '--model', req.modelId];
|
|
65
|
+
if (tools.length) args.push('--tools', tools.join(','));
|
|
66
|
+
else args.push('--no-tools');
|
|
67
|
+
if (tools.includes('ludi_exec')) args.push('-e', shellGate);
|
|
68
|
+
args.push('--system-prompt', req.systemPrompt ?? '', '--', `@${promptPath}`);
|
|
69
|
+
// Execution budget: req.limits carries the RESOLVED initial turn budget for this
|
|
70
|
+
// task's role+complexity (runner computes it). Extension is bounded and only
|
|
71
|
+
// granted while the subagent keeps making meaningful progress — an extension is
|
|
72
|
+
// inside the same model invocation and does NOT consume the attempt budget.
|
|
73
|
+
const initialTurns = limits.max_turns ?? 12;
|
|
74
|
+
const extensionTurns = limits.extension_turns ?? 0;
|
|
75
|
+
const maxExtensions = limits.max_extensions ?? 0;
|
|
76
|
+
const absoluteMax = limits.absolute_max_turns ?? initialTurns;
|
|
77
|
+
let turnCap = initialTurns;
|
|
78
|
+
let extensionsGranted = 0;
|
|
79
|
+
// Live progress for the orchestrator's public activity snapshot. Events are
|
|
80
|
+
// sanitized (tool name / file only — never arguments, output, or prompt text).
|
|
81
|
+
const emit = (type, data = {}) => { try { req.onEvent?.(type, data); } catch { /* never break a child on telemetry */ } };
|
|
82
|
+
let lastTurn = 0;
|
|
83
|
+
const seenTools = new Set();
|
|
84
|
+
const child = {
|
|
85
|
+
taskId: req.taskId ?? null, runId: req.runId ?? null, childSessionId, agent: req.agent ?? null,
|
|
86
|
+
modelId: req.modelId, backend: req.backend ?? null, startedAt, finishedAt: null, status: 'running',
|
|
87
|
+
toolCalls: 0, turns: 0, uniqueFilesInspected: 0, toolNames: {}, extensionsGranted: 0, initialTurns, finalTurnLimit: initialTurns, stopReason: null,
|
|
88
|
+
};
|
|
89
|
+
const events = [];
|
|
90
|
+
let stdout = '';
|
|
91
|
+
let stderr = '';
|
|
92
|
+
try {
|
|
93
|
+
const result = await new Promise(resolvePromise => {
|
|
94
|
+
const proc = spawnImpl(process.execPath, args, {
|
|
95
|
+
cwd: req.cwd, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
|
|
96
|
+
env: { ...process.env, PI_SKIP_VERSION_CHECK: '1', LUDI_SHELL_MODE: req.access?.shell ?? 'false', LUDI_SHELL_NETWORK: req.access?.network ? '1' : '0' },
|
|
97
|
+
});
|
|
98
|
+
let settled = false;
|
|
99
|
+
const finish = (value) => { if (!settled) { settled = true; clearTimeout(timer); resolvePromise(value); } };
|
|
100
|
+
const timer = setTimeout(() => { proc.kill(); finish({ ok: false, error: `child timed out after ${runtimeMs}ms`, failureClass: 'TIMEOUT' }); }, runtimeMs);
|
|
101
|
+
proc.stdout?.setEncoding?.('utf8');
|
|
102
|
+
proc.stderr?.setEncoding?.('utf8');
|
|
103
|
+
proc.stdout?.on?.('data', chunk => {
|
|
104
|
+
stdout += chunk;
|
|
105
|
+
const lines = stdout.split(/\r?\n/);
|
|
106
|
+
stdout = lines.pop() ?? '';
|
|
107
|
+
for (const line of lines) {
|
|
108
|
+
if (!line.trim()) continue;
|
|
109
|
+
try {
|
|
110
|
+
const parsedLine = JSON.parse(line);
|
|
111
|
+
events.push(parsedLine);
|
|
112
|
+
if (parsedLine?.type === 'tool_execution_start') {
|
|
113
|
+
const key = parsedLine.toolCallId ?? `tool-${events.length}`;
|
|
114
|
+
if (!seenTools.has(key)) {
|
|
115
|
+
seenTools.add(key);
|
|
116
|
+
emit('invocation-tool', { tool: { name: parsedLine.toolName ?? 'tool', file: parsedLine.args?.path ?? parsedLine.args?.file } });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (parsedLine?.type === 'tool_execution_end') {
|
|
120
|
+
emit('invocation-tool-completed', { tool: { name: parsedLine.toolName ?? 'tool' } });
|
|
121
|
+
}
|
|
122
|
+
} catch { /* non-json diagnostic */ }
|
|
123
|
+
const seen = inspectPiEvents(events);
|
|
124
|
+
if (seen.turns > lastTurn) { lastTurn = seen.turns; emit('invocation-turn', { turn: seen.turns, turnCap, toolCalls: seen.toolCalls }); }
|
|
125
|
+
if (limits.max_tool_calls && seen.toolCalls > limits.max_tool_calls) { child.stopReason = 'tool-call-limit'; proc.kill(); finish({ ok: false, error: `tool call limit ${limits.max_tool_calls}`, failureClass: 'TIMEOUT', telemetry: seen }); }
|
|
126
|
+
if (seen.turns > turnCap) {
|
|
127
|
+
// Turn cap reached. Evaluate progress deterministically: if the agent is
|
|
128
|
+
// still doing useful work, grant a bounded extension; otherwise stop now.
|
|
129
|
+
const prog = progressScore(seen);
|
|
130
|
+
if (prog.meaningful && extensionsGranted < maxExtensions && turnCap < absoluteMax) {
|
|
131
|
+
extensionsGranted++;
|
|
132
|
+
child.extensionsGranted = extensionsGranted;
|
|
133
|
+
const oldLimit = turnCap;
|
|
134
|
+
turnCap = Math.min(turnCap + extensionTurns, absoluteMax);
|
|
135
|
+
child.finalTurnLimit = turnCap;
|
|
136
|
+
child.lastProgressReasons = prog.reasons;
|
|
137
|
+
emit('invocation-extension', { turn: seen.turns, turnCap, oldLimit, newLimit: turnCap, extensionsGranted, reason: 'meaningful progress' });
|
|
138
|
+
} else {
|
|
139
|
+
child.stopReason = prog.meaningful ? 'absolute-turn-limit' : 'no-progress-turn-limit';
|
|
140
|
+
proc.kill();
|
|
141
|
+
finish({ ok: false, error: `turn limit ${turnCap}`, failureClass: prog.meaningful ? 'PROGRESS_TIMEOUT' : 'NO_PROGRESS_TIMEOUT', telemetry: seen, progress: prog });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
proc.stderr?.on?.('data', chunk => { stderr += chunk; });
|
|
147
|
+
proc.on?.('error', error => finish({ ok: false, error: error.message, failureClass: 'MODEL_FAILURE' }));
|
|
148
|
+
proc.on?.('close', code => {
|
|
149
|
+
if (stdout.trim()) { try { events.push(JSON.parse(stdout)); } catch { /* remainder */ } }
|
|
150
|
+
const seen = inspectPiEvents(events);
|
|
151
|
+
child.toolCalls = seen.toolCalls;
|
|
152
|
+
child.turns = seen.turns;
|
|
153
|
+
child.uniqueFilesInspected = seen.uniqueFilesInspected;
|
|
154
|
+
child.toolNames = seen.toolNames;
|
|
155
|
+
child.commandsExecuted = seen.commands.length;
|
|
156
|
+
const reason = seen.error || stderr.trim();
|
|
157
|
+
if (code !== 0) finish({ ok: false, error: `pi exited ${code}: ${(reason || seen.text || '').slice(-800)}`, failureClass: classifyRun({ error: reason || seen.text }), text: seen.text, telemetry: seen });
|
|
158
|
+
else if (!seen.text.trim()) finish({ ok: false, error: (reason || 'empty model response').slice(-800), failureClass: classifyRun({ error: reason || 'empty model response' }), text: '', telemetry: seen });
|
|
159
|
+
else finish({ ok: true, text: seen.text, toolCalls: seen.toolCalls, turns: seen.turns, commands: seen.commands, telemetry: seen });
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
child.finishedAt = new Date().toISOString();
|
|
163
|
+
child.status = result.ok ? 'finished' : 'failed';
|
|
164
|
+
const tel = result.telemetry ?? {};
|
|
165
|
+
child.toolCalls = result.toolCalls ?? tel.toolCalls ?? child.toolCalls;
|
|
166
|
+
child.turns = result.turns ?? tel.turns ?? child.turns;
|
|
167
|
+
child.uniqueFilesInspected = tel.uniqueFilesInspected ?? child.uniqueFilesInspected;
|
|
168
|
+
child.toolNames = tel.toolNames ?? child.toolNames;
|
|
169
|
+
child.commandsExecuted = tel.commands?.length ?? child.commandsExecuted;
|
|
170
|
+
if (!child.stopReason) child.stopReason = result.ok ? 'completed' : (result.failureClass ?? 'failed');
|
|
171
|
+
return { ...result, child };
|
|
172
|
+
} finally {
|
|
173
|
+
rmSync(dir, { recursive: true, force: true });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
// Keep fingerprints, never tool output or commands, in bounded session memory.
|
|
7
|
+
function stable(value) {
|
|
8
|
+
if (Array.isArray(value)) return value.map(stable);
|
|
9
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(key => [key, stable(value[key])]));
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
function fingerprint(value) {
|
|
13
|
+
return createHash('sha256').update(JSON.stringify(stable(value))).digest('hex');
|
|
14
|
+
}
|
|
15
|
+
export default function loopGuard(pi) {
|
|
16
|
+
const policy = readFileSync(resolve(dirname(realpathSync(fileURLToPath(import.meta.url))), '../../../rules/loop-prevention.md'), 'utf8').trim();
|
|
17
|
+
let history = [], halted = false;
|
|
18
|
+
const reset = () => { history = []; halted = false; };
|
|
19
|
+
pi.on('session_start', reset);
|
|
20
|
+
pi.on('model_select', reset);
|
|
21
|
+
pi.on('input', event => {
|
|
22
|
+
// An automatic goal/follow-up must not silently bypass a tripped guard.
|
|
23
|
+
if (event.source === 'interactive' || event.source === 'rpc') reset();
|
|
24
|
+
});
|
|
25
|
+
pi.on('before_agent_start', event => {
|
|
26
|
+
return { systemPrompt: event.systemPrompt.includes(policy) ? event.systemPrompt : event.systemPrompt + '\n\n' + policy };
|
|
27
|
+
});
|
|
28
|
+
pi.on('tool_call', (_event, ctx) => {
|
|
29
|
+
if (halted) return { block: true, terminate: true, reason: 'Loop guard: stopped after repeated identical tool results. Wait for new user input.' };
|
|
30
|
+
});
|
|
31
|
+
pi.on('tool_result', (event, ctx) => {
|
|
32
|
+
if (halted) return;
|
|
33
|
+
const key = fingerprint([event.toolName, event.input]);
|
|
34
|
+
const output = fingerprint([event.content, Boolean(event.isError)]);
|
|
35
|
+
history.push({ key, output });
|
|
36
|
+
if (history.length > 12) history.shift();
|
|
37
|
+
let repeats = 0;
|
|
38
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
39
|
+
if (history[i].key !== key) continue;
|
|
40
|
+
if (history[i].output !== output) break;
|
|
41
|
+
repeats++;
|
|
42
|
+
}
|
|
43
|
+
if (repeats < 3) return;
|
|
44
|
+
halted = true;
|
|
45
|
+
ctx.abort();
|
|
46
|
+
pi.sendMessage({
|
|
47
|
+
customType: 'loop-guard', display: true,
|
|
48
|
+
content: `反復を停止しました。直近12件の結果中、同じ${event.toolName}入力から同じ結果が3回続きました。既存の結果を確認し、調査方針を変えて新しい指示を送ってください。自動再開は行いません。`,
|
|
49
|
+
}, { triggerTurn: false });
|
|
50
|
+
});
|
|
51
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "Phase 2 maintenance execution policy for the pi adapter. requiredQuality is the 0-100 quality bar per execution tier (free models below the bar are NOT used). costWeights blends api $/1M, local electricity $/run and a speed penalty into effectiveCostUsd. escalation rules decide when evaluate hands off to the reconfigure tier. No model names — selection reads model-catalog.json.",
|
|
3
|
+
"version": 1,
|
|
4
|
+
"requiredQuality": { "monitor": 40, "evaluate": 65, "reconfigure": 80 },
|
|
5
|
+
"costWeights": { "api": 1, "electricity": 1, "speed": 0.02 },
|
|
6
|
+
"electricityPricePerKwh": 0.30,
|
|
7
|
+
"taskProfiles": {
|
|
8
|
+
"monitor": { "estimatedInputTokens": 8000, "estimatedOutputTokens": 800, "estimatedTaskMinutes": 1 },
|
|
9
|
+
"evaluate": { "estimatedInputTokens": 20000, "estimatedOutputTokens": 3000, "estimatedTaskMinutes": 3 },
|
|
10
|
+
"reconfigure": { "estimatedInputTokens": 60000, "estimatedOutputTokens": 8000, "estimatedTaskMinutes": 8 }
|
|
11
|
+
},
|
|
12
|
+
"qualityTier": { "monitor": "low", "evaluate": "mid", "reconfigure": "high" },
|
|
13
|
+
"capabilityRequirements": {
|
|
14
|
+
"cheap-code": { "coding": 50 },
|
|
15
|
+
"strong-code": { "coding": 75, "reasoning": 65 },
|
|
16
|
+
"deep-review": { "coding": 70, "reasoning": 70 },
|
|
17
|
+
"orchestration": { "reasoning": 55 },
|
|
18
|
+
"browser": { "reasoning": 70 },
|
|
19
|
+
"vision-reasoning": { "reasoning": 80 }
|
|
20
|
+
},
|
|
21
|
+
"escalation": {
|
|
22
|
+
"minCapabilities": 2,
|
|
23
|
+
"minAgents": 3,
|
|
24
|
+
"minQualitySwing": 15,
|
|
25
|
+
"maxScoreDelta": 4,
|
|
26
|
+
"structuralEvents": ["removed", "deprecated"],
|
|
27
|
+
"escalateOnLowConfidence": true,
|
|
28
|
+
"escalateOnStructural": false,
|
|
29
|
+
"minEvaluateConfidence": 0.5
|
|
30
|
+
},
|
|
31
|
+
"budget": {
|
|
32
|
+
"maxEstimatedCostPerRunUsd": 0.05,
|
|
33
|
+
"maxPremiumInvocationsPerRun": 1,
|
|
34
|
+
"maxTotalInvocationsPerRun": 4
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "Model knowledge for the maintenance task (scripts/reevaluate-models.mjs). Concrete ids live here because this is the pi adapter; lib/maintenance.mjs stays neutral. Update `updatedAt` and entries from provider pricing/model docs or market snapshots (see docs/model-maintenance.md). Scores are heuristic 0-100; null = unknown (excluded from scoring, lowers confidence). status: active | free-campaign | deprecated | removed. cost in USD per 1M tokens; free:true means currently $0. postCampaignCost is the expected price when a free campaign ends. Never store credentials.",
|
|
3
|
+
"version": 1,
|
|
4
|
+
"updatedAt": "2026-09-25",
|
|
5
|
+
"models": [
|
|
6
|
+
{
|
|
7
|
+
"provider": "freetoken",
|
|
8
|
+
"model": "Qwen3.6-35B-A3B-NVFP4",
|
|
9
|
+
"status": "active",
|
|
10
|
+
"cost": { "free": true },
|
|
11
|
+
"location": "local",
|
|
12
|
+
"local": { "powerWatts": 450, "taskMinutes": 4 },
|
|
13
|
+
"contextK": 64,
|
|
14
|
+
"vision": false,
|
|
15
|
+
"toolUse": "basic",
|
|
16
|
+
"thinking": "off",
|
|
17
|
+
"scores": { "coding": 40, "reasoning": 35, "speed": 70 },
|
|
18
|
+
"notes": "Local NVFP4 quant served through the freetoken endpoint. Electricity estimate: 450W x 4min at the policy electricity price."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"provider": "qoder",
|
|
22
|
+
"model": "Qwen3.8-Flash",
|
|
23
|
+
"status": "free-campaign",
|
|
24
|
+
"cost": { "free": true },
|
|
25
|
+
"postCampaignCost": null,
|
|
26
|
+
"freeUntil": null,
|
|
27
|
+
"contextK": 1024,
|
|
28
|
+
"vision": false,
|
|
29
|
+
"toolUse": "good",
|
|
30
|
+
"thinking": "low",
|
|
31
|
+
"scores": { "coding": 62, "reasoning": 55, "speed": 85 },
|
|
32
|
+
"notes": "Verified 2026-09-24 via `pi --list-models` + ~/.pi/agent/qoder-models-cache.json: priceFactor 0 (free campaign), 1M context, 128K max output, reasoning+effort, image input. Qoder pricing is subscription priceFactor-based, not per-token — post-campaign per-token cost is genuinely unknown (postCampaignCost null -> costUnknown on campaign end, re-evaluated by maintenance). freeUntil not published by the provider."
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"provider": "devin",
|
|
36
|
+
"model": "swe-2-high",
|
|
37
|
+
"status": "free-campaign",
|
|
38
|
+
"cost": { "free": true },
|
|
39
|
+
"postCampaignCost": { "usdPerMInput": 0.75, "usdPerMOutput": 3.75 },
|
|
40
|
+
"freeUntil": "2026-10-10T15:00:00Z",
|
|
41
|
+
"contextK": 256,
|
|
42
|
+
"vision": true,
|
|
43
|
+
"toolUse": "good",
|
|
44
|
+
"thinking": "high",
|
|
45
|
+
"scores": { "coding": 80, "reasoning": 75, "speed": 50 },
|
|
46
|
+
"notes": "User-reported Devin free campaign through 2026-10-10 (interpreted inclusive JST, cutoff 2026-10-11 00:00 JST; independently unverified). 2026-09-24 pi model metadata lists $0.75/$3.75 per 1M tokens as post-campaign reference, 256K context, 128K max output. Actual entitlement and billing must be checked before live usage. Scores are heuristic placeholders pending measured results; sibling variants are not assumed free."
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"provider": "openai-codex",
|
|
50
|
+
"model": "gpt-5.6-luna",
|
|
51
|
+
"status": "active",
|
|
52
|
+
"cost": { "usdPerMInput": 0.4, "usdPerMOutput": 1.6 },
|
|
53
|
+
"contextK": 272,
|
|
54
|
+
"vision": false,
|
|
55
|
+
"toolUse": "good",
|
|
56
|
+
"thinking": "low",
|
|
57
|
+
"scores": { "coding": 68, "reasoning": 60, "speed": 80 },
|
|
58
|
+
"notes": "Current cheap backend binding."
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"provider": "openai-codex",
|
|
62
|
+
"model": "gpt-5.6-sol",
|
|
63
|
+
"status": "active",
|
|
64
|
+
"cost": { "usdPerMInput": 1.5, "usdPerMOutput": 6.0 },
|
|
65
|
+
"contextK": 400,
|
|
66
|
+
"vision": false,
|
|
67
|
+
"toolUse": "good",
|
|
68
|
+
"thinking": "medium",
|
|
69
|
+
"scores": { "coding": 85, "reasoning": 82, "speed": 55 },
|
|
70
|
+
"notes": "Current sol backend binding."
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"provider": "openai-codex",
|
|
74
|
+
"model": "gpt-6-astra",
|
|
75
|
+
"status": "active",
|
|
76
|
+
"cost": { "usdPerMInput": 2.5, "usdPerMOutput": 10.0 },
|
|
77
|
+
"contextK": 400,
|
|
78
|
+
"vision": true,
|
|
79
|
+
"toolUse": "good",
|
|
80
|
+
"thinking": "medium",
|
|
81
|
+
"scores": { "coding": 88, "reasoning": 90, "speed": 50 },
|
|
82
|
+
"notes": "Current astra backend binding; vision-capable."
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"provider": "openai-codex",
|
|
86
|
+
"model": "gpt-5.5",
|
|
87
|
+
"status": "active",
|
|
88
|
+
"cost": { "usdPerMInput": 1.25, "usdPerMOutput": 5.0 },
|
|
89
|
+
"contextK": 400,
|
|
90
|
+
"vision": false,
|
|
91
|
+
"toolUse": "good",
|
|
92
|
+
"thinking": "high",
|
|
93
|
+
"scores": { "coding": 82, "reasoning": 84, "speed": 45 },
|
|
94
|
+
"notes": "Current codex backend binding."
|
|
95
|
+
}
|
|
96
|
+
]
|
|
97
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "Shared TEMPLATE binding of logical routing backends to pi provider/model ids. Real per-machine bindings go in models.local.json (gitignored) and override these per backend. TODO-* values are placeholders that the resolver skips. Allowed binding keys: provider, model, thinking, vision, note. Never store credentials here.",
|
|
3
|
+
"version": 1,
|
|
4
|
+
"backends": {
|
|
5
|
+
"local": { "provider": "TODO-provider", "model": "TODO-local-model", "thinking": "off", "note": "OpenAI-compatible local endpoint declared in ~/.pi/agent/models.json" },
|
|
6
|
+
"cheap": { "provider": "TODO-provider", "model": "TODO-cheap-model", "thinking": "low" },
|
|
7
|
+
"sol": { "provider": "TODO-provider", "model": "TODO-sol-model", "thinking": "high" },
|
|
8
|
+
"astra": { "provider": "TODO-provider", "model": "TODO-astra-model", "thinking": "medium", "vision": true },
|
|
9
|
+
"codex": { "provider": "TODO-provider", "model": "TODO-codex-model", "thinking": "high", "note": "Codex as a pi provider; or use pi-subagents codex-exec adapter instead" },
|
|
10
|
+
"qoder": { "provider": "TODO-provider", "model": "TODO-qoder-model", "thinking": "low", "note": "Qoder-hosted model slot; bind in models.local.json from `pi --list-models`" },
|
|
11
|
+
"devin": { "provider": "TODO-provider", "model": "TODO-devin-model", "thinking": "high", "note": "Devin-hosted model slot; bind in models.local.json from `pi --list-models`" }
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "EXAMPLE of models.local.json. Copy to models.local.json and replace with ids from `pi --list-models`; check readiness with `pi auth check --provider <p>`.",
|
|
3
|
+
"version": 1,
|
|
4
|
+
"backends": {
|
|
5
|
+
"local": { "provider": "freetoken", "model": "Qwen3.6-35B-A3B-NVFP4", "thinking": "off" },
|
|
6
|
+
"cheap": { "provider": "openai-codex", "model": "gpt-5.6-luna", "thinking": "low" },
|
|
7
|
+
"sol": { "provider": "openai-codex", "model": "gpt-5.6-sol", "thinking": "medium" },
|
|
8
|
+
"astra": { "provider": "openai-codex", "model": "gpt-6-astra", "thinking": "medium", "vision": true },
|
|
9
|
+
"codex": { "provider": "openai-codex", "model": "gpt-5.5", "thinking": "high" },
|
|
10
|
+
"qoder": { "provider": "qoder", "model": "Qwen3.8-Flash", "thinking": "low", "note": "Qoder-hosted slot; id from `pi --list-models` / qoder-models-cache.json (priceFactor 0 = free campaign)" },
|
|
11
|
+
"devin": { "provider": "devin", "model": "swe-2-high", "thinking": "high", "note": "Devin-hosted slot; api devin-cloud via server.codeium.com, ids from `pi --list-models`" }
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Pure slash-command routing; read-only verbs must never become a new request.
|
|
2
|
+
export function parseOrchestrateCommand(args) {
|
|
3
|
+
const parts = String(args ?? '').trim().split(/\s+/).filter(Boolean);
|
|
4
|
+
const cmd = parts[0] || 'list';
|
|
5
|
+
if (['list', 'status', 'children', 'result', 'decisions'].includes(cmd)) {
|
|
6
|
+
return { action: cmd, params: { runId: parts[1] } };
|
|
7
|
+
}
|
|
8
|
+
if (cmd === 'resume') return { action: cmd, params: { runId: parts[1] } };
|
|
9
|
+
if (cmd === 'answer') return { action: cmd, params: { runId: parts[1], decisionId: parts[2], answer: parts.slice(3).join(' ') } };
|
|
10
|
+
if (cmd === 'prune') return { action: cmd, params: { olderThan: parts[1] } };
|
|
11
|
+
if (cmd === 'delete') return { action: cmd, params: { runId: parts[1], force: parts.includes('--force') } };
|
|
12
|
+
if (cmd === 'clear') return { action: cmd, params: { force: parts.includes('--force'), includeActive: parts.includes('--include-active') } };
|
|
13
|
+
return { action: 'start', params: { request: parts.join(' ') } };
|
|
14
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// pi extension: slash commands and one tool over the persistent orchestration API.
|
|
2
|
+
// Loaded from adapters/pi/orchestrator-ext (junction extensions/ludi-orchestrator). No pi core changes.
|
|
3
|
+
// When accessed via a junction/symlink (e.g. ~/.pi/agent/extensions/…/index.js) the file URL
|
|
4
|
+
// points at the junction, so relative imports would resolve wrong. Resolve via realpathSync
|
|
5
|
+
// to the physical location and use dynamic imports built from kitRoot.
|
|
6
|
+
import { Type } from 'typebox';
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
8
|
+
import { dirname, resolve } from 'node:path';
|
|
9
|
+
import { realpathSync } from 'node:fs';
|
|
10
|
+
import { parseOrchestrateCommand } from './command.mjs';
|
|
11
|
+
|
|
12
|
+
// Resolve junctions so that __dir is the physical location under the kit tree.
|
|
13
|
+
const __file = realpathSync(fileURLToPath(import.meta.url));
|
|
14
|
+
const __dir = dirname(__file);
|
|
15
|
+
const kitRoot = resolve(__dir, '../../../');
|
|
16
|
+
|
|
17
|
+
// Lazy-import cache – resolved once via file:// URL built from kitRoot.
|
|
18
|
+
let _imports = null;
|
|
19
|
+
async function _importsFn() {
|
|
20
|
+
if (!_imports) {
|
|
21
|
+
_imports = {
|
|
22
|
+
invoke: await import(pathToFileURL(resolve(kitRoot, 'adapters/pi/lib/invoke.mjs')).href),
|
|
23
|
+
subagent: await import(pathToFileURL(resolve(kitRoot, 'adapters/pi/lib/subagent.mjs')).href),
|
|
24
|
+
api: await import(pathToFileURL(resolve(kitRoot, 'lib/orchestrator/api.mjs')).href),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
return _imports;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function kitPath() {
|
|
31
|
+
return decodeURIComponent(resolve(kitRoot).replace(/^\/[A-Za-z]:/, '$1'));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function text(body) {
|
|
35
|
+
return { content: [{ type: 'text', text: body }], details: undefined };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export default function orchestratorExtension(pi) {
|
|
39
|
+
// The pi session id is AUTHORITATIVE for clientContext kind 'pi-web'. A caller
|
|
40
|
+
// may not claim a different session — mismatch is rejected before any run starts.
|
|
41
|
+
const run = async (action, params, executionCtx = null) => {
|
|
42
|
+
const root = kitPath();
|
|
43
|
+
const im = await _importsFn();
|
|
44
|
+
const { loadOrchestrationContext, listOrchestrationRuns, showRun, formatReport, formatRunList, defaultStorePath, pendingDecisions, createRunHealth, createRunRunner, startOrchestration, resumeOrchestration, answerOrchestration, parseOlderThan, pruneOrchestrationRuns, deleteOrchestrationRun, clearOrchestrationRuns, previewRunCleanup, formatCleanup } = im.api;
|
|
45
|
+
const createPiInvoker = im.invoke.createPiInvoker;
|
|
46
|
+
const createPiSubagentRunner = im.subagent.createPiSubagentRunner;
|
|
47
|
+
const requestedClient = params.clientContext;
|
|
48
|
+
const sessionId = executionCtx?.sessionManager?.getSessionId?.() ?? null;
|
|
49
|
+
if (requestedClient && requestedClient.kind !== 'pi-web') return 'unsupported clientContext.kind';
|
|
50
|
+
if (requestedClient && !sessionId) return 'pi session id unavailable; cannot bind run';
|
|
51
|
+
if (requestedClient?.sessionId && requestedClient.sessionId !== sessionId) return 'clientContext sessionId mismatch';
|
|
52
|
+
const cc = requestedClient ? { kind: 'pi-web', sessionId } : null;
|
|
53
|
+
const ctx = loadOrchestrationContext({ kit: root, storePath: params.store || defaultStorePath(root), clientContext: cc });
|
|
54
|
+
try {
|
|
55
|
+
if (ctx.errors.length) return ctx.errors.join('\n');
|
|
56
|
+
if (action === 'list') return formatRunList(listOrchestrationRuns(ctx, { status: params.status || null }));
|
|
57
|
+
if (action === 'status' && !params.runId) return formatRunList(listOrchestrationRuns(ctx, { status: params.status || null }));
|
|
58
|
+
if (['status', 'children', 'result'].includes(action) && !params.runId) return `${action} requires runId`;
|
|
59
|
+
if (params.runId && ['status', 'children', 'result'].includes(action)) {
|
|
60
|
+
const shown = showRun(ctx, params.runId);
|
|
61
|
+
const children = (shown.trace ?? []).filter(e => e.type === 'child');
|
|
62
|
+
const active = shown.tasks.filter(t => t.status === 'running');
|
|
63
|
+
if (action === 'children') return active.length ? active.map(t => `${t.id} ${t.assignedAgent} running`).join('\n') : 'active children: none';
|
|
64
|
+
if (action === 'result') return shown.tasks.map(t => `${t.id} ${t.status} ${String(t.result?.summary ?? '').split('\n')[0]}`).join('\n');
|
|
65
|
+
return `${formatReport(shown)}\n\nactive children: ${active.length ? active.map(t => t.id).join(', ') : 'none'}\nchild sessions: ${children.length}`;
|
|
66
|
+
}
|
|
67
|
+
if (action === 'decisions') {
|
|
68
|
+
const rows = pendingDecisions(ctx, { runId: params.runId || null });
|
|
69
|
+
return rows.length ? rows.map(d => `${d.runId} ${d.id} [${d.taskId}] ${d.question}`).join('\n') : 'decisions: none';
|
|
70
|
+
}
|
|
71
|
+
if (action === 'prune') {
|
|
72
|
+
// Destructive: deletes terminal run history. Always previews in the reply.
|
|
73
|
+
const ms = params.olderThan ? parseOlderThan(params.olderThan) : undefined;
|
|
74
|
+
return formatCleanup(pruneOrchestrationRuns(ctx, { olderThanMs: ms }), { verb: '削除' });
|
|
75
|
+
}
|
|
76
|
+
if (action === 'delete') {
|
|
77
|
+
// Destructive only with force=true; otherwise a preview of that one run.
|
|
78
|
+
if (!params.runId) return 'delete requires runId';
|
|
79
|
+
if (params.force) {
|
|
80
|
+
const r = deleteOrchestrationRun(ctx, params.runId, { force: true });
|
|
81
|
+
return `削除しました: ${r.id} (tasks:${r.deleted.tasks} decisions:${r.deleted.decisions} trace:${r.deleted.trace} health:${r.deleted.health})`;
|
|
82
|
+
}
|
|
83
|
+
const target = previewRunCleanup(ctx, { includeActive: true }).runs.find(r => r.id === params.runId);
|
|
84
|
+
if (!target) return `run not found: ${params.runId}`;
|
|
85
|
+
if (!target.deletable) return `削除できません: ${target.id} — ${target.reason}`;
|
|
86
|
+
return `削除対象(プレビュー): ${target.id} ${target.status} tasks:${target.counts.tasks} decisions:${target.counts.decisions} trace:${target.counts.trace}\n実行するには force=true`;
|
|
87
|
+
}
|
|
88
|
+
if (action === 'clear') {
|
|
89
|
+
// force=false previews; force=true deletes all terminal runs; includeActive adds active ones.
|
|
90
|
+
return formatCleanup(clearOrchestrationRuns(ctx, { force: !!params.force, includeActive: !!params.includeActive }), { verb: '削除' });
|
|
91
|
+
}
|
|
92
|
+
const health = createRunHealth(ctx);
|
|
93
|
+
const invoke = createPiInvoker();
|
|
94
|
+
const runner = createRunRunner(ctx, { invoke, runSubagent: createPiSubagentRunner(), repoRoot: params.repo || null, apply: false, health });
|
|
95
|
+
if (action === 'answer') {
|
|
96
|
+
answerOrchestration(ctx, { runId: params.runId, decisionId: params.decisionId, answer: params.answer });
|
|
97
|
+
const result = await resumeOrchestration(ctx, { runId: params.runId, repoRoot: params.repo || null, runner, invoke, health });
|
|
98
|
+
return formatReport(result);
|
|
99
|
+
}
|
|
100
|
+
if (action === 'resume') {
|
|
101
|
+
const result = await resumeOrchestration(ctx, { runId: params.runId, repoRoot: params.repo || null, runner, invoke, health });
|
|
102
|
+
return formatReport(result);
|
|
103
|
+
}
|
|
104
|
+
const result = await startOrchestration(ctx, { request: params.request, repoRoot: params.repo || null, runner, invoke, health });
|
|
105
|
+
return formatReport(result);
|
|
106
|
+
} finally {
|
|
107
|
+
ctx.session.close();
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
pi.registerCommand('orchestrate', {
|
|
112
|
+
description: 'Start, list, resume, or answer a persistent ludi orchestration run',
|
|
113
|
+
handler: async (args, ctx) => {
|
|
114
|
+
const { action, params } = parseOrchestrateCommand(args);
|
|
115
|
+
try {
|
|
116
|
+
const body = await run(action, params, ctx);
|
|
117
|
+
ctx.ui.notify(body.slice(0, 500), 'info');
|
|
118
|
+
} catch (e) { ctx.ui.notify(`orchestration error: ${e.message}`, 'error'); }
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
pi.registerTool({
|
|
123
|
+
name: 'ludi_orchestrate',
|
|
124
|
+
label: 'Ludi orchestrate',
|
|
125
|
+
description: 'Persistent ludi orchestrator: start, list, status, children, result, decisions, resume, answer, or clean up run history (prune/delete/clear).',
|
|
126
|
+
promptSnippet: 'ludi_orchestrate: start, list, resume, answer, or clean up history of a persistent orchestration run.',
|
|
127
|
+
promptGuidelines: [
|
|
128
|
+
'Use ludi_orchestrate to continue a high-level request across sessions. Do not re-ask a decision that is already pending; answer it or list it.',
|
|
129
|
+
'prune, delete and clear are destructive. delete and clear preview unless force=true; delete active requires force=true; clear active requires both force=true and includeActive=true.',
|
|
130
|
+
],
|
|
131
|
+
parameters: Type.Object({
|
|
132
|
+
action: Type.String({ description: 'start | list | status | children | result | decisions | resume | answer | prune | delete | clear' }),
|
|
133
|
+
request: Type.Optional(Type.String({ description: 'High-level request for action=start' })),
|
|
134
|
+
runId: Type.Optional(Type.String({ description: 'Run id for status, children, result, decisions, resume, answer, or delete' })),
|
|
135
|
+
decisionId: Type.Optional(Type.String({ description: 'Pending decision id for action=answer' })),
|
|
136
|
+
answer: Type.Optional(Type.String({ description: 'User answer text for action=answer' })),
|
|
137
|
+
status: Type.Optional(Type.String({ description: 'Filter for action=list' })),
|
|
138
|
+
olderThan: Type.Optional(Type.String({ description: 'Duration for action=prune, e.g. "7d", "12h", "30m", "2w"' })),
|
|
139
|
+
force: Type.Optional(Type.Boolean({ description: 'Actually delete for action=delete/clear (otherwise preview only)' })),
|
|
140
|
+
includeActive: Type.Optional(Type.Boolean({ description: 'For action=clear with force=true: also delete active and resumable runs' })),
|
|
141
|
+
repo: Type.Optional(Type.String({ description: 'Repository root the agents should read' })),
|
|
142
|
+
store: Type.Optional(Type.String({ description: 'Override path to the orchestration sqlite file' })),
|
|
143
|
+
clientContext: Type.Optional(Type.Object({ kind: Type.String(), sessionId: Type.Optional(Type.String()) }, { description: 'Client binding request; pi-web session UUID is obtained from the current pi session' })),
|
|
144
|
+
}),
|
|
145
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
146
|
+
try { return text(await run(params.action || 'list', params, ctx)); }
|
|
147
|
+
catch (e) { return text(`orchestration error: ${e.message}`); }
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "Template fragment. sync-pi.ps1 never writes settings.json; it only reports the diff between this fragment and the live file. Model/provider stay user-owned.",
|
|
3
|
+
"defaultTools": ["read", "powershell", "edit", "write"],
|
|
4
|
+
"enableSkillCommands": true,
|
|
5
|
+
"defaultProjectTrust": "ask",
|
|
6
|
+
"enableInstallTelemetry": false
|
|
7
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// pi extension loaded only by the orchestrator's tool-capable child.
|
|
2
|
+
// Replaces raw shell: every command is classified before it runs, and a refusal stays inside the child
|
|
3
|
+
// so the model returns a decision instead of asking the user directly.
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { Type } from 'typebox';
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
7
|
+
import { dirname, resolve } from 'node:path';
|
|
8
|
+
import { realpathSync } from 'node:fs';
|
|
9
|
+
|
|
10
|
+
// Resolve junctions so that __dir is the physical location under the kit tree.
|
|
11
|
+
const __file = realpathSync(fileURLToPath(import.meta.url));
|
|
12
|
+
const __dir = dirname(__file);
|
|
13
|
+
const kitRoot = resolve(__dir, '../../../');
|
|
14
|
+
|
|
15
|
+
// Lazy-import for the shell-policy module.
|
|
16
|
+
let _decideShell = null;
|
|
17
|
+
async function _load() {
|
|
18
|
+
if (!_decideShell) {
|
|
19
|
+
const m = await import(pathToFileURL(resolve(kitRoot, 'lib/orchestrator/shell-policy.mjs')).href);
|
|
20
|
+
_decideShell = m.decideShell;
|
|
21
|
+
}
|
|
22
|
+
return _decideShell;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function runCommand(command, cwd) {
|
|
26
|
+
return new Promise(resolve => {
|
|
27
|
+
const proc = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', command], { cwd, windowsHide: true });
|
|
28
|
+
let stdout = '';
|
|
29
|
+
let stderr = '';
|
|
30
|
+
const timer = setTimeout(() => { proc.kill(); resolve({ code: 124, stdout, stderr: `${stderr}\ntimeout` }); }, 120000);
|
|
31
|
+
proc.stdout.on('data', chunk => { stdout += chunk; });
|
|
32
|
+
proc.stderr.on('data', chunk => { stderr += chunk; });
|
|
33
|
+
proc.on('error', error => { clearTimeout(timer); resolve({ code: 1, stdout, stderr: error.message }); });
|
|
34
|
+
proc.on('close', code => { clearTimeout(timer); resolve({ code: code ?? 1, stdout, stderr }); });
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let _decideShellPromise = null;
|
|
39
|
+
|
|
40
|
+
function _ensureDecideShell() {
|
|
41
|
+
if (!_decideShellPromise) {
|
|
42
|
+
_decideShellPromise = _load();
|
|
43
|
+
}
|
|
44
|
+
return _decideShellPromise;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default function shellGate(pi) {
|
|
48
|
+
// Tool registration is synchronous, but decideShell is loaded via dynamic import.
|
|
49
|
+
// The execute callback awaits the promise on first use.
|
|
50
|
+
pi.registerTool({
|
|
51
|
+
name: 'ludi_exec',
|
|
52
|
+
label: 'Ludi exec',
|
|
53
|
+
description: 'Run one workspace command. Tests, lint, build, and read-only git are allowed. Push, publish, deploy, reset, and recursive delete are refused.',
|
|
54
|
+
promptSnippet: 'ludi_exec: run an allowed shell command in the task workspace.',
|
|
55
|
+
promptGuidelines: ['Use ludi_exec instead of powershell or bash. If it returns POLICY_BLOCK, do not retry the command; report status needs_decision.'],
|
|
56
|
+
parameters: Type.Object({
|
|
57
|
+
command: Type.String({ description: 'One shell command. No user prompts.' }),
|
|
58
|
+
}),
|
|
59
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
60
|
+
const decideShell = await _ensureDecideShell();
|
|
61
|
+
const decision = decideShell(params.command, { shell: process.env.LUDI_SHELL_MODE ?? 'limited', network: process.env.LUDI_SHELL_NETWORK === '1' });
|
|
62
|
+
if (!decision.allow) {
|
|
63
|
+
return { content: [{ type: 'text', text: `POLICY_BLOCK ${decision.reason}. Do not run this command. Return status "needs_decision" with the command in decisions.` }], isError: true };
|
|
64
|
+
}
|
|
65
|
+
const ran = await runCommand(params.command, ctx?.cwd);
|
|
66
|
+
const body = [`exit ${ran.code}`, ran.stdout.slice(-4000), ran.stderr.slice(-2000)].filter(Boolean).join('\n');
|
|
67
|
+
return { content: [{ type: 'text', text: body }], isError: ran.code !== 0, details: { command: params.command, exitCode: ran.code } };
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|