@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.
Files changed (43) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/LICENSE +21 -0
  3. package/README.md +359 -0
  4. package/index.ts +1521 -0
  5. package/package.json +103 -0
  6. package/skills/pi-acp-agents/SKILL.md +112 -0
  7. package/src/acp-widget.ts +379 -0
  8. package/src/adapter-factory.ts +55 -0
  9. package/src/adapters/acpx.ts +215 -0
  10. package/src/adapters/base.ts +117 -0
  11. package/src/adapters/codex.ts +77 -0
  12. package/src/adapters/custom.ts +14 -0
  13. package/src/adapters/gemini.ts +66 -0
  14. package/src/adapters/opencode.ts +101 -0
  15. package/src/config/config.ts +312 -0
  16. package/src/config/types.ts +203 -0
  17. package/src/coordination/alias-resolver.ts +208 -0
  18. package/src/coordination/coordinator.ts +266 -0
  19. package/src/coordination/worker-dispatcher.ts +191 -0
  20. package/src/core/async-executor.ts +149 -0
  21. package/src/core/circuit-breaker.ts +254 -0
  22. package/src/core/client.ts +661 -0
  23. package/src/core/health-monitor.ts +200 -0
  24. package/src/core/protocol-validator.ts +259 -0
  25. package/src/core/session-lifecycle.ts +46 -0
  26. package/src/core/session-manager.ts +64 -0
  27. package/src/extension-safety.ts +200 -0
  28. package/src/logger.ts +92 -0
  29. package/src/management/event-log.ts +31 -0
  30. package/src/management/governance-store.ts +123 -0
  31. package/src/management/heartbeat-parser.ts +92 -0
  32. package/src/management/mailbox-manager.ts +95 -0
  33. package/src/management/runtime-paths.ts +34 -0
  34. package/src/management/safe-mkdir.ts +78 -0
  35. package/src/management/session-archive-store.ts +136 -0
  36. package/src/management/session-name-store.ts +88 -0
  37. package/src/management/task-store.ts +260 -0
  38. package/src/management/worker-store.ts +164 -0
  39. package/src/public-api.ts +72 -0
  40. package/src/settings/agent-config-tui.ts +456 -0
  41. package/src/settings/agents-command.ts +138 -0
  42. package/src/settings/config.ts +201 -0
  43. package/src/settings/configure-tui.ts +135 -0
@@ -0,0 +1,138 @@
1
+ /**
2
+ * agents-command.ts — Handler for /acp agents <add|remove|list|config>.
3
+ *
4
+ * Extracted from index.ts for testability. Pure logic + ctx.ui.notify calls.
5
+ * No pi.registerCommand dependency.
6
+ */
7
+
8
+ import {
9
+ loadConfig,
10
+ saveConfig,
11
+ upsertAgentServer,
12
+ removeAgentServer,
13
+ AGENT_PRESETS,
14
+ } from "../config/config.js";
15
+ import type { SettingsUI } from "./agent-config-tui.js";
16
+
17
+ // Re-export so index.ts can import from here
18
+ export { openAgentConfigTUI } from "./agent-config-tui.js";
19
+
20
+ // ── Types ────────────────────────────────────────────────
21
+
22
+ export interface AgentsCommandCtx {
23
+ ui: {
24
+ notify(message: string, type: "info" | "warning" | "error"): void;
25
+ };
26
+ }
27
+
28
+ // ── Handler ──────────────────────────────────────────────
29
+
30
+ /**
31
+ * Handle /acp agents subcommands.
32
+ *
33
+ * @param tokens - Parsed tokens after "agents" (e.g. ["add", "gemini", "--command", "gemini"])
34
+ * @param ctx - Context with ui.notify
35
+ */
36
+ export async function handleAgentsCommand(
37
+ tokens: string[],
38
+ ctx: AgentsCommandCtx,
39
+ ): Promise<void> {
40
+ const [subcommand] = tokens;
41
+
42
+ if (subcommand === "config") {
43
+ const { openAgentConfigTUI } = await import("./agent-config-tui.js");
44
+ try {
45
+ await openAgentConfigTUI(ctx.ui as unknown as SettingsUI);
46
+ } catch (e) {
47
+ ctx.ui.notify("Failed to open agent config TUI.", "error");
48
+ }
49
+ return;
50
+ }
51
+
52
+ if (subcommand === "list") {
53
+ const cfg = loadConfig();
54
+ const agentLines = Object.entries(cfg.agent_servers)
55
+ .map(([name, a]) => {
56
+ const isDefault = cfg.defaultAgent === name ? " (default)" : "";
57
+ return ` ${name}: ${a.command} ${(a.args ?? []).join(" ")}${isDefault}`;
58
+ })
59
+ .join("\n");
60
+ ctx.ui.notify(
61
+ `Agent Servers:\n${agentLines || " (none)"}\n\nDefault: ${cfg.defaultAgent ?? "none"}`,
62
+ "info",
63
+ );
64
+ return;
65
+ }
66
+
67
+ if (subcommand === "add") {
68
+ const name = tokens[1];
69
+ if (!name) {
70
+ ctx.ui.notify(
71
+ "Usage: /acp agents add <name> [--command <cmd>] [--args <a1,a2>] [--model <m>]",
72
+ "error",
73
+ );
74
+ return;
75
+ }
76
+ // Parse optional flags
77
+ let command = "";
78
+ let args: string[] = [];
79
+ let model = "";
80
+ for (let i = 2; i < tokens.length; i++) {
81
+ if (tokens[i] === "--command" && tokens[i + 1]) {
82
+ command = tokens[++i]!;
83
+ } else if (tokens[i] === "--args" && tokens[i + 1]) {
84
+ args = tokens[++i]!.split(",");
85
+ } else if (tokens[i] === "--model" && tokens[i + 1]) {
86
+ model = tokens[++i]!;
87
+ }
88
+ }
89
+ // If no command specified, try preset
90
+ if (!command) {
91
+ const preset = AGENT_PRESETS[name]?.();
92
+ if (!preset) {
93
+ ctx.ui.notify(
94
+ `No command specified and "${name}" is not a known preset. Use --command <cmd>.`,
95
+ "error",
96
+ );
97
+ return;
98
+ }
99
+ command = preset.command ?? "";
100
+ args = preset.args ?? [];
101
+ }
102
+ try {
103
+ const cfg = loadConfig();
104
+ const updated = upsertAgentServer(cfg, name, {
105
+ command,
106
+ args,
107
+ default_model: model || undefined,
108
+ });
109
+ saveConfig(updated);
110
+ ctx.ui.notify(
111
+ `Agent "${name}" added: ${command} ${args.join(" ")}`,
112
+ "info",
113
+ );
114
+ } catch (e) {
115
+ ctx.ui.notify(
116
+ `Failed to add agent: ${(e as Error).message}`,
117
+ "error",
118
+ );
119
+ }
120
+ return;
121
+ }
122
+
123
+ if (subcommand === "remove") {
124
+ const name = tokens[1];
125
+ if (!name) {
126
+ ctx.ui.notify("Usage: /acp agents remove <name>", "error");
127
+ return;
128
+ }
129
+ const cfg = loadConfig();
130
+ const updated = removeAgentServer(cfg, name);
131
+ saveConfig(updated);
132
+ ctx.ui.notify(`Agent "${name}" removed.`, "info");
133
+ return;
134
+ }
135
+
136
+ // Default: show help
137
+ ctx.ui.notify("/acp agents <add|remove|list|config>", "info");
138
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * ACP tool enable/disable settings.
3
+ *
4
+ * Pattern copied from pi-gitnexus-local/config.ts:
5
+ * - Global config at ~/.pi/acp-agents/settings.json
6
+ * - Local (project) config at <cwd>/.pi/acp-agents/settings.json
7
+ * - deepMerge(global, local) with local overriding global per key
8
+ * - DEFAULT_SETTINGS has all tools enabled
9
+ * - Unknown tool names are ignored gracefully
10
+ */
11
+
12
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { resolve } from "node:path";
15
+ import { createNoopLogger } from "../logger.js";
16
+
17
+ const log = createNoopLogger();
18
+
19
+ // ── Tool names ──────────────────────────────────────────
20
+
21
+ export const ACP_TOOL_NAMES = [
22
+ "acp_prompt",
23
+ "acp_status",
24
+ "acp_session_new",
25
+ "acp_session_load",
26
+ "acp_session_set_model",
27
+ "acp_session_set_mode",
28
+ "acp_cancel",
29
+ "acp_session_list",
30
+ "acp_session_shutdown",
31
+ "acp_session_kill",
32
+ "acp_prune",
33
+ "acp_delegate",
34
+ "acp_broadcast",
35
+ "acp_compare",
36
+ "acp_task_create",
37
+ "acp_task_list",
38
+ "acp_task_get",
39
+ "acp_task_assign",
40
+ "acp_task_set_status",
41
+ "acp_task_dependency_add",
42
+ "acp_task_dependency_remove",
43
+ "acp_task_clear",
44
+ "acp_message_send",
45
+ "acp_message_list",
46
+ "acp_plan_request",
47
+ "acp_plan_resolve",
48
+ "acp_model_policy_get",
49
+ "acp_model_policy_check",
50
+ "acp_doctor",
51
+ "acp_runtime_info",
52
+ "acp_env",
53
+ "acp_event_log",
54
+ "acp_cleanup",
55
+ "acp_worker_spawn",
56
+ "acp_worker_list",
57
+ "acp_worker_steer",
58
+ "acp_worker_shutdown",
59
+ "acp_worker_kill",
60
+ "acp_worker_prune",
61
+ ] as const;
62
+
63
+ export type AcpToolName = (typeof ACP_TOOL_NAMES)[number];
64
+
65
+ // ── Types ───────────────────────────────────────────────
66
+
67
+ export interface AcpToolSettings {
68
+ tools: Record<AcpToolName, { enabled: boolean }>;
69
+ }
70
+
71
+ export type AcpToolSettingsInput = {
72
+ tools?: Partial<Record<string, { enabled: boolean }>>;
73
+ };
74
+
75
+ // ── Defaults ────────────────────────────────────────────
76
+
77
+ function buildDefaultTools(): Record<AcpToolName, { enabled: boolean }> {
78
+ const tools = {} as Record<AcpToolName, { enabled: boolean }>;
79
+ for (const name of ACP_TOOL_NAMES) {
80
+ tools[name] = { enabled: true };
81
+ }
82
+ return tools;
83
+ }
84
+
85
+ export const DEFAULT_SETTINGS: AcpToolSettings = {
86
+ tools: buildDefaultTools(),
87
+ };
88
+
89
+ // ── Deep merge (copied from pi-gitnexus-local/config.ts) ──
90
+
91
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
92
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
93
+ }
94
+
95
+ export function deepMergeSettings<T>(base: T, override: unknown): T {
96
+ if (!override) return structuredClone(base);
97
+ if (Array.isArray(base) || Array.isArray(override)) {
98
+ return structuredClone(override as T);
99
+ }
100
+ if (isPlainObject(base) && isPlainObject(override)) {
101
+ const result: Record<string, unknown> = { ...base };
102
+ for (const [key, value] of Object.entries(override)) {
103
+ const current = result[key];
104
+ if (isPlainObject(current) && isPlainObject(value)) {
105
+ result[key] = deepMergeSettings(current, value);
106
+ } else {
107
+ result[key] = structuredClone(value);
108
+ }
109
+ }
110
+ return result as T;
111
+ }
112
+ return structuredClone(override as T);
113
+ }
114
+
115
+ // ── Config file I/O ─────────────────────────────────────
116
+
117
+ export const GLOBAL_SETTINGS_PATH = resolve(
118
+ homedir(),
119
+ ".pi",
120
+ "acp-agents",
121
+ "settings.json",
122
+ );
123
+
124
+ export function getProjectSettingsPath(cwd: string): string {
125
+ return resolve(cwd, ".pi", "acp-agents", "settings.json");
126
+ }
127
+
128
+ function loadJsonLike(path: string): AcpToolSettingsInput | null {
129
+ if (!existsSync(path)) return null;
130
+ try {
131
+ const raw = readFileSync(path, "utf-8");
132
+ const stripped = raw.replace(/^\s*\/\/.*$/gm, "").trim();
133
+ if (!stripped) return null;
134
+ return JSON.parse(stripped) as AcpToolSettingsInput;
135
+ } catch (e) {
136
+ // JSON parse failed for settings file — return null to use defaults
137
+ log.debug("settings loadJsonLike parse failed", e);
138
+ return null;
139
+ }
140
+ }
141
+
142
+ export function readGlobalSettings(): AcpToolSettingsInput | null {
143
+ return loadJsonLike(GLOBAL_SETTINGS_PATH);
144
+ }
145
+
146
+ export function writeGlobalSettings(config: AcpToolSettingsInput): void {
147
+ const dir = resolve(homedir(), ".pi", "acp-agents");
148
+ mkdirSync(dir, { recursive: true });
149
+ writeFileSync(
150
+ GLOBAL_SETTINGS_PATH,
151
+ `${JSON.stringify(config, null, 2)}\n`,
152
+ "utf-8",
153
+ );
154
+ }
155
+
156
+ // ── Merge with validation ───────────────────────────────
157
+
158
+ function validateAndStrip(settings: AcpToolSettingsInput): AcpToolSettingsInput {
159
+ if (!settings?.tools) return { tools: {} };
160
+ const valid: Record<string, { enabled: boolean }> = {};
161
+ for (const name of ACP_TOOL_NAMES) {
162
+ if (settings.tools[name] !== undefined) {
163
+ valid[name] = { enabled: settings.tools[name].enabled };
164
+ }
165
+ }
166
+ return { tools: valid };
167
+ }
168
+
169
+ export function mergeSettingsLayers(
170
+ globalConfig?: AcpToolSettingsInput | null,
171
+ projectConfig?: AcpToolSettingsInput | null,
172
+ ): AcpToolSettings {
173
+ const validatedGlobal = globalConfig ? validateAndStrip(globalConfig) : null;
174
+ const validatedLocal = projectConfig ? validateAndStrip(projectConfig) : null;
175
+ let merged = deepMergeSettings(DEFAULT_SETTINGS, validatedGlobal);
176
+ merged = deepMergeSettings(merged, validatedLocal);
177
+ return merged;
178
+ }
179
+
180
+ export function loadSettings(cwd: string): AcpToolSettings {
181
+ const projectPath = getProjectSettingsPath(cwd);
182
+ return mergeSettingsLayers(readGlobalSettings(), loadJsonLike(projectPath));
183
+ }
184
+
185
+ export function loadSettingsLayers(cwd: string): {
186
+ global: AcpToolSettingsInput | null;
187
+ local: AcpToolSettingsInput | null;
188
+ merged: AcpToolSettings;
189
+ } {
190
+ const global = readGlobalSettings();
191
+ const local = loadJsonLike(getProjectSettingsPath(cwd));
192
+ return { global, local, merged: mergeSettingsLayers(global, local) };
193
+ }
194
+
195
+ // ── Helper ──────────────────────────────────────────────
196
+
197
+ export function isToolEnabled(settings: AcpToolSettings, toolName: string): boolean {
198
+ const entry = settings.tools[toolName as AcpToolName];
199
+ if (!entry) return true; // unknown tools default to enabled
200
+ return entry.enabled;
201
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * TUI settings wizard for ACP tool enable/disable.
3
+ *
4
+ * Pattern copied from pi-gitnexus-local/index.ts configureExtension():
5
+ * Uses ctx.ui.confirm(), ctx.ui.select(), ctx.ui.input() for interactive settings.
6
+ * NOT ctx.ui.custom().
7
+ */
8
+
9
+ import type { AcpToolSettings, AcpToolSettingsInput, AcpToolName } from "./config.js";
10
+ import { ACP_TOOL_NAMES, readGlobalSettings, writeGlobalSettings, loadSettings } from "./config.js";
11
+
12
+ const TOOL_GROUPS: { label: string; tools: AcpToolName[] }[] = [
13
+ {
14
+ label: "Core",
15
+ tools: ["acp_prompt", "acp_status"],
16
+ },
17
+ {
18
+ label: "Session",
19
+ tools: [
20
+ "acp_session_new", "acp_session_load", "acp_session_set_model",
21
+ "acp_session_set_mode", "acp_cancel",
22
+ ],
23
+ },
24
+ {
25
+ label: "Lifecycle",
26
+ tools: ["acp_session_list", "acp_session_shutdown", "acp_session_kill", "acp_prune"],
27
+ },
28
+ {
29
+ label: "Coordination",
30
+ tools: ["acp_delegate", "acp_broadcast", "acp_compare"],
31
+ },
32
+ {
33
+ label: "Task",
34
+ tools: [
35
+ "acp_task_create", "acp_task_list", "acp_task_get", "acp_task_assign",
36
+ "acp_task_set_status", "acp_task_dependency_add", "acp_task_dependency_remove",
37
+ "acp_task_clear",
38
+ ],
39
+ },
40
+ {
41
+ label: "Message",
42
+ tools: ["acp_message_send", "acp_message_list"],
43
+ },
44
+ {
45
+ label: "Governance",
46
+ tools: ["acp_plan_request", "acp_plan_resolve", "acp_model_policy_get", "acp_model_policy_check"],
47
+ },
48
+ {
49
+ label: "Runtime",
50
+ tools: ["acp_doctor", "acp_runtime_info", "acp_env", "acp_event_log", "acp_cleanup"],
51
+ },
52
+ ];
53
+
54
+ export async function configureToolSettings(
55
+ ctx: {
56
+ hasUI: boolean;
57
+ ui: {
58
+ confirm(title: string, body: string): Promise<boolean>;
59
+ input(prompt: string, placeholder?: string): Promise<string | undefined>;
60
+ notify(message: string, type: "info" | "warning" | "error"): void;
61
+ select(prompt: string, items: string[]): Promise<string | undefined>;
62
+ };
63
+ },
64
+ cwd: string,
65
+ ): Promise<AcpToolSettings | null> {
66
+ if (!ctx.hasUI) {
67
+ ctx.ui.notify("Interactive UI required for ACP settings", "warning");
68
+ return null;
69
+ }
70
+
71
+ const current = loadSettings(cwd);
72
+ const base = structuredClone(readGlobalConfigForEdit());
73
+
74
+ // Ask which group to configure
75
+ while (true) {
76
+ const groupLabels = TOOL_GROUPS.map((g) => {
77
+ const enabled = g.tools.filter((t) => current.tools[t].enabled).length;
78
+ return `${g.label} (${enabled}/${g.tools.length})`;
79
+ });
80
+ groupLabels.push("Done");
81
+
82
+ const choice = await ctx.ui.select(
83
+ "ACP tool settings — pick a group to configure",
84
+ groupLabels,
85
+ );
86
+
87
+ if (!choice || choice === "Done") break;
88
+
89
+ const groupIndex = groupLabels.indexOf(choice);
90
+ const group = TOOL_GROUPS[groupIndex];
91
+
92
+ // Toggle individual tools in the group
93
+ for (const toolName of group.tools) {
94
+ const isEnabled = base.tools?.[toolName]?.enabled ?? current.tools[toolName].enabled;
95
+ const toggle = await ctx.ui.select(
96
+ `${toolName}`,
97
+ [
98
+ isEnabled ? "✓ enabled (keep)" : "○ disabled (keep)",
99
+ isEnabled ? "Disable" : "Enable",
100
+ ],
101
+ );
102
+ if (toggle?.startsWith("Enable") || toggle?.startsWith("Disable")) {
103
+ if (!base.tools) base.tools = {};
104
+ base.tools[toolName] = { enabled: toggle.startsWith("Enable") };
105
+ }
106
+ }
107
+
108
+ // Refresh current view
109
+ for (const toolName of group.tools) {
110
+ if (base.tools?.[toolName] !== undefined) {
111
+ current.tools[toolName] = { enabled: base.tools[toolName].enabled };
112
+ }
113
+ }
114
+ }
115
+
116
+ // Check if anything changed
117
+ const hasChanges = Object.keys(base.tools ?? {}).length > 0;
118
+ if (!hasChanges) {
119
+ ctx.ui.notify("No changes made.", "info");
120
+ return null;
121
+ }
122
+
123
+ // Save
124
+ writeGlobalSettings(base);
125
+ ctx.ui.notify(
126
+ "ACP tool settings saved to global config.\nRestart pi for tool changes to take effect.",
127
+ "info",
128
+ );
129
+ return loadSettings(cwd);
130
+ }
131
+
132
+ function readGlobalConfigForEdit(): AcpToolSettingsInput {
133
+ const raw = readGlobalSettings();
134
+ return raw ? structuredClone(raw) : { tools: {} };
135
+ }