@huseynsnmz/pi-suite 0.0.0-stage → 0.2.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/CHANGELOG.md +28 -0
- package/LICENSE +21 -0
- package/README.md +88 -2
- package/SECURITY.md +5 -0
- package/package.json +112 -4
- package/packages/board/CHANGELOG.md +340 -0
- package/packages/board/CONTRIBUTING.md +7 -0
- package/packages/board/LICENSE +21 -0
- package/packages/board/README.md +88 -0
- package/packages/board/SECURITY.md +19 -0
- package/packages/board/docs/how-it-works.md +31 -0
- package/packages/board/docs/reference.md +278 -0
- package/packages/board/package.json +81 -0
- package/packages/board/runner/job-runner-legacy.mjs +68 -0
- package/packages/board/runner/job-runner.mjs +834 -0
- package/packages/board/runner/pty-runner-legacy.mjs +50 -0
- package/packages/board/runner/pty-runner.mjs +2175 -0
- package/packages/board/runner/state-coordinator.mjs +673 -0
- package/packages/board/runner/state-runner.mjs +203 -0
- package/packages/board/runner/title-runner.mjs +80 -0
- package/packages/board/src/commands/agent-board.ts +394 -0
- package/packages/board/src/commands/attach-decision.mjs +66 -0
- package/packages/board/src/commands/attach-flow.ts +251 -0
- package/packages/board/src/commands/bg.ts +103 -0
- package/packages/board/src/commands/board-model.ts +21 -0
- package/packages/board/src/core/atomic.mjs +184 -0
- package/packages/board/src/core/attach-delete.mjs +24 -0
- package/packages/board/src/core/attach-frame-proof.mjs +870 -0
- package/packages/board/src/core/attach-frame-reporter.mjs +648 -0
- package/packages/board/src/core/attach-frame-stream.mjs +266 -0
- package/packages/board/src/core/attach-view-proof.mjs +709 -0
- package/packages/board/src/core/auto-state.mjs +377 -0
- package/packages/board/src/core/background-runs.mjs +273 -0
- package/packages/board/src/core/code-refs-store.mjs +315 -0
- package/packages/board/src/core/code-refs.mjs +913 -0
- package/packages/board/src/core/config.mjs +77 -0
- package/packages/board/src/core/control-protocol.mjs +482 -0
- package/packages/board/src/core/coordinator-client.mjs +633 -0
- package/packages/board/src/core/coordinator-journal.mjs +282 -0
- package/packages/board/src/core/coordinator-protocol.mjs +14 -0
- package/packages/board/src/core/cwd-stats.mjs +114 -0
- package/packages/board/src/core/dashboard-render.mjs +10 -0
- package/packages/board/src/core/derive.mjs +117 -0
- package/packages/board/src/core/diagnostics.mjs +133 -0
- package/packages/board/src/core/double-press.mjs +26 -0
- package/packages/board/src/core/editor-state-reporter.mjs +112 -0
- package/packages/board/src/core/events.mjs +286 -0
- package/packages/board/src/core/evidence.mjs +245 -0
- package/packages/board/src/core/follow-up-queue.mjs +211 -0
- package/packages/board/src/core/foreground-preview-cache.mjs +117 -0
- package/packages/board/src/core/foreground-projection-cache.mjs +99 -0
- package/packages/board/src/core/frame-witness-update.mjs +35 -0
- package/packages/board/src/core/heuristics.mjs +313 -0
- package/packages/board/src/core/hold-input.mjs +27 -0
- package/packages/board/src/core/host-coordination.mjs +300 -0
- package/packages/board/src/core/host-crash.mjs +79 -0
- package/packages/board/src/core/host-probe.mjs +207 -0
- package/packages/board/src/core/host-protocol.mjs +73 -0
- package/packages/board/src/core/ids.mjs +35 -0
- package/packages/board/src/core/ime-cursor-coalesce.mjs +193 -0
- package/packages/board/src/core/invocation.mjs +43 -0
- package/packages/board/src/core/launch-options.mjs +418 -0
- package/packages/board/src/core/launch.mjs +157 -0
- package/packages/board/src/core/load-config.mjs +4 -0
- package/packages/board/src/core/locks.mjs +530 -0
- package/packages/board/src/core/paths.mjs +183 -0
- package/packages/board/src/core/pid.mjs +75 -0
- package/packages/board/src/core/prewarm-schedule.mjs +41 -0
- package/packages/board/src/core/process-identity-probe.mjs +228 -0
- package/packages/board/src/core/prompt-transport.mjs +13 -0
- package/packages/board/src/core/pty-attach-jiggle-controller.mjs +356 -0
- package/packages/board/src/core/pty-attach-jiggle-retry.mjs +109 -0
- package/packages/board/src/core/pty-attach-reconnect.mjs +61 -0
- package/packages/board/src/core/pty-attach-render.mjs +229 -0
- package/packages/board/src/core/pty-links.mjs +81 -0
- package/packages/board/src/core/pty-scroll.mjs +522 -0
- package/packages/board/src/core/pty-support.mjs +327 -0
- package/packages/board/src/core/repo.mjs +103 -0
- package/packages/board/src/core/rows.mjs +414 -0
- package/packages/board/src/core/screen-log-gc.mjs +198 -0
- package/packages/board/src/core/screen-log.mjs +160 -0
- package/packages/board/src/core/session-view.mjs +174 -0
- package/packages/board/src/core/slash-commands.mjs +52 -0
- package/packages/board/src/core/state-commands.mjs +757 -0
- package/packages/board/src/core/status-consistency.mjs +98 -0
- package/packages/board/src/core/steering-prompts.mjs +34 -0
- package/packages/board/src/core/steering.mjs +133 -0
- package/packages/board/src/core/stop-latch.mjs +41 -0
- package/packages/board/src/core/store.mjs +523 -0
- package/packages/board/src/core/suspend.mjs +36 -0
- package/packages/board/src/core/terminal-attach-client.mjs +981 -0
- package/packages/board/src/core/terminal-attach-protocol.mjs +460 -0
- package/packages/board/src/core/terminal-model.mjs +222 -0
- package/packages/board/src/core/terminal-query-sequences.mjs +239 -0
- package/packages/board/src/core/terminal-reply-guard.mjs +222 -0
- package/packages/board/src/core/terminal-snapshot.mjs +440 -0
- package/packages/board/src/core/test-knobs.mjs +28 -0
- package/packages/board/src/core/title.mjs +43 -0
- package/packages/board/src/core/types.mjs +414 -0
- package/packages/board/src/core/warm-host-sweeper.mjs +168 -0
- package/packages/board/src/core/worktree.mjs +65 -0
- package/packages/board/src/index.ts +303 -0
- package/packages/board/src/runtime/service.mjs +3222 -0
- package/packages/board/src/ui/chrome.ts +20 -0
- package/packages/board/src/ui/dashboard-decisions.mjs +55 -0
- package/packages/board/src/ui/dashboard-evidence.mjs +85 -0
- package/packages/board/src/ui/dashboard.ts +2237 -0
- package/packages/board/src/ui/double-left.ts +30 -0
- package/packages/board/src/ui/external-editor.ts +25 -0
- package/packages/board/src/ui/focus.ts +32 -0
- package/packages/board/src/ui/pty-attach.ts +3184 -0
- package/packages/board/src/ui/waiting.ts +30 -0
- package/packages/commands/LICENSE +21 -0
- package/packages/commands/README.md +19 -0
- package/packages/commands/package.json +22 -0
- package/packages/commands/src/index.ts +43 -0
- package/packages/commands/src/security-review.ts +109 -0
- package/packages/frame/CHANGELOG.md +113 -0
- package/packages/frame/CONTRIBUTING.md +7 -0
- package/packages/frame/LICENSE +22 -0
- package/packages/frame/README.md +105 -0
- package/packages/frame/SECURITY.md +16 -0
- package/packages/frame/docs/how-it-works.md +53 -0
- package/packages/frame/docs/reference.md +78 -0
- package/packages/frame/package.json +72 -0
- package/packages/frame/src/data/git.ts +90 -0
- package/packages/frame/src/data/github.ts +140 -0
- package/packages/frame/src/data/gitlab.ts +63 -0
- package/packages/frame/src/data/mode.ts +49 -0
- package/packages/frame/src/data/past-prompts.ts +57 -0
- package/packages/frame/src/data/runner.ts +32 -0
- package/packages/frame/src/data/snapshot.ts +223 -0
- package/packages/frame/src/data/start-notes.ts +104 -0
- package/packages/frame/src/data/telemetry.ts +337 -0
- package/packages/frame/src/data/todos.ts +162 -0
- package/packages/frame/src/data/totals.ts +94 -0
- package/packages/frame/src/data/visits.ts +45 -0
- package/packages/frame/src/index.ts +696 -0
- package/packages/frame/src/kit/LICENSE +21 -0
- package/packages/frame/src/kit/app/attribution.ts +30 -0
- package/packages/frame/src/kit/app/builder.ts +186 -0
- package/packages/frame/src/kit/app/claim.ts +27 -0
- package/packages/frame/src/kit/app/feature.ts +91 -0
- package/packages/frame/src/kit/app/host.ts +214 -0
- package/packages/frame/src/kit/app/renderers.ts +62 -0
- package/packages/frame/src/kit/app/scope.ts +125 -0
- package/packages/frame/src/kit/board/model.ts +337 -0
- package/packages/frame/src/kit/contract.ts +79 -0
- package/packages/frame/src/kit/contracts/accounts.ts +33 -0
- package/packages/frame/src/kit/contracts/ask.ts +120 -0
- package/packages/frame/src/kit/contracts/board.ts +6 -0
- package/packages/frame/src/kit/contracts/by.ts +22 -0
- package/packages/frame/src/kit/contracts/calls.ts +46 -0
- package/packages/frame/src/kit/contracts/events.ts +89 -0
- package/packages/frame/src/kit/contracts/mode.ts +26 -0
- package/packages/frame/src/kit/contracts/names.ts +12 -0
- package/packages/frame/src/kit/contracts/peers.ts +77 -0
- package/packages/frame/src/kit/contracts/prompts.ts +8 -0
- package/packages/frame/src/kit/contracts/screen.ts +75 -0
- package/packages/frame/src/kit/contracts/skills.ts +81 -0
- package/packages/frame/src/kit/contracts/sources.ts +81 -0
- package/packages/frame/src/kit/contracts/views.ts +42 -0
- package/packages/frame/src/kit/control.ts +59 -0
- package/packages/frame/src/kit/dark-theme.ts +41 -0
- package/packages/frame/src/kit/decode.ts +210 -0
- package/packages/frame/src/kit/events/log.ts +313 -0
- package/packages/frame/src/kit/format.ts +25 -0
- package/packages/frame/src/kit/host.ts +4 -0
- package/packages/frame/src/kit/index.ts +25 -0
- package/packages/frame/src/kit/legacy-tasks.ts +39 -0
- package/packages/frame/src/kit/overlay-images.ts +36 -0
- package/packages/frame/src/kit/paths.ts +16 -0
- package/packages/frame/src/kit/screen/client.ts +74 -0
- package/packages/frame/src/kit/screen/feature.ts +64 -0
- package/packages/frame/src/kit/screen/model.ts +182 -0
- package/packages/frame/src/kit/screen/view.ts +838 -0
- package/packages/frame/src/kit/settings/files.ts +90 -0
- package/packages/frame/src/kit/settings/setting.ts +57 -0
- package/packages/frame/src/kit/settings/store.ts +192 -0
- package/packages/frame/src/kit/sources/file.ts +129 -0
- package/packages/frame/src/kit/sources/follow.ts +155 -0
- package/packages/frame/src/kit/testing.ts +260 -0
- package/packages/frame/src/kit/totals.ts +72 -0
- package/packages/frame/src/kit/ui/entry.ts +75 -0
- package/packages/frame/src/kit/ui/frame.ts +212 -0
- package/packages/frame/src/kit/ui/glyphs.ts +132 -0
- package/packages/frame/src/kit/ui/scroll.ts +14 -0
- package/packages/frame/src/render/diff.ts +157 -0
- package/packages/frame/src/render/fit.ts +86 -0
- package/packages/frame/src/render/format.ts +108 -0
- package/packages/frame/src/render/frame.ts +106 -0
- package/packages/frame/src/render/glyphs.ts +189 -0
- package/packages/frame/src/render/keys.ts +43 -0
- package/packages/frame/src/render/lead.ts +37 -0
- package/packages/frame/src/render/paint.ts +135 -0
- package/packages/frame/src/render/safe.ts +19 -0
- package/packages/frame/src/render/segments.ts +735 -0
- package/packages/frame/src/render/streaming.ts +223 -0
- package/packages/frame/src/render/timing.ts +106 -0
- package/packages/frame/src/render/tools.ts +167 -0
- package/packages/frame/src/settings.ts +105 -0
- package/packages/frame/src/ui/editor.ts +272 -0
- package/packages/frame/src/ui/effort.ts +36 -0
- package/packages/frame/src/ui/footer.ts +165 -0
- package/packages/frame/src/ui/header.ts +362 -0
- package/packages/frame/src/ui/history-search.ts +23 -0
- package/packages/frame/src/ui/interrupt.ts +21 -0
- package/packages/frame/src/ui/outputs.ts +170 -0
- package/packages/frame/src/ui/pi-entries.ts +298 -0
- package/packages/frame/src/ui/previews.ts +72 -0
- package/packages/frame/src/ui/queue.ts +75 -0
- package/packages/frame/src/ui/screen.ts +59 -0
- package/packages/frame/src/ui/timings.ts +48 -0
- package/packages/frame/src/ui/todos.ts +205 -0
- package/packages/frame/src/ui/tool-frame.ts +595 -0
- package/packages/frame/src/ui/working.ts +25 -0
- package/packages/frame/src/view/state.ts +97 -0
- package/packages/frame/src/view/transcript.ts +175 -0
- package/packages/frame/src/view/view.ts +162 -0
- package/packages/memory/CONTRIBUTING.md +7 -0
- package/packages/memory/LICENSE +21 -0
- package/packages/memory/README.md +44 -0
- package/packages/memory/SECURITY.md +20 -0
- package/packages/memory/package.json +55 -0
- package/packages/memory/skills/review-memory/SKILL.md +48 -0
- package/packages/memory/src/index.ts +163 -0
- package/packages/memory/src/prompt.ts +25 -0
- package/packages/memory/src/review.ts +150 -0
- package/packages/memory/src/secrets.ts +22 -0
- package/packages/memory/src/store.ts +173 -0
- package/packages/modes/CHANGELOG.md +104 -0
- package/packages/modes/CONTRIBUTING.md +7 -0
- package/packages/modes/LICENSE +21 -0
- package/packages/modes/README.md +174 -0
- package/packages/modes/SECURITY.md +17 -0
- package/packages/modes/package.json +55 -0
- package/packages/modes/skills/review-permissions/SKILL.md +54 -0
- package/packages/modes/src/bash.ts +218 -0
- package/packages/modes/src/deny-off.ts +62 -0
- package/packages/modes/src/git-safety.ts +72 -0
- package/packages/modes/src/index.ts +338 -0
- package/packages/modes/src/interview.ts +206 -0
- package/packages/modes/src/judge-gate.ts +113 -0
- package/packages/modes/src/modes.ts +121 -0
- package/packages/modes/src/review/apply.mjs +156 -0
- package/packages/modes/src/review/collect.mjs +208 -0
- package/packages/modes/src/review/tools.ts +71 -0
- package/packages/modes/src/sandbox.ts +37 -0
- package/packages/router/CHANGELOG.md +8 -0
- package/packages/router/LICENSE +21 -0
- package/packages/router/README.md +90 -0
- package/packages/router/package.json +51 -0
- package/packages/router/src/config.ts +65 -0
- package/packages/router/src/index.ts +172 -0
- package/packages/router/src/judge.ts +51 -0
- package/packages/router/src/route.ts +67 -0
- package/packages/web/CHANGELOG.md +36 -0
- package/packages/web/LICENSE +21 -0
- package/packages/web/README.md +87 -0
- package/packages/web/SECURITY.md +13 -0
- package/packages/web/package.json +59 -0
- package/packages/web/src/config.ts +109 -0
- package/packages/web/src/dnsfilter.ts +97 -0
- package/packages/web/src/extract.ts +65 -0
- package/packages/web/src/index.ts +217 -0
- package/packages/web/src/markdown.ts +122 -0
- package/packages/web/src/net.ts +109 -0
- package/packages/web/src/reader.ts +73 -0
- package/packages/web/src/sanitize.ts +50 -0
- package/packages/web/src/search.ts +76 -0
- package/packages/web/src/secrets.ts +22 -0
- package/packages/web/src/validate.ts +186 -0
- package/src/index.ts +156 -0
- package/src/settings.ts +79 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { type ExtensionAPI, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { isKeyRelease, matchesKey, Text, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import { borrowStatusReader, loadsFirst, sandboxInstalled, sandboxIsOn, type StatusReader } from "./sandbox.ts";
|
|
7
|
+
import { runsGit } from "./bash.ts";
|
|
8
|
+
import { gitRisk } from "./git-safety.ts";
|
|
9
|
+
import { registerInterview } from "./interview.ts";
|
|
10
|
+
import { registerReviewTools } from "./review/tools.ts";
|
|
11
|
+
import { aliasDefinitions, DENY_OFF_PROMPT, withAliases, withoutAliases } from "./deny-off.ts";
|
|
12
|
+
import { clearGate, hasPermissionSystem, installJudgeGate, JUDGE_NAMES, judgeGated, registerYoloLink, setGateMode, YOLO_LINK } from "./judge-gate.ts";
|
|
13
|
+
import { decideToolCall, displayCommand, fromExpectedPackage, notReadOnly, isMode, loadsAfterPermissionSystem, type Mode, MODES, modePrompt, nextMode, resumedMode, startingMode, SUBMIT_PLAN_TOOL } from "./modes.ts";
|
|
14
|
+
|
|
15
|
+
const STATUS_KEY = "pi-modes";
|
|
16
|
+
const DENY_STATUS_KEY = "pi-modes-deny";
|
|
17
|
+
const ENTRY_TYPE = "pi-modes";
|
|
18
|
+
const CYCLE_KEY = "shift+tab";
|
|
19
|
+
const DEFAULTABLE: string[] = MODES.filter((name) => name !== "yolo");
|
|
20
|
+
|
|
21
|
+
const STATUS_COLOR = { normal: "dim", auto: "warning", plan: "accent", "read-only": "success", yolo: "error" } as const;
|
|
22
|
+
|
|
23
|
+
function chainNames(wanted: (name: unknown) => boolean): boolean {
|
|
24
|
+
try {
|
|
25
|
+
const path = join(getAgentDir(), "extensions", "pi-permission-system", "config.json");
|
|
26
|
+
const chain: unknown = JSON.parse(readFileSync(path, "utf8")).authorizerChain;
|
|
27
|
+
return Array.isArray(chain) && chain.some(wanted);
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function chainNamesJudge(): boolean {
|
|
34
|
+
return chainNames((name) => JUDGE_NAMES.has(name as string));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Only Enter starts it, so no other key, like a stray y, can agree to it. Deny rules stay on
|
|
39
|
+
* unless the user presses d. Undefined means cancelled.
|
|
40
|
+
*/
|
|
41
|
+
async function confirmYolo(ctx: ExtensionContext, canIgnoreDeny: boolean, sandboxed: boolean): Promise<{ denyOff: boolean } | undefined> {
|
|
42
|
+
let denyOff = false;
|
|
43
|
+
const agreed = await ctx.ui.custom<boolean>((tui, theme, _keys, done) => ({
|
|
44
|
+
invalidate() {},
|
|
45
|
+
handleInput(data: string) {
|
|
46
|
+
if (matchesKey(data, "enter")) done(true);
|
|
47
|
+
else if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) done(false);
|
|
48
|
+
else if (canIgnoreDeny && matchesKey(data, "d")) {
|
|
49
|
+
denyOff = !denyOff;
|
|
50
|
+
tui.requestRender();
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
render(width: number) {
|
|
54
|
+
const red = (text: string) => theme.fg("error", text);
|
|
55
|
+
const rule = red("─".repeat(width));
|
|
56
|
+
const body = [
|
|
57
|
+
red(theme.bold("▲ YOLO")),
|
|
58
|
+
"",
|
|
59
|
+
"The agent stops asking. Anything it wants to run is approved, including commands that delete files or reach the network.",
|
|
60
|
+
theme.fg("dim", "It lasts until you change mode or leave this session."),
|
|
61
|
+
"",
|
|
62
|
+
denyOff
|
|
63
|
+
? red(theme.bold("Deny rules: IGNORED for bash, read, write and edit. Secrets you protected can be read."))
|
|
64
|
+
: `Deny rules: ${theme.bold("respected")}${sandboxed ? `, and everything stays inside the ${theme.bold("sandbox")}` : ""}`,
|
|
65
|
+
"",
|
|
66
|
+
`${red(theme.bold("Enter"))} start ${canIgnoreDeny ? `${theme.fg("dim", denyOff ? "d respect deny rules" : "d ignore deny rules")} ` : ""}${theme.fg("dim", "Esc cancel")}`,
|
|
67
|
+
];
|
|
68
|
+
return [rule, ...body.flatMap((line) => (line === "" ? [""] : wrapTextWithAnsi(line, width))), rule];
|
|
69
|
+
},
|
|
70
|
+
}));
|
|
71
|
+
return agreed === true ? { denyOff } : undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function loadOrderIsLate(): boolean {
|
|
75
|
+
try {
|
|
76
|
+
return loadsAfterPermissionSystem(JSON.parse(readFileSync(join(getAgentDir(), "settings.json"), "utf8")).packages);
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function packagesLoadThisFirst(): boolean {
|
|
83
|
+
try {
|
|
84
|
+
return loadsFirst(JSON.parse(readFileSync(join(getAgentDir(), "settings.json"), "utf8")).packages);
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const configPath = () => join(getAgentDir(), "extensions", "pi-modes", "config.json");
|
|
91
|
+
|
|
92
|
+
function readConfig(): Record<string, unknown> {
|
|
93
|
+
try {
|
|
94
|
+
const config: unknown = JSON.parse(readFileSync(configPath(), "utf8"));
|
|
95
|
+
return typeof config === "object" && config !== null ? (config as Record<string, unknown>) : {};
|
|
96
|
+
} catch {
|
|
97
|
+
return {};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function saveDefaultMode(mode: Mode): void {
|
|
102
|
+
mkdirSync(dirname(configPath()), { recursive: true });
|
|
103
|
+
writeFileSync(configPath(), `${JSON.stringify({ ...readConfig(), defaultMode: mode }, null, 2)}\n`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function restoredMode(ctx: ExtensionContext): Mode {
|
|
107
|
+
let mode: Mode | undefined;
|
|
108
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
109
|
+
if (entry.type !== "custom" || entry.customType !== ENTRY_TYPE) continue;
|
|
110
|
+
const saved: unknown = (entry.data as { mode?: unknown } | undefined)?.mode;
|
|
111
|
+
if (isMode(saved)) mode = saved;
|
|
112
|
+
}
|
|
113
|
+
return mode === undefined ? startingMode(readConfig().defaultMode) : resumedMode(mode);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export default function piModes(pi: ExtensionAPI): void {
|
|
117
|
+
installJudgeGate();
|
|
118
|
+
|
|
119
|
+
let mode: Mode = "normal";
|
|
120
|
+
let stopListening: (() => void) | undefined;
|
|
121
|
+
let warnedUngated = false;
|
|
122
|
+
let disposeYoloLink: (() => void) | undefined;
|
|
123
|
+
let denyOff = false;
|
|
124
|
+
let replacedTools: string[] = [];
|
|
125
|
+
let statuses: StatusReader | undefined;
|
|
126
|
+
|
|
127
|
+
for (const tool of aliasDefinitions(() => mode === "yolo" && denyOff)) pi.registerTool(tool);
|
|
128
|
+
|
|
129
|
+
pi.events.on("permissions:ready", (data) => {
|
|
130
|
+
const sessionId = (data as { sessionId?: unknown } | undefined)?.sessionId;
|
|
131
|
+
if (disposeYoloLink !== undefined || typeof sessionId !== "string") return;
|
|
132
|
+
disposeYoloLink = registerYoloLink(sessionId);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
function apply(next: Mode, ctx: ExtensionContext): void {
|
|
136
|
+
mode = next;
|
|
137
|
+
if (next !== "yolo") denyOff = false;
|
|
138
|
+
setGateMode(ctx.sessionManager.getSessionId(), next);
|
|
139
|
+
let others = withoutAliases(pi.getActiveTools().filter((name) => name !== SUBMIT_PLAN_TOOL), replacedTools);
|
|
140
|
+
replacedTools = [];
|
|
141
|
+
if (denyOff) ({ active: others, replaced: replacedTools } = withAliases(others));
|
|
142
|
+
pi.setActiveTools(next === "plan" ? [...others, SUBMIT_PLAN_TOOL] : others);
|
|
143
|
+
if (ctx.hasUI) {
|
|
144
|
+
ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg(STATUS_COLOR[next], `mode: ${next}`));
|
|
145
|
+
ctx.ui.setStatus(DENY_STATUS_KEY, denyOff ? ctx.ui.theme.fg("error", "deny rules off") : undefined);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function warnIfJudgeUncontrolled(ctx: ExtensionContext): void {
|
|
150
|
+
if (warnedUngated || !ctx.hasUI || judgeGated(ctx.sessionManager.getSessionId()) || !chainNamesJudge()) return;
|
|
151
|
+
warnedUngated = true;
|
|
152
|
+
ctx.ui.notify(
|
|
153
|
+
"pi-modes could not take control of the permission judge. It may still auto-approve outside auto mode. See the README.",
|
|
154
|
+
"warning",
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Whoever asks, such as pi-router, passes an object, so load order doesn't matter.
|
|
159
|
+
pi.events.on("pi-modes:mode", (probe) => {
|
|
160
|
+
(probe as { mode?: Mode }).mode = mode;
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
function change(next: Mode, ctx: ExtensionContext): void {
|
|
164
|
+
if (next === mode) return;
|
|
165
|
+
apply(next, ctx);
|
|
166
|
+
pi.appendEntry(ENTRY_TYPE, { mode: next });
|
|
167
|
+
if (!ctx.hasUI) return;
|
|
168
|
+
// A judge named in the chain may not have registered yet: some load orders
|
|
169
|
+
// only register it during the first prompt. agent_start checks that case.
|
|
170
|
+
if (next === "auto" && !judgeGated(ctx.sessionManager.getSessionId()) && !chainNamesJudge()) {
|
|
171
|
+
ctx.ui.notify(
|
|
172
|
+
"Auto has no permission judge, so it behaves like normal. Set up pi-permission-classifier to use it.",
|
|
173
|
+
"warning",
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Every way into a mode a person can pick goes through here, so yolo can't skip its question. */
|
|
179
|
+
async function pick(next: Mode, ctx: ExtensionContext): Promise<void> {
|
|
180
|
+
if (next === "yolo" && next !== mode) {
|
|
181
|
+
if (!ctx.hasUI) return;
|
|
182
|
+
if (hasPermissionSystem(ctx.sessionManager.getSessionId()) && !chainNames((name) => name === YOLO_LINK)) {
|
|
183
|
+
ctx.ui.notify(`Yolo needs "${YOLO_LINK}" in authorizerChain in pi-permission-system's config.json, then a restart. See the README.`, "error");
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
// The tools that ignore deny rules can't reach the sandbox's bash, so they'd run outside it.
|
|
187
|
+
const sandboxed = sandboxInstalled(pi.getCommands());
|
|
188
|
+
if (sandboxed && !sandboxIsOn(statuses)) {
|
|
189
|
+
ctx.ui.notify("Yolo only runs inside the sandbox. Turn it on with /sandbox-enable. If it is on, move pi-suite first in packages in settings.json.", "error");
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const agreed = await confirmYolo(ctx, hasPermissionSystem(ctx.sessionManager.getSessionId()) && !sandboxed, sandboxed);
|
|
193
|
+
if (agreed === undefined) return;
|
|
194
|
+
denyOff = agreed.denyOff;
|
|
195
|
+
}
|
|
196
|
+
change(next, ctx);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
pi.on("session_start", (_event, ctx) => {
|
|
200
|
+
apply(restoredMode(ctx), ctx);
|
|
201
|
+
if (ctx.mode === "tui") {
|
|
202
|
+
if (sandboxInstalled(pi.getCommands()) && packagesLoadThisFirst()) statuses = borrowStatusReader(ctx.ui as never);
|
|
203
|
+
if (loadOrderIsLate()) {
|
|
204
|
+
ctx.ui.notify(
|
|
205
|
+
"pi-suite loads after pi-permission-system, so you may be asked to approve actions that are then blocked. Move it first in packages in settings.json.",
|
|
206
|
+
"warning",
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
stopListening = ctx.ui.onTerminalInput((data) => {
|
|
210
|
+
if (!matchesKey(data, CYCLE_KEY)) return undefined;
|
|
211
|
+
if (!isKeyRelease(data)) change(nextMode(mode), ctx);
|
|
212
|
+
return { consume: true };
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
pi.on("session_tree", (_event, ctx) => apply(restoredMode(ctx), ctx));
|
|
218
|
+
|
|
219
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
220
|
+
stopListening?.();
|
|
221
|
+
stopListening = undefined;
|
|
222
|
+
statuses = undefined;
|
|
223
|
+
disposeYoloLink?.();
|
|
224
|
+
disposeYoloLink = undefined;
|
|
225
|
+
clearGate(ctx.sessionManager.getSessionId());
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// Runs after every before_agent_start handler, which is the judge's last chance to register.
|
|
229
|
+
pi.on("agent_start", (_event, ctx) => warnIfJudgeUncontrolled(ctx));
|
|
230
|
+
|
|
231
|
+
pi.on("before_agent_start", (event) => {
|
|
232
|
+
const prompt = denyOff ? DENY_OFF_PROMPT : modePrompt(mode);
|
|
233
|
+
return prompt ? { systemPrompt: `${event.systemPrompt}\n\n${prompt}` } : undefined;
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
237
|
+
if (mode === "yolo" && sandboxInstalled(pi.getCommands()) && !sandboxIsOn(statuses)) {
|
|
238
|
+
change("normal", ctx);
|
|
239
|
+
if (ctx.hasUI) ctx.ui.notify("The sandbox went off, so yolo ended.", "warning");
|
|
240
|
+
return { block: true, reason: "Yolo ended because the sandbox is off." };
|
|
241
|
+
}
|
|
242
|
+
let decision = decideToolCall(mode, event.toolName, event.input);
|
|
243
|
+
if (decision.action === "allow" && (mode === "plan" || mode === "read-only") && event.toolName === "bash") {
|
|
244
|
+
const risk = runsGit((event.input as { command: string }).command) ? await gitRisk(ctx.cwd) : undefined;
|
|
245
|
+
if (risk !== undefined) decision = notReadOnly(mode, risk);
|
|
246
|
+
}
|
|
247
|
+
if (decision.action === "allow" && (mode === "plan" || mode === "read-only")) {
|
|
248
|
+
const path = pi.getAllTools().find((tool) => tool.name === event.toolName)?.sourceInfo.path;
|
|
249
|
+
if (!fromExpectedPackage(event.toolName, path)) decision = { action: "block", reason: `${mode} mode blocks ${event.toolName} from another package.` };
|
|
250
|
+
}
|
|
251
|
+
if (decision.action === "allow") return undefined;
|
|
252
|
+
if (decision.action === "block") return { block: true, reason: decision.reason };
|
|
253
|
+
|
|
254
|
+
const denied = `Plan mode blocked this command: ${decision.reason}.`;
|
|
255
|
+
if (!ctx.hasUI) return { block: true, reason: denied };
|
|
256
|
+
const command = (event.input as { command: string }).command;
|
|
257
|
+
const approved = await ctx.ui.confirm("Plan mode: run this command?", `${displayCommand(decision.reason)}\n\n${displayCommand(command)}`);
|
|
258
|
+
return approved ? undefined : { block: true, reason: denied };
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
registerReviewTools(pi);
|
|
262
|
+
|
|
263
|
+
registerInterview(pi);
|
|
264
|
+
|
|
265
|
+
pi.registerTool({
|
|
266
|
+
name: SUBMIT_PLAN_TOOL,
|
|
267
|
+
label: "Submit plan",
|
|
268
|
+
description: "Show the finished plan to the user for approval. Only available in plan mode.",
|
|
269
|
+
parameters: Type.Object({
|
|
270
|
+
plan: Type.String({ description: "The complete plan, as markdown" }),
|
|
271
|
+
}),
|
|
272
|
+
renderCall: (args, theme) => new Text(`${theme.bold("Plan")}\n${args.plan ?? ""}`, 0, 0),
|
|
273
|
+
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
274
|
+
const reply = (text: string) => ({ content: [{ type: "text" as const, text }], details: {} });
|
|
275
|
+
if (mode !== "plan") return reply("Plan mode is off, so there is nothing to approve.");
|
|
276
|
+
if (!ctx.hasUI) return reply("Nobody can approve a plan here. Stay in plan mode.");
|
|
277
|
+
|
|
278
|
+
const NORMAL = "Approve, and ask before risky actions (normal)";
|
|
279
|
+
const AUTO = "Approve, and let the judge approve actions (auto)";
|
|
280
|
+
const KEEP = "Keep planning";
|
|
281
|
+
const choice = await ctx.ui.select("Approve this plan?", [NORMAL, AUTO, KEEP]);
|
|
282
|
+
|
|
283
|
+
if (choice === NORMAL || choice === AUTO) {
|
|
284
|
+
const next: Mode = choice === AUTO ? "auto" : "normal";
|
|
285
|
+
change(next, ctx);
|
|
286
|
+
return reply(`The user approved the plan and switched to ${next} mode. Implement it now.`);
|
|
287
|
+
}
|
|
288
|
+
const feedback = await ctx.ui.input("What should change?", "");
|
|
289
|
+
return reply(`The user did not approve the plan. Stay in plan mode. ${feedback ? `Feedback: ${feedback}` : "Ask what to change."}`);
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
pi.registerCommand("mode", {
|
|
294
|
+
description: `Show or set the mode: ${MODES.join(", ")}. "default" sets what new sessions start in`,
|
|
295
|
+
getArgumentCompletions: (prefix) => {
|
|
296
|
+
const wanted = prefix.trim();
|
|
297
|
+
if (wanted.startsWith("default ")) {
|
|
298
|
+
const name = wanted.slice("default ".length);
|
|
299
|
+
const items = DEFAULTABLE.filter((value) => value.startsWith(name)).map((value) => ({ value: `default ${value}`, label: value }));
|
|
300
|
+
return items.length > 0 ? items : null;
|
|
301
|
+
}
|
|
302
|
+
const items = [...MODES, "default"].filter((name) => name.startsWith(wanted)).map((value) => ({ value, label: value }));
|
|
303
|
+
return items.length > 0 ? items : null;
|
|
304
|
+
},
|
|
305
|
+
handler: async (args, ctx) => {
|
|
306
|
+
const requested = args.trim();
|
|
307
|
+
if (requested === "default" || requested.startsWith("default ")) {
|
|
308
|
+
const now = startingMode(readConfig().defaultMode);
|
|
309
|
+
const wanted = requested === "default"
|
|
310
|
+
? ctx.hasUI ? await ctx.ui.select(`Mode new sessions start in (now ${now})`, DEFAULTABLE) : undefined
|
|
311
|
+
: requested.slice("default ".length).trim();
|
|
312
|
+
if (wanted === undefined) return;
|
|
313
|
+
if (!DEFAULTABLE.includes(wanted)) {
|
|
314
|
+
const why = wanted === "yolo" ? "Yolo can't be the default, because it has to be asked for each time." : `Unknown mode ${wanted}.`;
|
|
315
|
+
ctx.ui.notify(`${why} Use one of: ${DEFAULTABLE.join(", ")}.`, "error");
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
try {
|
|
319
|
+
saveDefaultMode(wanted as Mode);
|
|
320
|
+
ctx.ui.notify(`New sessions start in ${wanted}. This session stays in ${mode}.`, "info");
|
|
321
|
+
} catch (error) {
|
|
322
|
+
ctx.ui.notify(`Couldn't save the default mode: ${String(error)}`, "error");
|
|
323
|
+
}
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (requested === "") {
|
|
327
|
+
const picked = ctx.hasUI ? await ctx.ui.select(`Mode (now ${mode})`, [...MODES]) : undefined;
|
|
328
|
+
if (isMode(picked)) await pick(picked, ctx);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (!isMode(requested)) {
|
|
332
|
+
ctx.ui.notify(`Unknown mode ${requested}. Use one of: ${MODES.join(", ")}.`, "error");
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
await pick(requested, ctx);
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// The model asks, the user answers. An answer is only information: tool permissions stay as they are.
|
|
2
|
+
import type { ExtensionAPI, ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { matchesKey, Text, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
import { ASK_TOOL, displayCommand } from "./modes.ts";
|
|
6
|
+
|
|
7
|
+
const TYPES = ["text", "yes_no", "select", "multi_select"] as const;
|
|
8
|
+
type QuestionType = (typeof TYPES)[number];
|
|
9
|
+
const MAX_QUESTIONS = 6;
|
|
10
|
+
const MAX_OPTIONS = 10;
|
|
11
|
+
const MAX_OPTION_CHARS = 200;
|
|
12
|
+
const MAX_QUESTION_CHARS = 500;
|
|
13
|
+
const CALL_LINE_CHARS = 120;
|
|
14
|
+
export const OWN_ANSWER = "Type my own answer";
|
|
15
|
+
|
|
16
|
+
export interface Question {
|
|
17
|
+
question: string;
|
|
18
|
+
type: string;
|
|
19
|
+
options?: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface Clean {
|
|
23
|
+
question: string;
|
|
24
|
+
type: QuestionType;
|
|
25
|
+
options: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type Dialogs = Pick<ExtensionUIContext, "select" | "input" | "custom">;
|
|
29
|
+
|
|
30
|
+
/** Dialogs draw this text, so it can't drive the terminal or fake another row. */
|
|
31
|
+
function oneLine(text: string): string {
|
|
32
|
+
return displayCommand(text.replace(/\s+/g, " ").trim());
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function cleanOption(option: string): string {
|
|
36
|
+
return oneLine(option).slice(0, MAX_OPTION_CHARS);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The model writes the rest of the dialog, so this part says who is asking. Without it, a question
|
|
41
|
+
* could pass for Pi's permission prompts or plan approval.
|
|
42
|
+
*/
|
|
43
|
+
function titleOf(question: string, index: number, total: number): string {
|
|
44
|
+
return `Agent question${total === 1 ? "" : ` ${index + 1}/${total}`}: ${question}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const SENT_TO_MODEL = "(your answer goes to the model)";
|
|
48
|
+
|
|
49
|
+
/** A bad call tells the model what to fix, before the user sees any of it. */
|
|
50
|
+
export function checkedQuestions(questions: readonly Question[]): Clean[] {
|
|
51
|
+
if (questions.length === 0) throw new Error("Ask at least one question.");
|
|
52
|
+
if (questions.length > MAX_QUESTIONS) throw new Error(`Ask at most ${MAX_QUESTIONS} questions at a time.`);
|
|
53
|
+
return questions.map(({ question, type, options }) => {
|
|
54
|
+
const text = oneLine(question);
|
|
55
|
+
if (text === "") throw new Error("A question needs text.");
|
|
56
|
+
if (text.length > MAX_QUESTION_CHARS) throw new Error(`Keep a question under ${MAX_QUESTION_CHARS} characters.`);
|
|
57
|
+
if (!(TYPES as readonly string[]).includes(type)) throw new Error(`Unknown question type "${type}". Use ${TYPES.join(", ")}.`);
|
|
58
|
+
const kind = type as QuestionType;
|
|
59
|
+
if (kind === "text" || kind === "yes_no") {
|
|
60
|
+
if (options !== undefined) throw new Error(`A ${kind} question takes no options.`);
|
|
61
|
+
return { question: text, type: kind, options: kind === "yes_no" ? ["Yes", "No"] : [] };
|
|
62
|
+
}
|
|
63
|
+
const clean = (options ?? []).map(cleanOption);
|
|
64
|
+
if (clean.length < 2 || clean.length > MAX_OPTIONS) throw new Error(`A ${kind} question needs 2 to ${MAX_OPTIONS} options.`);
|
|
65
|
+
if (clean.includes("") || new Set(clean).size < clean.length) throw new Error("Options must be different and not empty.");
|
|
66
|
+
if (clean.includes(OWN_ANSWER)) throw new Error(`"${OWN_ANSWER}" is added for you. Leave it out.`);
|
|
67
|
+
return { question: text, type: kind, options: clean };
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Space picks, Enter answers. Undefined means Esc, or the run was stopped. */
|
|
72
|
+
export function pickSeveral(
|
|
73
|
+
ui: Dialogs,
|
|
74
|
+
title: string,
|
|
75
|
+
options: readonly string[],
|
|
76
|
+
signal?: AbortSignal,
|
|
77
|
+
already: readonly string[] = [],
|
|
78
|
+
): Promise<string[] | undefined> {
|
|
79
|
+
return ui.custom<string[] | undefined>((tui, theme, _keys, done) => {
|
|
80
|
+
const picked = new Set(already.map((option) => options.indexOf(option)).filter((index) => index >= 0));
|
|
81
|
+
let row = 0;
|
|
82
|
+
const stop = () => done(undefined);
|
|
83
|
+
signal?.addEventListener("abort", stop, { once: true });
|
|
84
|
+
const finish = (value: string[] | undefined) => {
|
|
85
|
+
signal?.removeEventListener("abort", stop);
|
|
86
|
+
done(value);
|
|
87
|
+
};
|
|
88
|
+
return {
|
|
89
|
+
invalidate() {},
|
|
90
|
+
handleInput(data: string) {
|
|
91
|
+
if (matchesKey(data, "up")) row = (row + options.length - 1) % options.length;
|
|
92
|
+
else if (matchesKey(data, "down")) row = (row + 1) % options.length;
|
|
93
|
+
else if (matchesKey(data, "space")) {
|
|
94
|
+
if (!picked.delete(row)) picked.add(row);
|
|
95
|
+
} else if (matchesKey(data, "enter")) return finish(options.filter((_option, index) => picked.has(index)));
|
|
96
|
+
else if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) return finish(undefined);
|
|
97
|
+
tui.requestRender();
|
|
98
|
+
},
|
|
99
|
+
render(width: number) {
|
|
100
|
+
const rule = theme.fg("accent", "─".repeat(width));
|
|
101
|
+
// Wrapped, not cut: the model receives the whole option, so the user has to see all of it.
|
|
102
|
+
const rows = options.flatMap((option, index) => {
|
|
103
|
+
const box = picked.has(index) ? "[x]" : "[ ]";
|
|
104
|
+
const lead = ` ${index === row ? "→" : " "} ${box} `;
|
|
105
|
+
return wrapTextWithAnsi(option, Math.max(1, width - lead.length)).map((part, at) => {
|
|
106
|
+
const line = truncateToWidth(`${at === 0 ? lead : " ".repeat(lead.length)}${part}`, width);
|
|
107
|
+
return index === row ? theme.fg("accent", line) : line;
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
const hint = theme.fg("dim", " ↑↓ navigate space pick enter answer escape cancel");
|
|
111
|
+
const heading = wrapTextWithAnsi(theme.bold(title), Math.max(1, width - 2)).map((line) => ` ${line}`);
|
|
112
|
+
return [rule, "", ...heading, "", ...rows, "", truncateToWidth(hint, width), "", rule];
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function ownAnswer(ui: Dialogs, title: string, signal?: AbortSignal): Promise<string | undefined> {
|
|
119
|
+
const typed = await ui.input(`${title} ${SENT_TO_MODEL}`, "", { signal });
|
|
120
|
+
return typed?.trim() || undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Undefined means the user closed it. Leaving their own answer empty goes back to the options. */
|
|
124
|
+
async function answerOf(ui: Dialogs, question: Clean, title: string, drawsDialogs: boolean, signal?: AbortSignal): Promise<string | undefined> {
|
|
125
|
+
if (question.type === "text") {
|
|
126
|
+
const typed = await ui.input(`${title} ${SENT_TO_MODEL}`, "", { signal });
|
|
127
|
+
return typed === undefined ? undefined : typed.trim() || "(left empty)";
|
|
128
|
+
}
|
|
129
|
+
// Only the terminal can draw the list of boxes, and other clients answer it as if the user had closed it.
|
|
130
|
+
if (question.type === "multi_select" && !drawsDialogs) {
|
|
131
|
+
const typed = await ui.input(`${title} Pick from: ${question.options.join(" | ")} ${SENT_TO_MODEL}`, "", { signal });
|
|
132
|
+
return typed === undefined ? undefined : typed.trim() || "(none picked)";
|
|
133
|
+
}
|
|
134
|
+
const options = [...question.options, OWN_ANSWER];
|
|
135
|
+
let already: string[] = [];
|
|
136
|
+
for (;;) {
|
|
137
|
+
if (signal?.aborted) return undefined;
|
|
138
|
+
if (question.type === "multi_select") {
|
|
139
|
+
const picked = await pickSeveral(ui, title, options, signal, already);
|
|
140
|
+
if (picked === undefined) return undefined;
|
|
141
|
+
already = picked;
|
|
142
|
+
const named = picked.filter((option) => option !== OWN_ANSWER);
|
|
143
|
+
if (named.length === picked.length) return named.length === 0 ? "(none picked)" : JSON.stringify(named);
|
|
144
|
+
const typed = await ownAnswer(ui, title, signal);
|
|
145
|
+
if (typed !== undefined) return JSON.stringify([...named, typed]);
|
|
146
|
+
} else {
|
|
147
|
+
const picked = await ui.select(title, options, { signal });
|
|
148
|
+
if (picked === undefined) return undefined;
|
|
149
|
+
if (picked !== OWN_ANSWER) return picked;
|
|
150
|
+
const typed = await ownAnswer(ui, title, signal);
|
|
151
|
+
if (typed !== undefined) return typed;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function interview(ui: Dialogs, questions: readonly Clean[], signal?: AbortSignal, drawsDialogs = true): Promise<string> {
|
|
157
|
+
const lines: string[] = [];
|
|
158
|
+
for (const [index, question] of questions.entries()) {
|
|
159
|
+
const title = titleOf(question.question, index, questions.length);
|
|
160
|
+
const answer = await answerOf(ui, question, title, drawsDialogs, signal);
|
|
161
|
+
if (answer === undefined) {
|
|
162
|
+
const left = lines.length === 0 ? "" : `\nAnswered before that:\n${lines.join("\n")}`;
|
|
163
|
+
const who = signal?.aborted ? "The run was stopped" : "The user closed the questions";
|
|
164
|
+
return `${who} at: ${question.question}\nDon't guess the rest. Go on only with what they already told you, or ask what they want.${left}`;
|
|
165
|
+
}
|
|
166
|
+
lines.push(`Q: ${question.question}\nA: ${answer}`);
|
|
167
|
+
}
|
|
168
|
+
return lines.join("\n");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The questions, not their JSON. The arguments arrive a piece at a time, so any of it can be missing. */
|
|
172
|
+
export function askedLine(args: { questions?: readonly Partial<Question>[] }): string {
|
|
173
|
+
const questions = Array.isArray(args.questions) ? args.questions : [];
|
|
174
|
+
const first = typeof questions[0]?.question === "string" ? oneLine(questions[0].question).slice(0, CALL_LINE_CHARS) : "";
|
|
175
|
+
return questions.length > 1 ? `${first} (+${questions.length - 1} more)` : first;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function registerInterview(pi: ExtensionAPI): void {
|
|
179
|
+
pi.registerTool({
|
|
180
|
+
name: ASK_TOOL,
|
|
181
|
+
label: "Ask",
|
|
182
|
+
description: "Ask the user questions, one dialog at a time, and get their answers. The user can always type their own answer instead of the options.",
|
|
183
|
+
promptSnippet: "Ask the user questions when a decision is theirs",
|
|
184
|
+
promptGuidelines: [
|
|
185
|
+
`Use ${ASK_TOOL} when a decision is the user's to make and the work depends on it, or to interview them about what they want. Don't ask what you can find out by reading, and don't use it for permission to run a tool.`,
|
|
186
|
+
],
|
|
187
|
+
executionMode: "sequential",
|
|
188
|
+
parameters: Type.Object({
|
|
189
|
+
questions: Type.Array(
|
|
190
|
+
Type.Object({
|
|
191
|
+
question: Type.String(),
|
|
192
|
+
type: Type.String({ enum: [...TYPES], description: "text: free answer. yes_no. select: one option. multi_select: any number." }),
|
|
193
|
+
options: Type.Optional(Type.Array(Type.String(), { description: "For select and multi_select" })),
|
|
194
|
+
}),
|
|
195
|
+
),
|
|
196
|
+
}),
|
|
197
|
+
renderCall: (args, theme) => new Text(theme.fg("muted", askedLine(args)), 0, 0),
|
|
198
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
199
|
+
const questions = checkedQuestions(params.questions);
|
|
200
|
+
const text = ctx.hasUI
|
|
201
|
+
? await interview(ctx.ui, questions, signal, ctx.mode === "tui")
|
|
202
|
+
: "Nobody can answer here. Decide for yourself, and say what you assumed.";
|
|
203
|
+
return { content: [{ type: "text", text }], details: {} };
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lets the permission judge run only in auto mode, per session.
|
|
3
|
+
*
|
|
4
|
+
* pi-permission-system reads `authorizerChain` from a config file shared by
|
|
5
|
+
* every Pi process, so editing it would flip auto for all open sessions. It
|
|
6
|
+
* also has no API to read or disable a registered link. Instead this wraps the
|
|
7
|
+
* judge at registration: outside auto the wrapper defers, which sends the ask
|
|
8
|
+
* on to the normal prompt.
|
|
9
|
+
*
|
|
10
|
+
* Package load order must not matter, so the wrap is installed where each
|
|
11
|
+
* session's service is published, which happens before any extension can
|
|
12
|
+
* register on it. This depends on the service map's documented global key. If
|
|
13
|
+
* that changes, `judgeGated` stays false and the extension warns.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Mode } from "./modes.ts";
|
|
17
|
+
|
|
18
|
+
export const JUDGE_NAMES: ReadonlySet<string> = new Set(["permission-judge", "classifier"]);
|
|
19
|
+
|
|
20
|
+
export const YOLO_LINK = "pi-modes";
|
|
21
|
+
|
|
22
|
+
const SERVICES_KEY = Symbol.for("@gotgenes/pi-permission-system:session-services");
|
|
23
|
+
const STATE_KEY = Symbol.for("pi-modes:session-state");
|
|
24
|
+
const PATCHED = Symbol.for("pi-modes:patched");
|
|
25
|
+
|
|
26
|
+
type Authorize = (details: unknown, query: unknown, log: unknown) => Promise<unknown>;
|
|
27
|
+
|
|
28
|
+
interface ServiceLike {
|
|
29
|
+
registerAuthorizer?: (name: string, authorize: Authorize) => () => void;
|
|
30
|
+
[PATCHED]?: true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type ServiceMap = Map<string, ServiceLike> & { [PATCHED]?: true };
|
|
34
|
+
|
|
35
|
+
interface SessionState {
|
|
36
|
+
mode: Mode;
|
|
37
|
+
judgeGated: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type Store = Record<symbol, unknown>;
|
|
41
|
+
|
|
42
|
+
function sessionStates(store: Store): Map<string, SessionState> {
|
|
43
|
+
store[STATE_KEY] ??= new Map<string, SessionState>();
|
|
44
|
+
return store[STATE_KEY] as Map<string, SessionState>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function stateFor(store: Store, sessionId: string): SessionState {
|
|
48
|
+
const states = sessionStates(store);
|
|
49
|
+
let state = states.get(sessionId);
|
|
50
|
+
if (!state) {
|
|
51
|
+
state = { mode: "normal", judgeGated: false };
|
|
52
|
+
states.set(sessionId, state);
|
|
53
|
+
}
|
|
54
|
+
return state;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function gateService(store: Store, sessionId: string, service: ServiceLike): void {
|
|
58
|
+
if (service[PATCHED] || typeof service.registerAuthorizer !== "function") return;
|
|
59
|
+
service[PATCHED] = true;
|
|
60
|
+
const register = service.registerAuthorizer.bind(service);
|
|
61
|
+
service.registerAuthorizer = (name, authorize) => {
|
|
62
|
+
if (!JUDGE_NAMES.has(name)) return register(name, authorize);
|
|
63
|
+
stateFor(store, sessionId).judgeGated = true;
|
|
64
|
+
return register(name, async (details, query, log) =>
|
|
65
|
+
stateFor(store, sessionId).mode === "auto" ? authorize(details, query, log) : { kind: "defer" },
|
|
66
|
+
);
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Safe to call from every extension instance: the map is patched once per process. */
|
|
71
|
+
export function installJudgeGate(store: Store = globalThis as unknown as Store): void {
|
|
72
|
+
store[SERVICES_KEY] ??= new Map<string, ServiceLike>();
|
|
73
|
+
const services = store[SERVICES_KEY] as ServiceMap;
|
|
74
|
+
if (services[PATCHED]) return;
|
|
75
|
+
services[PATCHED] = true;
|
|
76
|
+
|
|
77
|
+
for (const [sessionId, service] of services) gateService(store, sessionId, service);
|
|
78
|
+
const set = services.set.bind(services);
|
|
79
|
+
services.set = (sessionId, service) => {
|
|
80
|
+
gateService(store, sessionId, service);
|
|
81
|
+
return set(sessionId, service);
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Registers the link that makes yolo work: it allows every ask in yolo and defers otherwise.
|
|
87
|
+
* It decides nothing until the user names it in `authorizerChain`, and because it only runs on
|
|
88
|
+
* an ask, `deny` rules still block. The review log names it, so yolo's approvals stay visible.
|
|
89
|
+
* Returns a disposer, or undefined when this session has no permission system.
|
|
90
|
+
*/
|
|
91
|
+
export function registerYoloLink(sessionId: string, store: Store = globalThis as unknown as Store): (() => void) | undefined {
|
|
92
|
+
const service = (store[SERVICES_KEY] as ServiceMap | undefined)?.get(sessionId);
|
|
93
|
+
if (typeof service?.registerAuthorizer !== "function") return undefined;
|
|
94
|
+
return service.registerAuthorizer(YOLO_LINK, async () =>
|
|
95
|
+
stateFor(store, sessionId).mode === "yolo" ? { kind: "allow" } : { kind: "defer" },
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function hasPermissionSystem(sessionId: string, store: Store = globalThis as unknown as Store): boolean {
|
|
100
|
+
return (store[SERVICES_KEY] as ServiceMap | undefined)?.has(sessionId) === true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function setGateMode(sessionId: string, mode: Mode, store: Store = globalThis as unknown as Store): void {
|
|
104
|
+
stateFor(store, sessionId).mode = mode;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function judgeGated(sessionId: string, store: Store = globalThis as unknown as Store): boolean {
|
|
108
|
+
return stateFor(store, sessionId).judgeGated;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function clearGate(sessionId: string, store: Store = globalThis as unknown as Store): void {
|
|
112
|
+
sessionStates(store).delete(sessionId);
|
|
113
|
+
}
|