@cjhyy/code-shell-core 0.5.0-rc.0 → 0.5.0-rc.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.
@@ -0,0 +1,14 @@
1
+ import { type AgentDefinition } from "./agent-definition.js";
2
+ /**
3
+ * Loads reusable sub-agent role definitions from a directory of `*.md` files.
4
+ * Non-recursive. Malformed files are skipped with a warning rather than
5
+ * failing the whole load — one bad role file must not break the agent system.
6
+ */
7
+ export declare class AgentDefinitionRegistry {
8
+ private defs;
9
+ readonly warnings: string[];
10
+ static loadFromDir(dir: string): AgentDefinitionRegistry;
11
+ has(name: string): boolean;
12
+ get(name: string): AgentDefinition | undefined;
13
+ list(): AgentDefinition[];
14
+ }
@@ -0,0 +1,45 @@
1
+ import { readdirSync, readFileSync, existsSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { parseAgentDefinition } from "./agent-definition.js";
4
+ /**
5
+ * Loads reusable sub-agent role definitions from a directory of `*.md` files.
6
+ * Non-recursive. Malformed files are skipped with a warning rather than
7
+ * failing the whole load — one bad role file must not break the agent system.
8
+ */
9
+ export class AgentDefinitionRegistry {
10
+ defs = new Map();
11
+ warnings = [];
12
+ static loadFromDir(dir) {
13
+ const reg = new AgentDefinitionRegistry();
14
+ if (!existsSync(dir) || !statSync(dir).isDirectory())
15
+ return reg;
16
+ for (const entry of readdirSync(dir).sort()) {
17
+ if (!entry.endsWith(".md"))
18
+ continue;
19
+ const full = join(dir, entry);
20
+ try {
21
+ if (!statSync(full).isFile())
22
+ continue;
23
+ const def = parseAgentDefinition(readFileSync(full, "utf8"), entry);
24
+ if (reg.defs.has(def.name)) {
25
+ reg.warnings.push(`${entry}: duplicate agent name '${def.name}' ignored (first definition wins)`);
26
+ continue;
27
+ }
28
+ reg.defs.set(def.name, def);
29
+ }
30
+ catch (err) {
31
+ reg.warnings.push(`${entry}: ${err.message}`);
32
+ }
33
+ }
34
+ return reg;
35
+ }
36
+ has(name) {
37
+ return this.defs.has(name);
38
+ }
39
+ get(name) {
40
+ return this.defs.get(name);
41
+ }
42
+ list() {
43
+ return [...this.defs.values()];
44
+ }
45
+ }
@@ -0,0 +1,20 @@
1
+ /** A reusable sub-agent role, loaded from a Markdown file. */
2
+ export interface AgentDefinition {
3
+ /** Unique role key, e.g. "researcher". Matched against Agent({ agent_type }). */
4
+ name: string;
5
+ /** Human-facing summary of when to use this role. */
6
+ description: string;
7
+ /** Optional ModelPool key (e.g. "flash"). Undefined → inherit parent model. */
8
+ model?: string;
9
+ /** Optional turn cap for this role. Undefined → caller/default decides. */
10
+ maxTurns?: number;
11
+ /** Optional tool allowlist. Undefined → inherit parent's full tool set. */
12
+ tools?: string[];
13
+ /** Markdown body — becomes the child Engine's appendSystemPrompt. */
14
+ systemPrompt: string;
15
+ }
16
+ /**
17
+ * Parse a Markdown agent-definition file (YAML frontmatter + body).
18
+ * Pure: no filesystem access. `sourceName` is only used in error messages.
19
+ */
20
+ export declare function parseAgentDefinition(raw: string, sourceName: string): AgentDefinition;
@@ -0,0 +1,38 @@
1
+ import { parse as parseYaml } from "yaml";
2
+ /**
3
+ * Parse a Markdown agent-definition file (YAML frontmatter + body).
4
+ * Pure: no filesystem access. `sourceName` is only used in error messages.
5
+ */
6
+ export function parseAgentDefinition(raw, sourceName) {
7
+ const match = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(raw.trim());
8
+ if (!match) {
9
+ throw new Error(`${sourceName}: missing YAML frontmatter (expected leading '---' block)`);
10
+ }
11
+ const [, yamlSrc, body] = match;
12
+ let fm;
13
+ try {
14
+ fm = (parseYaml(yamlSrc) ?? {});
15
+ }
16
+ catch (err) {
17
+ throw new Error(`${sourceName}: invalid YAML frontmatter — ${err.message}`, { cause: err });
18
+ }
19
+ if (typeof fm.name !== "string" || fm.name.trim().length === 0) {
20
+ throw new Error(`${sourceName}: frontmatter must include a non-empty 'name'`);
21
+ }
22
+ if (typeof fm.description !== "string" || fm.description.trim().length === 0) {
23
+ throw new Error(`${sourceName}: frontmatter must include a non-empty 'description'`);
24
+ }
25
+ const def = {
26
+ name: fm.name.trim(),
27
+ description: fm.description.trim(),
28
+ systemPrompt: body.trim(),
29
+ };
30
+ if (typeof fm.model === "string" && fm.model.trim())
31
+ def.model = fm.model.trim();
32
+ if (typeof fm.maxTurns === "number")
33
+ def.maxTurns = fm.maxTurns;
34
+ if (Array.isArray(fm.tools)) {
35
+ def.tools = fm.tools.filter((t) => typeof t === "string");
36
+ }
37
+ return def;
38
+ }
@@ -40,7 +40,9 @@ import { CostTracker } from "../cost-tracker.js";
40
40
  // ─── Read base config from environment / settings ─────────────────
41
41
  const cwd = process.env.AGENT_CWD ?? process.cwd();
42
42
  // Load settings once to derive llm config for the seed engine.
43
- const settingsManager = new SettingsManager(cwd);
43
+ // Desktop is a host application: read the full disk hierarchy (incl. the
44
+ // user's ~/.code-shell). The SDK default 'project' would skip user config.
45
+ const settingsManager = new SettingsManager(cwd, "full");
44
46
  const settings = settingsManager.get();
45
47
  const llmConfig = {
46
48
  provider: settings.model.provider,
@@ -54,6 +56,7 @@ const llmConfig = {
54
56
  const seedEngine = new Engine({
55
57
  llm: llmConfig,
56
58
  cwd,
59
+ settingsScope: "full",
57
60
  // No runtime — Engine.populateModelPoolFromSettings() runs in ctor.
58
61
  });
59
62
  // ─── Step 2: extract shared resources ────────────────────────────
@@ -93,6 +96,8 @@ const chatManager = new ChatSessionManager({
93
96
  llm: resolvedLlmConfig,
94
97
  cwd,
95
98
  runtime,
99
+ // Inherit full scope so spawned subagents read user config too.
100
+ settingsScope: "full",
96
101
  // Per-session overrides from the protocol request
97
102
  permissionMode: slice.permissionMode,
98
103
  preset: slice.preset,
@@ -10,10 +10,12 @@ import type { HookHandler } from "../hooks/registry.js";
10
10
  import { SessionManager } from "../session/session-manager.js";
11
11
  import type { CostStateStore } from "./cost-store.js";
12
12
  import type { AskUserFn } from "../tool-system/builtin/ask-user.js";
13
+ import { type SettingsScope } from "../settings/manager.js";
13
14
  import type { ToolContext } from "../tool-system/context.js";
14
15
  import { type SandboxConfig } from "../tool-system/sandbox/index.js";
15
16
  import { type AgentPresetName } from "../preset/index.js";
16
17
  import { ModelPool, type ModelEntry } from "../llm/model-pool.js";
18
+ import { AgentDefinitionRegistry } from "../agent/agent-definition-registry.js";
17
19
  import { EngineRuntime } from "./runtime.js";
18
20
  export interface EngineConfig {
19
21
  llm: LLMConfig;
@@ -68,6 +70,16 @@ export interface EngineConfig {
68
70
  * work unchanged — T11 will migrate them.
69
71
  */
70
72
  runtime?: EngineRuntime;
73
+ /**
74
+ * Which disk config layers this Engine may read. Defaults to 'project' —
75
+ * the safe default: a library/SDK embedding never silently inherits the
76
+ * host user's personal ~/.code-shell config (keys, models, MCP, hooks); it
77
+ * only reads the project-level ${cwd}/.code-shell that travels with a repo.
78
+ * Host-terminal entrypoints (TUI/desktop/CLI) pass 'full' to restore the
79
+ * managed+user+project+local behavior. 'isolated' reads no disk at all.
80
+ * Subagents inherit the parent Engine's scope. See SettingsScope.
81
+ */
82
+ settingsScope?: SettingsScope;
71
83
  }
72
84
  export interface EngineHookConfig {
73
85
  event: HookEventName;
@@ -82,6 +94,27 @@ export interface EngineResult {
82
94
  turnCount: number;
83
95
  usage: TokenUsage;
84
96
  }
97
+ /**
98
+ * Resolve the LLM config for a spawned child Engine.
99
+ * - `modelKey` set + present in pool → that model's config (over parent base).
100
+ * - otherwise (no key, no pool, or key miss) → the parent's llm unchanged.
101
+ * Key miss is a soft fallback, NOT an error: a stale agent definition must not
102
+ * crash the spawn.
103
+ */
104
+ export declare function resolveChildLlm(modelKey: string | undefined, pool: ModelPool | undefined, parentLlm: LLMConfig): LLMConfig;
105
+ /** Load reusable sub-agent role definitions from <cwd>/.code-shell/agents. */
106
+ export declare function loadAgentDefinitionsForCwd(cwd: string): AgentDefinitionRegistry;
107
+ /**
108
+ * Compute a child Engine's tool scope.
109
+ * - `allowlist` set → child enabled = allowlist minus nested-agent tools
110
+ * (a per-role tool whitelist, e.g. a read-only researcher).
111
+ * - `allowlist` undefined → inherit parent enabled/disabled, always with the
112
+ * nested-agent tools forced into `disabled` (no grandchildren).
113
+ */
114
+ export declare function resolveChildToolScope(allowlist: string[] | undefined, parentDisabled: string[] | undefined, parentEnabled: string[] | undefined): {
115
+ enabled?: string[];
116
+ disabled: string[];
117
+ };
85
118
  export declare class Engine {
86
119
  private config;
87
120
  private readonly preset;
@@ -90,6 +123,8 @@ export declare class Engine {
90
123
  private sessionManager;
91
124
  private mcpManager;
92
125
  private modelPool;
126
+ /** Memoized sub-agent role registry, keyed by the cwd it was loaded from. */
127
+ private agentDefsCache?;
93
128
  /** Shared resources supplied at construction (adapter pattern — null when self-constructed). */
94
129
  readonly runtime: EngineRuntime | null;
95
130
  /** Active permission mode for this Engine instance. */
@@ -279,6 +314,12 @@ export declare class Engine {
279
314
  * Also syncs permissionMode to keep both fields consistent.
280
315
  */
281
316
  setPlanMode(value: boolean): void;
317
+ /**
318
+ * Sub-agent role registry for the given cwd, memoized per-cwd so the
319
+ * directory is read once rather than every turn. A new cwd (e.g. via
320
+ * run({ cwd })) reloads.
321
+ */
322
+ private getAgentDefinitions;
282
323
  /**
283
324
  * Build a base ToolContext for this Engine. Used by run() (which then
284
325
  * overlays turn-specific fields like sandbox and subAgentSpawner) and
@@ -286,10 +327,15 @@ export declare class Engine {
286
327
  */
287
328
  buildToolContext(): ToolContext;
288
329
  /**
289
- * Read settings.disabledSkills. Sub-agents skip this for the same
290
- * reason they skip settings.hooks / plugin hooks (registerSettingsHooks
291
- * at ~line 237): they run with a minimal surface area. Defaults to []
292
- * so callers don't have to null-check.
330
+ * Read settings.disabledSkills + settings.disabledPlugins in a single
331
+ * pass. Sub-agents skip both for the same reason they skip
332
+ * settings.hooks / plugin hooks (registerSettingsHooks at ~line 237):
333
+ * they run with a minimal surface area. Defaults to [] for both
334
+ * fields so callers don't have to null-check.
335
+ *
336
+ * Combined read avoids drift if settings change between two separate
337
+ * reads — the prompt composer and the tool context will always see
338
+ * the same snapshot.
293
339
  */
294
- private readDisabledSkills;
340
+ private readDisabledLists;
295
341
  }
@@ -27,6 +27,7 @@ import { FileHistory } from "../session/file-history.js";
27
27
  import { defaultSandboxConfig, resolveSandboxBackend, } from "../tool-system/sandbox/index.js";
28
28
  import { resolveAgentPreset, resolveBuiltinToolNames, } from "../preset/index.js";
29
29
  import { ModelPool } from "../llm/model-pool.js";
30
+ import { AgentDefinitionRegistry } from "../agent/agent-definition-registry.js";
30
31
  import { ProviderCatalog } from "../llm/provider-catalog.js";
31
32
  import { defaultCacheDir } from "../llm/model-cache.js";
32
33
  import { detectProviderFromApiKey, buildModelPool, } from "../onboarding.js";
@@ -37,6 +38,44 @@ import { MemoryOrchestrator } from "../services/memory-orchestrator.js";
37
38
  import { join } from "node:path";
38
39
  import { homedir } from "node:os";
39
40
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
41
+ /**
42
+ * Resolve the LLM config for a spawned child Engine.
43
+ * - `modelKey` set + present in pool → that model's config (over parent base).
44
+ * - otherwise (no key, no pool, or key miss) → the parent's llm unchanged.
45
+ * Key miss is a soft fallback, NOT an error: a stale agent definition must not
46
+ * crash the spawn.
47
+ */
48
+ export function resolveChildLlm(modelKey, pool, parentLlm) {
49
+ if (modelKey && pool?.has(modelKey)) {
50
+ const resolved = pool.resolveLLMConfig(modelKey, parentLlm);
51
+ if (resolved)
52
+ return resolved;
53
+ }
54
+ return parentLlm;
55
+ }
56
+ /** Load reusable sub-agent role definitions from <cwd>/.code-shell/agents. */
57
+ export function loadAgentDefinitionsForCwd(cwd) {
58
+ return AgentDefinitionRegistry.loadFromDir(`${cwd}/.code-shell/agents`);
59
+ }
60
+ const NESTED_AGENT_TOOLS = ["Agent", "AgentStatus", "AgentCancel"];
61
+ /**
62
+ * Compute a child Engine's tool scope.
63
+ * - `allowlist` set → child enabled = allowlist minus nested-agent tools
64
+ * (a per-role tool whitelist, e.g. a read-only researcher).
65
+ * - `allowlist` undefined → inherit parent enabled/disabled, always with the
66
+ * nested-agent tools forced into `disabled` (no grandchildren).
67
+ */
68
+ export function resolveChildToolScope(allowlist, parentDisabled, parentEnabled) {
69
+ if (allowlist) {
70
+ return {
71
+ enabled: allowlist.filter((t) => !NESTED_AGENT_TOOLS.includes(t)),
72
+ disabled: [...NESTED_AGENT_TOOLS],
73
+ };
74
+ }
75
+ const disabled = Array.from(new Set([...(parentDisabled ?? []), ...NESTED_AGENT_TOOLS]));
76
+ const enabled = parentEnabled?.filter((t) => !NESTED_AGENT_TOOLS.includes(t));
77
+ return { enabled, disabled };
78
+ }
40
79
  export class Engine {
41
80
  config;
42
81
  preset;
@@ -45,6 +84,8 @@ export class Engine {
45
84
  sessionManager;
46
85
  mcpManager;
47
86
  modelPool;
87
+ /** Memoized sub-agent role registry, keyed by the cwd it was loaded from. */
88
+ agentDefsCache;
48
89
  /** Shared resources supplied at construction (adapter pattern — null when self-constructed). */
49
90
  runtime;
50
91
  /** Active permission mode for this Engine instance. */
@@ -201,24 +242,31 @@ export class Engine {
201
242
  // We then switch the pool and write the resolved entry's credentials
202
243
  // into config.llm, so the first run() uses the right endpoint instead
203
244
  // of whatever env-derived fallback repl.ts seeded earlier.
204
- const activeKey = settings.activeKey;
205
- let match;
206
- if (activeKey) {
207
- match = settings.models.find((m) => m.key === activeKey);
208
- }
209
- if (!match) {
210
- const currentModel = this.config.llm.model;
211
- // OpenRouter stores entries as "provider/model-name"; the top-level
212
- // settings.model.name is just "model-name". Match either form.
213
- match = settings.models.find((m) => m.model === currentModel ||
214
- (currentModel && m.model?.endsWith(`/${currentModel}`)));
215
- }
216
- if (match) {
217
- const entry = this.modelPool.switch(match.key);
218
- this.config = {
219
- ...this.config,
220
- llm: this.modelPool.toLLMConfig(entry, this.config.llm),
221
- };
245
+ // Sub-agents skip the activeKey resync: their llm is chosen by the
246
+ // parent's resolveChildLlm (per-role model routing). activeKey is the
247
+ // *user's* current UI model selection and must not clobber a child's
248
+ // routed model without this guard a role's `model: flash` is silently
249
+ // overridden back to whatever the user has active in the foreground.
250
+ if (this.config.isSubAgent !== true) {
251
+ const activeKey = settings.activeKey;
252
+ let match;
253
+ if (activeKey) {
254
+ match = settings.models.find((m) => m.key === activeKey);
255
+ }
256
+ if (!match) {
257
+ const currentModel = this.config.llm.model;
258
+ // OpenRouter stores entries as "provider/model-name"; the top-level
259
+ // settings.model.name is just "model-name". Match either form.
260
+ match = settings.models.find((m) => m.model === currentModel ||
261
+ (currentModel && m.model?.endsWith(`/${currentModel}`)));
262
+ }
263
+ if (match) {
264
+ const entry = this.modelPool.switch(match.key);
265
+ this.config = {
266
+ ...this.config,
267
+ llm: this.modelPool.toLLMConfig(entry, this.config.llm),
268
+ };
269
+ }
222
270
  }
223
271
  }
224
272
  else if (this.config.llm.apiKey) {
@@ -388,29 +436,27 @@ export class Engine {
388
436
  // background process explosion), and the sid / approval / dock
389
437
  // model assumes a flat parent→children hierarchy. Layered with a
390
438
  // runtime check in agent.ts as defense-in-depth.
391
- const NESTED_AGENT_TOOLS = ["Agent", "AgentStatus", "AgentCancel"];
392
- const childDisabled = Array.from(new Set([
393
- ...(this.config.disabledBuiltinTools ?? []),
394
- ...NESTED_AGENT_TOOLS,
395
- ]));
396
- // If enabledBuiltinTools is set (explicit allow-list mode), strip
397
- // the nested-agent tools from it too so the disable above isn't
398
- // contradicted by an explicit allow.
399
- const childEnabled = this.config.enabledBuiltinTools?.filter((t) => !NESTED_AGENT_TOOLS.includes(t));
439
+ const { enabled: childEnabled, disabled: childDisabled } = resolveChildToolScope(req.toolAllowlist, this.config.disabledBuiltinTools, this.config.enabledBuiltinTools);
440
+ const childLlm = resolveChildLlm(req.model, this.modelPool, this.config.llm);
400
441
  const child = new Engine({
401
- llm: { ...this.config.llm, retryMaxAttempts: 2 },
442
+ llm: { ...childLlm, retryMaxAttempts: 2 },
402
443
  cwd,
403
444
  permissionMode: this.config.permissionMode,
404
445
  preset: this.preset.name,
405
446
  enabledBuiltinTools: childEnabled,
406
447
  disabledBuiltinTools: childDisabled,
407
448
  customSystemPrompt: this.config.customSystemPrompt,
408
- appendSystemPrompt: this.config.appendSystemPrompt,
449
+ appendSystemPrompt: [this.config.appendSystemPrompt, req.appendSystemPrompt]
450
+ .filter(Boolean)
451
+ .join("\n\n") || undefined,
409
452
  maxTurns: req.maxTurns,
410
453
  maxContextTokens: this.config.maxContextTokens ?? 200_000,
411
454
  sessionStorageDir: this.config.sessionStorageDir,
412
455
  headless: this.config.headless,
413
456
  sandbox: this.config.sandbox,
457
+ // Subagents inherit the parent's scope: a child runs in the same
458
+ // cwd/session, so it should see the same config layers the parent did.
459
+ settingsScope: this.config.settingsScope ?? "project",
414
460
  isSubAgent: true,
415
461
  });
416
462
  // Where the spawned child Engine's stream events go. AgentTool's
@@ -470,6 +516,7 @@ export class Engine {
470
516
  const toolCtx = {
471
517
  ...this.buildToolContext(),
472
518
  subAgentSpawner,
519
+ agentDefinitions: this.getAgentDefinitions(cwd),
473
520
  sandbox: sandboxBackend,
474
521
  cwd,
475
522
  // TodoWrite reads this to push task_update events independently
@@ -715,13 +762,15 @@ export class Engine {
715
762
  maxTokens: this.resolveMaxContextTokens(),
716
763
  });
717
764
  this.lastContextManager = contextManager;
765
+ const { disabledSkills, disabledPlugins } = this.readDisabledLists();
718
766
  const promptComposer = new PromptComposer({
719
767
  cwd,
720
768
  model: this.config.llm.model,
721
769
  preset: this.preset,
722
770
  customSystemPrompt: this.config.customSystemPrompt,
723
771
  appendSystemPrompt: this.config.appendSystemPrompt,
724
- disabledSkills: this.readDisabledSkills(),
772
+ disabledSkills,
773
+ disabledPlugins,
725
774
  });
726
775
  // Connect MCP servers (if configured and not already connected).
727
776
  // B1: prefer the Runtime-owned MCPManager so all sessions in a
@@ -1305,7 +1354,7 @@ export class Engine {
1305
1354
  }
1306
1355
  getSettingsManager() {
1307
1356
  if (!this.settingsManager) {
1308
- this.settingsManager = new SettingsManager(this.config.cwd);
1357
+ this.settingsManager = new SettingsManager(this.config.cwd, this.config.settingsScope ?? "project");
1309
1358
  }
1310
1359
  return this.settingsManager;
1311
1360
  }
@@ -1357,7 +1406,7 @@ export class Engine {
1357
1406
  rules.push({ tool: "Bash", decision: "allow" });
1358
1407
  }
1359
1408
  try {
1360
- const settingsManager = new SettingsManager(cwd);
1409
+ const settingsManager = new SettingsManager(cwd, this.config.settingsScope ?? "project");
1361
1410
  const settings = settingsManager.get();
1362
1411
  if (settings.permissions?.rules?.length) {
1363
1412
  rules.unshift(...settings.permissions.rules);
@@ -1428,12 +1477,24 @@ export class Engine {
1428
1477
  this.planMode = value;
1429
1478
  }
1430
1479
  }
1480
+ /**
1481
+ * Sub-agent role registry for the given cwd, memoized per-cwd so the
1482
+ * directory is read once rather than every turn. A new cwd (e.g. via
1483
+ * run({ cwd })) reloads.
1484
+ */
1485
+ getAgentDefinitions(cwd) {
1486
+ if (this.agentDefsCache?.cwd !== cwd) {
1487
+ this.agentDefsCache = { cwd, reg: loadAgentDefinitionsForCwd(cwd) };
1488
+ }
1489
+ return this.agentDefsCache.reg;
1490
+ }
1431
1491
  /**
1432
1492
  * Build a base ToolContext for this Engine. Used by run() (which then
1433
1493
  * overlays turn-specific fields like sandbox and subAgentSpawner) and
1434
1494
  * by tests that want a ToolContext without a full run() cycle.
1435
1495
  */
1436
1496
  buildToolContext() {
1497
+ const { disabledSkills, disabledPlugins } = this.readDisabledLists();
1437
1498
  return {
1438
1499
  cwd: this.config.cwd ?? process.cwd(),
1439
1500
  llmConfig: this.config.llm,
@@ -1444,24 +1505,34 @@ export class Engine {
1444
1505
  hooks: this.hooks,
1445
1506
  planMode: this.planMode,
1446
1507
  engine: this,
1447
- disabledSkills: this.readDisabledSkills(),
1508
+ disabledSkills,
1509
+ disabledPlugins,
1448
1510
  };
1449
1511
  }
1450
1512
  /**
1451
- * Read settings.disabledSkills. Sub-agents skip this for the same
1452
- * reason they skip settings.hooks / plugin hooks (registerSettingsHooks
1453
- * at ~line 237): they run with a minimal surface area. Defaults to []
1454
- * so callers don't have to null-check.
1513
+ * Read settings.disabledSkills + settings.disabledPlugins in a single
1514
+ * pass. Sub-agents skip both for the same reason they skip
1515
+ * settings.hooks / plugin hooks (registerSettingsHooks at ~line 237):
1516
+ * they run with a minimal surface area. Defaults to [] for both
1517
+ * fields so callers don't have to null-check.
1518
+ *
1519
+ * Combined read avoids drift if settings change between two separate
1520
+ * reads — the prompt composer and the tool context will always see
1521
+ * the same snapshot.
1455
1522
  */
1456
- readDisabledSkills() {
1457
- if (this.config.isSubAgent === true)
1458
- return [];
1523
+ readDisabledLists() {
1524
+ if (this.config.isSubAgent === true) {
1525
+ return { disabledSkills: [], disabledPlugins: [] };
1526
+ }
1459
1527
  try {
1460
1528
  const settings = this.getSettingsManager().get();
1461
- return settings.disabledSkills ?? [];
1529
+ return {
1530
+ disabledSkills: settings.disabledSkills ?? [],
1531
+ disabledPlugins: settings.disabledPlugins ?? [],
1532
+ };
1462
1533
  }
1463
1534
  catch {
1464
- return [];
1535
+ return { disabledSkills: [], disabledPlugins: [] };
1465
1536
  }
1466
1537
  }
1467
1538
  }
package/dist/index.d.ts CHANGED
@@ -72,7 +72,7 @@ export type { IterateConfig, IterateResult, IterateSubject, IterateFormat, Itera
72
72
  export { RunManager, type RunManagerConfig, type RunStore, FileRunStore, RunQueue, EngineRunner, type EngineRunnerConfig, type RunExecutionHandle, type RunExecutor, type CustomToolEntry, RunApprovalBackend, createRunAskUserFn, type RunLifecycleHooks, CheckpointWriter, ArtifactTracker, RunLock, Heartbeat, NoopEvaluator, CompositeEvaluator, type Evaluator, type EvaluatorResult, type EvaluatorContext, type RunStatus, type RunSnapshot, type RunEvent, type RunCheckpoint, type RunApproval, type RunArtifactRef, type SubmitRunInput, type ResumeRunInput, type ListRunsQuery, type RunStreamEvent, type RunStreamCallback, type DetachFn, VALID_TRANSITIONS, createRunManager, type CreateRunManagerOptions, } from "./run/index.js";
73
73
  export { defineProduct, type ProductDefinition, type ProductPreset, type ProductAdapter, type ProductContract, type CustomTool, type ProductRuntimeOptions, type ProductInstance, } from "./product/index.js";
74
74
  export { logger } from "./logging/logger.js";
75
- export { SettingsManager } from "./settings/manager.js";
75
+ export { SettingsManager, type SettingsScope } from "./settings/manager.js";
76
76
  export { SettingsSchema, validateSettings } from "./settings/schema.js";
77
77
  export { getSessionId, switchSession, getOriginalCwd, setOriginalCwd, getProjectRoot, setProjectRoot, getCwdState, getIsInteractive, updateLastInteractionTime, flushInteractionTime, markScrollActivity, type AttributedCounter, type ChannelEntry, } from "./state.js";
78
78
  export { getGraphemeSegmenter, firstGrapheme, lastGrapheme, getWordSegmenter, getRelativeTimeFormat, getTimeZone, getSystemLocaleLanguage, } from "./utils/intl.js";
@@ -23,6 +23,12 @@ export interface ComposerOptions {
23
23
  * dispatch — see scanSkills(opts.disabledSkills) and skillTool.
24
24
  */
25
25
  disabledSkills?: string[];
26
+ /**
27
+ * Plugin names the user has totally disabled. Coarser knob than
28
+ * disabledSkills — every skill whose namespaced name starts with
29
+ * `${pluginName}:` is filtered. See scanSkills(opts.disabledPlugins).
30
+ */
31
+ disabledPlugins?: string[];
26
32
  }
27
33
  export declare class PromptComposer {
28
34
  private readonly options;
@@ -136,6 +136,7 @@ export class PromptComposer {
136
136
  compute: () => {
137
137
  const skills = scanSkills(this.options.cwd, {
138
138
  disabledSkills: this.options.disabledSkills,
139
+ disabledPlugins: this.options.disabledPlugins,
139
140
  });
140
141
  return buildSkillListing(skills);
141
142
  },
@@ -5,11 +5,22 @@
5
5
  */
6
6
  import { type ValidatedSettings } from "./schema.js";
7
7
  export type SettingsSourceName = "managed" | "user" | "project" | "local" | "flag";
8
+ /**
9
+ * Which disk layers a SettingsManager is allowed to read.
10
+ * 'full' — managed + user (~/.code-shell) + project + local (host terminal entrypoints)
11
+ * 'project' — project + local only (${cwd}/.code-shell); never the host user dir. [default]
12
+ * 'isolated' — no disk layers at all; only explicit flag overrides.
13
+ * Flag overrides always apply regardless of scope. Default is 'project' so a
14
+ * codeshell library/SDK embedding never silently inherits the host user's
15
+ * personal ~/.code-shell config (keys, models, MCP servers, hooks).
16
+ */
17
+ export type SettingsScope = "isolated" | "project" | "full";
8
18
  export declare class SettingsManager {
9
19
  private readonly cwd;
20
+ private readonly scope;
10
21
  private sources;
11
22
  private merged;
12
- constructor(cwd?: string);
23
+ constructor(cwd?: string, scope?: SettingsScope);
13
24
  /**
14
25
  * Load settings from all sources.
15
26
  */
@@ -20,29 +20,39 @@ function userHome() {
20
20
  }
21
21
  export class SettingsManager {
22
22
  cwd;
23
+ scope;
23
24
  sources = [];
24
25
  merged = null;
25
- constructor(cwd = process.cwd()) {
26
+ constructor(cwd = process.cwd(), scope = "project") {
26
27
  this.cwd = cwd;
28
+ this.scope = scope;
27
29
  }
28
30
  /**
29
31
  * Load settings from all sources.
30
32
  */
31
33
  load(flagOverrides) {
32
34
  this.sources = [];
33
- // 1. Managed (lowest priority)
34
- this.loadJsonFile(join(userHome(), ".code-shell", "settings.managed.json"), "managed", 0);
35
- // 2. User only ~/.code-shell/. We used to also read ~/.claude/settings.json
36
- // for "zero-migration from Claude Code", but Claude Code's schema diverges
37
- // (e.g. `model` is a string there, an object here). Merging caused boot
38
- // crashes on machines that had Claude Code installed but never ran us.
39
- // File-level compat (CLAUDE.md, .claude/skills/) is kept elsewhere — only
40
- // the settings.json read is dropped.
41
- this.loadJsonFile(join(userHome(), ".code-shell", "settings.json"), "user", 1);
42
- // 3. Project
43
- this.loadJsonFile(join(this.cwd, ".code-shell", "settings.json"), "project", 2);
44
- // 4. Local
45
- this.loadJsonFile(join(this.cwd, ".code-shell", "settings.local.json"), "local", 3);
35
+ // Scope gates which disk layers we read. 'full' reads the host user dir
36
+ // (~/.code-shell); 'project' and 'isolated' never do. See SettingsScope.
37
+ const readUser = this.scope === "full";
38
+ const readProject = this.scope !== "isolated";
39
+ if (readUser) {
40
+ // 1. Managed (lowest priority)
41
+ this.loadJsonFile(join(userHome(), ".code-shell", "settings.managed.json"), "managed", 0);
42
+ // 2. User — only ~/.code-shell/. We used to also read ~/.claude/settings.json
43
+ // for "zero-migration from Claude Code", but Claude Code's schema diverges
44
+ // (e.g. `model` is a string there, an object here). Merging caused boot
45
+ // crashes on machines that had Claude Code installed but never ran us.
46
+ // File-level compat (CLAUDE.md, .claude/skills/) is kept elsewhere — only
47
+ // the settings.json read is dropped.
48
+ this.loadJsonFile(join(userHome(), ".code-shell", "settings.json"), "user", 1);
49
+ }
50
+ if (readProject) {
51
+ // 3. Project
52
+ this.loadJsonFile(join(this.cwd, ".code-shell", "settings.json"), "project", 2);
53
+ // 4. Local
54
+ this.loadJsonFile(join(this.cwd, ".code-shell", "settings.local.json"), "local", 3);
55
+ }
46
56
  // 5. CLI flags (highest priority)
47
57
  if (flagOverrides && Object.keys(flagOverrides).length > 0) {
48
58
  this.sources.push({ name: "flag", priority: 4, data: flagOverrides });
@@ -54,9 +64,10 @@ export class SettingsManager {
54
64
  // Auto-migrate legacy models[] in the user settings file. Runs directly
55
65
  // on the user-scope file (not the merged result), because the merge
56
66
  // collapses provenance and the migration needs to write back to a
57
- // single physical file.
67
+ // single physical file. Gated on readUser: under non-full scope we must
68
+ // not read — let alone rewrite — the host's ~/.code-shell/settings.json.
58
69
  const userPath = join(userHome(), ".code-shell", "settings.json");
59
- if (existsSync(userPath)) {
70
+ if (readUser && existsSync(userPath)) {
60
71
  try {
61
72
  const userRaw = JSON.parse(readFileSync(userPath, "utf-8"));
62
73
  const result = migrateModels({
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,73 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { SettingsManager } from "./manager.js";
6
+ /**
7
+ * Scope isolation tests. We point HOME at a temp dir (userHome() reads
8
+ * process.env.HOME first) and use a separate temp cwd, then seed each of the
9
+ * four disk layers with a uniquely-named mcpServers entry. mcpServers is a
10
+ * record merged key-by-key, so every layer that was actually read leaves its
11
+ * own key in the merged result — letting us detect which layers were read
12
+ * (unlike arrays, which deep-merge replaces wholesale).
13
+ */
14
+ describe("SettingsManager scope", () => {
15
+ let home;
16
+ let cwd;
17
+ let prevHome;
18
+ function serverFor(tag) {
19
+ return { [tag]: { name: tag, command: "echo", transport: "stdio" } };
20
+ }
21
+ function writeSettings(dir, file, tag) {
22
+ mkdirSync(join(dir, ".code-shell"), { recursive: true });
23
+ writeFileSync(join(dir, ".code-shell", file), JSON.stringify({ mcpServers: serverFor(tag) }), "utf-8");
24
+ }
25
+ function layersIn(sm) {
26
+ return Object.keys(sm.get().mcpServers ?? {}).sort();
27
+ }
28
+ beforeEach(() => {
29
+ prevHome = process.env.HOME;
30
+ home = mkdtempSync(join(tmpdir(), "cs-home-"));
31
+ cwd = mkdtempSync(join(tmpdir(), "cs-cwd-"));
32
+ process.env.HOME = home;
33
+ writeSettings(home, "settings.managed.json", "MANAGED");
34
+ writeSettings(home, "settings.json", "USER");
35
+ writeSettings(cwd, "settings.json", "PROJECT");
36
+ writeSettings(cwd, "settings.local.json", "LOCAL");
37
+ });
38
+ afterEach(() => {
39
+ process.env.HOME = prevHome;
40
+ rmSync(home, { recursive: true, force: true });
41
+ rmSync(cwd, { recursive: true, force: true });
42
+ });
43
+ test("full reads all four disk layers", () => {
44
+ expect(layersIn(new SettingsManager(cwd, "full"))).toEqual([
45
+ "LOCAL",
46
+ "MANAGED",
47
+ "PROJECT",
48
+ "USER",
49
+ ]);
50
+ });
51
+ test("project (default) reads project+local, never user/managed", () => {
52
+ expect(layersIn(new SettingsManager(cwd, "project"))).toEqual(["LOCAL", "PROJECT"]);
53
+ });
54
+ test("default scope is project", () => {
55
+ expect(layersIn(new SettingsManager(cwd))).toEqual(["LOCAL", "PROJECT"]);
56
+ });
57
+ test("isolated reads no disk layers", () => {
58
+ expect(layersIn(new SettingsManager(cwd, "isolated"))).toEqual([]);
59
+ });
60
+ test("flag overrides apply even under isolated", () => {
61
+ const sm = new SettingsManager(cwd, "isolated");
62
+ sm.load({ mcpServers: serverFor("FLAG") });
63
+ expect(layersIn(sm)).toEqual(["FLAG"]);
64
+ });
65
+ test("non-full scope never triggers user-file model migration write-back", () => {
66
+ // Legacy models[] in the user file would normally be migrated (and the
67
+ // file rewritten with a .bak). Under project/isolated we must not touch
68
+ // the user file at all.
69
+ writeFileSync(join(home, ".code-shell", "settings.json"), JSON.stringify({ models: [{ id: "legacy", provider: "x" }] }), "utf-8");
70
+ new SettingsManager(cwd, "project").get();
71
+ expect(existsSync(join(home, ".code-shell", "settings.json.bak"))).toBe(false);
72
+ });
73
+ });
@@ -257,6 +257,13 @@ export declare const SettingsSchema: z.ZodObject<{
257
257
  * tool both see the filtered set — see scanSkills(opts).
258
258
  */
259
259
  disabledSkills: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
260
+ /**
261
+ * Plugin-level total switch: every skill whose namespaced name
262
+ * starts with `${pluginName}:` is filtered. Coarser knob than
263
+ * disabledSkills; both are honored simultaneously. Bare plugin
264
+ * names (no colon suffix). See scanSkills(opts.disabledPlugins).
265
+ */
266
+ disabledPlugins: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
260
267
  instructions: z.ZodDefault<z.ZodObject<{
261
268
  fileName: z.ZodDefault<z.ZodString>;
262
269
  scanDirs: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
@@ -661,6 +668,13 @@ export declare const SettingsSchema: z.ZodObject<{
661
668
  * tool both see the filtered set — see scanSkills(opts).
662
669
  */
663
670
  disabledSkills: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
671
+ /**
672
+ * Plugin-level total switch: every skill whose namespaced name
673
+ * starts with `${pluginName}:` is filtered. Coarser knob than
674
+ * disabledSkills; both are honored simultaneously. Bare plugin
675
+ * names (no colon suffix). See scanSkills(opts.disabledPlugins).
676
+ */
677
+ disabledPlugins: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
664
678
  instructions: z.ZodDefault<z.ZodObject<{
665
679
  fileName: z.ZodDefault<z.ZodString>;
666
680
  scanDirs: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
@@ -1065,6 +1079,13 @@ export declare const SettingsSchema: z.ZodObject<{
1065
1079
  * tool both see the filtered set — see scanSkills(opts).
1066
1080
  */
1067
1081
  disabledSkills: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
1082
+ /**
1083
+ * Plugin-level total switch: every skill whose namespaced name
1084
+ * starts with `${pluginName}:` is filtered. Coarser knob than
1085
+ * disabledSkills; both are honored simultaneously. Bare plugin
1086
+ * names (no colon suffix). See scanSkills(opts.disabledPlugins).
1087
+ */
1088
+ disabledPlugins: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
1068
1089
  instructions: z.ZodDefault<z.ZodObject<{
1069
1090
  fileName: z.ZodDefault<z.ZodString>;
1070
1091
  scanDirs: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
@@ -155,6 +155,13 @@ export const SettingsSchema = z
155
155
  * tool both see the filtered set — see scanSkills(opts).
156
156
  */
157
157
  disabledSkills: z.array(z.string()).default([]),
158
+ /**
159
+ * Plugin-level total switch: every skill whose namespaced name
160
+ * starts with `${pluginName}:` is filtered. Coarser knob than
161
+ * disabledSkills; both are honored simultaneously. Bare plugin
162
+ * names (no colon suffix). See scanSkills(opts.disabledPlugins).
163
+ */
164
+ disabledPlugins: z.array(z.string()).default([]),
158
165
  instructions: z
159
166
  .object({
160
167
  fileName: z.string().default("CODESHELL.md"),
@@ -17,15 +17,22 @@ export interface SkillDefinition {
17
17
  source: "project" | "user" | "plugin";
18
18
  }
19
19
  /**
20
- * Options accepted by scanSkills. The disabled-skill filter is applied
21
- * after the memoized scan returns so the cache stays warm across
22
- * different filter values — changing `settings.disabledSkills` should
23
- * never force a re-scan. Names in `disabledSkills` must match the
24
- * SkillDefinition.name exactly, including any "<plugin>:" prefix —
25
- * see scanInstalledPlugins() at line ~168 for namespace construction.
20
+ * Options accepted by scanSkills. Both filters are applied after the
21
+ * memoized scan returns so the cache stays warm across different filter
22
+ * values — changing `settings.disabledSkills` or `settings.disabledPlugins`
23
+ * should never force a re-scan.
24
+ *
25
+ * - `disabledSkills` names must match the SkillDefinition.name exactly,
26
+ * including any "<plugin>:" prefix — see scanInstalledPlugins() at
27
+ * line ~168 for namespace construction.
28
+ * - `disabledPlugins` names are bare plugin names (no colon suffix).
29
+ * Every skill whose name starts with `${pluginName}:` is filtered.
30
+ * This is the coarse "plugin total switch" knob; `disabledSkills` is
31
+ * the per-skill knob.
26
32
  */
27
33
  export interface ScanSkillsOptions {
28
34
  disabledSkills?: string[];
35
+ disabledPlugins?: string[];
29
36
  }
30
37
  export declare function scanSkills(cwd: string, opts?: ScanSkillsOptions): SkillDefinition[];
31
38
  export declare function invalidateSkillCache(): void;
@@ -160,11 +160,30 @@ function installedPluginsMtime() {
160
160
  const memoized = memoize(scanOnce, (cwd) => `${cwd}\0${userHome()}\0${installedPluginsMtime()}`);
161
161
  export function scanSkills(cwd, opts) {
162
162
  const all = memoized(cwd);
163
- const disabled = opts?.disabledSkills;
164
- if (!disabled || disabled.length === 0)
163
+ const disabledSkills = opts?.disabledSkills;
164
+ const disabledPlugins = opts?.disabledPlugins;
165
+ const hasSkillFilter = disabledSkills && disabledSkills.length > 0;
166
+ const hasPluginFilter = disabledPlugins && disabledPlugins.length > 0;
167
+ if (!hasSkillFilter && !hasPluginFilter)
165
168
  return all;
166
- const set = new Set(disabled);
167
- return all.filter((s) => !set.has(s.name));
169
+ const skillSet = hasSkillFilter ? new Set(disabledSkills) : null;
170
+ const pluginSet = hasPluginFilter ? new Set(disabledPlugins) : null;
171
+ return all.filter((s) => {
172
+ if (skillSet && skillSet.has(s.name))
173
+ return false;
174
+ if (pluginSet) {
175
+ // Use indexOf, not split — skill names may theoretically contain
176
+ // more colons after the first; the namespace boundary is the
177
+ // first ":" only.
178
+ const colon = s.name.indexOf(":");
179
+ if (colon > 0) {
180
+ const prefix = s.name.slice(0, colon);
181
+ if (pluginSet.has(prefix))
182
+ return false;
183
+ }
184
+ }
185
+ return true;
186
+ });
168
187
  }
169
188
  export function invalidateSkillCache() {
170
189
  memoized.cache.clear?.();
@@ -12,6 +12,8 @@
12
12
  * RunManager, not here.
13
13
  */
14
14
  export type AsyncAgentStatus = "running" | "completed" | "failed" | "cancelled";
15
+ /** Process-wide cap on concurrent background sub-agents (aligns with Codex max_threads=6). */
16
+ export declare const MAX_BACKGROUND_AGENTS = 6;
15
17
  /**
16
18
  * Minimal structural shape for an entry in an agent's transcript. We avoid
17
19
  * importing the UI's `ChatEntry` here to prevent a tool-system → ui import
@@ -44,6 +46,8 @@ declare class AsyncAgentRegistry {
44
46
  subscribe: (cb: () => void) => (() => void);
45
47
  getSnapshot: () => AsyncAgentEntry[];
46
48
  hasRunning: () => boolean;
49
+ /** Count of agents currently in the "running" state (cap enforcement). */
50
+ runningCount(): number;
47
51
  private notify;
48
52
  register(entry: AsyncAgentEntry): void;
49
53
  appendToTranscript(agentId: string, entry: AgentTranscriptEntry): void;
@@ -11,6 +11,8 @@
11
11
  * the right boundary: long-running cross-process work belongs to
12
12
  * RunManager, not here.
13
13
  */
14
+ /** Process-wide cap on concurrent background sub-agents (aligns with Codex max_threads=6). */
15
+ export const MAX_BACKGROUND_AGENTS = 6;
14
16
  class AsyncAgentRegistry {
15
17
  agents = new Map();
16
18
  listeners = new Set();
@@ -28,6 +30,10 @@ class AsyncAgentRegistry {
28
30
  hasRunning = () => {
29
31
  return this.snapshot.some((e) => e.status === "running");
30
32
  };
33
+ /** Count of agents currently in the "running" state (cap enforcement). */
34
+ runningCount() {
35
+ return this.snapshot.filter((e) => e.status === "running").length;
36
+ }
31
37
  notify() {
32
38
  this.snapshot = [...this.agents.values()];
33
39
  for (const cb of this.listeners) {
@@ -8,9 +8,45 @@
8
8
  */
9
9
  import type { ToolDefinition } from "../../types.js";
10
10
  import type { ToolContext } from "../context.js";
11
+ import type { AgentDefinitionRegistry } from "../../agent/agent-definition-registry.js";
12
+ import type { HookRegistry } from "../../hooks/registry.js";
13
+ export interface AgentTypeOverrides {
14
+ model?: string;
15
+ maxTurns?: number;
16
+ toolAllowlist?: string[];
17
+ appendSystemPrompt?: string;
18
+ }
19
+ /**
20
+ * Resolve an `agent_type` against the role registry into spawn overrides.
21
+ * Omitted type → empty overrides (ephemeral mode). Unknown type → throw, so
22
+ * the LLM gets a clear correction instead of silently running a generic agent.
23
+ */
24
+ export declare function resolveAgentTypeOverrides(agentType: string | undefined, registry: AgentDefinitionRegistry | undefined): AgentTypeOverrides;
25
+ type SubAgentLifecycle = "subagent_start" | "subagent_finish" | "subagent_error";
26
+ /**
27
+ * Emit a sub-agent lifecycle event via the existing `notification` hook,
28
+ * tagged with a `kind`. No-op when hooks are absent. Fire-and-forget: emit is
29
+ * async, we deliberately `void` it so bookkeeping never blocks on a handler
30
+ * (mirrors the background-completion notification below).
31
+ */
32
+ export declare function emitSubAgentHook(hooks: HookRegistry | undefined, kind: SubAgentLifecycle, payload: {
33
+ agentId: string;
34
+ description: string;
35
+ text?: string;
36
+ error?: string;
37
+ }): void;
38
+ /** Default per-sub-agent wall-clock timeout (5 minutes). */
39
+ export declare const DEFAULT_SUBAGENT_TIMEOUT_MS: number;
40
+ /**
41
+ * Run `work()` with a timeout. On expiry, calls `onTimeout` (to abort the
42
+ * child) and rejects with a timeout error. The child's own abort handling
43
+ * unwinds its resources.
44
+ */
45
+ export declare function runWithTimeout<T>(work: () => Promise<T>, timeoutMs: number, onTimeout: () => void): Promise<T>;
11
46
  export declare const agentToolDef: ToolDefinition;
12
47
  export declare function agentTool(args: Record<string, unknown>, ctx?: ToolContext): Promise<string>;
13
48
  export declare const agentStatusToolDef: ToolDefinition;
14
49
  export declare function agentStatusTool(args: Record<string, unknown>): Promise<string>;
15
50
  export declare const agentCancelToolDef: ToolDefinition;
16
51
  export declare function agentCancelTool(args: Record<string, unknown>): Promise<string>;
52
+ export {};
@@ -6,7 +6,7 @@
6
6
  * Supports AbortSignal for cascading cancellation.
7
7
  * Supports onStream for real-time output passthrough.
8
8
  */
9
- import { asyncAgentRegistry } from "./agent-registry.js";
9
+ import { asyncAgentRegistry, MAX_BACKGROUND_AGENTS } from "./agent-registry.js";
10
10
  import { createTranscriptTranslator } from "./agent-transcript-translator.js";
11
11
  import { notificationQueue } from "./agent-notifications.js";
12
12
  import { nanoid } from "nanoid";
@@ -32,6 +32,58 @@ function safeEmit(sink, event) {
32
32
  });
33
33
  }
34
34
  }
35
+ /**
36
+ * Resolve an `agent_type` against the role registry into spawn overrides.
37
+ * Omitted type → empty overrides (ephemeral mode). Unknown type → throw, so
38
+ * the LLM gets a clear correction instead of silently running a generic agent.
39
+ */
40
+ export function resolveAgentTypeOverrides(agentType, registry) {
41
+ if (!agentType)
42
+ return {};
43
+ const def = registry?.get(agentType);
44
+ if (!def) {
45
+ const available = registry?.list().map((d) => d.name).join(", ") || "(none defined)";
46
+ throw new Error(`unknown agent_type '${agentType}'. Available: ${available}`);
47
+ }
48
+ return {
49
+ model: def.model,
50
+ maxTurns: def.maxTurns,
51
+ toolAllowlist: def.tools,
52
+ appendSystemPrompt: def.systemPrompt,
53
+ };
54
+ }
55
+ /**
56
+ * Emit a sub-agent lifecycle event via the existing `notification` hook,
57
+ * tagged with a `kind`. No-op when hooks are absent. Fire-and-forget: emit is
58
+ * async, we deliberately `void` it so bookkeeping never blocks on a handler
59
+ * (mirrors the background-completion notification below).
60
+ */
61
+ export function emitSubAgentHook(hooks, kind, payload) {
62
+ void hooks?.emit("notification", { kind, ...payload });
63
+ }
64
+ /** Default per-sub-agent wall-clock timeout (5 minutes). */
65
+ export const DEFAULT_SUBAGENT_TIMEOUT_MS = 5 * 60_000;
66
+ /**
67
+ * Run `work()` with a timeout. On expiry, calls `onTimeout` (to abort the
68
+ * child) and rejects with a timeout error. The child's own abort handling
69
+ * unwinds its resources.
70
+ */
71
+ export async function runWithTimeout(work, timeoutMs, onTimeout) {
72
+ let timer;
73
+ const timeout = new Promise((_, reject) => {
74
+ timer = setTimeout(() => {
75
+ onTimeout();
76
+ reject(new Error(`Sub-agent timed out after ${timeoutMs}ms`));
77
+ }, timeoutMs);
78
+ });
79
+ try {
80
+ return await Promise.race([work(), timeout]);
81
+ }
82
+ finally {
83
+ if (timer)
84
+ clearTimeout(timer);
85
+ }
86
+ }
35
87
  export const agentToolDef = {
36
88
  name: "Agent",
37
89
  description: "Launch a sub-agent to handle a complex task autonomously. " +
@@ -53,6 +105,12 @@ export const agentToolDef = {
53
105
  "Shown in the agent dock to identify what kind of work this sub-agent is doing. " +
54
106
  "Keep it 1-2 words. Defaults to 'Agent' if omitted.",
55
107
  },
108
+ agent_type: {
109
+ type: "string",
110
+ description: "Optional reusable role defined in .code-shell/agents/*.md (e.g. 'researcher'). " +
111
+ "Loads that role's model, tool allowlist, turn cap, and system prompt. " +
112
+ "Omit to run an ad-hoc agent described entirely by 'prompt'.",
113
+ },
56
114
  description: {
57
115
  type: "string",
58
116
  description: "A short (3-5 word) description of the task",
@@ -100,6 +158,7 @@ streamOverride) {
100
158
  const { agentId, name, description } = opts;
101
159
  const startEndSink = uiStream ?? spawner.parentStream;
102
160
  safeEmit(startEndSink, { type: "agent_start", agentId, name, description });
161
+ emitSubAgentHook(opts.hooks, "subagent_start", { agentId, description });
103
162
  // `resetPlanMode` / `restorePlanMode` operated on a module-level singleton
104
163
  // that no longer exists. The child Engine is a fresh instance; plan-mode
105
164
  // isolation between parent and child is enforced via separate Engine
@@ -107,6 +166,7 @@ streamOverride) {
107
166
  const text = await spawner.spawn({ ...opts, streamOverride });
108
167
  const finalText = text || `Agent completed but produced no text output.`;
109
168
  safeEmit(startEndSink, { type: "agent_end", agentId, name, description, text: finalText });
169
+ emitSubAgentHook(opts.hooks, "subagent_finish", { agentId, description, text: finalText });
110
170
  return finalText;
111
171
  }
112
172
  export async function agentTool(args, ctx) {
@@ -132,7 +192,15 @@ export async function agentTool(args, ctx) {
132
192
  if (parentSignal?.aborted) {
133
193
  return "Agent aborted before starting.";
134
194
  }
135
- const maxTurns = args.max_turns || 15;
195
+ const agentType = args.agent_type?.trim() || undefined;
196
+ let overrides;
197
+ try {
198
+ overrides = resolveAgentTypeOverrides(agentType, ctx?.agentDefinitions);
199
+ }
200
+ catch (err) {
201
+ return `Error: ${err.message}`;
202
+ }
203
+ const maxTurns = args.max_turns || overrides.maxTurns || 15;
136
204
  const runInBackground = args.run_in_background === true;
137
205
  const agentId = nanoid(8);
138
206
  const parentStream = spawner.parentStream;
@@ -142,6 +210,10 @@ export async function agentTool(args, ctx) {
142
210
  // (background agents survive the spawning turn). Cancellation goes
143
211
  // through AgentCancel(agent_id).
144
212
  if (runInBackground) {
213
+ if (asyncAgentRegistry.runningCount() >= MAX_BACKGROUND_AGENTS) {
214
+ return `Error: too many background agents running (limit ${MAX_BACKGROUND_AGENTS}). ` +
215
+ `Wait for some to finish or cancel one with AgentCancel(agent_id) before launching more.`;
216
+ }
145
217
  const controller = new AbortController();
146
218
  asyncAgentRegistry.register({
147
219
  agentId,
@@ -171,6 +243,10 @@ export async function agentTool(args, ctx) {
171
243
  description,
172
244
  prompt,
173
245
  maxTurns,
246
+ model: overrides.model,
247
+ toolAllowlist: overrides.toolAllowlist,
248
+ appendSystemPrompt: overrides.appendSystemPrompt,
249
+ hooks: ctx?.hooks,
174
250
  signal: controller.signal,
175
251
  }, parentStream, // uiStream: agent_start/end → main feed
176
252
  transcriptSink)
@@ -262,22 +338,38 @@ export async function agentTool(args, ctx) {
262
338
  ].join("\n");
263
339
  }
264
340
  // ─── Synchronous path ──────────────────────────────────────────
341
+ // A timeout-capable controller: the timeout callback aborts the child, and
342
+ // a parent abort is forwarded to it too. Keeping the timeout OUTSIDE
343
+ // runSubAgent (and off the background path) avoids the background path's
344
+ // "abort means user-cancel → drop silently" semantics; here a timeout is a
345
+ // genuine error surfaced to the parent.
346
+ const syncController = new AbortController();
347
+ const onParentAbort = () => syncController.abort();
348
+ parentSignal?.addEventListener("abort", onParentAbort, { once: true });
265
349
  try {
266
- return await runSubAgent(spawner, {
350
+ return await runWithTimeout(() => runSubAgent(spawner, {
267
351
  agentId,
268
352
  name,
269
353
  description,
270
354
  prompt,
271
355
  maxTurns,
272
- signal: parentSignal ?? new AbortController().signal,
273
- });
356
+ model: overrides.model,
357
+ toolAllowlist: overrides.toolAllowlist,
358
+ appendSystemPrompt: overrides.appendSystemPrompt,
359
+ hooks: ctx?.hooks,
360
+ signal: syncController.signal,
361
+ }), DEFAULT_SUBAGENT_TIMEOUT_MS, () => syncController.abort());
274
362
  }
275
363
  catch (err) {
364
+ emitSubAgentHook(ctx?.hooks, "subagent_error", { agentId, description, error: err.message });
276
365
  safeEmit(parentStream, { type: "agent_end", agentId, name, description, error: err.message });
277
366
  if (parentSignal?.aborted)
278
367
  return "Agent was aborted.";
279
368
  return `Agent error: ${err.message}`;
280
369
  }
370
+ finally {
371
+ parentSignal?.removeEventListener("abort", onParentAbort);
372
+ }
281
373
  }
282
374
  // ─── AgentStatus / AgentCancel — companions to run_in_background ─
283
375
  export const agentStatusToolDef = {
@@ -31,16 +31,34 @@ export async function skillTool(args, ctx) {
31
31
  if (!skillName) {
32
32
  return "Error: skill name is required.";
33
33
  }
34
- // Reject disabled skills before scanning so the user gets a clear
35
- // message that distinguishes "disabled" from "not found" — matches
36
- // the UI's toggle semantics. The scanner would also filter the entry
37
- // out, which alone would produce a misleading "not found" reply.
34
+ // Reject disabled skills/plugins before scanning so the user gets a
35
+ // clear message that distinguishes "disabled" from "not found" —
36
+ // matches the UI's toggle semantics. The scanner would also filter
37
+ // these entries out, which alone would produce a misleading "not
38
+ // found" reply.
39
+ //
40
+ // Ordering: per-skill check fires BEFORE plugin-level check so the
41
+ // more-specific match wins. Both produce distinct error strings so
42
+ // ordering is observable but not load-bearing.
38
43
  const disabledSkills = ctx?.disabledSkills;
44
+ const disabledPlugins = ctx?.disabledPlugins;
39
45
  if (disabledSkills && disabledSkills.includes(skillName)) {
40
46
  return `Skill "${skillName}" is disabled. Enable it in Customize or remove it from settings.disabledSkills.`;
41
47
  }
48
+ if (disabledPlugins && disabledPlugins.length > 0) {
49
+ const colon = skillName.indexOf(":");
50
+ if (colon > 0) {
51
+ const pluginName = skillName.slice(0, colon);
52
+ if (disabledPlugins.includes(pluginName)) {
53
+ return `Skill "${skillName}" is in disabled plugin "${pluginName}". Enable the plugin in Customize or remove "${pluginName}" from settings.disabledPlugins.`;
54
+ }
55
+ }
56
+ }
42
57
  // A4: scan skills from the Engine's cwd, not the host process cwd.
43
- const skills = scanSkills(ctx?.cwd ?? process.cwd(), { disabledSkills });
58
+ const skills = scanSkills(ctx?.cwd ?? process.cwd(), {
59
+ disabledSkills,
60
+ disabledPlugins,
61
+ });
44
62
  const found = skills.find((s) => s.name === skillName);
45
63
  if (!found) {
46
64
  return `Skill "${skillName}" not found. Run /skills to list available skills.`;
@@ -65,6 +65,23 @@ export interface SubAgentSpawnRequest {
65
65
  * gets `agent_start` / `agent_end` markers via `spawner.parentStream`.
66
66
  */
67
67
  streamOverride?: StreamCallback;
68
+ /**
69
+ * Optional ModelPool key for the child Engine's LLM (e.g. "flash").
70
+ * Undefined → child inherits the parent's model (current behavior).
71
+ */
72
+ model?: string;
73
+ /**
74
+ * Optional tool-name allowlist for the child. When set, the child's tool
75
+ * pool is restricted to these names (still minus the nested-agent tools).
76
+ * Undefined → child inherits the parent's full tool set (current behavior).
77
+ */
78
+ toolAllowlist?: string[];
79
+ /**
80
+ * Optional per-call system prompt appended to the child Engine's prompt
81
+ * (the role definition's Markdown body). Undefined → child inherits only
82
+ * the parent's appendSystemPrompt (current behavior).
83
+ */
84
+ appendSystemPrompt?: string;
68
85
  }
69
86
  export interface SubAgentSpawner {
70
87
  /** Run a sub-agent synchronously and return its text output. */
@@ -97,6 +114,12 @@ export interface ToolContext {
97
114
  askUser?: AskUserFn;
98
115
  /** Sub-agent spawner (Agent tool). Undefined → Agent tool unavailable. */
99
116
  subAgentSpawner?: SubAgentSpawner;
117
+ /**
118
+ * Reusable sub-agent role definitions (loaded from .code-shell/agents/*.md).
119
+ * The Agent tool reads this to resolve `agent_type`. Undefined → only the
120
+ * ephemeral (inline prompt) mode is available.
121
+ */
122
+ agentDefinitions?: import("../agent/agent-definition-registry.js").AgentDefinitionRegistry;
100
123
  /**
101
124
  * True when this Engine is itself a sub-agent. Set from EngineConfig.
102
125
  * The Agent tool refuses to spawn when this is true — runtime check
@@ -150,6 +173,14 @@ export interface ToolContext {
150
173
  * Populated from `settings.disabledSkills` by Engine.run().
151
174
  */
152
175
  disabledSkills?: string[];
176
+ /**
177
+ * Plugin names the user has totally disabled. Every skill whose
178
+ * namespaced name starts with `${pluginName}:` is hidden from the
179
+ * LLM and rejected at dispatch by the skill builtin tool with a
180
+ * distinct "disabled plugin" message. Populated from
181
+ * `settings.disabledPlugins` by Engine.run().
182
+ */
183
+ disabledPlugins?: string[];
153
184
  }
154
185
  /**
155
186
  * Per-Engine container that produces a fresh ToolContext on demand.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cjhyy/code-shell-core",
3
- "version": "0.5.0-rc.0",
3
+ "version": "0.5.0-rc.1",
4
4
  "description": "Core engine for code-shell — agent orchestration, tool execution, hooks, protocol. UI-agnostic.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",