@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/README.md CHANGED
@@ -17,9 +17,110 @@ Scaffolded subagents:
17
17
  Profiles can be marked with `primary: true` in config/catalog to indicate direct
18
18
  invocation as a top-level tool entrypoint instead of task-tool-only invocation.
19
19
 
20
+ Primary profiles are registered as direct tools automatically. Tool names are derived
21
+ from profile IDs with deterministic collision handling.
22
+
23
+ `primary: true` is additive:
24
+
25
+ - profile gets a direct top-level tool
26
+ - profile stays available in `task` subagent roster (`subagent_type`)
27
+ - direct-tool execution and task-routed execution share the same runtime/result envelope
28
+
20
29
  The orchestration tool name is **`task`**. Async orchestration lifecycle
21
30
  operations (`start/status/wait/send/cancel`) are exposed through this tool.
22
31
 
32
+ ## Task tool (current)
33
+
34
+ Current behavior:
35
+
36
+ - supports `op: "start"` for a single task payload (sync + `async:true`)
37
+ - supports batched `op: "start"` payloads via `tasks[]` with optional `parallel:true`
38
+ - supports lifecycle operations: `status`, `wait`, `send`, `cancel`
39
+ - compatibility aliases: `status`/`wait` accept `id` or `ids`; `op:"result"` is normalized to `status`
40
+ - returns `task_id`, status, and deterministic task details
41
+ - persists task registry snapshots to disk for resume/reload behavior
42
+ - enforces terminal-task retention expiry with explicit `task_expired` lookup errors
43
+ - validates all payloads with TypeBox boundary schema + typed Result errors
44
+
45
+ ## Subagent backend behavior
46
+
47
+ Runtime backend is selected from `subagentBackend` config:
48
+
49
+ - `interactive-shell` (default): executes a real nested `pi` run for subagent prompts
50
+ using built-in tools (`read,bash,edit,write,grep,find,ls`)
51
+ - `none`: uses deterministic scaffold backend (echo-style debug output)
52
+ - `custom-plugin`: currently returns `unsupported_subagent_backend`
53
+
54
+ If output appears like prompt regurgitation, verify `subagentBackend` is not set to `none`.
55
+
56
+ Example payload:
57
+
58
+ ```jsonc
59
+ {
60
+ "op": "start",
61
+ "subagent_type": "finder",
62
+ "description": "Auth flow scan",
63
+ "prompt": "Trace token validation + refresh paths",
64
+ }
65
+ ```
66
+
67
+ Batch execution notes:
68
+
69
+ - aggregate item order is deterministic (input order)
70
+ - bounded parallelism is enforced by `subagents.taskMaxConcurrency` (default `3`)
71
+ - task failures are isolated; one failed batch item does not abort siblings
72
+
73
+ ## Task permission policy
74
+
75
+ Task orchestration enforces policy decisions from runtime config:
76
+
77
+ - `allow` — task execution proceeds
78
+ - `deny` — execution is blocked with `task_permission_denied`
79
+
80
+ Config shape:
81
+
82
+ ```jsonc
83
+ {
84
+ "subagents": {
85
+ "permissions": {
86
+ "default": "allow",
87
+ "subagents": {
88
+ "finder": "deny",
89
+ },
90
+ "allowInternalRouting": false,
91
+ },
92
+ },
93
+ }
94
+ ```
95
+
96
+ Additional hardening behaviors:
97
+
98
+ - internal profiles (`internal:true`) are hidden from task roster exposure unless
99
+ `allowInternalRouting` is enabled
100
+ - wait timeout/abort are explicit (`task_wait_timeout`, `task_wait_aborted`)
101
+ - tool error payloads include stable `error_category`
102
+
103
+ Persistence details:
104
+
105
+ - default snapshot path: `${PI_CONFIG_DIR|PI_CODING_AGENT_DIR|PI_AGENT_DIR|~/.pi/agent}/ohm.subagents.tasks.json`
106
+ - retention window is configurable via `OHM_SUBAGENTS_TASK_RETENTION_MS` (positive integer ms)
107
+ - corrupt snapshot files are auto-recovered to `*.corrupt-<epoch>` and runtime falls back to empty state
108
+
109
+ ## Migration notes
110
+
111
+ Behavior has moved from scaffold-only single start calls to a lifecycle runtime,
112
+ with real backend execution as the default:
113
+
114
+ - orchestration now supports `start/status/wait/send/cancel`
115
+ - batched `start` supports deterministic ordering and bounded concurrency
116
+ - primary tools and task-routed calls share one execution/runtime contract
117
+ - policy-denied calls now fail deterministically instead of silently proceeding
118
+
119
+ Existing slash commands remain unchanged:
120
+
121
+ - `/ohm-subagents`
122
+ - `/ohm-subagent <id>`
123
+
23
124
  ## Live TUI feedback
24
125
 
25
126
  `@pi-ohm/subagents` uses `@mariozechner/pi-tui` for task runtime visuals.
@@ -31,6 +132,12 @@ Baseline running-task display includes:
31
132
  - in-flight tool call count
32
133
  - elapsed time (`mm:ss`)
33
134
 
135
+ Runtime UI surfaces are synchronized from one task snapshot model:
136
+
137
+ - footer status (`setStatus`) with running/active counters
138
+ - widget task list (`setWidget`) using two-line per-task renderer
139
+ - headless fallback `onUpdate` text with equivalent description/tool-count/elapsed info
140
+
34
141
  Example running block:
35
142
 
36
143
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-ohm/subagents",
3
- "version": "0.6.3",
3
+ "version": "0.6.4-dev.22132458683.1.8646f56",
4
4
  "homepage": "https://github.com/pi-ohm/pi-ohm/tree/dev/packages/subagents#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -20,10 +20,22 @@
20
20
  },
21
21
  "dependencies": {
22
22
  "@mariozechner/pi-coding-agent": "catalog:pi",
23
- "@pi-ohm/config": "^0.6.3"
23
+ "@pi-ohm/config": "0.6.4-dev.22132458683.1.8646f56",
24
+ "better-result": "catalog:",
25
+ "zod": "catalog:"
24
26
  },
25
27
  "peerDependencies": {
26
- "@mariozechner/pi-coding-agent": "*"
28
+ "@mariozechner/pi-coding-agent": "*",
29
+ "@mariozechner/pi-tui": "*",
30
+ "@sinclair/typebox": "*"
31
+ },
32
+ "peerDependenciesMeta": {
33
+ "@mariozechner/pi-tui": {
34
+ "optional": true
35
+ },
36
+ "@sinclair/typebox": {
37
+ "optional": true
38
+ }
27
39
  },
28
40
  "pi": {
29
41
  "extensions": [
@@ -0,0 +1,71 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { getSubagentById, OHM_SUBAGENT_CATALOG } from "./catalog";
4
+
5
+ function defineTest(name: string, run: () => void | Promise<void>): void {
6
+ void test(name, run);
7
+ }
8
+
9
+ defineTest("catalog ids are unique", () => {
10
+ const ids = OHM_SUBAGENT_CATALOG.map((agent) => agent.id);
11
+ const uniqueIds = new Set(ids);
12
+
13
+ assert.equal(uniqueIds.size, ids.length);
14
+ });
15
+
16
+ defineTest("catalog includes expected default subagents", () => {
17
+ const ids = OHM_SUBAGENT_CATALOG.map((agent) => agent.id);
18
+ assert.deepEqual(ids, ["librarian", "oracle", "finder"]);
19
+ });
20
+
21
+ defineTest("librarian is primary by default", () => {
22
+ const librarian = getSubagentById("librarian");
23
+ assert.notEqual(librarian, undefined);
24
+ if (!librarian) {
25
+ assert.fail("Expected librarian profile in catalog");
26
+ }
27
+
28
+ assert.equal(librarian.primary, true);
29
+ });
30
+
31
+ defineTest("finder and oracle are task-routed by default", () => {
32
+ const finder = getSubagentById("finder");
33
+ const oracle = getSubagentById("oracle");
34
+
35
+ assert.notEqual(finder, undefined);
36
+ assert.notEqual(oracle, undefined);
37
+
38
+ if (!finder || !oracle) {
39
+ assert.fail("Expected finder and oracle profiles in catalog");
40
+ }
41
+
42
+ assert.equal(finder.primary, undefined);
43
+ assert.equal(oracle.primary, undefined);
44
+ });
45
+
46
+ defineTest("getSubagentById is case-insensitive and trims input", () => {
47
+ const match = getSubagentById(" LiBrArIaN ");
48
+ assert.notEqual(match, undefined);
49
+ if (!match) {
50
+ assert.fail("Expected to find librarian profile");
51
+ }
52
+
53
+ assert.equal(match.id, "librarian");
54
+ });
55
+
56
+ defineTest("getSubagentById returns undefined for unknown ids", () => {
57
+ const match = getSubagentById("does-not-exist");
58
+ assert.equal(match, undefined);
59
+ });
60
+
61
+ defineTest("catalog entries contain required non-empty scaffold fields", () => {
62
+ for (const agent of OHM_SUBAGENT_CATALOG) {
63
+ assert.ok(agent.name.trim().length > 0);
64
+ assert.ok(agent.summary.trim().length > 0);
65
+ assert.ok(agent.scaffoldPrompt.trim().length > 0);
66
+ assert.ok(agent.whenToUse.length > 0);
67
+ for (const condition of agent.whenToUse) {
68
+ assert.ok(condition.trim().length > 0);
69
+ }
70
+ }
71
+ });
package/src/catalog.ts CHANGED
@@ -9,6 +9,11 @@ export interface OhmSubagentDefinition {
9
9
  * instead of requiring delegated Task-style invocation.
10
10
  */
11
11
  primary?: boolean;
12
+ /**
13
+ * Internal profiles are hidden from model-facing task roster exposure unless
14
+ * policy explicitly allows internal routing.
15
+ */
16
+ internal?: boolean;
12
17
  whenToUse: string[];
13
18
  scaffoldPrompt: string;
14
19
  requiresPackage?: string;
@@ -0,0 +1,72 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ SubagentPersistenceError,
5
+ SubagentPolicyError,
6
+ SubagentRuntimeError,
7
+ SubagentValidationError,
8
+ isSubagentError,
9
+ } from "./errors";
10
+
11
+ function defineTest(name: string, run: () => void | Promise<void>): void {
12
+ void test(name, run);
13
+ }
14
+
15
+ defineTest("SubagentValidationError derives message from cause when omitted", () => {
16
+ const error = new SubagentValidationError({
17
+ code: "invalid_payload",
18
+ cause: new Error("bad shape"),
19
+ });
20
+
21
+ assert.equal(error._tag, "SubagentValidationError");
22
+ assert.equal(error.code, "invalid_payload");
23
+ assert.match(error.message, /Validation failed/);
24
+ assert.match(error.message, /bad shape/);
25
+ });
26
+
27
+ defineTest("SubagentPolicyError keeps explicit message", () => {
28
+ const error = new SubagentPolicyError({
29
+ code: "policy_denied",
30
+ action: "start",
31
+ message: "Policy blocked task start",
32
+ });
33
+
34
+ assert.equal(error._tag, "SubagentPolicyError");
35
+ assert.equal(error.action, "start");
36
+ assert.equal(error.message, "Policy blocked task start");
37
+ });
38
+
39
+ defineTest("SubagentRuntimeError and SubagentPersistenceError expose typed tags", () => {
40
+ const runtimeError = new SubagentRuntimeError({
41
+ code: "runtime_unavailable",
42
+ stage: "execute",
43
+ });
44
+
45
+ const persistenceError = new SubagentPersistenceError({
46
+ code: "state_write_failed",
47
+ resource: "task-registry",
48
+ });
49
+
50
+ assert.equal(runtimeError._tag, "SubagentRuntimeError");
51
+ assert.equal(runtimeError.stage, "execute");
52
+ assert.equal(persistenceError._tag, "SubagentPersistenceError");
53
+ assert.equal(persistenceError.resource, "task-registry");
54
+ });
55
+
56
+ defineTest("isSubagentError narrows tagged errors", () => {
57
+ const validationError = new SubagentValidationError({
58
+ code: "invalid_id",
59
+ message: "id required",
60
+ });
61
+
62
+ const runtimeError = new SubagentRuntimeError({
63
+ code: "runtime_failure",
64
+ message: "executor unavailable",
65
+ });
66
+
67
+ const notError = { ok: true };
68
+
69
+ assert.equal(isSubagentError(validationError), true);
70
+ assert.equal(isSubagentError(runtimeError), true);
71
+ assert.equal(isSubagentError(notError), false);
72
+ });
package/src/errors.ts ADDED
@@ -0,0 +1,146 @@
1
+ import { TaggedError, type Result } from "better-result";
2
+
3
+ export type SubagentErrorMeta = Record<string, unknown>;
4
+
5
+ function messageFromCause(cause: unknown): string {
6
+ if (cause instanceof Error && cause.message.trim().length > 0) return cause.message;
7
+ if (typeof cause === "string" && cause.trim().length > 0) return cause;
8
+ return String(cause);
9
+ }
10
+
11
+ export class SubagentValidationError extends TaggedError("SubagentValidationError")<{
12
+ code: string;
13
+ message: string;
14
+ path?: string;
15
+ cause?: unknown;
16
+ meta?: SubagentErrorMeta;
17
+ }>() {
18
+ constructor(args: {
19
+ code: string;
20
+ path?: string;
21
+ message?: string;
22
+ cause?: unknown;
23
+ meta?: SubagentErrorMeta;
24
+ }) {
25
+ const derivedMessage =
26
+ args.message ??
27
+ (args.cause
28
+ ? `Validation failed (${args.code}): ${messageFromCause(args.cause)}`
29
+ : `Validation failed (${args.code})`);
30
+
31
+ super({
32
+ code: args.code,
33
+ path: args.path,
34
+ cause: args.cause,
35
+ meta: args.meta,
36
+ message: derivedMessage,
37
+ });
38
+ }
39
+ }
40
+
41
+ export class SubagentPolicyError extends TaggedError("SubagentPolicyError")<{
42
+ code: string;
43
+ message: string;
44
+ action?: string;
45
+ cause?: unknown;
46
+ meta?: SubagentErrorMeta;
47
+ }>() {
48
+ constructor(args: {
49
+ code: string;
50
+ action?: string;
51
+ message?: string;
52
+ cause?: unknown;
53
+ meta?: SubagentErrorMeta;
54
+ }) {
55
+ const derivedMessage =
56
+ args.message ??
57
+ (args.cause
58
+ ? `Policy denied (${args.code}): ${messageFromCause(args.cause)}`
59
+ : `Policy denied (${args.code})`);
60
+
61
+ super({
62
+ code: args.code,
63
+ action: args.action,
64
+ cause: args.cause,
65
+ meta: args.meta,
66
+ message: derivedMessage,
67
+ });
68
+ }
69
+ }
70
+
71
+ export class SubagentRuntimeError extends TaggedError("SubagentRuntimeError")<{
72
+ code: string;
73
+ message: string;
74
+ stage?: string;
75
+ cause?: unknown;
76
+ meta?: SubagentErrorMeta;
77
+ }>() {
78
+ constructor(args: {
79
+ code: string;
80
+ stage?: string;
81
+ message?: string;
82
+ cause?: unknown;
83
+ meta?: SubagentErrorMeta;
84
+ }) {
85
+ const derivedMessage =
86
+ args.message ??
87
+ (args.cause
88
+ ? `Runtime failure (${args.code}): ${messageFromCause(args.cause)}`
89
+ : `Runtime failure (${args.code})`);
90
+
91
+ super({
92
+ code: args.code,
93
+ stage: args.stage,
94
+ cause: args.cause,
95
+ meta: args.meta,
96
+ message: derivedMessage,
97
+ });
98
+ }
99
+ }
100
+
101
+ export class SubagentPersistenceError extends TaggedError("SubagentPersistenceError")<{
102
+ code: string;
103
+ message: string;
104
+ resource?: string;
105
+ cause?: unknown;
106
+ meta?: SubagentErrorMeta;
107
+ }>() {
108
+ constructor(args: {
109
+ code: string;
110
+ resource?: string;
111
+ message?: string;
112
+ cause?: unknown;
113
+ meta?: SubagentErrorMeta;
114
+ }) {
115
+ const derivedMessage =
116
+ args.message ??
117
+ (args.cause
118
+ ? `Persistence failure (${args.code}): ${messageFromCause(args.cause)}`
119
+ : `Persistence failure (${args.code})`);
120
+
121
+ super({
122
+ code: args.code,
123
+ resource: args.resource,
124
+ cause: args.cause,
125
+ meta: args.meta,
126
+ message: derivedMessage,
127
+ });
128
+ }
129
+ }
130
+
131
+ export type SubagentError =
132
+ | SubagentValidationError
133
+ | SubagentPolicyError
134
+ | SubagentRuntimeError
135
+ | SubagentPersistenceError;
136
+
137
+ export type SubagentResult<T, E extends SubagentError = SubagentError> = Result<T, E>;
138
+
139
+ export function isSubagentError(value: unknown): value is SubagentError {
140
+ return (
141
+ SubagentValidationError.is(value) ||
142
+ SubagentPolicyError.is(value) ||
143
+ SubagentRuntimeError.is(value) ||
144
+ SubagentPersistenceError.is(value)
145
+ );
146
+ }
@@ -0,0 +1,137 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import type { OhmRuntimeConfig } from "@pi-ohm/config";
4
+ import { getSubagentById } from "./catalog";
5
+ import {
6
+ buildSubagentDetailText,
7
+ buildSubagentsOverviewText,
8
+ getSubagentInvocationMode,
9
+ normalizeCommandArgs,
10
+ registerSubagentTools,
11
+ } from "./extension";
12
+
13
+ function defineTest(name: string, run: () => void | Promise<void>): void {
14
+ void test(name, run);
15
+ }
16
+
17
+ const configFixture: OhmRuntimeConfig = {
18
+ defaultMode: "smart",
19
+ subagentBackend: "none",
20
+ features: {
21
+ handoff: true,
22
+ subagents: true,
23
+ sessionThreadSearch: true,
24
+ handoffVisualizer: true,
25
+ painterImagegen: false,
26
+ },
27
+ painter: {
28
+ googleNanoBanana: {
29
+ enabled: false,
30
+ model: "",
31
+ },
32
+ openai: {
33
+ enabled: false,
34
+ model: "",
35
+ },
36
+ azureOpenai: {
37
+ enabled: false,
38
+ deployment: "",
39
+ endpoint: "",
40
+ apiVersion: "",
41
+ },
42
+ },
43
+ subagents: {
44
+ taskMaxConcurrency: 2,
45
+ taskRetentionMs: 1000,
46
+ permissions: {
47
+ default: "allow",
48
+ subagents: {},
49
+ allowInternalRouting: false,
50
+ },
51
+ },
52
+ };
53
+
54
+ defineTest("normalizeCommandArgs supports array input", () => {
55
+ const parsed = normalizeCommandArgs(["finder", 42, "oracle", null]);
56
+ assert.deepEqual(parsed, ["finder", "oracle"]);
57
+ });
58
+
59
+ defineTest("normalizeCommandArgs splits raw string input", () => {
60
+ const parsed = normalizeCommandArgs(" finder oracle librarian ");
61
+ assert.deepEqual(parsed, ["finder", "oracle", "librarian"]);
62
+ });
63
+
64
+ defineTest("normalizeCommandArgs supports envelope args array", () => {
65
+ const parsed = normalizeCommandArgs({
66
+ args: ["finder", 1, "oracle"],
67
+ });
68
+
69
+ assert.deepEqual(parsed, ["finder", "oracle"]);
70
+ });
71
+
72
+ defineTest("normalizeCommandArgs supports envelope raw string", () => {
73
+ const parsed = normalizeCommandArgs({
74
+ raw: "finder oracle",
75
+ });
76
+
77
+ assert.deepEqual(parsed, ["finder", "oracle"]);
78
+ });
79
+
80
+ defineTest("normalizeCommandArgs returns empty list for unsupported payloads", () => {
81
+ assert.deepEqual(normalizeCommandArgs(undefined), []);
82
+ assert.deepEqual(normalizeCommandArgs(123), []);
83
+ assert.deepEqual(normalizeCommandArgs({}), []);
84
+ });
85
+
86
+ defineTest("getSubagentInvocationMode returns primary-tool for primary profiles", () => {
87
+ assert.equal(getSubagentInvocationMode(true), "primary-tool");
88
+ });
89
+
90
+ defineTest("getSubagentInvocationMode returns task-routed for non-primary profiles", () => {
91
+ assert.equal(getSubagentInvocationMode(false), "task-routed");
92
+ assert.equal(getSubagentInvocationMode(undefined), "task-routed");
93
+ });
94
+
95
+ defineTest("registerSubagentTools registers task tool + primary tools", () => {
96
+ const registeredTools: string[] = [];
97
+
98
+ registerSubagentTools({
99
+ registerTool(definition) {
100
+ registeredTools.push(definition.name);
101
+ },
102
+ });
103
+
104
+ assert.equal(registeredTools.includes("task"), true);
105
+ assert.equal(registeredTools.includes("librarian"), true);
106
+ assert.equal(registeredTools.includes("finder"), false);
107
+ assert.equal(registeredTools.includes("oracle"), false);
108
+ });
109
+
110
+ defineTest("buildSubagentsOverviewText preserves command compatibility output", () => {
111
+ const text = buildSubagentsOverviewText({
112
+ config: configFixture,
113
+ loadedFrom: ["/tmp/global/ohm.json"],
114
+ });
115
+
116
+ assert.match(text, /Pi OHM: subagents/);
117
+ assert.match(text, /Scaffolded subagents:/);
118
+ assert.match(text, /Use \/ohm-subagent <id> to inspect one profile\./);
119
+ assert.match(text, /loadedFrom: \/tmp\/global\/ohm.json/);
120
+ });
121
+
122
+ defineTest("buildSubagentDetailText preserves detailed subagent view", () => {
123
+ const librarian = getSubagentById("librarian");
124
+ assert.notEqual(librarian, undefined);
125
+ if (!librarian) {
126
+ assert.fail("Expected librarian profile");
127
+ }
128
+
129
+ const text = buildSubagentDetailText({
130
+ config: configFixture,
131
+ subagent: librarian,
132
+ });
133
+
134
+ assert.match(text, /Subagent: Librarian/);
135
+ assert.match(text, /When to use:/);
136
+ assert.match(text, /Scaffold prompt:/);
137
+ });