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,315 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import { THINKING_LEVELS as ROUTING_THINKING_LEVELS, type ThinkingLevel as RoutingThinkingLevel } from "./model-routing-authority.ts";
|
|
4
|
+
|
|
5
|
+
// Gentle Agents configuration. Agent definitions are markdown files with YAML
|
|
6
|
+
// frontmatter (the format gentle-ai installs) and runtime settings come from
|
|
7
|
+
// subagents.json at the global and project level. Everything here is pure
|
|
8
|
+
// apart from the discovery helpers, which take their roots as arguments.
|
|
9
|
+
|
|
10
|
+
export const AGENT_MODE = {
|
|
11
|
+
TASK: "task",
|
|
12
|
+
BACKGROUND: "background",
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
export type AgentMode = (typeof AGENT_MODE)[keyof typeof AGENT_MODE];
|
|
16
|
+
|
|
17
|
+
export type ThinkingLevel = RoutingThinkingLevel;
|
|
18
|
+
|
|
19
|
+
// Preserve the uppercase compatibility keys without maintaining a second level list.
|
|
20
|
+
export const THINKING_LEVEL = Object.fromEntries(
|
|
21
|
+
ROUTING_THINKING_LEVELS.map((level) => [level.toUpperCase(), level]),
|
|
22
|
+
) as { readonly [Level in ThinkingLevel as Uppercase<Level>]: Level };
|
|
23
|
+
|
|
24
|
+
export const AGENT_SCOPE = {
|
|
25
|
+
GLOBAL: "global",
|
|
26
|
+
PROJECT: "project",
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
export type AgentScope = (typeof AGENT_SCOPE)[keyof typeof AGENT_SCOPE];
|
|
30
|
+
|
|
31
|
+
export const PROFILE_SOURCE = {
|
|
32
|
+
PROFILE: "profile",
|
|
33
|
+
DEFINITION: "definition",
|
|
34
|
+
DEFAULT: "default",
|
|
35
|
+
UNRESOLVED: "unresolved",
|
|
36
|
+
} as const;
|
|
37
|
+
|
|
38
|
+
export type ProfileSource = (typeof PROFILE_SOURCE)[keyof typeof PROFILE_SOURCE];
|
|
39
|
+
|
|
40
|
+
export interface ModelRef {
|
|
41
|
+
provider: string | undefined;
|
|
42
|
+
id: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface AgentDefinition {
|
|
46
|
+
name: string;
|
|
47
|
+
description: string;
|
|
48
|
+
filePath: string;
|
|
49
|
+
scope: AgentScope;
|
|
50
|
+
instructions: string;
|
|
51
|
+
model: ModelRef | undefined;
|
|
52
|
+
thinking: ThinkingLevel | undefined;
|
|
53
|
+
mode: AgentMode | undefined;
|
|
54
|
+
tools: string[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface AgentDefinitionError {
|
|
58
|
+
filePath: string;
|
|
59
|
+
error: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ModelProfile {
|
|
63
|
+
model: ModelRef | undefined;
|
|
64
|
+
thinking: ThinkingLevel | undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface AgentsConfig {
|
|
68
|
+
defaultModel: ModelRef | undefined;
|
|
69
|
+
defaultThinking: ThinkingLevel | undefined;
|
|
70
|
+
defaultMode: AgentMode;
|
|
71
|
+
modelProfiles: Record<string, ModelProfile>;
|
|
72
|
+
stallTimeoutMs: number;
|
|
73
|
+
maxConcurrency: number;
|
|
74
|
+
historyMaxTasks: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface ProfileSources {
|
|
78
|
+
model: ProfileSource;
|
|
79
|
+
thinking: ProfileSource;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface ResolvedProfile {
|
|
83
|
+
model: ModelRef | undefined;
|
|
84
|
+
thinking: ThinkingLevel | undefined;
|
|
85
|
+
source: ProfileSources;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface DiscoveryRoots {
|
|
89
|
+
cwd: string;
|
|
90
|
+
home: string;
|
|
91
|
+
agentHome?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface DiscoveryResult {
|
|
95
|
+
agents: AgentDefinition[];
|
|
96
|
+
errors: string[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export type FrontmatterValue = string | string[];
|
|
100
|
+
|
|
101
|
+
export interface Frontmatter {
|
|
102
|
+
data: Record<string, FrontmatterValue>;
|
|
103
|
+
body: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const DEFAULT_STALL_TIMEOUT_MS = 4 * 60_000;
|
|
107
|
+
const DEFAULT_MAX_CONCURRENCY = 5;
|
|
108
|
+
const DEFAULT_HISTORY_MAX_TASKS = 200;
|
|
109
|
+
const THINKING_LEVELS: readonly string[] = ROUTING_THINKING_LEVELS;
|
|
110
|
+
const AGENT_MODES = Object.values(AGENT_MODE) as string[];
|
|
111
|
+
|
|
112
|
+
function unquote(value: string): string {
|
|
113
|
+
const trimmed = value.trim();
|
|
114
|
+
const quoted = (trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"));
|
|
115
|
+
return quoted && trimmed.length >= 2 ? trimmed.slice(1, -1) : trimmed;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function parseInlineList(value: string): string[] {
|
|
119
|
+
return value.slice(1, -1).split(",").map(unquote).filter((item) => item.length > 0);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Just enough YAML for agent frontmatter: `key: scalar`, `key: [a, b]`, and
|
|
123
|
+
// `key:` followed by `- item` lines. Anything else stays a plain string.
|
|
124
|
+
export function parseFrontmatter(text: string): Frontmatter {
|
|
125
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text);
|
|
126
|
+
if (!match) return { data: {}, body: text.trim() };
|
|
127
|
+
const data: Record<string, FrontmatterValue> = {};
|
|
128
|
+
let listKey: string | undefined;
|
|
129
|
+
for (const raw of match[1].split(/\r?\n/)) {
|
|
130
|
+
const item = /^\s*-\s+(.*)$/.exec(raw);
|
|
131
|
+
if (item && listKey) {
|
|
132
|
+
(data[listKey] as string[]).push(unquote(item[1]));
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const pair = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(raw);
|
|
136
|
+
if (!pair) continue;
|
|
137
|
+
const [, key, value] = pair;
|
|
138
|
+
const trimmed = value.trim();
|
|
139
|
+
if (trimmed.length === 0) {
|
|
140
|
+
data[key] = [];
|
|
141
|
+
listKey = key;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
listKey = undefined;
|
|
145
|
+
data[key] = trimmed.startsWith("[") && trimmed.endsWith("]") ? parseInlineList(trimmed) : unquote(trimmed);
|
|
146
|
+
}
|
|
147
|
+
return { data, body: match[2].trim() };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function parseModelRef(value: unknown): ModelRef | undefined {
|
|
151
|
+
if (typeof value !== "string") return undefined;
|
|
152
|
+
const trimmed = value.trim();
|
|
153
|
+
if (trimmed.length === 0) return undefined;
|
|
154
|
+
const slash = trimmed.indexOf("/");
|
|
155
|
+
if (slash <= 0) return { provider: undefined, id: trimmed };
|
|
156
|
+
return { provider: trimmed.slice(0, slash), id: trimmed.slice(slash + 1) };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function parseThinking(value: unknown): ThinkingLevel | undefined | string {
|
|
160
|
+
if (value === undefined) return undefined;
|
|
161
|
+
const normalized = String(value).trim().toLowerCase();
|
|
162
|
+
return THINKING_LEVELS.includes(normalized) ? (normalized as ThinkingLevel) : `thinking "${value}" is not one of ${THINKING_LEVELS.join(", ")}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function parseMode(value: unknown): AgentMode | undefined | string {
|
|
166
|
+
if (value === undefined) return undefined;
|
|
167
|
+
const normalized = String(value).trim().toLowerCase();
|
|
168
|
+
return AGENT_MODES.includes(normalized) ? (normalized as AgentMode) : `mode "${value}" is not one of ${AGENT_MODES.join(", ")}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function parseTools(value: FrontmatterValue | undefined): string[] {
|
|
172
|
+
if (value === undefined) return [];
|
|
173
|
+
const items = Array.isArray(value) ? value : value.split(",");
|
|
174
|
+
return items.map((item) => item.trim()).filter((item) => item.length > 0);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function scalar(value: FrontmatterValue | undefined): string | undefined {
|
|
178
|
+
return typeof value === "string" ? value : undefined;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function parseAgentDefinition(text: string, filePath: string, scope: AgentScope): AgentDefinition | AgentDefinitionError {
|
|
182
|
+
const { data, body } = parseFrontmatter(text);
|
|
183
|
+
const thinking = parseThinking(scalar(data.thinking) ?? scalar(data.effort) ?? scalar(data.thinking_level));
|
|
184
|
+
if (thinking !== undefined && !THINKING_LEVELS.includes(thinking)) return { filePath, error: thinking };
|
|
185
|
+
const mode = parseMode(scalar(data.subagent_mode) ?? scalar(data.mode));
|
|
186
|
+
if (mode !== undefined && !AGENT_MODES.includes(mode)) return { filePath, error: mode };
|
|
187
|
+
if (body.length === 0) return { filePath, error: "no instructions after the frontmatter" };
|
|
188
|
+
const name = scalar(data.name)?.trim() || basename(filePath).replace(/\.md$/i, "");
|
|
189
|
+
return {
|
|
190
|
+
name,
|
|
191
|
+
description: scalar(data.description)?.trim() ?? "",
|
|
192
|
+
filePath,
|
|
193
|
+
scope,
|
|
194
|
+
instructions: body,
|
|
195
|
+
model: parseModelRef(data.model),
|
|
196
|
+
thinking: thinking as ThinkingLevel | undefined,
|
|
197
|
+
mode: mode as AgentMode | undefined,
|
|
198
|
+
tools: parseTools(data.tools),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Discovery order is precedence order: a later directory replaces an earlier
|
|
203
|
+
// definition with the same name, so project beats global and `subagents/`
|
|
204
|
+
// beats `agents/` within each scope.
|
|
205
|
+
function profileRoot(roots: DiscoveryRoots): string {
|
|
206
|
+
return roots.agentHome ?? join(roots.home, ".pi", "agent");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function agentDirectories(roots: DiscoveryRoots): Array<{ dir: string; scope: AgentScope }> {
|
|
210
|
+
const agentHome = profileRoot(roots);
|
|
211
|
+
return [
|
|
212
|
+
{ dir: join(agentHome, "agents"), scope: AGENT_SCOPE.GLOBAL },
|
|
213
|
+
{ dir: join(agentHome, "subagents"), scope: AGENT_SCOPE.GLOBAL },
|
|
214
|
+
{ dir: join(roots.cwd, ".pi", "agents"), scope: AGENT_SCOPE.PROJECT },
|
|
215
|
+
{ dir: join(roots.cwd, ".pi", "subagents"), scope: AGENT_SCOPE.PROJECT },
|
|
216
|
+
];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function discoverAgents(roots: DiscoveryRoots): DiscoveryResult {
|
|
220
|
+
const agents = new Map<string, AgentDefinition>();
|
|
221
|
+
const errors: string[] = [];
|
|
222
|
+
for (const { dir, scope } of agentDirectories(roots)) {
|
|
223
|
+
if (!existsSync(dir)) continue;
|
|
224
|
+
for (const file of readdirSync(dir).filter((entry) => entry.toLowerCase().endsWith(".md")).sort()) {
|
|
225
|
+
const filePath = join(dir, file);
|
|
226
|
+
const parsed = parseAgentDefinition(readFileSync(filePath, "utf8"), filePath, scope);
|
|
227
|
+
if ("error" in parsed) errors.push(`${filePath}: ${parsed.error}`);
|
|
228
|
+
else agents.set(parsed.name, parsed);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return { agents: [...agents.values()].sort((a, b) => a.name.localeCompare(b.name)), errors };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
type RawConfig = Record<string, unknown> | undefined;
|
|
235
|
+
|
|
236
|
+
function positiveInteger(value: unknown, fallback: number): number {
|
|
237
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function parseProfiles(value: unknown): Record<string, ModelProfile> {
|
|
241
|
+
const profiles: Record<string, ModelProfile> = {};
|
|
242
|
+
if (!value || typeof value !== "object") return profiles;
|
|
243
|
+
for (const [name, raw] of Object.entries(value as Record<string, unknown>)) {
|
|
244
|
+
if (!raw || typeof raw !== "object") continue;
|
|
245
|
+
const entry = raw as Record<string, unknown>;
|
|
246
|
+
const thinking = parseThinking(entry.effort ?? entry.thinking);
|
|
247
|
+
profiles[name] = { model: parseModelRef(entry.model), thinking: thinking !== undefined && THINKING_LEVELS.includes(thinking) ? (thinking as ThinkingLevel) : undefined };
|
|
248
|
+
}
|
|
249
|
+
return profiles;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function mergeProfiles(base: Record<string, ModelProfile>, override: Record<string, ModelProfile>): Record<string, ModelProfile> {
|
|
253
|
+
const merged = { ...base };
|
|
254
|
+
for (const [name, profile] of Object.entries(override)) {
|
|
255
|
+
merged[name] = { model: profile.model ?? base[name]?.model, thinking: profile.thinking ?? base[name]?.thinking };
|
|
256
|
+
}
|
|
257
|
+
return merged;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Project settings override global ones field by field; model profiles merge
|
|
261
|
+
// per agent so a project can change one effort without repeating the model.
|
|
262
|
+
export function parseAgentsConfig(global: RawConfig, project: RawConfig): AgentsConfig {
|
|
263
|
+
const merged: Record<string, unknown> = { ...(global ?? {}), ...(project ?? {}) };
|
|
264
|
+
const thinking = parseThinking(merged.default_effort ?? merged.default_thinking_level ?? merged.default_thinking);
|
|
265
|
+
const mode = parseMode(merged.default_mode);
|
|
266
|
+
return {
|
|
267
|
+
defaultModel: parseModelRef(merged.default_model),
|
|
268
|
+
defaultThinking: thinking !== undefined && THINKING_LEVELS.includes(thinking) ? (thinking as ThinkingLevel) : undefined,
|
|
269
|
+
defaultMode: mode !== undefined && AGENT_MODES.includes(mode) ? (mode as AgentMode) : AGENT_MODE.TASK,
|
|
270
|
+
modelProfiles: mergeProfiles(parseProfiles(global?.model_profiles), parseProfiles(project?.model_profiles)),
|
|
271
|
+
// `timeout_ms` remains accepted as an inert legacy key so existing JSON
|
|
272
|
+
// files load normally; only silence is bounded by `stall_timeout_ms`.
|
|
273
|
+
stallTimeoutMs: positiveInteger(merged.stall_timeout_ms, DEFAULT_STALL_TIMEOUT_MS),
|
|
274
|
+
maxConcurrency: positiveInteger(merged.max_concurrency, DEFAULT_MAX_CONCURRENCY),
|
|
275
|
+
historyMaxTasks: positiveInteger(merged.history_max_tasks, DEFAULT_HISTORY_MAX_TASKS),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function readJson(path: string): RawConfig {
|
|
280
|
+
if (!existsSync(path)) return undefined;
|
|
281
|
+
try {
|
|
282
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
|
|
283
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : undefined;
|
|
284
|
+
} catch {
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function loadAgentsConfig(roots: DiscoveryRoots): AgentsConfig {
|
|
290
|
+
return parseAgentsConfig(readJson(join(profileRoot(roots), "subagents.json")), readJson(join(roots.cwd, ".pi", "subagents.json")));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function pick<T>(candidates: Array<[T | undefined, ProfileSource]>): [T | undefined, ProfileSource] {
|
|
294
|
+
return candidates.find(([value]) => value !== undefined) ?? [undefined, PROFILE_SOURCE.UNRESOLVED];
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function resolveAgentProfile(agent: AgentDefinition, config: AgentsConfig): ResolvedProfile {
|
|
298
|
+
const profile = config.modelProfiles[agent.name];
|
|
299
|
+
const [model, modelSource] = pick<ModelRef>([
|
|
300
|
+
[profile?.model, PROFILE_SOURCE.PROFILE],
|
|
301
|
+
[agent.model, PROFILE_SOURCE.DEFINITION],
|
|
302
|
+
[config.defaultModel, PROFILE_SOURCE.DEFAULT],
|
|
303
|
+
]);
|
|
304
|
+
const [thinking, thinkingSource] = pick<ThinkingLevel>([
|
|
305
|
+
[profile?.thinking, PROFILE_SOURCE.PROFILE],
|
|
306
|
+
[agent.thinking, PROFILE_SOURCE.DEFINITION],
|
|
307
|
+
[config.defaultThinking, PROFILE_SOURCE.DEFAULT],
|
|
308
|
+
]);
|
|
309
|
+
return { model, thinking, source: { model: modelSource, thinking: thinkingSource } };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function formatModelRef(model: ModelRef | undefined): string {
|
|
313
|
+
if (!model) return "default";
|
|
314
|
+
return model.provider ? `${model.provider}/${model.id}` : model.id;
|
|
315
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { emptyThread, type TaskRecord, type TaskThread } from "./agents-protocol.ts";
|
|
4
|
+
|
|
5
|
+
// Gentle Agents history: one JSON file per finished task, written by the
|
|
6
|
+
// host after the child is gone and read back lazily when the overlay opens
|
|
7
|
+
// or a tool asks for a task from an earlier session. Everything is async so
|
|
8
|
+
// the terminal never waits on disk.
|
|
9
|
+
|
|
10
|
+
export interface StoredTask {
|
|
11
|
+
task: TaskRecord;
|
|
12
|
+
thread: TaskThread;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function remediationUnresolved(task: TaskRecord): boolean {
|
|
16
|
+
const state = task.sddRemediation;
|
|
17
|
+
if (!state) return false;
|
|
18
|
+
if (state.acquireUncertain || state.settlementUncertain) return true;
|
|
19
|
+
if (state.settlement) return state.settlement.state === "blocked";
|
|
20
|
+
return !!state.token || !!state.actorClaimed || !["blocked", "complete"].includes(state.acquireResult?.state ?? "");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const FILE_SUFFIX = ".json";
|
|
24
|
+
const SAFE_ID = /^[a-z0-9-]+$/i;
|
|
25
|
+
|
|
26
|
+
export function historyDir(home: string, agentHome = join(home, ".pi", "agent")): string {
|
|
27
|
+
return join(agentHome, "gentle-agents", "tasks");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function fileFor(dir: string, id: string): string {
|
|
31
|
+
if (!SAFE_ID.test(id)) throw new Error(`invalid task id: ${id}`);
|
|
32
|
+
return join(dir, `${id}${FILE_SUFFIX}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isRecord(value: unknown): value is TaskRecord {
|
|
36
|
+
const task = value as Partial<TaskRecord> | undefined;
|
|
37
|
+
return typeof task?.id === "string" && typeof task.agent === "string" && typeof task.status === "string" && typeof task.createdAt === "number";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseStored(text: string): StoredTask | undefined {
|
|
41
|
+
try {
|
|
42
|
+
const parsed = JSON.parse(text) as Partial<StoredTask>;
|
|
43
|
+
if (!isRecord(parsed.task)) return undefined;
|
|
44
|
+
const thread = parsed.thread && Array.isArray(parsed.thread.items) ? parsed.thread : emptyThread();
|
|
45
|
+
return { task: parsed.task, thread: { ...emptyThread(), ...thread } };
|
|
46
|
+
} catch {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function saveTask(dir: string, task: TaskRecord, thread: TaskThread): Promise<void> {
|
|
52
|
+
await mkdir(dir, { recursive: true });
|
|
53
|
+
const target = fileFor(dir, task.id);
|
|
54
|
+
const temp = `${target}.${process.pid}-${Math.random().toString(36).slice(2, 8)}.tmp`;
|
|
55
|
+
await writeFile(temp, JSON.stringify({ task, thread }), "utf8");
|
|
56
|
+
await rename(temp, target);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function loadStoredTask(dir: string, id: string): Promise<StoredTask | undefined> {
|
|
60
|
+
if (!SAFE_ID.test(id)) return undefined;
|
|
61
|
+
try {
|
|
62
|
+
return parseStored(await readFile(fileFor(dir, id), "utf8"));
|
|
63
|
+
} catch {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function listFiles(dir: string): Promise<string[]> {
|
|
69
|
+
try {
|
|
70
|
+
return (await readdir(dir)).filter((name) => name.endsWith(FILE_SUFFIX));
|
|
71
|
+
} catch {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function loadHistory(dir: string): Promise<StoredTask[]> {
|
|
77
|
+
const files = await listFiles(dir);
|
|
78
|
+
const stored = await Promise.all(files.map(async (name) => parseStored(await readFile(join(dir, name), "utf8").catch(() => ""))));
|
|
79
|
+
return stored.filter((entry): entry is StoredTask => entry !== undefined).sort((a, b) => b.task.createdAt - a.task.createdAt);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Keep the newest `maxTasks` files; the rest go. Returns how many were removed.
|
|
83
|
+
export async function pruneHistory(dir: string, maxTasks: number): Promise<number> {
|
|
84
|
+
const stored = await loadHistory(dir);
|
|
85
|
+
const extra = stored.filter(({ task }) => !remediationUnresolved(task)).slice(Math.max(0, maxTasks));
|
|
86
|
+
await Promise.all(extra.map((entry) => rm(fileFor(dir, entry.task.id), { force: true })));
|
|
87
|
+
return extra.length;
|
|
88
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
export const CHILD_MESSAGE_MAX_BYTES = 8 * 1024;
|
|
2
|
+
export const CHILD_ACK_ERROR_MAX_BYTES = 256;
|
|
3
|
+
export const CHILD_QUERY_ERROR_MAX_BYTES = 256;
|
|
4
|
+
export const CHILD_MESSAGE_MAX_INFLIGHT = 8;
|
|
5
|
+
export const CHILD_MESSAGE_TIMEOUT_MS = 2_000;
|
|
6
|
+
export const CHILD_QUERY_MAX_INFLIGHT = 4;
|
|
7
|
+
export const CHILD_QUERY_TIMEOUT_MS = 30_000;
|
|
8
|
+
|
|
9
|
+
export interface NotificationFrame {
|
|
10
|
+
id: string;
|
|
11
|
+
kind: "notification";
|
|
12
|
+
message: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface QueryFrame {
|
|
16
|
+
id: string;
|
|
17
|
+
kind: "query";
|
|
18
|
+
message: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type ChildFrame = NotificationFrame | QueryFrame;
|
|
22
|
+
|
|
23
|
+
interface AckFrame {
|
|
24
|
+
id: string;
|
|
25
|
+
accepted: boolean;
|
|
26
|
+
error?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type ReplyFrame = { id: string; message: string } | { id: string; error: string };
|
|
30
|
+
|
|
31
|
+
export interface IpcEndpoint {
|
|
32
|
+
connected?: boolean;
|
|
33
|
+
send(message: unknown, callback?: (error: Error | null) => void): boolean;
|
|
34
|
+
on(event: "message" | "disconnect", listener: (...args: unknown[]) => void): unknown;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type Cancel = () => void;
|
|
38
|
+
type Schedule = (fn: () => void, ms: number) => Cancel;
|
|
39
|
+
type Pending<T> = { resolve: (value: T) => void; reject: (error: Error) => void; cancel: Cancel };
|
|
40
|
+
|
|
41
|
+
function record(value: unknown): value is Record<string, unknown> {
|
|
42
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function exact(value: Record<string, unknown>, keys: readonly string[]): boolean {
|
|
46
|
+
return Object.keys(value).every((key) => keys.includes(key));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function wellFormedUnicode(value: string): boolean {
|
|
50
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
51
|
+
const code = value.charCodeAt(index);
|
|
52
|
+
if (code >= 0xD800 && code <= 0xDBFF) {
|
|
53
|
+
const next = value.charCodeAt(index + 1);
|
|
54
|
+
if (index + 1 >= value.length || next < 0xDC00 || next > 0xDFFF) return false;
|
|
55
|
+
index += 1;
|
|
56
|
+
} else if (code >= 0xDC00 && code <= 0xDFFF) return false;
|
|
57
|
+
}
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function boundedText(value: unknown, maxBytes: number): value is string {
|
|
62
|
+
return typeof value === "string" && wellFormedUnicode(value) && Buffer.byteLength(value, "utf8") <= maxBytes;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function validCorrelation(value: unknown, prefix: "n" | "q"): value is string {
|
|
66
|
+
if (typeof value !== "string" || !new RegExp(`^${prefix}[1-9]\\d{0,15}$`).test(value)) return false;
|
|
67
|
+
const counter = Number(value.slice(1));
|
|
68
|
+
return Number.isSafeInteger(counter) && counter > 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function validChildId(value: unknown): value is string {
|
|
72
|
+
return validCorrelation(value, "n");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function validChildQueryId(value: unknown): value is string {
|
|
76
|
+
return validCorrelation(value, "q");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function validChildMessage(value: unknown): value is string {
|
|
80
|
+
return boundedText(value, CHILD_MESSAGE_MAX_BYTES);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function parseChildFrame(value: unknown): { frame?: ChildFrame; id?: string; error?: string } {
|
|
84
|
+
if (!record(value)) return { error: "invalid child IPC frame" };
|
|
85
|
+
const id = validChildQueryId(value.id) || validChildId(value.id) ? value.id : undefined;
|
|
86
|
+
if (!exact(value, ["id", "kind", "message"])) return { ...(id === undefined ? {} : { id }), error: "invalid child IPC frame" };
|
|
87
|
+
if (id === undefined) return { error: "invalid child IPC correlation" };
|
|
88
|
+
if (value.kind !== "notification" && value.kind !== "query") return { id, error: "unsupported child IPC kind" };
|
|
89
|
+
if (value.kind === "notification" && !validChildId(id) || value.kind === "query" && !validChildQueryId(id)) return { id, error: "invalid child IPC correlation" };
|
|
90
|
+
if (!validChildMessage(value.message)) return { id, error: "invalid child IPC message" };
|
|
91
|
+
return { frame: { id, kind: value.kind, message: value.message } } as { frame: ChildFrame };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parseAck(value: unknown): AckFrame | undefined {
|
|
95
|
+
if (!record(value) || !exact(value, ["id", "kind", "accepted", "error"]) || !validChildId(value.id) || value.kind !== "ack" || typeof value.accepted !== "boolean") return undefined;
|
|
96
|
+
if (value.accepted && value.error !== undefined) return undefined;
|
|
97
|
+
if (value.error !== undefined && !boundedText(value.error, CHILD_ACK_ERROR_MAX_BYTES)) return undefined;
|
|
98
|
+
return { id: value.id, accepted: value.accepted, ...(value.error === undefined ? {} : { error: value.error }) };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function parseReply(value: unknown): ReplyFrame | undefined {
|
|
102
|
+
if (!record(value) || !validChildQueryId(value.id) || value.kind !== "reply") return undefined;
|
|
103
|
+
if (exact(value, ["id", "kind", "message"]) && validChildMessage(value.message)) return { id: value.id, message: value.message };
|
|
104
|
+
if (exact(value, ["id", "kind", "error"]) && boundedText(value.error, CHILD_QUERY_ERROR_MAX_BYTES)) return { id: value.id, error: value.error };
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Child-side admissions make acceptance explicit, but do not imply the parent
|
|
109
|
+
// model read the message or that any later delivery is guaranteed.
|
|
110
|
+
export class ChildMessenger {
|
|
111
|
+
private readonly pending = new Map<string, Pending<void>>();
|
|
112
|
+
private readonly queries = new Map<string, Pending<string>>();
|
|
113
|
+
private readonly endpoint: IpcEndpoint;
|
|
114
|
+
private readonly schedule: Schedule;
|
|
115
|
+
private nextId = 0;
|
|
116
|
+
private nextQueryId = 0;
|
|
117
|
+
private closed = false;
|
|
118
|
+
|
|
119
|
+
constructor(endpoint: IpcEndpoint, schedule: Schedule = (fn, ms) => {
|
|
120
|
+
const timer = setTimeout(fn, ms);
|
|
121
|
+
timer.unref?.();
|
|
122
|
+
return () => clearTimeout(timer);
|
|
123
|
+
}) {
|
|
124
|
+
this.endpoint = endpoint;
|
|
125
|
+
this.schedule = schedule;
|
|
126
|
+
endpoint.on("message", (value) => this.receive(value));
|
|
127
|
+
endpoint.on("disconnect", () => this.close("parent notification channel closed"));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
notify(message: string): Promise<void> {
|
|
131
|
+
if (!this.available()) return Promise.reject(new Error("parent notification channel closed"));
|
|
132
|
+
if (!validChildMessage(message)) return Promise.reject(new Error("message is not well-formed UTF-16 or exceeds 8KiB"));
|
|
133
|
+
if (this.pending.size >= CHILD_MESSAGE_MAX_INFLIGHT) return Promise.reject(new Error("too many pending parent-message admissions"));
|
|
134
|
+
if (this.nextId >= Number.MAX_SAFE_INTEGER) return Promise.reject(new Error("parent-message correlation exhausted"));
|
|
135
|
+
return this.send(this.pending, `n${++this.nextId}`, "notification", message, CHILD_MESSAGE_TIMEOUT_MS, "parent-message admission timed out");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
query(message: string): Promise<string> {
|
|
139
|
+
if (!this.available()) return Promise.reject(new Error("parent notification channel closed"));
|
|
140
|
+
if (!validChildMessage(message)) return Promise.reject(new Error("query is not well-formed UTF-16 or exceeds 8KiB"));
|
|
141
|
+
if (this.queries.size >= CHILD_QUERY_MAX_INFLIGHT) return Promise.reject(new Error("too many pending parent queries"));
|
|
142
|
+
if (this.nextQueryId >= Number.MAX_SAFE_INTEGER) return Promise.reject(new Error("parent query correlation exhausted"));
|
|
143
|
+
return this.send(this.queries, `q${++this.nextQueryId}`, "query", message, CHILD_QUERY_TIMEOUT_MS, "parent query timed out");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
close(reason = "parent notification channel closed"): void {
|
|
147
|
+
if (this.closed) return;
|
|
148
|
+
this.closed = true;
|
|
149
|
+
for (const id of [...this.pending.keys()]) this.settle(this.pending, id, new Error(reason));
|
|
150
|
+
for (const id of [...this.queries.keys()]) this.settle(this.queries, id, new Error(reason));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private available(): boolean {
|
|
154
|
+
if (!this.closed && this.endpoint.connected !== false) return true;
|
|
155
|
+
this.close("parent notification channel closed");
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private send<T>(pending: Map<string, Pending<T>>, id: string, kind: "notification" | "query", message: string, timeout: number, timeoutMessage: string): Promise<T> {
|
|
160
|
+
return new Promise((resolve, reject) => {
|
|
161
|
+
const settle = (error?: Error, reply?: T) => this.settle(pending, id, error, reply);
|
|
162
|
+
const cancel = this.schedule(() => settle(new Error(timeoutMessage)), timeout);
|
|
163
|
+
pending.set(id, { resolve, reject, cancel });
|
|
164
|
+
try { this.endpoint.send({ id, kind, message }, (error) => { if (error) settle(error); }); }
|
|
165
|
+
catch (error) { settle(error instanceof Error ? error : new Error(String(error))); }
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private receive(value: unknown): void {
|
|
170
|
+
const ack = parseAck(value);
|
|
171
|
+
if (ack) {
|
|
172
|
+
this.settle(this.pending, ack.id, ack.accepted ? undefined : new Error(ack.error || "parent rejected notification"));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const reply = parseReply(value);
|
|
176
|
+
if (reply) this.settle(this.queries, reply.id, "error" in reply ? new Error(reply.error || "parent rejected query") : undefined, "message" in reply ? reply.message : undefined);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private settle<T>(pending: Map<string, Pending<T>>, id: string, error?: Error, value?: T): void {
|
|
180
|
+
const entry = pending.get(id);
|
|
181
|
+
if (!entry) return;
|
|
182
|
+
pending.delete(id);
|
|
183
|
+
entry.cancel();
|
|
184
|
+
if (error) entry.reject(error);
|
|
185
|
+
else entry.resolve(value as T);
|
|
186
|
+
}
|
|
187
|
+
}
|