@pi-ohm/subagents 0.6.3 → 0.6.4-dev.22132458683.1.8646f56

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/src/extension.ts CHANGED
@@ -1,8 +1,20 @@
1
1
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
- import { loadOhmRuntimeConfig, registerOhmSettings } from "@pi-ohm/config";
2
+ import { loadOhmRuntimeConfig, registerOhmSettings, type OhmRuntimeConfig } from "@pi-ohm/config";
3
3
  import { getSubagentById, OHM_SUBAGENT_CATALOG } from "./catalog";
4
+ import { isSubagentVisibleInTaskRoster } from "./policy";
5
+ import { registerPrimarySubagentTools } from "./tools/primary";
6
+ import { createDefaultTaskToolDependencies, registerTaskTool } from "./tools/task";
4
7
 
5
- function normalizeCommandArgs(args: unknown): string[] {
8
+ interface CommandArgsEnvelope {
9
+ args?: unknown;
10
+ raw?: unknown;
11
+ }
12
+
13
+ function isCommandArgsEnvelope(value: unknown): value is CommandArgsEnvelope {
14
+ return typeof value === "object" && value !== null;
15
+ }
16
+
17
+ export function normalizeCommandArgs(args: unknown): string[] {
6
18
  if (Array.isArray(args)) {
7
19
  return args.filter((value): value is string => typeof value === "string");
8
20
  }
@@ -14,15 +26,13 @@ function normalizeCommandArgs(args: unknown): string[] {
14
26
  .filter((part) => part.length > 0);
15
27
  }
16
28
 
17
- if (args && typeof args === "object") {
18
- const asRecord = args as { args?: unknown; raw?: unknown };
19
-
20
- if (Array.isArray(asRecord.args)) {
21
- return asRecord.args.filter((value): value is string => typeof value === "string");
29
+ if (isCommandArgsEnvelope(args)) {
30
+ if (Array.isArray(args.args)) {
31
+ return args.args.filter((value): value is string => typeof value === "string");
22
32
  }
23
33
 
24
- if (typeof asRecord.raw === "string") {
25
- return asRecord.raw
34
+ if (typeof args.raw === "string") {
35
+ return args.raw
26
36
  .split(/\s+/)
27
37
  .map((part) => part.trim())
28
38
  .filter((part) => part.length > 0);
@@ -32,42 +42,109 @@ function normalizeCommandArgs(args: unknown): string[] {
32
42
  return [];
33
43
  }
34
44
 
45
+ export type SubagentInvocationMode = "primary-tool" | "task-routed";
46
+
47
+ export function getSubagentInvocationMode(primary: boolean | undefined): SubagentInvocationMode {
48
+ return primary ? "primary-tool" : "task-routed";
49
+ }
50
+
51
+ function listSubagentIds(): string {
52
+ return OHM_SUBAGENT_CATALOG.map((agent) => agent.id).join("|");
53
+ }
54
+
55
+ function getVisibleSubagents(config: OhmRuntimeConfig) {
56
+ return OHM_SUBAGENT_CATALOG.filter((agent) => isSubagentVisibleInTaskRoster(agent, config));
57
+ }
58
+
59
+ export function buildSubagentsOverviewText(input: {
60
+ readonly config: OhmRuntimeConfig;
61
+ readonly loadedFrom: readonly string[];
62
+ }): string {
63
+ const visibleSubagents = getVisibleSubagents(input.config);
64
+
65
+ const lines = visibleSubagents.map((agent) => {
66
+ const needsPainterPackage = agent.id === "painter";
67
+ const available = !needsPainterPackage || input.config.features.painterImagegen;
68
+ const availability = available ? "available" : "requires painter feature/package";
69
+ const invocation = getSubagentInvocationMode(agent.primary);
70
+ return `- ${agent.name} (${agent.id}): ${agent.summary} [${availability} · ${invocation}]`;
71
+ });
72
+
73
+ return [
74
+ "Pi OHM: subagents",
75
+ "",
76
+ `enabled: ${input.config.features.subagents ? "yes" : "no"}`,
77
+ `backend: ${input.config.subagentBackend}`,
78
+ "",
79
+ "Scaffolded subagents:",
80
+ ...lines,
81
+ "",
82
+ "Use /ohm-subagent <id> to inspect one profile.",
83
+ `loadedFrom: ${input.loadedFrom.length > 0 ? input.loadedFrom.join(", ") : "defaults + extension settings"}`,
84
+ ].join("\n");
85
+ }
86
+
87
+ export function buildSubagentDetailText(input: {
88
+ readonly config: OhmRuntimeConfig;
89
+ readonly subagent: (typeof OHM_SUBAGENT_CATALOG)[number];
90
+ }): string {
91
+ const isAvailable = input.subagent.id !== "painter" || input.config.features.painterImagegen;
92
+
93
+ return [
94
+ `Subagent: ${input.subagent.name}`,
95
+ `id: ${input.subagent.id}`,
96
+ `available: ${isAvailable ? "yes" : "no"}`,
97
+ `invocation: ${getSubagentInvocationMode(input.subagent.primary)}`,
98
+ input.subagent.requiresPackage
99
+ ? `requiresPackage: ${input.subagent.requiresPackage}`
100
+ : "requiresPackage: none",
101
+ "",
102
+ "When to use:",
103
+ ...input.subagent.whenToUse.map((line) => `- ${line}`),
104
+ "",
105
+ "Scaffold prompt:",
106
+ input.subagent.scaffoldPrompt,
107
+ ].join("\n");
108
+ }
109
+
110
+ export function registerSubagentTools(pi: Pick<ExtensionAPI, "registerTool">): {
111
+ readonly primaryToolCount: number;
112
+ readonly diagnosticsCount: number;
113
+ } {
114
+ const taskDeps = createDefaultTaskToolDependencies();
115
+ registerTaskTool(pi, taskDeps);
116
+ const primaryToolRegistration = registerPrimarySubagentTools(pi, {
117
+ taskDeps,
118
+ });
119
+
120
+ return {
121
+ primaryToolCount: primaryToolRegistration.registeredTools.length,
122
+ diagnosticsCount: primaryToolRegistration.diagnostics.length,
123
+ };
124
+ }
125
+
35
126
  export default function registerSubagentsExtension(pi: ExtensionAPI): void {
36
127
  registerOhmSettings(pi);
128
+ const toolRegistration = registerSubagentTools(pi);
37
129
 
38
130
  pi.on("session_start", async (_event, ctx) => {
39
131
  const { config } = await loadOhmRuntimeConfig(ctx.cwd);
40
132
  if (!ctx.hasUI) return;
41
133
 
42
134
  const enabled = config.features.subagents ? "on" : "off";
43
- ctx.ui.setStatus("ohm-subagents", `subagents:${enabled} · backend:${config.subagentBackend}`);
135
+ const primaryTools = toolRegistration.primaryToolCount;
136
+ const diagnostics = toolRegistration.diagnosticsCount;
137
+ ctx.ui.setStatus(
138
+ "ohm-subagents",
139
+ `subagents:${enabled} · backend:${config.subagentBackend} · primary:${primaryTools} · diag:${diagnostics}`,
140
+ );
44
141
  });
45
142
 
46
143
  pi.registerCommand("ohm-subagents", {
47
144
  description: "Show scaffolded subagents and backend status",
48
145
  handler: async (_args, ctx) => {
49
146
  const { config, loadedFrom } = await loadOhmRuntimeConfig(ctx.cwd);
50
-
51
- const lines = OHM_SUBAGENT_CATALOG.map((agent) => {
52
- const needsPainterPackage = agent.id === "painter";
53
- const available = !needsPainterPackage || config.features.painterImagegen;
54
- const availability = available ? "available" : "requires painter feature/package";
55
- const invocation = agent.primary ? "primary-tool" : "delegated";
56
- return `- ${agent.name} (${agent.id}): ${agent.summary} [${availability} · ${invocation}]`;
57
- });
58
-
59
- const text = [
60
- "Pi OHM: subagents",
61
- "",
62
- `enabled: ${config.features.subagents ? "yes" : "no"}`,
63
- `backend: ${config.subagentBackend}`,
64
- "",
65
- "Scaffolded subagents:",
66
- ...lines,
67
- "",
68
- "Use /ohm-subagent <id> to inspect one profile.",
69
- `loadedFrom: ${loadedFrom.length > 0 ? loadedFrom.join(", ") : "defaults + extension settings"}`,
70
- ].join("\n");
147
+ const text = buildSubagentsOverviewText({ config, loadedFrom });
71
148
 
72
149
  if (!ctx.hasUI) {
73
150
  console.log(text);
@@ -79,18 +156,19 @@ export default function registerSubagentsExtension(pi: ExtensionAPI): void {
79
156
  });
80
157
 
81
158
  pi.registerCommand("ohm-subagent", {
82
- description: "Inspect one subagent scaffold (librarian|oracle|finder|task|painter)",
159
+ description: `Inspect one subagent scaffold (${listSubagentIds()})`,
83
160
  handler: async (args, ctx) => {
84
161
  const { config } = await loadOhmRuntimeConfig(ctx.cwd);
85
162
  const [requested = ""] = normalizeCommandArgs(args);
86
163
  const match = getSubagentById(requested);
87
164
 
88
- if (!match) {
89
- const usage = [
90
- "Usage: /ohm-subagent <id>",
91
- "",
92
- `Valid ids: ${OHM_SUBAGENT_CATALOG.map((agent) => agent.id).join(", ")}`,
93
- ].join("\n");
165
+ const visibleSubagents = getVisibleSubagents(config);
166
+ const visibleIds = visibleSubagents.map((agent) => agent.id);
167
+
168
+ if (!match || !visibleIds.includes(match.id)) {
169
+ const usage = ["Usage: /ohm-subagent <id>", "", `Valid ids: ${visibleIds.join(", ")}`].join(
170
+ "\n",
171
+ );
94
172
 
95
173
  if (!ctx.hasUI) {
96
174
  console.log(usage);
@@ -101,23 +179,7 @@ export default function registerSubagentsExtension(pi: ExtensionAPI): void {
101
179
  return;
102
180
  }
103
181
 
104
- const isAvailable = match.id !== "painter" || config.features.painterImagegen;
105
-
106
- const text = [
107
- `Subagent: ${match.name}`,
108
- `id: ${match.id}`,
109
- `available: ${isAvailable ? "yes" : "no"}`,
110
- `invocation: ${match.primary ? "primary-tool" : "delegated"}`,
111
- match.requiresPackage
112
- ? `requiresPackage: ${match.requiresPackage}`
113
- : "requiresPackage: none",
114
- "",
115
- "When to use:",
116
- ...match.whenToUse.map((line) => `- ${line}`),
117
- "",
118
- "Scaffold prompt:",
119
- match.scaffoldPrompt,
120
- ].join("\n");
182
+ const text = buildSubagentDetailText({ config, subagent: match });
121
183
 
122
184
  if (!ctx.hasUI) {
123
185
  console.log(text);
@@ -0,0 +1,173 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import type { OhmRuntimeConfig } from "@pi-ohm/config";
4
+ import { Result } from "better-result";
5
+ import type { OhmSubagentDefinition } from "./catalog";
6
+ import {
7
+ evaluateTaskPermission,
8
+ getTaskPermissionDecision,
9
+ getTaskPermissionPolicy,
10
+ isSubagentVisibleInTaskRoster,
11
+ } from "./policy";
12
+
13
+ function defineTest(name: string, run: () => void | Promise<void>): void {
14
+ void test(name, run);
15
+ }
16
+
17
+ const baseSubagentRuntimeConfig = {
18
+ taskMaxConcurrency: 3,
19
+ taskRetentionMs: 1000 * 60,
20
+ permissions: {
21
+ default: "allow",
22
+ subagents: {},
23
+ allowInternalRouting: false,
24
+ },
25
+ } as const;
26
+
27
+ const baseConfig: OhmRuntimeConfig = {
28
+ defaultMode: "smart",
29
+ subagentBackend: "none",
30
+ features: {
31
+ handoff: true,
32
+ subagents: true,
33
+ sessionThreadSearch: true,
34
+ handoffVisualizer: true,
35
+ painterImagegen: true,
36
+ },
37
+ painter: {
38
+ googleNanoBanana: {
39
+ enabled: false,
40
+ model: "",
41
+ },
42
+ openai: {
43
+ enabled: false,
44
+ model: "",
45
+ },
46
+ azureOpenai: {
47
+ enabled: false,
48
+ deployment: "",
49
+ endpoint: "",
50
+ apiVersion: "",
51
+ },
52
+ },
53
+ subagents: baseSubagentRuntimeConfig,
54
+ };
55
+
56
+ const finder: OhmSubagentDefinition = {
57
+ id: "finder",
58
+ name: "Finder",
59
+ summary: "Search specialist",
60
+ whenToUse: ["Search behavior paths"],
61
+ scaffoldPrompt: "Search code",
62
+ };
63
+
64
+ const internalFinder: OhmSubagentDefinition = {
65
+ ...finder,
66
+ internal: true,
67
+ };
68
+
69
+ defineTest("getTaskPermissionPolicy normalizes defaults", () => {
70
+ const normalized = getTaskPermissionPolicy(baseConfig);
71
+ assert.equal(normalized.defaultDecision, "allow");
72
+ assert.equal(normalized.allowInternalRouting, false);
73
+ assert.deepEqual(normalized.perSubagent, {});
74
+ });
75
+
76
+ defineTest("getTaskPermissionDecision resolves explicit override first", () => {
77
+ const config: OhmRuntimeConfig = {
78
+ ...baseConfig,
79
+ subagents: {
80
+ ...baseSubagentRuntimeConfig,
81
+ permissions: {
82
+ default: "allow",
83
+ subagents: {
84
+ finder: "deny",
85
+ },
86
+ allowInternalRouting: false,
87
+ },
88
+ },
89
+ };
90
+
91
+ assert.equal(getTaskPermissionDecision(finder, config), "deny");
92
+ });
93
+
94
+ defineTest("evaluateTaskPermission returns deny policy errors", () => {
95
+ const denyConfig: OhmRuntimeConfig = {
96
+ ...baseConfig,
97
+ subagents: {
98
+ ...baseSubagentRuntimeConfig,
99
+ permissions: {
100
+ default: "deny",
101
+ subagents: {},
102
+ allowInternalRouting: true,
103
+ },
104
+ },
105
+ };
106
+
107
+ const denied = evaluateTaskPermission(finder, denyConfig);
108
+ assert.equal(Result.isError(denied), true);
109
+ if (Result.isOk(denied)) {
110
+ assert.fail("Expected denied policy error");
111
+ }
112
+ assert.equal(denied.error.code, "task_permission_denied");
113
+ });
114
+
115
+ defineTest("deprecated ask policy is treated as deny-safe behavior", () => {
116
+ const legacyAskConfig: OhmRuntimeConfig = {
117
+ ...baseConfig,
118
+ subagents: {
119
+ ...baseSubagentRuntimeConfig,
120
+ permissions: {
121
+ default: "allow",
122
+ subagents: {},
123
+ allowInternalRouting: false,
124
+ },
125
+ },
126
+ };
127
+
128
+ if (!legacyAskConfig.subagents) {
129
+ assert.fail("Expected subagent config");
130
+ }
131
+
132
+ void Reflect.set(legacyAskConfig.subagents.permissions, "default", "ask");
133
+ void Reflect.set(legacyAskConfig.subagents.permissions.subagents, "finder", "ask");
134
+
135
+ const normalized = getTaskPermissionPolicy(legacyAskConfig);
136
+ assert.equal(normalized.defaultDecision, "deny");
137
+ assert.equal(normalized.perSubagent.finder, "deny");
138
+ });
139
+
140
+ defineTest(
141
+ "internal subagents are hidden from roster unless policy allows internal routing",
142
+ () => {
143
+ assert.equal(isSubagentVisibleInTaskRoster(internalFinder, baseConfig), false);
144
+
145
+ const internalAllowedConfig: OhmRuntimeConfig = {
146
+ ...baseConfig,
147
+ subagents: {
148
+ ...baseSubagentRuntimeConfig,
149
+ permissions: {
150
+ default: "allow",
151
+ subagents: {},
152
+ allowInternalRouting: true,
153
+ },
154
+ },
155
+ };
156
+
157
+ assert.equal(isSubagentVisibleInTaskRoster(internalFinder, internalAllowedConfig), true);
158
+ },
159
+ );
160
+
161
+ defineTest(
162
+ "evaluateTaskPermission blocks internal subagent when policy disallows internal routing",
163
+ () => {
164
+ const blocked = evaluateTaskPermission(internalFinder, baseConfig);
165
+
166
+ assert.equal(Result.isError(blocked), true);
167
+ if (Result.isOk(blocked)) {
168
+ assert.fail("Expected internal routing policy error");
169
+ }
170
+
171
+ assert.equal(blocked.error.code, "task_internal_subagent_hidden");
172
+ },
173
+ );
package/src/policy.ts ADDED
@@ -0,0 +1,114 @@
1
+ import type { OhmRuntimeConfig } from "@pi-ohm/config";
2
+ import { Result } from "better-result";
3
+ import type { OhmSubagentDefinition } from "./catalog";
4
+ import { SubagentPolicyError } from "./errors";
5
+
6
+ export type TaskPermissionDecision = "allow" | "deny";
7
+
8
+ export interface TaskPermissionPolicySnapshot {
9
+ readonly defaultDecision: TaskPermissionDecision;
10
+ readonly perSubagent: Readonly<Record<string, TaskPermissionDecision>>;
11
+ readonly allowInternalRouting: boolean;
12
+ }
13
+
14
+ function normalizeDecision(value: unknown): TaskPermissionDecision | undefined {
15
+ if (value === "allow" || value === "deny") return value;
16
+
17
+ // Legacy compatibility: treat deprecated "ask" policy as deny-safe behavior.
18
+ if (value === "ask") return "deny";
19
+
20
+ return undefined;
21
+ }
22
+
23
+ function normalizePermissionMap(value: unknown): Readonly<Record<string, TaskPermissionDecision>> {
24
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
25
+ return {};
26
+ }
27
+
28
+ const entries: Array<readonly [string, TaskPermissionDecision]> = [];
29
+
30
+ for (const [key, rawDecision] of Object.entries(value)) {
31
+ const normalizedKey = key.trim().toLowerCase();
32
+ if (normalizedKey.length === 0) continue;
33
+
34
+ const normalizedDecision = normalizeDecision(rawDecision);
35
+ if (!normalizedDecision) continue;
36
+
37
+ entries.push([normalizedKey, normalizedDecision]);
38
+ }
39
+
40
+ return Object.fromEntries(entries);
41
+ }
42
+
43
+ export function getTaskPermissionPolicy(config: OhmRuntimeConfig): TaskPermissionPolicySnapshot {
44
+ const permissions = config.subagents?.permissions;
45
+
46
+ const defaultDecision = normalizeDecision(permissions?.default) ?? "allow";
47
+ const perSubagent = normalizePermissionMap(permissions?.subagents);
48
+ const allowInternalRouting =
49
+ typeof permissions?.allowInternalRouting === "boolean"
50
+ ? permissions.allowInternalRouting
51
+ : false;
52
+
53
+ return {
54
+ defaultDecision,
55
+ perSubagent,
56
+ allowInternalRouting,
57
+ };
58
+ }
59
+
60
+ export function getTaskPermissionDecision(
61
+ subagent: OhmSubagentDefinition,
62
+ config: OhmRuntimeConfig,
63
+ ): TaskPermissionDecision {
64
+ const policy = getTaskPermissionPolicy(config);
65
+ const explicit = policy.perSubagent[subagent.id];
66
+ if (explicit) return explicit;
67
+ return policy.defaultDecision;
68
+ }
69
+
70
+ export function isSubagentVisibleInTaskRoster(
71
+ subagent: OhmSubagentDefinition,
72
+ config: OhmRuntimeConfig,
73
+ ): boolean {
74
+ if (!subagent.internal) return true;
75
+ const policy = getTaskPermissionPolicy(config);
76
+ return policy.allowInternalRouting;
77
+ }
78
+
79
+ export function evaluateTaskPermission(
80
+ subagent: OhmSubagentDefinition,
81
+ config: OhmRuntimeConfig,
82
+ ): Result<true, SubagentPolicyError> {
83
+ const policy = getTaskPermissionPolicy(config);
84
+
85
+ if (subagent.internal && !policy.allowInternalRouting) {
86
+ return Result.err(
87
+ new SubagentPolicyError({
88
+ code: "task_internal_subagent_hidden",
89
+ action: "task.invoke",
90
+ message: `Subagent '${subagent.id}' is internal and unavailable by current policy`,
91
+ meta: {
92
+ subagentId: subagent.id,
93
+ allowInternalRouting: policy.allowInternalRouting,
94
+ },
95
+ }),
96
+ );
97
+ }
98
+
99
+ const decision = getTaskPermissionDecision(subagent, config);
100
+
101
+ if (decision === "allow") return Result.ok(true);
102
+
103
+ return Result.err(
104
+ new SubagentPolicyError({
105
+ code: "task_permission_denied",
106
+ action: "task.invoke",
107
+ message: `Subagent '${subagent.id}' is denied by task permission policy`,
108
+ meta: {
109
+ subagentId: subagent.id,
110
+ decision,
111
+ },
112
+ }),
113
+ );
114
+ }