@buihongduc132/pi-acp-agents 0.3.1
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 +45 -0
- package/LICENSE +21 -0
- package/README.md +359 -0
- package/index.ts +1521 -0
- package/package.json +103 -0
- package/skills/pi-acp-agents/SKILL.md +112 -0
- package/src/acp-widget.ts +379 -0
- package/src/adapter-factory.ts +55 -0
- package/src/adapters/acpx.ts +215 -0
- package/src/adapters/base.ts +117 -0
- package/src/adapters/codex.ts +77 -0
- package/src/adapters/custom.ts +14 -0
- package/src/adapters/gemini.ts +66 -0
- package/src/adapters/opencode.ts +101 -0
- package/src/config/config.ts +312 -0
- package/src/config/types.ts +203 -0
- package/src/coordination/alias-resolver.ts +208 -0
- package/src/coordination/coordinator.ts +266 -0
- package/src/coordination/worker-dispatcher.ts +191 -0
- package/src/core/async-executor.ts +149 -0
- package/src/core/circuit-breaker.ts +254 -0
- package/src/core/client.ts +661 -0
- package/src/core/health-monitor.ts +200 -0
- package/src/core/protocol-validator.ts +259 -0
- package/src/core/session-lifecycle.ts +46 -0
- package/src/core/session-manager.ts +64 -0
- package/src/extension-safety.ts +200 -0
- package/src/logger.ts +92 -0
- package/src/management/event-log.ts +31 -0
- package/src/management/governance-store.ts +123 -0
- package/src/management/heartbeat-parser.ts +92 -0
- package/src/management/mailbox-manager.ts +95 -0
- package/src/management/runtime-paths.ts +34 -0
- package/src/management/safe-mkdir.ts +78 -0
- package/src/management/session-archive-store.ts +136 -0
- package/src/management/session-name-store.ts +88 -0
- package/src/management/task-store.ts +260 -0
- package/src/management/worker-store.ts +164 -0
- package/src/public-api.ts +72 -0
- package/src/settings/agent-config-tui.ts +456 -0
- package/src/settings/agents-command.ts +138 -0
- package/src/settings/config.ts +201 -0
- package/src/settings/configure-tui.ts +135 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-acp-agents — Extension safety module (R-SP1, R-SP4).
|
|
3
|
+
*
|
|
4
|
+
* Provides base-detection, safe activation guards, and version
|
|
5
|
+
* compatibility checks for the pi-acp-advanced extension package.
|
|
6
|
+
*
|
|
7
|
+
* R-SP1: Extension MUST fail loudly but never crash when base is missing.
|
|
8
|
+
* R-SP4: Extension MUST declare and verify minimum base version.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
// ── R-SP1: Base detection ──────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export interface BaseDetectionResult {
|
|
17
|
+
/** Whether the base package runtime is available */
|
|
18
|
+
ok: boolean;
|
|
19
|
+
/** Runtime directory path (only when ok=true) */
|
|
20
|
+
runtimeDir?: string;
|
|
21
|
+
/** Config file path (only when ok=true) */
|
|
22
|
+
configFile?: string;
|
|
23
|
+
/** Human-readable warning (only when ok=false) */
|
|
24
|
+
warning?: string;
|
|
25
|
+
/** Base version string (only when ok=true and detectable) */
|
|
26
|
+
baseVersion?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Detect whether the pi-acp-agents base package is loaded and configured.
|
|
31
|
+
*
|
|
32
|
+
* Checks:
|
|
33
|
+
* 1. Runtime directory exists (~/.pi/acp-agents/)
|
|
34
|
+
* 2. Config file exists (~/.pi/acp-agents/config.json)
|
|
35
|
+
* 3. Config contains at least one agent_server entry (base is initialized)
|
|
36
|
+
*
|
|
37
|
+
* Returns a result object with ok=true/false and context.
|
|
38
|
+
*/
|
|
39
|
+
export function detectBaseLoaded(runtimeDirOverride?: string): BaseDetectionResult {
|
|
40
|
+
const runtimeDir = runtimeDirOverride ?? join(homedir(), ".pi", "acp-agents");
|
|
41
|
+
const configFile = join(runtimeDir, "config.json");
|
|
42
|
+
|
|
43
|
+
if (!existsSync(runtimeDir)) {
|
|
44
|
+
return {
|
|
45
|
+
ok: false,
|
|
46
|
+
warning: `⚠️ pi-acp-advanced requires pi-acp-agents to be installed and loaded first.\n Runtime dir missing: ${runtimeDir}\n Fix: Add "npm:pi-acp-agents" to your settings.json packages BEFORE "npm:pi-acp-advanced".\n Extension is inactive until base is available.`,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!existsSync(configFile)) {
|
|
51
|
+
return {
|
|
52
|
+
ok: false,
|
|
53
|
+
warning: `⚠️ pi-acp-advanced requires pi-acp-agents config at ${configFile}.\n Fix: Add "npm:pi-acp-agents" to your settings.json BEFORE "npm:pi-acp-advanced".\n Extension is inactive until base is available.`,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Config exists — check if base has been initialized (has agent_servers)
|
|
58
|
+
let baseVersion: string | undefined;
|
|
59
|
+
try {
|
|
60
|
+
const raw = readFileSync(configFile, "utf-8");
|
|
61
|
+
const config = JSON.parse(raw);
|
|
62
|
+
const hasAgents = config.agent_servers && Object.keys(config.agent_servers).length > 0;
|
|
63
|
+
if (!hasAgents) {
|
|
64
|
+
return {
|
|
65
|
+
ok: false,
|
|
66
|
+
warning: `⚠️ pi-acp-agents runtime dir exists but no agents are configured.\n Fix: Configure at least one agent in ${configFile}, then restart pi.\n Extension is inactive until base has agents.`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
// Try to read base version from package.json in parent dir
|
|
70
|
+
try {
|
|
71
|
+
const pkgPath = join(runtimeDir, "../../agent/git/github.com/buihongduc132/pi-acp-agents/package.json");
|
|
72
|
+
if (existsSync(pkgPath)) {
|
|
73
|
+
baseVersion = JSON.parse(readFileSync(pkgPath, "utf-8")).version;
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
// Version detection is best-effort
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
// Corrupt config file — treat as not loaded
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
warning: `⚠️ pi-acp-agents config file at ${configFile} is corrupt or unreadable.\n Fix: Restore a valid config.json or reconfigure pi-acp-agents.`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { ok: true, runtimeDir, configFile, baseVersion };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── R-SP1: Safe activation guard ───────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
export interface ActivationResult {
|
|
92
|
+
activated: boolean;
|
|
93
|
+
warning?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Attempt to activate the extension safely.
|
|
98
|
+
*
|
|
99
|
+
* If base is not loaded, returns { activated: false, warning: ... }.
|
|
100
|
+
* If base is loaded, returns { activated: true }.
|
|
101
|
+
*
|
|
102
|
+
* Never throws — caller should use the result to decide whether
|
|
103
|
+
* to register tools.
|
|
104
|
+
*/
|
|
105
|
+
export function activateExtensionSafely(runtimeDirOverride?: string): ActivationResult {
|
|
106
|
+
try {
|
|
107
|
+
const detection = detectBaseLoaded(runtimeDirOverride);
|
|
108
|
+
if (!detection.ok) {
|
|
109
|
+
return { activated: false, warning: detection.warning };
|
|
110
|
+
}
|
|
111
|
+
return { activated: true };
|
|
112
|
+
} catch (err) {
|
|
113
|
+
// Absolute safety net — never crash
|
|
114
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
115
|
+
return {
|
|
116
|
+
activated: false,
|
|
117
|
+
warning: `⚠️ pi-acp-advanced failed during base detection: ${msg}\n Extension is inactive. Fix the error above and restart pi.`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── R-SP4: Version compatibility ───────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
export interface VersionCheckResult {
|
|
125
|
+
compatible: boolean;
|
|
126
|
+
currentVersion?: string;
|
|
127
|
+
requiredVersion: string;
|
|
128
|
+
warning?: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Minimum base version required by the extension */
|
|
132
|
+
export const MIN_BASE_VERSION = "0.3.0";
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Parse a semver string into [major, minor, patch].
|
|
136
|
+
* Returns null for invalid input.
|
|
137
|
+
*/
|
|
138
|
+
function parseSemver(version: string): [number, number, number] | null {
|
|
139
|
+
const match = version.match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
140
|
+
if (!match) return null;
|
|
141
|
+
return [parseInt(match[1], 10), parseInt(match[2], 10), parseInt(match[3], 10)];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Compare two semver tuples: returns -1 if a < b, 0 if equal, 1 if a > b.
|
|
146
|
+
*/
|
|
147
|
+
function compareSemver(a: [number, number, number], b: [number, number, number]): number {
|
|
148
|
+
if (a[0] !== b[0]) return a[0] < b[0] ? -1 : 1;
|
|
149
|
+
if (a[1] !== b[1]) return a[1] < b[1] ? -1 : 1;
|
|
150
|
+
if (a[2] !== b[2]) return a[2] < b[2] ? -1 : 1;
|
|
151
|
+
return 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Check whether the detected base version is compatible with the extension.
|
|
156
|
+
*
|
|
157
|
+
* @param baseVersion The version string detected from the base package
|
|
158
|
+
* @param requiredVersion Minimum required version (default: MIN_BASE_VERSION)
|
|
159
|
+
* @returns VersionCheckResult with compatibility status
|
|
160
|
+
*/
|
|
161
|
+
export function checkVersionCompatibility(
|
|
162
|
+
baseVersion: string | undefined,
|
|
163
|
+
requiredVersion: string = MIN_BASE_VERSION,
|
|
164
|
+
): VersionCheckResult {
|
|
165
|
+
if (!baseVersion) {
|
|
166
|
+
return {
|
|
167
|
+
compatible: false,
|
|
168
|
+
requiredVersion,
|
|
169
|
+
warning: `⚠️ Cannot determine pi-acp-agents version. Extension requires >=${requiredVersion}.\n Fix: Ensure pi-acp-agents is installed and properly initialized.`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const current = parseSemver(baseVersion);
|
|
174
|
+
const required = parseSemver(requiredVersion);
|
|
175
|
+
|
|
176
|
+
if (!current) {
|
|
177
|
+
return {
|
|
178
|
+
compatible: false,
|
|
179
|
+
currentVersion: baseVersion,
|
|
180
|
+
requiredVersion,
|
|
181
|
+
warning: `⚠️ Invalid base version format: "${baseVersion}". Expected semver (e.g., 0.3.0).`,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!required) {
|
|
186
|
+
// If required version is invalid, assume compatible (don't block)
|
|
187
|
+
return { compatible: true, currentVersion: baseVersion, requiredVersion };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (compareSemver(current, required) < 0) {
|
|
191
|
+
return {
|
|
192
|
+
compatible: false,
|
|
193
|
+
currentVersion: baseVersion,
|
|
194
|
+
requiredVersion,
|
|
195
|
+
warning: `⚠️ pi-acp-advanced requires pi-acp-agents >=${requiredVersion} (found: ${baseVersion}).\n Fix: npm i pi-acp-agents@latest`,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return { compatible: true, currentVersion: baseVersion, requiredVersion };
|
|
200
|
+
}
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logger — central logging for ACP agent interactions.
|
|
3
|
+
*/
|
|
4
|
+
import { mkdirSync, appendFileSync, existsSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
export interface Logger {
|
|
8
|
+
info(msg: string, data?: unknown): void;
|
|
9
|
+
error(msg: string, data?: unknown): void;
|
|
10
|
+
debug(msg: string, data?: unknown): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** No-op logger — all methods do nothing */
|
|
14
|
+
export function createNoopLogger(): Logger {
|
|
15
|
+
return {
|
|
16
|
+
info() {},
|
|
17
|
+
error() {},
|
|
18
|
+
debug() {},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** File logger — writes JSON lines to a log directory */
|
|
23
|
+
export function createFileLogger(logsDir: string, sessionId?: string): Logger {
|
|
24
|
+
if (!existsSync(logsDir)) {
|
|
25
|
+
try {
|
|
26
|
+
mkdirSync(logsDir, { recursive: true });
|
|
27
|
+
} catch (err) {
|
|
28
|
+
console.log("[acp-logger] failed to create logsDir:", err);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const mainLogPath = join(logsDir, "main.log");
|
|
33
|
+
|
|
34
|
+
function write(level: string, msg: string, data?: unknown): void {
|
|
35
|
+
const entry = {
|
|
36
|
+
timestamp: new Date().toISOString(),
|
|
37
|
+
level,
|
|
38
|
+
msg,
|
|
39
|
+
...(data !== undefined ? { data } : {}),
|
|
40
|
+
};
|
|
41
|
+
try {
|
|
42
|
+
appendFileSync(mainLogPath, JSON.stringify(entry) + "\n", "utf-8");
|
|
43
|
+
} catch (err) {
|
|
44
|
+
console.log("[acp-logger] failed to write main log:", err);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (sessionId) {
|
|
49
|
+
const sessionDir = join(logsDir, sessionId);
|
|
50
|
+
if (!existsSync(sessionDir)) {
|
|
51
|
+
try {
|
|
52
|
+
mkdirSync(sessionDir, { recursive: true });
|
|
53
|
+
} catch (err) {
|
|
54
|
+
console.log("[acp-logger] failed to create sessionDir:", err);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const tracePath = join(sessionDir, "trace.jsonl");
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
info(msg, data) {
|
|
61
|
+
write("info", msg, data);
|
|
62
|
+
try {
|
|
63
|
+
appendFileSync(tracePath, JSON.stringify({ timestamp: new Date().toISOString(), level: "info", msg, data }) + "\n", "utf-8");
|
|
64
|
+
} catch (err) {
|
|
65
|
+
console.log("[acp-logger] failed to write trace:", err);
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
error(msg, data) {
|
|
69
|
+
write("error", msg, data);
|
|
70
|
+
try {
|
|
71
|
+
appendFileSync(tracePath, JSON.stringify({ timestamp: new Date().toISOString(), level: "error", msg, data }) + "\n", "utf-8");
|
|
72
|
+
} catch (err) {
|
|
73
|
+
console.log("[acp-logger] failed to write trace:", err);
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
debug(msg, data) {
|
|
77
|
+
write("debug", msg, data);
|
|
78
|
+
try {
|
|
79
|
+
appendFileSync(tracePath, JSON.stringify({ timestamp: new Date().toISOString(), level: "debug", msg, data }) + "\n", "utf-8");
|
|
80
|
+
} catch (err) {
|
|
81
|
+
console.log("[acp-logger] failed to write trace:", err);
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
info(msg, data) { write("info", msg, data); },
|
|
89
|
+
error(msg, data) { write("error", msg, data); },
|
|
90
|
+
debug(msg, data) { write("debug", msg, data); },
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { appendFileSync } from "node:fs";
|
|
2
|
+
import { ensureRuntimeDir } from "./runtime-paths.js";
|
|
3
|
+
import { createNoopLogger } from "../logger.js";
|
|
4
|
+
|
|
5
|
+
const log = createNoopLogger();
|
|
6
|
+
|
|
7
|
+
export interface AcpEventLogEntry {
|
|
8
|
+
timestamp: string;
|
|
9
|
+
type: string;
|
|
10
|
+
data?: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class AcpEventLog {
|
|
14
|
+
constructor(private rootDir?: string) {}
|
|
15
|
+
|
|
16
|
+
append(type: string, data?: Record<string, unknown>): AcpEventLogEntry {
|
|
17
|
+
const entry: AcpEventLogEntry = {
|
|
18
|
+
timestamp: new Date().toISOString(),
|
|
19
|
+
type,
|
|
20
|
+
...(data ? { data } : {}),
|
|
21
|
+
};
|
|
22
|
+
try {
|
|
23
|
+
const paths = ensureRuntimeDir(this.rootDir);
|
|
24
|
+
appendFileSync(paths.eventLogFile, JSON.stringify(entry) + "\n", "utf-8");
|
|
25
|
+
} catch (e) {
|
|
26
|
+
// EACCES or other FS error — silently degrade. Event log is non-critical.
|
|
27
|
+
log.debug("event log write failed", e);
|
|
28
|
+
}
|
|
29
|
+
return entry;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { ensureRuntimeDir } from "./runtime-paths.js";
|
|
3
|
+
import { createNoopLogger } from "../logger.js";
|
|
4
|
+
|
|
5
|
+
const log = createNoopLogger();
|
|
6
|
+
|
|
7
|
+
export interface PlanApprovalRequest {
|
|
8
|
+
agent: string;
|
|
9
|
+
status: "pending" | "approved" | "rejected";
|
|
10
|
+
requestedAt: string;
|
|
11
|
+
resolvedAt?: string;
|
|
12
|
+
feedback?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ModelPolicyState {
|
|
16
|
+
allowedModels: string[];
|
|
17
|
+
blockedModels: string[];
|
|
18
|
+
requireProviderPrefix: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface GovernancePayload {
|
|
22
|
+
planApprovals: Record<string, PlanApprovalRequest>;
|
|
23
|
+
modelPolicy: ModelPolicyState;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DEFAULT_PAYLOAD: GovernancePayload = {
|
|
27
|
+
planApprovals: {},
|
|
28
|
+
modelPolicy: {
|
|
29
|
+
allowedModels: [],
|
|
30
|
+
blockedModels: [],
|
|
31
|
+
requireProviderPrefix: false,
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export class GovernanceStore {
|
|
36
|
+
constructor(private rootDir?: string) {}
|
|
37
|
+
|
|
38
|
+
getPlan(agent: string): PlanApprovalRequest | undefined {
|
|
39
|
+
return this.read().planApprovals[agent];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
requestPlan(agent: string): PlanApprovalRequest {
|
|
43
|
+
const payload = this.read();
|
|
44
|
+
const plan: PlanApprovalRequest = {
|
|
45
|
+
agent,
|
|
46
|
+
status: "pending",
|
|
47
|
+
requestedAt: new Date().toISOString(),
|
|
48
|
+
};
|
|
49
|
+
payload.planApprovals[agent] = plan;
|
|
50
|
+
this.write(payload);
|
|
51
|
+
return plan;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
resolvePlan(agent: string, status: "approved" | "rejected", feedback?: string): PlanApprovalRequest {
|
|
55
|
+
const payload = this.read();
|
|
56
|
+
const existing = payload.planApprovals[agent] ?? {
|
|
57
|
+
agent,
|
|
58
|
+
status: "pending" as const,
|
|
59
|
+
requestedAt: new Date().toISOString(),
|
|
60
|
+
};
|
|
61
|
+
existing.status = status;
|
|
62
|
+
existing.feedback = feedback;
|
|
63
|
+
existing.resolvedAt = new Date().toISOString();
|
|
64
|
+
payload.planApprovals[agent] = existing;
|
|
65
|
+
this.write(payload);
|
|
66
|
+
return existing;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
getModelPolicy(): ModelPolicyState {
|
|
70
|
+
return this.read().modelPolicy;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
setModelPolicy(policy: Partial<ModelPolicyState>): ModelPolicyState {
|
|
74
|
+
const payload = this.read();
|
|
75
|
+
payload.modelPolicy = {
|
|
76
|
+
...payload.modelPolicy,
|
|
77
|
+
...policy,
|
|
78
|
+
allowedModels: policy.allowedModels ?? payload.modelPolicy.allowedModels,
|
|
79
|
+
blockedModels: policy.blockedModels ?? payload.modelPolicy.blockedModels,
|
|
80
|
+
};
|
|
81
|
+
this.write(payload);
|
|
82
|
+
return payload.modelPolicy;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
checkModel(model?: string): { ok: boolean; reason: string } {
|
|
86
|
+
const policy = this.getModelPolicy();
|
|
87
|
+
if (!model) return { ok: true, reason: "no model override provided" };
|
|
88
|
+
if (policy.requireProviderPrefix && !model.includes("/")) {
|
|
89
|
+
return { ok: false, reason: "model must include provider/model format" };
|
|
90
|
+
}
|
|
91
|
+
if (policy.blockedModels.includes(model)) {
|
|
92
|
+
return { ok: false, reason: `model \"${model}\" is blocked` };
|
|
93
|
+
}
|
|
94
|
+
if (policy.allowedModels.length > 0 && !policy.allowedModels.includes(model)) {
|
|
95
|
+
return { ok: false, reason: `model \"${model}\" is not in allowed list` };
|
|
96
|
+
}
|
|
97
|
+
return { ok: true, reason: "model allowed" };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private read(): GovernancePayload {
|
|
101
|
+
try {
|
|
102
|
+
const paths = ensureRuntimeDir(this.rootDir);
|
|
103
|
+
if (!existsSync(paths.governanceFile)) {
|
|
104
|
+
return structuredClone(DEFAULT_PAYLOAD);
|
|
105
|
+
}
|
|
106
|
+
return JSON.parse(readFileSync(paths.governanceFile, "utf-8")) as GovernancePayload;
|
|
107
|
+
} catch (e) {
|
|
108
|
+
// EACCES, corrupt file, etc — degrade gracefully, don't crash the extension
|
|
109
|
+
log.debug("governance-store read failed", e);
|
|
110
|
+
return structuredClone(DEFAULT_PAYLOAD);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private write(payload: GovernancePayload): void {
|
|
115
|
+
try {
|
|
116
|
+
const paths = ensureRuntimeDir(this.rootDir);
|
|
117
|
+
writeFileSync(paths.governanceFile, JSON.stringify(payload, null, 2) + "\n", "utf-8");
|
|
118
|
+
} catch (e) {
|
|
119
|
+
// EACCES or other FS error — silently degrade. Governance is non-critical state.
|
|
120
|
+
log.debug("governance-store write failed", e);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Heartbeat delta parsing — defensive parsing for ACP session/update events.
|
|
3
|
+
*
|
|
4
|
+
* Task 2.2: malformed/missing fields are treated as zero-delta rather than
|
|
5
|
+
* crashing the heartbeat consumer. A thrown error is propagated to the caller
|
|
6
|
+
* so it can be logged via `AcpEventLog` (`heartbeat_parse_error`).
|
|
7
|
+
*/
|
|
8
|
+
import type { SessionUpdate } from "@agentclientprotocol/sdk";
|
|
9
|
+
|
|
10
|
+
export interface HeartbeatDeltas {
|
|
11
|
+
tokenDelta: number;
|
|
12
|
+
toolCallDelta: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Dependencies for {@link consumeHeartbeat}. Injected so the consumer is a
|
|
17
|
+
* pure, testable function (no filesystem / global state required in tests).
|
|
18
|
+
*/
|
|
19
|
+
export interface HeartbeatConsumerDeps {
|
|
20
|
+
/** Resolve the worker name bound to a session id (undefined = not worker-bound). */
|
|
21
|
+
resolveWorkerName(sessionId: string): string | undefined;
|
|
22
|
+
/** Apply deltas to a worker (wraps `WorkerStore.touch`). May throw. */
|
|
23
|
+
touch(
|
|
24
|
+
name: string,
|
|
25
|
+
deltas?: { tokenDelta?: number; toolCallDelta?: number },
|
|
26
|
+
): unknown;
|
|
27
|
+
/** Log a malformed/unexpected event to `AcpEventLog` (`heartbeat_parse_error`). */
|
|
28
|
+
logParseError(entry: {
|
|
29
|
+
workerName: string;
|
|
30
|
+
sessionId: string;
|
|
31
|
+
error: string;
|
|
32
|
+
}): void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Parse token/tool deltas from a single session/update event.
|
|
37
|
+
*
|
|
38
|
+
* - `usage_update`: extracts `used` tokens; missing/non-number `used`/`size`
|
|
39
|
+
* are treated as zero (defensive).
|
|
40
|
+
* - `tool_call`: counts as one tool call.
|
|
41
|
+
* - Any other (or missing) `sessionUpdate`: zero-delta.
|
|
42
|
+
*
|
|
43
|
+
* @throws never for malformed input — defensive guards handle all shapes.
|
|
44
|
+
*/
|
|
45
|
+
export function parseHeartbeatDeltas(update: SessionUpdate): HeartbeatDeltas {
|
|
46
|
+
const updateRec = update as Record<string, unknown>;
|
|
47
|
+
const updateType = updateRec.sessionUpdate;
|
|
48
|
+
let tokenDelta = 0;
|
|
49
|
+
let toolCallDelta = 0;
|
|
50
|
+
|
|
51
|
+
if (updateType === "usage_update") {
|
|
52
|
+
// Defensive: treat missing/non-number 'used' as zero-delta
|
|
53
|
+
const used = typeof updateRec.used === "number" ? updateRec.used : 0;
|
|
54
|
+
const size = typeof updateRec.size === "number" ? updateRec.size : 0;
|
|
55
|
+
tokenDelta = used;
|
|
56
|
+
void size; // size is total context window, not useful as delta
|
|
57
|
+
} else if (updateType === "tool_call") {
|
|
58
|
+
toolCallDelta = 1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { tokenDelta, toolCallDelta };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Heartbeat consumer — process a single ACP `session/update` for a
|
|
66
|
+
* worker-bound session: defensively parse deltas, apply them via
|
|
67
|
+
* `WorkerStore.touch`, and log any thrown error as `heartbeat_parse_error`
|
|
68
|
+
* without crashing the event stream.
|
|
69
|
+
*
|
|
70
|
+
* (Tasks 2.1 + 2.2: defensive parsing built-in via {@link parseHeartbeatDeltas}.)
|
|
71
|
+
*
|
|
72
|
+
* Exported so it is unit-testable; the production wiring in `index.ts`
|
|
73
|
+
* delegates to this function.
|
|
74
|
+
*/
|
|
75
|
+
export function consumeHeartbeat(
|
|
76
|
+
deps: HeartbeatConsumerDeps,
|
|
77
|
+
sessionId: string,
|
|
78
|
+
update: SessionUpdate,
|
|
79
|
+
): void {
|
|
80
|
+
const workerName = deps.resolveWorkerName(sessionId);
|
|
81
|
+
if (!workerName) return; // Not a worker-bound session
|
|
82
|
+
try {
|
|
83
|
+
const { tokenDelta, toolCallDelta } = parseHeartbeatDeltas(update);
|
|
84
|
+
deps.touch(workerName, { tokenDelta, toolCallDelta });
|
|
85
|
+
} catch (err) {
|
|
86
|
+
deps.logParseError({
|
|
87
|
+
workerName,
|
|
88
|
+
sessionId,
|
|
89
|
+
error: err instanceof Error ? err.message : String(err),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { ensureRuntimeDir } from "./runtime-paths.js";
|
|
3
|
+
import { createNoopLogger } from "../logger.js";
|
|
4
|
+
|
|
5
|
+
const log = createNoopLogger();
|
|
6
|
+
|
|
7
|
+
export interface MailMessage {
|
|
8
|
+
id: string;
|
|
9
|
+
from: string;
|
|
10
|
+
to: string;
|
|
11
|
+
message: string;
|
|
12
|
+
kind: "dm" | "steer" | "broadcast";
|
|
13
|
+
createdAt: string;
|
|
14
|
+
readAt?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface MailboxPayload {
|
|
18
|
+
nextId: number;
|
|
19
|
+
messages: MailMessage[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const DEFAULT_PAYLOAD: MailboxPayload = {
|
|
23
|
+
nextId: 1,
|
|
24
|
+
messages: [],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export class MailboxManager {
|
|
28
|
+
constructor(private rootDir?: string) {}
|
|
29
|
+
|
|
30
|
+
send(input: { from: string; to: string; message: string; kind: MailMessage["kind"] }): MailMessage {
|
|
31
|
+
const payload = this.read();
|
|
32
|
+
const mail: MailMessage = {
|
|
33
|
+
id: String(payload.nextId++),
|
|
34
|
+
from: input.from,
|
|
35
|
+
to: input.to,
|
|
36
|
+
message: input.message,
|
|
37
|
+
kind: input.kind,
|
|
38
|
+
createdAt: new Date().toISOString(),
|
|
39
|
+
};
|
|
40
|
+
payload.messages.push(mail);
|
|
41
|
+
this.write(payload);
|
|
42
|
+
return mail;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
listFor(recipient: string): MailMessage[] {
|
|
46
|
+
return this.read().messages.filter((message) => message.to === recipient || message.to === "*");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
markRead(messageId: string): MailMessage {
|
|
50
|
+
const payload = this.read();
|
|
51
|
+
const message = payload.messages.find((item) => item.id === messageId);
|
|
52
|
+
if (!message) throw new Error(`Message \"${messageId}\" not found`);
|
|
53
|
+
message.readAt = new Date().toISOString();
|
|
54
|
+
this.write(payload);
|
|
55
|
+
return message;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
clearFor(recipient: string): number {
|
|
59
|
+
const payload = this.read();
|
|
60
|
+
const before = payload.messages.length;
|
|
61
|
+
payload.messages = payload.messages.filter((message) => !(message.to === recipient || message.to === "*"));
|
|
62
|
+
this.write(payload);
|
|
63
|
+
return before - payload.messages.length;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** List all messages across all recipients. */
|
|
67
|
+
listAll(): MailMessage[] {
|
|
68
|
+
return this.read().messages;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private read(): MailboxPayload {
|
|
72
|
+
try {
|
|
73
|
+
const paths = ensureRuntimeDir(this.rootDir);
|
|
74
|
+
if (!existsSync(paths.mailboxesFile)) {
|
|
75
|
+
return structuredClone(DEFAULT_PAYLOAD);
|
|
76
|
+
}
|
|
77
|
+
return JSON.parse(readFileSync(paths.mailboxesFile, "utf-8")) as MailboxPayload;
|
|
78
|
+
} catch (e) {
|
|
79
|
+
// File read failed — return default payload
|
|
80
|
+
log.debug("mailbox-manager read failed", e);
|
|
81
|
+
return structuredClone(DEFAULT_PAYLOAD);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private write(payload: MailboxPayload): void {
|
|
86
|
+
try {
|
|
87
|
+
const paths = ensureRuntimeDir(this.rootDir);
|
|
88
|
+
writeFileSync(paths.mailboxesFile, JSON.stringify(payload, null, 2) + "\n", "utf-8");
|
|
89
|
+
} catch (e) {
|
|
90
|
+
// File read failed — return default payload
|
|
91
|
+
// EACCES or other FS error — silently degrade. Mailboxes are non-critical runtime state.
|
|
92
|
+
log.debug("mailbox-manager write failed", e);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { safeMkdir } from "./safe-mkdir.js";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export interface AcpRuntimePaths {
|
|
6
|
+
rootDir: string;
|
|
7
|
+
tasksFile: string;
|
|
8
|
+
mailboxesFile: string;
|
|
9
|
+
governanceFile: string;
|
|
10
|
+
eventLogFile: string;
|
|
11
|
+
sessionArchiveFile: string;
|
|
12
|
+
sessionNameRegistryFile: string;
|
|
13
|
+
workersFile: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getRuntimePaths(rootDir?: string): AcpRuntimePaths {
|
|
17
|
+
const base = rootDir ?? join(homedir(), ".pi", "acp-agents", "runtime");
|
|
18
|
+
return {
|
|
19
|
+
rootDir: base,
|
|
20
|
+
tasksFile: join(base, "tasks.json"),
|
|
21
|
+
mailboxesFile: join(base, "mailboxes.json"),
|
|
22
|
+
governanceFile: join(base, "governance.json"),
|
|
23
|
+
eventLogFile: join(base, "events.jsonl"),
|
|
24
|
+
sessionArchiveFile: join(base, "session-archive.json"),
|
|
25
|
+
sessionNameRegistryFile: join(base, "session-name-registry.json"),
|
|
26
|
+
workersFile: join(base, "workers.json"),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function ensureRuntimeDir(rootDir?: string): AcpRuntimePaths {
|
|
31
|
+
const paths = getRuntimePaths(rootDir);
|
|
32
|
+
safeMkdir(paths.rootDir);
|
|
33
|
+
return paths;
|
|
34
|
+
}
|