gentle-pi 2.4.0 → 2.6.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/README.md +292 -25
- package/assets/agents/gentle-ai-worker.md +13 -0
- package/assets/agents/jd-fix-agent.md +18 -0
- package/assets/agents/jd-judge-a.md +1 -1
- package/assets/agents/jd-judge-b.md +1 -1
- package/assets/agents/sdd-apply.md +7 -5
- package/assets/agents/sdd-archive.md +5 -3
- package/assets/agents/sdd-design.md +4 -0
- package/assets/agents/sdd-explore.md +4 -0
- package/assets/agents/sdd-init.md +4 -0
- package/assets/agents/sdd-onboard.md +4 -0
- package/assets/agents/sdd-proposal.md +4 -0
- package/assets/agents/sdd-remediate.md +37 -0
- package/assets/agents/sdd-research.md +26 -3
- package/assets/agents/sdd-spec.md +4 -0
- package/assets/agents/sdd-status.md +9 -75
- package/assets/agents/sdd-sync.md +4 -0
- package/assets/agents/sdd-tasks.md +4 -0
- package/assets/agents/sdd-verify.md +5 -3
- package/assets/chains/sdd-full.chain.md +4 -0
- package/assets/chains/sdd-plan.chain.md +4 -0
- package/assets/chains/sdd-verify.chain.md +4 -0
- package/assets/migrations/managed-assets-v2.5.0.json +7 -0
- package/assets/orchestrator-delegation.md +39 -11
- package/assets/orchestrator.md +5 -5
- package/assets/sdd-orchestrator-workflow.md +54 -21
- package/assets/support/sdd-status-contract.md +34 -90
- package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
- package/docs/delegated-verification.md +25 -0
- package/docs/telemetry.md +94 -0
- package/docs/windows-startup-console-visibility.md +18 -0
- package/extensions/ask-user-choice.ts +159 -25
- package/extensions/codegraph-tools.ts +95 -5
- package/extensions/gentle-agents.ts +1337 -0
- package/extensions/gentle-ai.ts +2916 -386
- package/extensions/gentle-shell.ts +650 -0
- package/extensions/gentle-todo.ts +234 -0
- package/extensions/quiet-tools.ts +2 -1
- package/extensions/runtime-metrics.ts +130 -0
- package/extensions/sdd-init.ts +2 -2
- package/extensions/startup-banner.ts +52 -75
- package/lib/agent-profiles.ts +550 -0
- package/lib/agents-completion-delivery.ts +72 -0
- package/lib/agents-config.ts +315 -0
- package/lib/agents-history.ts +88 -0
- package/lib/agents-messaging.ts +187 -0
- package/lib/agents-protocol.ts +501 -0
- package/lib/agents-runner.ts +1012 -0
- package/lib/agents-thread-view.ts +57 -0
- package/lib/agents-transcript.ts +87 -0
- package/lib/agents-view-layout.ts +40 -0
- package/lib/agents-view.ts +914 -0
- package/lib/agents-widget.ts +241 -0
- package/lib/gentle-ai-binary.ts +3 -1
- package/lib/gentle-ai-renderer.ts +143 -25
- package/lib/native-choice-list.ts +194 -0
- package/lib/native-fullscreen-interaction.ts +47 -0
- package/lib/native-pointer-region.ts +164 -0
- package/lib/native-review-cli.ts +371 -13
- package/lib/orchestrator-presence.ts +337 -0
- package/lib/profiles-orchestrator.ts +203 -0
- package/lib/review-candidate-view-owner.ts +427 -0
- package/lib/review-candidate-view.ts +150 -48
- package/lib/review-consent-component.ts +247 -0
- package/lib/review-consent-ui.ts +110 -0
- package/lib/review-host-relay.ts +28 -0
- package/lib/review-integration-v2.ts +243 -11
- package/lib/review-last-event-controller.ts +8 -4
- package/lib/review-relay-contract.ts +11 -0
- package/lib/review-reminder-receipt.ts +74 -0
- package/lib/review-repository.ts +2 -2
- package/lib/review-risk-assessment.ts +339 -0
- package/lib/review-session-standing-permission-ipc.ts +309 -0
- package/lib/review-session-standing-permission.ts +240 -0
- package/lib/runtime-metrics-children.ts +199 -0
- package/lib/runtime-metrics-delivery.ts +68 -0
- package/lib/runtime-metrics-native.ts +166 -0
- package/lib/runtime-metrics-pi-identity.ts +113 -0
- package/lib/runtime-metrics-policy.ts +51 -0
- package/lib/runtime-metrics.ts +255 -0
- package/lib/sdd-preflight.ts +362 -81
- package/lib/sdd-research-capabilities.ts +228 -0
- package/lib/sdd-status.ts +29 -7
- package/lib/session-worktree-registry.ts +118 -0
- package/lib/shell-bar.ts +184 -0
- package/lib/shell-card.ts +133 -0
- package/lib/shell-changes-view.ts +530 -0
- package/lib/shell-changes.ts +290 -0
- package/lib/shell-gauge.ts +40 -0
- package/lib/shell-prompt.ts +115 -0
- package/lib/shell-sidebar-banner.ts +11 -0
- package/lib/shell-sidebar-layout.ts +213 -0
- package/lib/shell-sidebar.ts +41 -0
- package/lib/shell-todo.ts +297 -0
- package/lib/shell-usage-view.ts +76 -0
- package/lib/shell-usage.ts +246 -0
- package/lib/telemetry-trigger.ts +153 -0
- package/package.json +8 -5
- package/runtime/gentle-ai-binary.mjs +3 -1
- package/runtime/native-review-cli.mjs +370 -12
- package/runtime/review-integration-v2.mjs +243 -11
- package/runtime/review-relay-contract.mjs +11 -0
- package/runtime/review-risk-assessment.mjs +340 -0
- package/runtime/telemetry-trigger.mjs +154 -0
- package/scripts/build-runtime-modules.mjs +11 -1
- package/scripts/check-types.mjs +125 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/install-gentle-ai.mjs +12 -0
- package/scripts/install-tui-mode-setting.mjs +114 -0
- package/scripts/test-packed-runner.mjs +38 -2
- package/scripts/types-baseline.json +99 -0
- package/scripts/verify-package-files.mjs +8 -2
- package/skills/_shared/review-ledger-contract.md +20 -2
- package/skills/issue-creation/SKILL.md +3 -3
- package/skills/judgment-day/SKILL.md +17 -3
- package/skills/judgment-day/references/prompts-and-formats.md +14 -3
- package/tests/agent-profiles.test.ts +722 -0
- package/tests/agents-completion-delivery.test.ts +94 -0
- package/tests/agents-config.test.ts +205 -0
- package/tests/agents-fake-child.ts +66 -0
- package/tests/agents-grouping.test.ts +179 -0
- package/tests/agents-history.test.ts +54 -0
- package/tests/agents-integration.test.ts +100 -0
- package/tests/agents-messaging.test.ts +94 -0
- package/tests/agents-protocol.test.ts +198 -0
- package/tests/agents-queries.test.ts +190 -0
- package/tests/agents-responsive.test.ts +43 -0
- package/tests/agents-runner-process.test.ts +111 -0
- package/tests/agents-runner.test.ts +959 -0
- package/tests/agents-thread-view.test.ts +45 -0
- package/tests/agents-transcript.test.ts +30 -0
- package/tests/agents-view.test.ts +685 -0
- package/tests/agents-widget.test.ts +141 -0
- package/tests/artifact-language.test.ts +25 -2
- package/tests/ask-user-choice.test.ts +325 -5
- package/tests/asset-installation-runtime.test.ts +108 -0
- package/tests/autonomous-guard.test.ts +116 -1
- package/tests/codegraph-tools.test.ts +112 -2
- package/tests/delegated-key-learnings-contract.test.ts +1 -1
- package/tests/devbinary/native-review-parity.devtest.ts +110 -0
- package/tests/feature-request-form.test.ts +67 -0
- package/tests/fixtures/agents-messaging-child.mjs +5 -0
- package/tests/fixtures/agents-process-child.mjs +23 -0
- package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
- package/tests/gentle-agents.test.ts +2168 -0
- package/tests/gentle-ai-binary.test.ts +7 -2
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai-renderer.test.ts +103 -0
- package/tests/gentle-ai.test.ts +971 -15
- package/tests/gentle-card-text.ts +35 -0
- package/tests/gentle-shell.test.ts +818 -0
- package/tests/gentle-todo.test.ts +226 -0
- package/tests/install-tui-mode-setting.test.ts +324 -0
- package/tests/issue-creation-skill.test.ts +22 -0
- package/tests/model-routing-authority.test.ts +12 -0
- package/tests/native-choice-list.test.ts +202 -0
- package/tests/native-fullscreen-interaction.test.ts +125 -0
- package/tests/native-pointer-region.test.ts +245 -0
- package/tests/native-review-capability-contract.test.ts +27 -1
- package/tests/native-review-cli.test.ts +317 -3
- package/tests/native-review-consent.test.ts +91 -0
- package/tests/native-review-parity-runtime.test.ts +8 -2
- package/tests/native-review-parity.test.ts +43 -29
- package/tests/native-sdd-attempt-authority.test.ts +7 -2
- package/tests/orchestrator-budget.test.ts +69 -0
- package/tests/orchestrator-presence.test.ts +389 -0
- package/tests/orchestrator-rdd-ownership.test.ts +9 -0
- package/tests/package-manifest.test.ts +243 -7
- package/tests/profiles-orchestrator.test.ts +208 -0
- package/tests/quiet-tool-rendering.test.ts +97 -37
- package/tests/rdd-aware-verification-contract.test.ts +226 -0
- package/tests/rdd-status-line.test.ts +286 -0
- package/tests/review-agent-end-preflight.test.ts +332 -24
- package/tests/review-candidate-view.test.ts +751 -7
- package/tests/review-consent-ui.test.ts +352 -0
- package/tests/review-contract-prompt.test.ts +17 -0
- package/tests/review-controller-native-recovery.test.ts +29 -4
- package/tests/review-controller-native-routing.test.ts +884 -7
- package/tests/review-controller-workspace-root.test.ts +45 -2
- package/tests/review-controller.test.ts +26 -1
- package/tests/review-host-relay-restart-parity.test.ts +142 -1
- package/tests/review-host-relay-routing.test.ts +384 -8
- package/tests/review-host-relay.test.ts +29 -0
- package/tests/review-integration-v2-forward.test.ts +44 -0
- package/tests/review-integration-v2.test.ts +276 -0
- package/tests/review-last-event-closure.test.ts +112 -3
- package/tests/review-ledger-contract.test.ts +61 -6
- package/tests/review-relay-contract.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +62 -0
- package/tests/review-repository.test.ts +28 -1
- package/tests/review-risk-assessment.test.ts +626 -0
- package/tests/review-session-standing-permission-controller.test.ts +656 -0
- package/tests/review-session-standing-permission-ipc.test.ts +233 -0
- package/tests/review-session-standing-permission-runtime.test.ts +212 -0
- package/tests/review-session-standing-permission.test.ts +156 -0
- package/tests/runtime-harness.mjs +447 -39
- package/tests/runtime-metrics-children.test.ts +206 -0
- package/tests/runtime-metrics-delivery.test.ts +85 -0
- package/tests/runtime-metrics-extension.test.ts +187 -0
- package/tests/runtime-metrics-native.test.ts +209 -0
- package/tests/runtime-metrics-pi-identity.test.ts +113 -0
- package/tests/runtime-metrics-policy.test.ts +62 -0
- package/tests/runtime-metrics.test.ts +184 -0
- package/tests/sdd-agent-tools.test.ts +10 -1
- package/tests/sdd-execution-routing-contract.test.ts +28 -0
- package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
- package/tests/sdd-native-managed-uptake.test.ts +253 -0
- package/tests/sdd-planning-routing-contract.test.ts +45 -0
- package/tests/sdd-preflight.test.ts +252 -8
- package/tests/sdd-research-capabilities.test.ts +256 -0
- package/tests/sdd-research-live.test.ts +241 -0
- package/tests/sdd-selection-transport.test.ts +504 -0
- package/tests/sdd-status.test.ts +51 -0
- package/tests/session-worktree-registry.test.ts +135 -0
- package/tests/shell-bar.test.ts +176 -0
- package/tests/shell-card.test.ts +139 -0
- package/tests/shell-changes-view.test.ts +609 -0
- package/tests/shell-changes.test.ts +350 -0
- package/tests/shell-prompt.test.ts +140 -0
- package/tests/shell-sidebar-banner.test.ts +23 -0
- package/tests/shell-sidebar-layout.test.ts +387 -0
- package/tests/shell-sidebar.test.ts +50 -0
- package/tests/shell-todo.test.ts +259 -0
- package/tests/shell-usage-view.test.ts +62 -0
- package/tests/shell-usage.test.ts +197 -0
- package/tests/startup-banner.test.ts +126 -0
- package/tests/telemetry-trigger.test.ts +351 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { stripVTControlCharacters } from "node:util";
|
|
5
|
+
|
|
6
|
+
// Same-profile OS-user trust boundary, not an authorization channel. POSIX modes
|
|
7
|
+
// restrict newly created storage; Windows deployments must supply their own ACLs.
|
|
8
|
+
// Synchronous bounded I/O serializes publication/disposal without promise races.
|
|
9
|
+
export const ACTIVITY_LIMIT = 16 * 1024 * 1024;
|
|
10
|
+
const HEADER_LIMIT = 16 * 1024;
|
|
11
|
+
const HASH = /^[a-f0-9]{64}$/;
|
|
12
|
+
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
|
|
13
|
+
const STATUSES = ["running", "queued", "waiting", "completed", "failed", "cancelled", "timed_out"];
|
|
14
|
+
type ObjectValue = Record<string, any>;
|
|
15
|
+
/** Remote records belong in a separate read-only store, never the local TaskStore.
|
|
16
|
+
* Key them by (header.sessionHash, header.incarnation, summary.id), not bare task ID.
|
|
17
|
+
* The header supplies parent identity; callers project only that session's tasks. */
|
|
18
|
+
export interface RemoteSummary {
|
|
19
|
+
id: string; agent: string; label: string; status: string; model: string;
|
|
20
|
+
createdAt: number; startedAt: number | null; endedAt: number | null; lastActivityAt: number;
|
|
21
|
+
}
|
|
22
|
+
export interface ActivityInput {
|
|
23
|
+
task: RemoteSummary;
|
|
24
|
+
thread: { version: number; dropped: number; items: readonly object[] };
|
|
25
|
+
}
|
|
26
|
+
export interface Activity { tasks: { summary: ActivityInput["task"]; thread: {
|
|
27
|
+
version: number; dropped: number; items: ObjectValue[];
|
|
28
|
+
} }[] }
|
|
29
|
+
export interface Target { sessionHash: string; incarnation: string }
|
|
30
|
+
export interface Header extends Target {
|
|
31
|
+
schema: 1; label: string; heartbeat: number; generation: number;
|
|
32
|
+
counts: { running: number; queued: number; waiting: number; finished: number };
|
|
33
|
+
digest: string | null; unavailable: "activity-too-large" | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const object = (v: unknown): v is ObjectValue => !!v && typeof v === "object" && !Array.isArray(v);
|
|
37
|
+
const integer = (v: unknown) => Number.isSafeInteger(v) && (v as number) >= 0;
|
|
38
|
+
const keys = (v: ObjectValue, names: string[]) => Object.keys(v).length === names.length && names.every((k) => Object.hasOwn(v, k));
|
|
39
|
+
const digest = (data: string | Buffer) => createHash("sha256").update(data).digest("hex");
|
|
40
|
+
function label(text: string) {
|
|
41
|
+
const clean = stripVTControlCharacters(text).replace(/[\p{Cc}\p{Cf}]/gu, " ").replace(/\s+/g, " ").trim();
|
|
42
|
+
return Array.from(clean).slice(0, 120).join("").trimEnd();
|
|
43
|
+
}
|
|
44
|
+
const pick = (v: ObjectValue, names: string[]) => Object.fromEntries(names.map((k) => [k, v[k]]));
|
|
45
|
+
const summaryTextKeys = ["id", "agent", "label", "status", "model"];
|
|
46
|
+
const summaryKeys = [...summaryTextKeys, "createdAt", "startedAt", "endedAt", "lastActivityAt"];
|
|
47
|
+
const toolKeys = ["kind", "callId", "name", "output", "running", "isError"];
|
|
48
|
+
|
|
49
|
+
/** Retains every item and every output character; no prompt fallback or invocation
|
|
50
|
+
* args (which can embed subagent prompts), session metadata, or control handles.
|
|
51
|
+
* This is a field whitelist, not content redaction: retained text can contain secrets. */
|
|
52
|
+
export function projectActivity(input: readonly ActivityInput[]): Activity {
|
|
53
|
+
const activity = { tasks: input.map(({ task, thread }) => ({
|
|
54
|
+
summary: pick(task, summaryKeys) as ActivityInput["task"],
|
|
55
|
+
thread: { version: thread.version, dropped: thread.dropped,
|
|
56
|
+
items: thread.items.map((item) => pick(item, (item as ObjectValue).kind === "tool" ? toolKeys : ["kind", "text"])) },
|
|
57
|
+
})) };
|
|
58
|
+
if (!validActivity(activity)) throw new Error("malformed-activity");
|
|
59
|
+
return activity;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function validActivity(value: unknown): value is Activity {
|
|
63
|
+
return object(value) && keys(value, ["tasks"]) && Array.isArray(value.tasks) && value.tasks.every((row: unknown) => {
|
|
64
|
+
if (!object(row) || !keys(row, ["summary", "thread"])) return false;
|
|
65
|
+
const { summary: s, thread: t } = row;
|
|
66
|
+
return object(s) && keys(s, summaryKeys) && summaryTextKeys.every((k) => typeof s[k] === "string") && STATUSES.includes(s.status)
|
|
67
|
+
&& integer(s.createdAt) && integer(s.lastActivityAt)
|
|
68
|
+
&& [s.startedAt, s.endedAt].every((time) => time === null || integer(time))
|
|
69
|
+
&& object(t) && keys(t, ["version", "dropped", "items"]) && integer(t.version) && integer(t.dropped)
|
|
70
|
+
&& Array.isArray(t.items) && t.items.every((item: unknown) => object(item) && (item.kind === "tool"
|
|
71
|
+
? keys(item, toolKeys) && ["callId", "name", "output"].every((k) => typeof item[k] === "string")
|
|
72
|
+
&& typeof item.running === "boolean" && typeof item.isError === "boolean"
|
|
73
|
+
: ["text", "thinking", "note"].includes(item.kind) && keys(item, ["kind", "text"]) && typeof item.text === "string"));
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function validTarget(value: Target) {
|
|
78
|
+
return object(value) && typeof value.sessionHash === "string" && HASH.test(value.sessionHash)
|
|
79
|
+
&& typeof value.incarnation === "string" && UUID.test(value.incarnation);
|
|
80
|
+
}
|
|
81
|
+
function validHeader(h: unknown): h is Header {
|
|
82
|
+
if (!object(h) || !keys(h, ["schema", "sessionHash", "incarnation", "label", "heartbeat", "generation", "counts", "digest", "unavailable"])) return false;
|
|
83
|
+
return validTarget(h as Header) && h.schema === 1 && typeof h.label === "string" && h.label === label(h.label)
|
|
84
|
+
&& integer(h.heartbeat) && integer(h.generation) && h.generation > 0
|
|
85
|
+
&& object(h.counts) && keys(h.counts, ["running", "queued", "waiting", "finished"]) && Object.values(h.counts).every(integer)
|
|
86
|
+
&& ((h.unavailable === null && typeof h.digest === "string" && HASH.test(h.digest))
|
|
87
|
+
|| (h.unavailable === "activity-too-large" && h.digest === null));
|
|
88
|
+
}
|
|
89
|
+
function filename(target: Target, kind: "header" | "activity") {
|
|
90
|
+
if (!validTarget(target)) throw new Error("malformed");
|
|
91
|
+
return `${target.sessionHash}.${target.incarnation}.${kind}.json`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function directory(path: string, privateMode = false, owned = privateMode) {
|
|
95
|
+
const stat = fs.lstatSync(path);
|
|
96
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || (owned && process.platform !== "win32"
|
|
97
|
+
&& ((stat.mode & (privateMode ? 0o077 : 0o022)) !== 0 || stat.uid !== process.getuid?.()))) throw new Error("unsafe-directory");
|
|
98
|
+
}
|
|
99
|
+
function rootFor(profile: string, create = false) {
|
|
100
|
+
const absolute = resolve(profile);
|
|
101
|
+
// Reject symlink ancestors too. The caller supplies an existing profile root.
|
|
102
|
+
for (let path = absolute;; path = dirname(path)) {
|
|
103
|
+
directory(path);
|
|
104
|
+
if (dirname(path) === path) break;
|
|
105
|
+
}
|
|
106
|
+
const shared = join(absolute, "gentle-agents");
|
|
107
|
+
const root = join(shared, "presence");
|
|
108
|
+
// History may already own a 0755 shared root. Never change its permissions.
|
|
109
|
+
for (const path of [shared, root]) {
|
|
110
|
+
if (create) {
|
|
111
|
+
try { fs.mkdirSync(path, { mode: 0o700 }); }
|
|
112
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
|
|
113
|
+
}
|
|
114
|
+
directory(path, path === root, true);
|
|
115
|
+
}
|
|
116
|
+
return root;
|
|
117
|
+
}
|
|
118
|
+
function regular(stat: fs.Stats) {
|
|
119
|
+
if (!stat.isFile() || stat.nlink !== 1 || (process.platform !== "win32" && stat.uid !== process.getuid?.())) throw new Error("unsafe-file");
|
|
120
|
+
}
|
|
121
|
+
function boundedRead(path: string, limit: number) {
|
|
122
|
+
const before = fs.lstatSync(path);
|
|
123
|
+
regular(before);
|
|
124
|
+
if (before.size > limit) throw new Error("oversized");
|
|
125
|
+
const fd = fs.openSync(path, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0));
|
|
126
|
+
try {
|
|
127
|
+
const stat = fs.fstatSync(fd);
|
|
128
|
+
regular(stat);
|
|
129
|
+
if (stat.ino !== before.ino || stat.dev !== before.dev) throw new Error("unsafe-file");
|
|
130
|
+
if (stat.size > limit) throw new Error("oversized");
|
|
131
|
+
const bytes = Buffer.alloc(stat.size + 1);
|
|
132
|
+
let used = 0;
|
|
133
|
+
while (used < bytes.length) {
|
|
134
|
+
const count = fs.readSync(fd, bytes, used, bytes.length - used, null);
|
|
135
|
+
if (!count) break;
|
|
136
|
+
used += count;
|
|
137
|
+
}
|
|
138
|
+
if (used !== stat.size) throw new Error("changed-file");
|
|
139
|
+
return bytes.subarray(0, used);
|
|
140
|
+
} finally { fs.closeSync(fd); }
|
|
141
|
+
}
|
|
142
|
+
function reason(error: unknown) {
|
|
143
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
144
|
+
return code === "ENOENT" ? "missing" : code ? "io-error" : error instanceof SyntaxError ? "malformed" : (error as Error).message;
|
|
145
|
+
}
|
|
146
|
+
function atomicWrite(root: string, name: string, bytes: string) {
|
|
147
|
+
directory(root, true);
|
|
148
|
+
const destination = join(root, name);
|
|
149
|
+
try { regular(fs.lstatSync(destination)); }
|
|
150
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
151
|
+
const temp = join(root, `.${name}.${randomUUID()}.tmp`);
|
|
152
|
+
const fd = fs.openSync(temp, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600);
|
|
153
|
+
try {
|
|
154
|
+
try { fs.writeFileSync(fd, bytes); } finally { fs.closeSync(fd); }
|
|
155
|
+
fs.renameSync(temp, destination);
|
|
156
|
+
} finally {
|
|
157
|
+
try { fs.unlinkSync(temp); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface PresencePage {
|
|
162
|
+
entries: (Header & { recent: boolean })[];
|
|
163
|
+
scanned: number; rejected: number; overflow: boolean; unavailable?: string;
|
|
164
|
+
}
|
|
165
|
+
function emptyPage(): PresencePage {
|
|
166
|
+
return { entries: [], scanned: 0, rejected: 0, overflow: false };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** A bounded, process-local continuation: each next() examines at most 128 entries.
|
|
170
|
+
* Exhaustion closes automatically; callers abandoning traversal must close in finally.
|
|
171
|
+
* Overflow means the page budget was exhausted; the next page may be empty.
|
|
172
|
+
* A stable directory is traversed once in OS order. Concurrent mutations can cause
|
|
173
|
+
* misses/duplicates: deduplicate activation keys and open a fresh cursor on refresh.
|
|
174
|
+
* This is not a snapshot or a liveness guarantee. No stale files are deleted. */
|
|
175
|
+
export class PresenceCursor {
|
|
176
|
+
private dir?: fs.Dir;
|
|
177
|
+
private readonly profile: string;
|
|
178
|
+
private readonly identity: fs.Stats;
|
|
179
|
+
constructor(profile: string) {
|
|
180
|
+
this.profile = resolve(profile);
|
|
181
|
+
const root = rootFor(this.profile);
|
|
182
|
+
this.identity = fs.lstatSync(root);
|
|
183
|
+
this.dir = fs.opendirSync(root);
|
|
184
|
+
}
|
|
185
|
+
next(now = Date.now()): PresencePage {
|
|
186
|
+
const result = emptyPage();
|
|
187
|
+
if (!this.dir) return { ...result, unavailable: "closed" };
|
|
188
|
+
try {
|
|
189
|
+
const stat = fs.lstatSync(rootFor(this.profile));
|
|
190
|
+
if (stat.dev !== this.identity.dev || stat.ino !== this.identity.ino) throw new Error("changed-directory");
|
|
191
|
+
while (result.scanned < 128) {
|
|
192
|
+
const entry = this.dir.readSync();
|
|
193
|
+
if (!entry) { this.close(); return result; }
|
|
194
|
+
result.scanned++;
|
|
195
|
+
if (!entry.name.endsWith(".header.json") || entry.name.startsWith(".")) continue;
|
|
196
|
+
try {
|
|
197
|
+
const h = JSON.parse(boundedRead(join(this.dir.path, entry.name), HEADER_LIMIT).toString("utf8"));
|
|
198
|
+
if (!validHeader(h) || filename(h, "header") !== entry.name) throw new Error("malformed");
|
|
199
|
+
result.entries.push({ ...h, recent: now >= h.heartbeat && now - h.heartbeat <= 15_000 });
|
|
200
|
+
} catch { result.rejected++; }
|
|
201
|
+
}
|
|
202
|
+
result.overflow = true;
|
|
203
|
+
} catch (error) { result.unavailable = reason(error); this.close(); }
|
|
204
|
+
return result;
|
|
205
|
+
}
|
|
206
|
+
close() {
|
|
207
|
+
const dir = this.dir;
|
|
208
|
+
this.dir = undefined;
|
|
209
|
+
dir?.closeSync();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** First-page convenience only. Use PresenceCursor when overflow is visible. */
|
|
214
|
+
export function listPresence(profile: string, now = Date.now()): PresencePage {
|
|
215
|
+
try {
|
|
216
|
+
const cursor = new PresenceCursor(profile);
|
|
217
|
+
try { return cursor.next(now); } finally { cursor.close(); }
|
|
218
|
+
} catch (error) { return { ...emptyPage(), unavailable: reason(error) }; }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The selection pins both activation and generation; never fall back to another session. */
|
|
222
|
+
export function readActivity(profile: string, selection: Header): { activity?: Activity; unavailable?: string } {
|
|
223
|
+
try {
|
|
224
|
+
// Directory list adds only this reader-owned display hint.
|
|
225
|
+
const { recent: _recent, ...h } = selection as Header & { recent?: boolean };
|
|
226
|
+
if (!validHeader(h)) throw new Error("malformed");
|
|
227
|
+
if (h.unavailable) return { unavailable: h.unavailable };
|
|
228
|
+
const bytes = boundedRead(join(rootFor(profile), filename(h, "activity")), ACTIVITY_LIMIT);
|
|
229
|
+
const value = JSON.parse(bytes.toString("utf8"));
|
|
230
|
+
if (object(value) && integer(value.generation) && value.generation !== h.generation) throw new Error("generation-mismatch");
|
|
231
|
+
if (digest(bytes) !== h.digest) throw new Error("digest-mismatch");
|
|
232
|
+
if (!object(value) || !keys(value, ["schema", "sessionHash", "incarnation", "generation", "activity"])
|
|
233
|
+
|| value.schema !== 1 || value.generation !== h.generation || value.sessionHash !== h.sessionHash
|
|
234
|
+
|| value.incarnation !== h.incarnation || !validActivity(value.activity)) throw new Error("malformed");
|
|
235
|
+
return { activity: value.activity };
|
|
236
|
+
} catch (error) { return { unavailable: reason(error) }; }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export class PresencePublisher {
|
|
240
|
+
readonly target: Readonly<Target>;
|
|
241
|
+
private readonly profile: string;
|
|
242
|
+
private readonly displayLabel: string;
|
|
243
|
+
private header!: Header;
|
|
244
|
+
private published = "";
|
|
245
|
+
private pending = "";
|
|
246
|
+
private timer?: ReturnType<typeof setTimeout>;
|
|
247
|
+
private heartbeat?: ReturnType<typeof setInterval>;
|
|
248
|
+
private disposed = false;
|
|
249
|
+
private owned = new Map<string, { dev: number; ino: number }>();
|
|
250
|
+
/** Timer I/O failures stop publication; consumers still apply the recent TTL. */
|
|
251
|
+
error?: string;
|
|
252
|
+
|
|
253
|
+
private constructor(options: { profile: string; sessionId: string; label: string }) {
|
|
254
|
+
this.profile = options.profile;
|
|
255
|
+
this.displayLabel = label(options.label);
|
|
256
|
+
this.target = Object.freeze({ sessionHash: digest(options.sessionId), incarnation: randomUUID() });
|
|
257
|
+
}
|
|
258
|
+
static start(options: { profile: string; sessionId: string; label: string; activity: readonly ActivityInput[] }) {
|
|
259
|
+
const publisher = new PresencePublisher(options);
|
|
260
|
+
try {
|
|
261
|
+
rootFor(options.profile, true);
|
|
262
|
+
publisher.pending = JSON.stringify(projectActivity(options.activity));
|
|
263
|
+
publisher.flush();
|
|
264
|
+
publisher.heartbeat = setInterval(() => publisher.guarded(() => publisher.publishHeader()), 5000);
|
|
265
|
+
publisher.heartbeat.unref();
|
|
266
|
+
return publisher;
|
|
267
|
+
} catch (error) { publisher.dispose(); throw error; }
|
|
268
|
+
}
|
|
269
|
+
/** Eagerly projects/serializes each supplied snapshot to detach caller-owned data.
|
|
270
|
+
* Only disk publication is coalesced; callers should avoid unrelated invalidations. */
|
|
271
|
+
update(input: readonly ActivityInput[]) {
|
|
272
|
+
if (this.disposed) throw new Error("disposed");
|
|
273
|
+
const next = JSON.stringify(projectActivity(input));
|
|
274
|
+
if (next === this.pending) return;
|
|
275
|
+
this.pending = next;
|
|
276
|
+
if (!this.timer && next !== this.published) {
|
|
277
|
+
this.timer = setTimeout(() => {
|
|
278
|
+
this.timer = undefined;
|
|
279
|
+
this.guarded(() => this.flush());
|
|
280
|
+
}, 400);
|
|
281
|
+
this.timer.unref();
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
private guarded(action: () => void) {
|
|
285
|
+
if (this.disposed) return;
|
|
286
|
+
try { action(); } catch (error) { this.error = reason(error); this.dispose(); }
|
|
287
|
+
}
|
|
288
|
+
private write(kind: "header" | "activity", bytes: string) {
|
|
289
|
+
const root = rootFor(this.profile);
|
|
290
|
+
const name = filename(this.target, kind);
|
|
291
|
+
atomicWrite(root, name, bytes);
|
|
292
|
+
const { dev, ino } = fs.lstatSync(join(root, name));
|
|
293
|
+
this.owned.set(name, { dev, ino });
|
|
294
|
+
}
|
|
295
|
+
private publishHeader() {
|
|
296
|
+
const header = { ...this.header, heartbeat: Date.now() };
|
|
297
|
+
if (!validHeader(header)) throw new Error("malformed-header");
|
|
298
|
+
this.write("header", JSON.stringify(header));
|
|
299
|
+
this.header = header;
|
|
300
|
+
}
|
|
301
|
+
private flush() {
|
|
302
|
+
if (this.pending === this.published) return;
|
|
303
|
+
const activity: Activity = JSON.parse(this.pending);
|
|
304
|
+
const generation = (this.header?.generation ?? 0) + 1;
|
|
305
|
+
const bytes = JSON.stringify({ schema: 1, ...this.target, generation, activity });
|
|
306
|
+
const oversized = Buffer.byteLength(bytes) > ACTIVITY_LIMIT;
|
|
307
|
+
const counts = { running: 0, queued: 0, waiting: 0, finished: 0 };
|
|
308
|
+
for (const { summary } of activity.tasks) {
|
|
309
|
+
const status = summary.status;
|
|
310
|
+
if (status === "running" || status === "queued" || status === "waiting") counts[status]++;
|
|
311
|
+
else counts.finished++;
|
|
312
|
+
}
|
|
313
|
+
if (!oversized) this.write("activity", bytes);
|
|
314
|
+
this.header = { schema: 1, ...this.target, label: this.displayLabel, heartbeat: Date.now(), generation,
|
|
315
|
+
counts, digest: oversized ? null : digest(bytes), unavailable: oversized ? "activity-too-large" : null };
|
|
316
|
+
this.publishHeader();
|
|
317
|
+
this.published = this.pending;
|
|
318
|
+
}
|
|
319
|
+
/** Idempotent, non-recursive cleanup, limited to files this activation published.
|
|
320
|
+
* Replaced or linked files are deliberately left for their owner to handle. */
|
|
321
|
+
dispose() {
|
|
322
|
+
if (this.disposed) return;
|
|
323
|
+
this.disposed = true;
|
|
324
|
+
clearTimeout(this.timer);
|
|
325
|
+
clearInterval(this.heartbeat);
|
|
326
|
+
this.pending = this.published = "";
|
|
327
|
+
for (const [name, identity] of this.owned) {
|
|
328
|
+
try {
|
|
329
|
+
const path = join(rootFor(this.profile), name);
|
|
330
|
+
const stat = fs.lstatSync(path);
|
|
331
|
+
regular(stat);
|
|
332
|
+
if (stat.dev === identity.dev && stat.ino === identity.ino) fs.unlinkSync(path);
|
|
333
|
+
} catch { /* Never follow or remove a replacement when storage becomes unsafe. */ }
|
|
334
|
+
}
|
|
335
|
+
this.owned.clear();
|
|
336
|
+
}
|
|
337
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Orchestrator selection for agent-model profiles.
|
|
2
|
+
//
|
|
3
|
+
// A profile is a complete snapshot of the routing, and the orchestrator is part
|
|
4
|
+
// of that routing. Unlike every agent, the orchestrator does not live in
|
|
5
|
+
// `models.json`: Pi keeps it in its own global `settings.json` as
|
|
6
|
+
// `defaultProvider` / `defaultModel` / `defaultThinkingLevel`. This module owns
|
|
7
|
+
// reading and writing those three keys and nothing else.
|
|
8
|
+
//
|
|
9
|
+
// The write is deliberately conservative. It re-serializes the whole file so
|
|
10
|
+
// every unrelated key Pi and other extensions own (`packages`, telemetry
|
|
11
|
+
// settings, theme, terminal options) survives, it refuses to touch a file it
|
|
12
|
+
// cannot parse instead of replacing it with a fresh object, and it swaps the
|
|
13
|
+
// destination through a sibling temp file plus a rename so an interrupted write
|
|
14
|
+
// can never leave truncated JSON behind.
|
|
15
|
+
|
|
16
|
+
import { randomUUID } from "node:crypto";
|
|
17
|
+
import { closeSync, constants, existsSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { basename, dirname, join } from "node:path";
|
|
19
|
+
import {
|
|
20
|
+
isThinkingLevel,
|
|
21
|
+
normalizeModelId,
|
|
22
|
+
type AgentRoutingEntry,
|
|
23
|
+
} from "./model-routing-authority.ts";
|
|
24
|
+
|
|
25
|
+
/** The three Pi settings keys a profile's orchestrator entry owns. */
|
|
26
|
+
export const ORCHESTRATOR_SETTINGS_KEYS = [
|
|
27
|
+
"defaultProvider",
|
|
28
|
+
"defaultModel",
|
|
29
|
+
"defaultThinkingLevel",
|
|
30
|
+
] as const;
|
|
31
|
+
|
|
32
|
+
export interface OrchestratorModelRef {
|
|
33
|
+
provider: string;
|
|
34
|
+
model: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Split a `provider/model` routing id. A profile entry stores one opaque model
|
|
39
|
+
* id, while Pi stores the two halves separately, so the split happens here and
|
|
40
|
+
* only here. Ids without a provider half are rejected rather than guessed: an
|
|
41
|
+
* orchestrator write is not the place to invent a provider.
|
|
42
|
+
*/
|
|
43
|
+
export function parseOrchestratorModelRef(modelId: unknown): OrchestratorModelRef | undefined {
|
|
44
|
+
const normalized = normalizeModelId(modelId);
|
|
45
|
+
if (normalized === undefined) return undefined;
|
|
46
|
+
const separator = normalized.indexOf("/");
|
|
47
|
+
if (separator <= 0 || separator === normalized.length - 1) return undefined;
|
|
48
|
+
return {
|
|
49
|
+
provider: normalized.slice(0, separator),
|
|
50
|
+
model: normalized.slice(separator + 1),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
55
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type OrchestratorSettingsReadResult =
|
|
59
|
+
| { status: "missing" }
|
|
60
|
+
| { status: "invalid"; reason: string }
|
|
61
|
+
| { status: "valid"; entry?: AgentRoutingEntry; value: Record<string, unknown> };
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Read the effective orchestrator selection from a Pi settings file. A settings
|
|
65
|
+
* file that is absent or unreadable is reported, never silently treated as
|
|
66
|
+
* "no orchestrator": the panel has to distinguish "not set" from "cannot tell".
|
|
67
|
+
*/
|
|
68
|
+
export function readOrchestratorSettings(settingsPath: string): OrchestratorSettingsReadResult {
|
|
69
|
+
if (!existsSync(settingsPath)) return { status: "missing" };
|
|
70
|
+
let value: unknown;
|
|
71
|
+
try {
|
|
72
|
+
value = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
73
|
+
} catch (error) {
|
|
74
|
+
return { status: "invalid", reason: describeError(error) };
|
|
75
|
+
}
|
|
76
|
+
if (!isRecord(value)) return { status: "invalid", reason: "the file is not a JSON object" };
|
|
77
|
+
const provider = value.defaultProvider;
|
|
78
|
+
const model = value.defaultModel;
|
|
79
|
+
if (typeof provider !== "string" || typeof model !== "string") {
|
|
80
|
+
return { status: "valid", entry: undefined, value };
|
|
81
|
+
}
|
|
82
|
+
if (provider.length === 0 || model.length === 0) {
|
|
83
|
+
return { status: "valid", entry: undefined, value };
|
|
84
|
+
}
|
|
85
|
+
const entry: AgentRoutingEntry = { model: `${provider}/${model}` };
|
|
86
|
+
if (isThinkingLevel(value.defaultThinkingLevel)) entry.thinking = value.defaultThinkingLevel;
|
|
87
|
+
return { status: "valid", entry, value };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type OrchestratorSettingsWriteResult =
|
|
91
|
+
| { status: "invalid"; reason: string }
|
|
92
|
+
| { status: "unchanged" }
|
|
93
|
+
| { status: "written"; previous?: string };
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Persist one orchestrator routing entry into a Pi settings file.
|
|
97
|
+
*
|
|
98
|
+
* `entry` is a profile routing entry: `{ model?, thinking? }`. A missing model
|
|
99
|
+
* means the profile says nothing about the orchestrator, so the file is left
|
|
100
|
+
* exactly as it was — a profile without an orchestrator entry must never move
|
|
101
|
+
* the orchestrator, not even to "unset".
|
|
102
|
+
*
|
|
103
|
+
* `previous` carries the raw bytes that were there before a successful write so
|
|
104
|
+
* the caller can honour its own revert contract. `undefined` means the file did
|
|
105
|
+
* not exist and restoring means removing it.
|
|
106
|
+
*/
|
|
107
|
+
export function applyOrchestratorSettings(
|
|
108
|
+
settingsPath: string,
|
|
109
|
+
entry: AgentRoutingEntry,
|
|
110
|
+
): OrchestratorSettingsWriteResult {
|
|
111
|
+
if (entry.model === undefined) return { status: "unchanged" };
|
|
112
|
+
const reference = parseOrchestratorModelRef(entry.model);
|
|
113
|
+
if (reference === undefined) {
|
|
114
|
+
return {
|
|
115
|
+
status: "invalid",
|
|
116
|
+
reason: `model id ${JSON.stringify(entry.model)} is not a provider/model pair`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const read = readOrchestratorSettings(settingsPath);
|
|
120
|
+
if (read.status === "invalid") return { status: "invalid", reason: read.reason };
|
|
121
|
+
// Safe after the invalid check above: the file either parses as an object or
|
|
122
|
+
// does not exist, and a missing file means there is nothing to restore.
|
|
123
|
+
const previous = readPrevious(settingsPath);
|
|
124
|
+
const next: Record<string, unknown> = { ...(read.status === "valid" ? read.value : {}) };
|
|
125
|
+
next.defaultProvider = reference.provider;
|
|
126
|
+
next.defaultModel = reference.model;
|
|
127
|
+
if (entry.thinking === undefined) delete next.defaultThinkingLevel;
|
|
128
|
+
else next.defaultThinkingLevel = entry.thinking;
|
|
129
|
+
const text = `${JSON.stringify(next, null, 2)}\n`;
|
|
130
|
+
if (previous === text) return { status: "unchanged" };
|
|
131
|
+
writeFileAtomically(settingsPath, text);
|
|
132
|
+
return { status: "written", previous };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function readPrevious(settingsPath: string): string | undefined {
|
|
136
|
+
try {
|
|
137
|
+
return readFileSync(settingsPath, "utf8");
|
|
138
|
+
} catch {
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Restore the bytes a successful orchestrator write replaced. `undefined` means
|
|
145
|
+
* the write created the file, so restoring removes it.
|
|
146
|
+
*/
|
|
147
|
+
export function restoreOrchestratorSettings(
|
|
148
|
+
settingsPath: string,
|
|
149
|
+
previous: string | undefined,
|
|
150
|
+
): void {
|
|
151
|
+
if (previous === undefined) {
|
|
152
|
+
try {
|
|
153
|
+
unlinkSync(settingsPath);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
156
|
+
}
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
writeFileAtomically(settingsPath, previous);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Sibling temp file plus rename, with the same failure discipline as the profile
|
|
164
|
+
* store: every step records its own error, cleanup never throws, and the error
|
|
165
|
+
* that actually broke the write is the one reported.
|
|
166
|
+
*/
|
|
167
|
+
function writeFileAtomically(path: string, text: string): void {
|
|
168
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
169
|
+
const temporary = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
|
|
170
|
+
const descriptor = openSync(
|
|
171
|
+
temporary,
|
|
172
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL,
|
|
173
|
+
0o600,
|
|
174
|
+
);
|
|
175
|
+
const failures: unknown[] = [];
|
|
176
|
+
try {
|
|
177
|
+
writeFileSync(descriptor, text);
|
|
178
|
+
} catch (error) {
|
|
179
|
+
failures.push(error);
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
closeSync(descriptor);
|
|
183
|
+
} catch (error) {
|
|
184
|
+
failures.push(error);
|
|
185
|
+
}
|
|
186
|
+
if (failures.length === 0) {
|
|
187
|
+
try {
|
|
188
|
+
renameSync(temporary, path);
|
|
189
|
+
} catch (error) {
|
|
190
|
+
failures.push(error);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
unlinkSync(temporary);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") failures.push(error);
|
|
197
|
+
}
|
|
198
|
+
if (failures.length > 0) throw failures[0];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function describeError(error: unknown): string {
|
|
202
|
+
return error instanceof Error ? error.message : String(error);
|
|
203
|
+
}
|