@selesai/code 0.13.33 → 0.13.35

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 (30) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/extensions/capability-gateway/catalog.ts +4 -1
  3. package/dist/extensions/capability-gateway/index.test.ts +93 -4
  4. package/dist/extensions/capability-gateway/index.ts +295 -59
  5. package/dist/extensions/capability-gateway/integration.test.ts +35 -0
  6. package/dist/extensions/capability-gateway/routing.test.ts +154 -1
  7. package/dist/extensions/capability-gateway/routing.ts +227 -45
  8. package/dist/extensions/jev/decisions.test.ts +37 -0
  9. package/dist/extensions/jev/decisions.ts +161 -43
  10. package/dist/extensions/jev-ask-tool.test.ts +501 -0
  11. package/dist/extensions/jev-ask-tool.ts +952 -0
  12. package/dist/extensions/package.json +1 -0
  13. package/dist/extensions/pi-hermes-memory/README.md +11 -36
  14. package/dist/extensions/pi-hermes-memory/src/config.ts +36 -8
  15. package/dist/extensions/pi-hermes-memory/src/constants.ts +5 -5
  16. package/dist/extensions/pi-hermes-memory/src/handlers/auto-consolidate.ts +60 -46
  17. package/dist/extensions/pi-hermes-memory/tests/config.test.ts +26 -2
  18. package/dist/extensions/pi-hermes-memory/tests/handlers/auto-consolidate.test.ts +7 -1
  19. package/dist/extensions/pi-intercom/index.ts +5 -1
  20. package/dist/extensions/pi-subagents/src/extension/public-execution.ts +6 -4
  21. package/dist/extensions/pi-subagents/src/extension/schemas.ts +1 -1
  22. package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +26 -1
  23. package/dist/extensions/pi-subagents/src/runs/shared/jev-subagent-routing.ts +255 -0
  24. package/dist/extensions/pi-subagents/test/unit/jev-subagent-routing.test.ts +117 -0
  25. package/dist/extensions/pi-subagents/test/unit/public-execution.test.ts +3 -1
  26. package/dist/extensions/rtk.test.ts +21 -13
  27. package/dist/extensions/tps.test.ts +32 -1
  28. package/dist/extensions/tps.ts +3 -1
  29. package/docs/settings.md +64 -7
  30. package/package.json +3 -3
@@ -19,6 +19,7 @@
19
19
  "./herdr-agent-state.ts",
20
20
  "./inline-skills.ts",
21
21
  "./jev-advisory-routing.ts",
22
+ "./jev-ask-tool.ts",
22
23
  "./rtk.ts",
23
24
  "./tokenin-onboarding.ts",
24
25
  "./pi-graft",
@@ -483,45 +483,19 @@ Move behavior:
483
483
 
484
484
  ## Configuration
485
485
 
486
- Create `~/.selesai/agent/hermes-memory-config.json`:
486
+ Set extension options under `hermesMemory` in `~/.selesai/agent/settings.json`:
487
487
 
488
488
  ```json
489
489
  {
490
- "memoryMode": "policy-only",
491
- "memoryPolicyStyle": "full",
492
- "memoryCharLimit": 5000,
493
- "userCharLimit": 5000,
494
- "projectCharLimit": 5000,
495
- "memoryDir": "~/.selesai/agent/pi-hermes-memory",
496
- "projectsMemoryDir": "projects-memory",
497
- "sessionSearch": { "variant": "legacy" },
498
- "sessionRetentionDays": 0,
499
- "quickCheckOnOpen": true,
500
- "llmModelOverride": "openrouter/deepseek/deepseek-v4-flash",
501
- "llmThinkingOverride": "off",
502
- "childExtensionPaths": ["~/.selesai/agent/git/github.com/example/custom-provider-extension/index.ts"],
503
- "nudgeInterval": 10,
504
- "nudgeToolCalls": 15,
505
- "reviewRecentMessages": 0,
506
- "reviewEnabled": true,
507
- "reviewTransport": "direct",
508
- "memoryOverflowStrategy": "auto-consolidate",
509
- "autoConsolidate": true,
510
- "correctionDetection": true,
511
- "failureInjectionEnabled": true,
512
- "failureInjectionMaxAgeDays": 7,
513
- "failureInjectionMaxEntries": 5,
514
- "consolidationTimeoutMs": 180000,
515
- "overflowGraceMs": 180000,
516
- "autoConsolidationWarnOnFailure": true,
517
- "flushOnCompact": true,
518
- "flushOnShutdown": true,
519
- "flushMinTurns": 6,
520
- "flushRecentMessages": 0,
521
- "standingInstructionsEnabled": true
490
+ "hermesMemory": {
491
+ "llmThinkingOverride": "off",
492
+ "consolidationTimeoutMs": 300000
493
+ }
522
494
  }
523
495
  ```
524
496
 
497
+ The extension reads these options at startup. Values in `settings.json` override the legacy `hermes-memory-config.json`, which remains a fallback for existing installations. All settings below are keys inside `hermesMemory`. `llmModelOverride` is optional and uses `provider/model` format (for example, `tokenin/deepseek-v4.1-flash` if your account has access); leave it unset to use the active model.
498
+
525
499
  | Setting | Default | Description |
526
500
  |---|---|---|
527
501
  | `memoryMode` | `policy-only` | Prompt behavior: `policy-only` injects only memory policy; `legacy-inject` restores full memory prompt injection |
@@ -536,7 +510,7 @@ Create `~/.selesai/agent/hermes-memory-config.json`:
536
510
  | `sessionSearch` | `{ "variant": "legacy" }` | Session search implementation: `legacy` keeps the existing SQLite/FTS snippet search; `anchors` uses the opt-in Markdown request surface and returns compact JSONL line-range anchors from `~/.selesai/agent/sessions/` |
537
511
  | `sessionRetentionDays` | `0` | Opt-in SQLite session retention, in days. `0` (default) disables pruning entirely and keeps the legacy count-only backfill preflight. When positive, sessions whose JSONL source file was last modified longer ago than the window are pruned from SQLite at startup — **rows only; the JSONL files in `~/.selesai/agent/sessions/` are never deleted** — and both the deferred backfill and `/memory-index-sessions` skip files outside the window, so pruned sessions stay pruned instead of being re-indexed |
538
512
  | `quickCheckOnOpen` | `true` | Run a full SQLite integrity check asynchronously after opening the database; set to `false` to skip the startup scan (operation-time recovery remains enabled) |
539
- | `llmModelOverride` | unset | Optional model override for background review (direct and subprocess), correction save, session flush, and consolidation |
513
+ | `llmModelOverride` | unset | Optional `provider/model` override for background review (direct and subprocess), correction save, session flush, and consolidation; unset uses the active model |
540
514
  | `llmThinkingOverride` | unset | Optional thinking override for those LLM calls; valid values are `off`, `minimal`, `low`, `medium`, `high`, and `xhigh`. If `llmModelOverride` is set and this is omitted, review/child calls default to `off` |
541
515
  | `childExtensionPaths` | unset | Trusted provider/auth extension sources explicitly allowed in isolated child Pi processes. Values are passed to Pi's standard `-e` resolver, so absolute paths, `~/...`, paths relative to the child working directory, and `git:`/`npm:` package sources are supported. Sibling packages matching the `*-oauth-adapter`/`*-auth-adapter` naming convention (including scoped packages, via their `package.json` `pi.extensions` manifest) are detected automatically. This setting is only needed for custom providers or adapters that are not detected. In-process direct transport (the default for review/flush/correction/consolidation) doesn't need it, since it reads whatever provider auth is already registered. |
542
516
  | `nudgeInterval` | `10` | Turns between auto-reviews |
@@ -546,7 +520,7 @@ Create `~/.selesai/agent/hermes-memory-config.json`:
546
520
  | `reviewTransport` | `direct` | LLM transport for background review, session flush, correction save, and manual consolidation: `direct` uses in-process `completeSimple()` with subprocess fallback; `subprocess` forces legacy `pi -p` only |
547
521
  | `memoryOverflowStrategy` | `auto-consolidate` | Legacy-inject behavior when a Markdown memory file reaches its character limit: `auto-consolidate` runs the existing consolidation flow; `reject` returns an error; `fifo-evict` rotates older entries in file order until the new entry fits |
548
522
  | `autoConsolidate` | `true` | Legacy alias for `memoryOverflowStrategy` when `memoryOverflowStrategy` is not set (`true` = `auto-consolidate`, `false` = `reject`) |
549
- | `consolidationTimeoutMs` | `180000` | Maximum time in milliseconds for a consolidation run (auto and `/memory-consolidate` alike). Configured values are used verbatim; a consolidation pays child-process boot plus a full LLM turn, so values below the default are frequently killed mid-run and log a warning at startup |
523
+ | `consolidationTimeoutMs` | `300000` | Maximum time in milliseconds for a consolidation run (auto and `/memory-consolidate` alike). Configured values are used verbatim; a consolidation pays child-process boot plus a full LLM turn, so values below the default are frequently killed mid-run and log a warning at startup |
550
524
  | `overflowGraceMs` | `180000` | Wall-clock grace period after a memory overflow before automatic consolidation is retried; this gives the active agent time to consolidate manually. Set to `0` to disable the grace period |
551
525
  | `autoConsolidationWarnOnFailure` | `true` | Log failed automatic consolidation attempts to the session console. Set to `false` to suppress only this warning; the memory tool result still reports the failure reason |
552
526
  | `correctionDetection` | `true` | Detect user corrections and save immediately |
@@ -602,7 +576,8 @@ The deferred backfill, live-index, and integrity-check spans may appear after st
602
576
  │ │ └── SKILL.md
603
577
  │ └── another-project/
604
578
  │ └── MEMORY.md
605
- ├── hermes-memory-config.json
579
+ ├── settings.json ← Global settings; extension options are under hermesMemory
580
+ ├── hermes-memory-config.json ← Legacy fallback for older extension builds
606
581
  └── ...
607
582
  ```
608
583
 
@@ -75,13 +75,42 @@ export const DEFAULT_CONFIG_PATH = path.join(
75
75
  AGENT_ROOT,
76
76
  "hermes-memory-config.json",
77
77
  );
78
+ export const DEFAULT_SETTINGS_PATH = path.join(AGENT_ROOT, "settings.json");
79
+ export const HERMES_MEMORY_SETTINGS_KEY = "hermesMemory";
78
80
 
79
- export function loadConfig(configPath = DEFAULT_CONFIG_PATH): MemoryConfig {
81
+ function readConfigObject(configPath: string | undefined): Record<string, unknown> | undefined {
82
+ if (!configPath) return undefined;
80
83
  try {
81
- if (fs.existsSync(configPath)) {
82
- const raw = fs.readFileSync(configPath, "utf-8");
83
- const parsed = JSON.parse(raw);
84
- // Merge: override defaults with user config
84
+ const parsed: unknown = JSON.parse(fs.readFileSync(configPath, "utf-8"));
85
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
86
+ ? parsed as Record<string, unknown>
87
+ : undefined;
88
+ } catch {
89
+ return undefined;
90
+ }
91
+ }
92
+
93
+ function isRecord(value: unknown): value is Record<string, unknown> {
94
+ return typeof value === "object" && value !== null && !Array.isArray(value);
95
+ }
96
+
97
+ /** Read extension settings from settings.json, falling back to the legacy file. */
98
+ export function loadConfig(
99
+ configPath = DEFAULT_CONFIG_PATH,
100
+ settingsPath?: string,
101
+ ): MemoryConfig {
102
+ try {
103
+ const legacyConfig = readConfigObject(configPath);
104
+ const resolvedSettingsPath = settingsPath
105
+ ?? (configPath === DEFAULT_CONFIG_PATH ? DEFAULT_SETTINGS_PATH : undefined);
106
+ const settings = readConfigObject(resolvedSettingsPath);
107
+ const extensionSettings = settings?.[HERMES_MEMORY_SETTINGS_KEY];
108
+ if (legacyConfig || isRecord(extensionSettings)) {
109
+ const parsed: Record<string, unknown> = {
110
+ ...(legacyConfig ?? {}),
111
+ ...(isRecord(extensionSettings) ? extensionSettings : {}),
112
+ };
113
+ // New settings.json values take precedence over the legacy file.
85
114
  const config: MemoryConfig = { ...DEFAULT_CONFIG };
86
115
  const isNonNegativeNumber = (value: unknown): value is number => (
87
116
  typeof value === "number" && Number.isFinite(value) && value >= 0
@@ -156,8 +185,7 @@ export function loadConfig(configPath = DEFAULT_CONFIG_PATH): MemoryConfig {
156
185
  if (normalizedProjectsMemoryDir) config.projectsMemoryDir = normalizedProjectsMemoryDir;
157
186
  }
158
187
  if (
159
- typeof parsed.sessionSearch === "object" &&
160
- parsed.sessionSearch !== null &&
188
+ isRecord(parsed.sessionSearch) &&
161
189
  isSessionSearchVariant(parsed.sessionSearch.variant)
162
190
  ) {
163
191
  config.sessionSearch = { variant: parsed.sessionSearch.variant };
@@ -204,7 +232,7 @@ export function loadConfig(configPath = DEFAULT_CONFIG_PATH): MemoryConfig {
204
232
  return config;
205
233
  }
206
234
  } catch {
207
- // Fall back to defaults on parse error or access issues
235
+ // Fall back to defaults on unexpected parse or access issues.
208
236
  }
209
237
  return { ...DEFAULT_CONFIG };
210
238
  }
@@ -29,12 +29,12 @@ export const DEFAULT_NUDGE_TOOL_CALLS = 15;
29
29
  export const DEFAULT_REVIEW_RECENT_MESSAGES = 0;
30
30
  export const DEFAULT_FLUSH_RECENT_MESSAGES = 0;
31
31
  /**
32
- * A consolidation run pays child-process boot plus a full LLM turn, which
33
- * routinely exceeds 60s — at the old 60s default the auto path was killed
34
- * mid-run on every attempt (#136). Configured values are honored verbatim,
35
- * including lower ones; `loadConfig` warns when a value below this is set.
32
+ * A consolidation run pays child-process boot plus a full LLM turn. Five
33
+ * minutes leaves room for slower models and larger memory stores. Configured
34
+ * values are honored verbatim, including lower ones; `loadConfig` warns when a
35
+ * value below this is set.
36
36
  */
37
- export const DEFAULT_CONSOLIDATION_TIMEOUT_MS = 180000;
37
+ export const DEFAULT_CONSOLIDATION_TIMEOUT_MS = 300000;
38
38
  /** Wall-clock grace after overflow before an automatic consolidation may run. */
39
39
  export const DEFAULT_OVERFLOW_GRACE_MS = 180000;
40
40
  export const DEFAULT_FAILURE_INJECTION_MAX_AGE_DAYS = 7;
@@ -337,64 +337,78 @@ export function registerConsolidateCommand(
337
337
  });
338
338
  }
339
339
 
340
- try {
341
- ctx.ui.notify(
342
- `🔄 Starting memory consolidation for ${targets.length} target${targets.length === 1 ? "" : "s"}...`,
343
- "info",
344
- );
345
- } catch {
346
- // Best-effort only. If the command context is already stale, continue
347
- // with the consolidation work rather than failing before it starts.
348
- }
349
-
350
- for (const item of targets) {
351
- const entries = entriesForTarget(item.store, item.target);
352
-
353
- if (entries.length === 0) {
354
- results.push(`${item.label}: (empty, nothing to consolidate)`);
355
- continue;
340
+ const setStatus = (text?: string) => {
341
+ try {
342
+ ctx.ui.setStatus("pi-hermes-memory:consolidation", text);
343
+ } catch {
344
+ // Best-effort progress feedback only.
356
345
  }
346
+ };
357
347
 
348
+ setStatus("Preparing memory consolidation…");
349
+ try {
358
350
  try {
359
351
  ctx.ui.notify(
360
- `⏳ Consolidating ${item.label}...`,
352
+ `🔄 Starting memory consolidation for ${targets.length} target${targets.length === 1 ? "" : "s"}...`,
361
353
  "info",
362
354
  );
363
355
  } catch {
364
- // Best-effort progress feedback only.
356
+ // Best-effort only. If the command context is already stale, continue
357
+ // with the consolidation work rather than failing before it starts.
365
358
  }
366
359
 
367
- const result = await triggerConsolidation(
368
- pi,
369
- item.store,
370
- item.target,
371
- ctx.signal,
372
- timeoutMs,
373
- item.toolTarget,
374
- llmConfig,
375
- ctx,
376
- dbManager,
377
- activeProjectName,
378
- deps,
379
- );
380
-
381
- if (result.consolidated) {
382
- await item.store.loadFromDisk();
383
- results.push(`${item.label}: ✅ consolidated`);
384
- } else {
385
- results.push(`${item.label}: ❌ ${result.error}`);
360
+ for (const [index, item] of targets.entries()) {
361
+ const entries = entriesForTarget(item.store, item.target);
362
+
363
+ if (entries.length === 0) {
364
+ results.push(`${item.label}: (empty, nothing to consolidate)`);
365
+ continue;
366
+ }
367
+
368
+ setStatus(`Consolidating ${item.label} (${index + 1}/${targets.length})…`);
369
+ try {
370
+ ctx.ui.notify(
371
+ `⏳ Consolidating ${item.label}...`,
372
+ "info",
373
+ );
374
+ } catch {
375
+ // Best-effort progress feedback only.
376
+ }
377
+
378
+ const result = await triggerConsolidation(
379
+ pi,
380
+ item.store,
381
+ item.target,
382
+ ctx.signal,
383
+ timeoutMs,
384
+ item.toolTarget,
385
+ llmConfig,
386
+ ctx,
387
+ dbManager,
388
+ activeProjectName,
389
+ deps,
390
+ );
391
+
392
+ if (result.consolidated) {
393
+ await item.store.loadFromDisk();
394
+ results.push(`${item.label}: ✅ consolidated`);
395
+ } else {
396
+ results.push(`${item.label}: ❌ ${result.error}`);
397
+ }
386
398
  }
387
- }
388
399
 
389
- const summary = `\n 🔄 Memory Consolidation\n ${"─".repeat(30)}\n${results.map((r) => ` ${r}`).join("\n")}`;
400
+ const summary = `\n 🔄 Memory Consolidation\n ${"─".repeat(30)}\n${results.map((r) => ` ${r}`).join("\n")}`;
390
401
 
391
- try {
392
- ctx.ui.notify(summary, "info");
393
- } catch {
394
- // Child consolidation can indirectly trigger a runtime reload/session
395
- // replacement. If that happens, the original command ctx is stale by
396
- // the time we reach the final summary, so the command should exit
397
- // quietly instead of surfacing a stale-ctx error.
402
+ try {
403
+ ctx.ui.notify(summary, "info");
404
+ } catch {
405
+ // Child consolidation can indirectly trigger a runtime reload/session
406
+ // replacement. If that happens, the original command ctx is stale by
407
+ // the time we reach the final summary, so the command should exit
408
+ // quietly instead of surfacing a stale-ctx error.
409
+ }
410
+ } finally {
411
+ setStatus(undefined);
398
412
  }
399
413
  },
400
414
  });
@@ -7,9 +7,11 @@ import { loadConfig } from "../src/config.js";
7
7
  import { AGENT_ROOT } from "../src/paths.js";
8
8
 
9
9
  const TEST_CONFIG_PATH = path.join(os.tmpdir(), `hermes-memory-config-test-${process.pid}.json`);
10
+ const TEST_SETTINGS_PATH = path.join(os.tmpdir(), `hermes-settings-test-${process.pid}.json`);
10
11
 
11
12
  afterEach(() => {
12
13
  fs.rmSync(TEST_CONFIG_PATH, { force: true });
14
+ fs.rmSync(TEST_SETTINGS_PATH, { force: true });
13
15
  });
14
16
 
15
17
  describe("loadConfig", () => {
@@ -30,7 +32,7 @@ describe("loadConfig", () => {
30
32
  assert.strictEqual(config.flushRecentMessages, 0);
31
33
  assert.strictEqual(config.memoryOverflowStrategy, "auto-consolidate");
32
34
  assert.strictEqual(config.autoConsolidate, true);
33
- assert.strictEqual(config.consolidationTimeoutMs, 180000);
35
+ assert.strictEqual(config.consolidationTimeoutMs, 300000);
34
36
  assert.strictEqual(config.overflowGraceMs, 180000);
35
37
  assert.strictEqual(config.autoConsolidationWarnOnFailure, true);
36
38
  assert.strictEqual(config.failureInjectionEnabled, true);
@@ -65,7 +67,7 @@ describe("loadConfig", () => {
65
67
  "a lower configured value must be honored, not clamped",
66
68
  );
67
69
  assert.strictEqual(warnings.length, 1, "a sub-default value should warn once");
68
- assert.match(warnings[0], /60000ms.*below the 180000ms default/);
70
+ assert.match(warnings[0], /60000ms.*below the 300000ms default/);
69
71
  } finally {
70
72
  console.warn = originalWarn;
71
73
  }
@@ -112,6 +114,28 @@ describe("loadConfig", () => {
112
114
  assert.strictEqual(config.reviewEnabled, true);
113
115
  });
114
116
 
117
+ it("loads hermesMemory from settings.json with precedence over the legacy config", () => {
118
+ fs.mkdirSync(path.dirname(TEST_CONFIG_PATH), { recursive: true });
119
+ fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({
120
+ llmThinkingOverride: "high",
121
+ consolidationTimeoutMs: 240000,
122
+ }));
123
+ fs.writeFileSync(TEST_SETTINGS_PATH, JSON.stringify({
124
+ theme: "dark",
125
+ hermesMemory: {
126
+ llmThinkingOverride: "off",
127
+ llmModelOverride: " tokenin/deepseek-v4.1-flash ",
128
+ consolidationTimeoutMs: 300000,
129
+ },
130
+ }));
131
+
132
+ const config = loadConfig(TEST_CONFIG_PATH, TEST_SETTINGS_PATH);
133
+ assert.strictEqual(config.llmThinkingOverride, "off");
134
+ assert.strictEqual(config.llmModelOverride, "tokenin/deepseek-v4.1-flash");
135
+ assert.strictEqual(config.consolidationTimeoutMs, 300000);
136
+ assert.strictEqual(config.memoryMode, "policy-only");
137
+ });
138
+
115
139
  it("only accepts boolean quickCheckOnOpen overrides", () => {
116
140
  fs.mkdirSync(path.dirname(TEST_CONFIG_PATH), { recursive: true });
117
141
  fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({ quickCheckOnOpen: "false" }));
@@ -631,6 +631,7 @@ describe("registerConsolidateCommand", () => {
631
631
  it("includes project memory when a project store is available", async () => {
632
632
  let handler: any;
633
633
  const notifications: string[] = [];
634
+ const statuses: Array<[string, string | undefined]> = [];
634
635
  let projectReloaded = false;
635
636
 
636
637
  const pi = {
@@ -655,7 +656,10 @@ describe("registerConsolidateCommand", () => {
655
656
  registerConsolidateCommand(pi, mockStore, 60000, projectStore, "demo-project");
656
657
  await handler({}, {
657
658
  signal: undefined,
658
- ui: { notify: (message: string) => { notifications.push(message); } },
659
+ ui: {
660
+ notify: (message: string) => { notifications.push(message); },
661
+ setStatus: (key: string, text: string | undefined) => { statuses.push([key, text]); },
662
+ },
659
663
  });
660
664
 
661
665
  assert.strictEqual(execCalls.length, 4, "should consolidate memory, user, failure, and project stores");
@@ -670,6 +674,8 @@ describe("registerConsolidateCommand", () => {
670
674
  assert.ok(projectReloaded, "project store should reload after consolidation");
671
675
  assert.ok(notifications.some((message) => message.includes("Starting memory consolidation")), "should show an initial progress notification");
672
676
  assert.ok(notifications.some((message) => message.includes("⏳ Consolidating memory")), "should show per-target progress");
677
+ assert.ok(statuses.some(([key, text]) => key === "pi-hermes-memory:consolidation" && text?.includes("Consolidating memory")), "should publish busy progress");
678
+ assert.deepStrictEqual(statuses.at(-1), ["pi-hermes-memory:consolidation", undefined], "should clear busy progress when done");
673
679
  const finalNotification = notifications[notifications.length - 1] ?? "";
674
680
  assert.ok(finalNotification.includes("failure: ✅ consolidated"), "final notification should include failure result");
675
681
  assert.ok(finalNotification.includes("project:demo-project: ✅ consolidated"), "final notification should include project result");
@@ -1186,7 +1186,11 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
1186
1186
  const deliveredEntry = { ...entry, message: injectedMessage, replyCommand };
1187
1187
  replyTracker.queueTurnContext({ from: entry.from, message: injectedMessage, receivedAt: Date.now() });
1188
1188
  const senderDisplay = entry.from.name || entry.from.id.slice(0, 8);
1189
- const replyInstruction = replyCommand ? `\n\nTo reply, use the intercom tool: ${replyCommand}` : "";
1189
+ // The tool may be an inactive optional capability: say how to activate it, not just its call.
1190
+ const activateHint = pi.getActiveTools().includes("intercom")
1191
+ ? ""
1192
+ : ' (not active yet: call capability_discover with name "intercom" first)';
1193
+ const replyInstruction = replyCommand ? `\n\nTo reply, use the intercom tool${activateHint}: ${replyCommand}` : "";
1190
1194
  const deliveryMetadata = formatInboundDeliveryMetadata(injectedMessage);
1191
1195
  pi.sendMessage(
1192
1196
  {
@@ -201,17 +201,19 @@ export function normalizePublicSubagentExecution<T extends PublicSubagentExecuti
201
201
  return { ok: false, error: "Structured single-child execution cannot be combined with workflow, workflowScript, or workflowScriptPath.", mode: "workflow" };
202
202
  }
203
203
  if (params.agent !== undefined || params.task !== undefined) {
204
- if (typeof params.agent !== "string" || !params.agent.trim()) {
205
- return { ok: false, error: "Structured single-child execution requires agent to be a non-empty string.", mode: "workflow" };
206
- }
207
204
  if (params.task !== undefined && typeof params.task !== "string") {
208
205
  return { ok: false, error: "Structured single-child task must be a string when provided.", mode: "workflow" };
209
206
  }
207
+ // A task without an agent is left for Jev subagent routing; the executor rejects it when routing cannot choose.
208
+ const agentless = params.agent === undefined && typeof params.task === "string" && params.task.trim() !== "";
209
+ if (!agentless && (typeof params.agent !== "string" || !params.agent.trim())) {
210
+ return { ok: false, error: "Structured single-child execution requires agent to be a non-empty string.", mode: "workflow" };
211
+ }
210
212
  return {
211
213
  ok: true,
212
214
  params: {
213
215
  ...params,
214
- agent: params.agent.trim(),
216
+ ...(typeof params.agent === "string" ? { agent: params.agent.trim() } : {}),
215
217
  output: params.output === undefined ? true : params.output,
216
218
  } as T,
217
219
  };
@@ -281,7 +281,7 @@ const ControlOverrides = Type.Object({
281
281
 
282
282
  const SubagentParamProperties = {
283
283
  agent: Type.Optional(Type.String({ description: "Agent for one-child execution, or target for agent management actions." })),
284
- task: Type.Optional(Type.String({ description: "Optional one-child task. Requires agent; cannot combine with action, workflowScript, or workflowScriptPath." })),
284
+ task: Type.Optional(Type.String({ description: "Optional one-child task. Requires agent unless Jev subagent routing is enabled, in which case agent may be omitted to let Jev choose by function; cannot combine with action, workflowScript, or workflowScriptPath." })),
285
285
  extensionBindings: Type.Optional(Type.Unsafe({ type: "object", maxProperties: 16, additionalProperties: true, description: "Namespaced, bounded plain-JSON metadata delivered only to the child runtime. Namespace keys use package.name/1 syntax." })),
286
286
  // Management action (when present, tool operates in management mode)
287
287
  action: Type.Optional(Type.String({ minLength: 1,
@@ -66,6 +66,7 @@ import { retainLiveForegroundNestedRoute } from "../../integrations/pi-web-sessi
66
66
  import { validateToolBudgetConfig } from "../shared/tool-budget.ts";
67
67
  import { usageBudgetExceededMessage, usageBudgetState, validateUsageBudgetConfig } from "../shared/usage-budget.ts";
68
68
  import { intersectSubagentCapabilityCeilings, resolveCurrentSubagentCapabilityCeiling, type ResolvedSubagentCapabilityCeiling } from "../shared/capability-ceiling.ts";
69
+ import { describeSubagentRouting, emitSubagentRoutingTelemetry, readSubagentRoutingSettings, routeSubagentLaunch, warnJevUnavailableOnce } from "../shared/jev-subagent-routing.ts";
69
70
  import { isAgentContract } from "../shared/agent-contract.ts";
70
71
  import { normalizeExtensionBindings, type ExtensionBindings } from "../shared/extension-bindings.ts";
71
72
  import { finalizeSingleOutput, injectSingleOutputInstruction, normalizeSingleOutputOverride, outputPathMappingFromTask, resolveSingleOutputPath, validateFileOnlyOutputMode } from "../shared/single-output.ts";
@@ -6637,11 +6638,35 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
6637
6638
  if (cwdError) return buildRequestedModeError(effectiveParams, cwdError);
6638
6639
  const parentSessionFile = ctx.sessionManager.getSessionFile() ?? null;
6639
6640
  const discovered = deps.discoverAgents(effectiveCwd, scope, requestParentModel?.provider);
6640
- const discoveredAgents = discovered.agents;
6641
+ let discoveredAgents = discovered.agents;
6641
6642
  const unknownAgentDiagnosticContext = diagnosticContextFromDiscovery(discovered, effectiveCwd, scope);
6642
6643
  const canonicalParams = canonicalizeExecutionParams(effectiveParams, discoveredAgents, discovered.agentDiagnostics, unknownAgentDiagnosticContext);
6643
6644
  if (canonicalParams.error) return buildRequestedModeError(effectiveParams, canonicalParams.error);
6644
6645
  effectiveParams = canonicalParams.params!;
6646
+ // Jev fills in what a public single-child call left open (agent, tools, model tier); opt-in.
6647
+ const routedSingle = publicExecution && !effectiveParams.chain?.length && !effectiveParams.tasks?.length && typeof effectiveParams.task === "string" && effectiveParams.task.trim() !== "";
6648
+ const jevRouting = routedSingle ? readSubagentRoutingSettings() : undefined;
6649
+ if (jevRouting) {
6650
+ const routed = await routeSubagentLaunch({
6651
+ task: effectiveParams.task!,
6652
+ agent: effectiveParams.agent,
6653
+ model: effectiveParams.model,
6654
+ agents: discoveredAgents,
6655
+ ceiling: intersectSubagentCapabilityCeilings(effectiveParams.capabilityCeiling, resolveCurrentSubagentCapabilityCeiling(resolveCurrentSessionId(ctx.sessionManager))),
6656
+ // pi-subagents' ModelRegistry typings predate `complete`; the host registry has it, and a missing one is an abstention.
6657
+ }, ctx as unknown as Parameters<typeof routeSubagentLaunch>[1], jevRouting);
6658
+ emitSubagentRoutingTelemetry(deps.pi.events, routed);
6659
+ if (routed.abstained === "no-credential" && ctx.hasUI) warnJevUnavailableOnce(ctx.ui, jevRouting.connection.provider);
6660
+ discoveredAgents = routed.agents;
6661
+ if (routed.agent !== undefined) effectiveParams = { ...effectiveParams, agent: routed.agent };
6662
+ const summary = describeSubagentRouting(routed, jevRouting.tiers);
6663
+ if (summary && ctx.hasUI) ctx.ui.notify(summary, "info");
6664
+ }
6665
+ if (routedSingle && !effectiveParams.agent) {
6666
+ return buildRequestedModeError(effectiveParams, jevRouting
6667
+ ? "Jev could not choose an agent for this task. Name one: call subagent with { action: \"list\", capabilities: true } to see them."
6668
+ : "Structured single-child execution requires agent; Jev subagent routing (jevAdvisory.routes.subagent.enabled) is off. Name an agent.");
6669
+ }
6645
6670
  if (effectiveParams.worktree === undefined && deps.config.worktree !== undefined) {
6646
6671
  effectiveParams = { ...effectiveParams, worktree: deps.config.worktree };
6647
6672
  }