@hncode/hncode 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/session.js CHANGED
@@ -10,6 +10,43 @@ import crypto from 'node:crypto';
10
10
  const DEFAULT_DIR = () => path.join(os.homedir(), '.hncode', 'sessions');
11
11
  function dir(custom) { return custom || process.env.HNCODE_SESSIONS_DIR || DEFAULT_DIR(); }
12
12
 
13
+ // ---- metadata cache --------------------------------------------------------
14
+ // Enumerating sessions (listSessions / latestSession / --continue) re-read every
15
+ // session file to grab its id/title/workspace/timestamps. With hundreds of
16
+ // sessions that is hundreds of open+read+parse per call. Since session files are
17
+ // only ever written through THIS module (saveSession/deleteSession), we cache the
18
+ // parsed metadata per directory and invalidate the affected entries on write. A
19
+ // caller that knows the dir changed externally (another hncode instance) can call
20
+ // invalidateSessionCache(). The cache never stores message content — only the
21
+ // cheap metadata head.
22
+ const _metaCache = new Map(); // key: storeDir (string) -> Map<fullPath, {meta, hasMessages}>
23
+ function cacheFor(storeDir, create = true) {
24
+ const d = dir(storeDir);
25
+ let c = _metaCache.get(d);
26
+ if (!c && create) { c = new Map(); _metaCache.set(d, c); }
27
+ return c;
28
+ }
29
+ export function invalidateSessionCache(storeDir) {
30
+ _metaCache.delete(dir(storeDir));
31
+ }
32
+ // Public types do not change; this is used by the TUI/headless before a re-list
33
+ // if external state is suspected. Our own save/delete already invalidate.
34
+
35
+ // FIX: single place for the error log so save/load failures are never silent.
36
+ function errorLogFile() {
37
+ return path.join(os.homedir(), '.hncode', 'session-errors.log');
38
+ }
39
+ function logSessionError(op, detail) {
40
+ try {
41
+ fs.mkdirSync(path.dirname(errorLogFile()), { recursive: true });
42
+ fs.appendFileSync(
43
+ errorLogFile(),
44
+ `[${new Date().toISOString()}] ${op}: ${detail}\n`,
45
+ 'utf8',
46
+ );
47
+ } catch { /* logging must never throw */ }
48
+ }
49
+
13
50
  export function newId() {
14
51
  // compact, mostly-sorted id (like a timestamp+rand ULID-ish)
15
52
  return Date.now().toString(36) + '-' + crypto.randomBytes(3).toString('hex');
@@ -19,55 +56,251 @@ export function sessionFile(id, storeDir) {
19
56
  return path.join(dir(storeDir), `${id}.json`);
20
57
  }
21
58
 
59
+ // FIX: build the serialized object with a FIXED key order so `updatedAt`
60
+ // always lands BEFORE `messages`. readSessionMeta() stops scanning at the
61
+ // `"messages"` key, so a session whose updatedAt sits after the transcript
62
+ // would report updatedAt = 0 and the session list / --continue would sort
63
+ // wrongly (or pick the wrong session). Key order here is authoritative.
64
+ function serializableSession(session) {
65
+ const out = {};
66
+ out.id = session.id;
67
+ out.title = session.title || '';
68
+ out.workspace = session.workspace || '';
69
+ out.model = session.model || '';
70
+ out.createdAt = session.createdAt || Date.now();
71
+ out.updatedAt = Date.now();
72
+ // Everything else (messages, rounds, steps, plan, focus, swarm, effort, …)
73
+ // is copied AFTER updatedAt. `messages` in particular must come after the
74
+ // metadata block.
75
+ for (const k of Object.keys(session)) {
76
+ if (k in out) continue;
77
+ if (k === 'messages') continue; // handled below, last
78
+ out[k] = session[k];
79
+ }
80
+ out.messages = Array.isArray(session.messages) ? session.messages : [];
81
+ return out;
82
+ }
83
+
22
84
  export function saveSession(session, storeDir) {
85
+ if (!session || !session.id) return session;
86
+ // Keep the live object's updatedAt in sync for callers that read it back.
23
87
  session.updatedAt = Date.now();
24
- fs.mkdirSync(dir(storeDir), { recursive: true });
25
- const tmp = sessionFile(session.id, storeDir) + '.tmp';
26
- fs.writeFileSync(tmp, JSON.stringify(session, null, 2), 'utf8');
27
- fs.renameSync(tmp, sessionFile(session.id, storeDir));
88
+ try {
89
+ fs.mkdirSync(dir(storeDir), { recursive: true });
90
+ const target = sessionFile(session.id, storeDir);
91
+ // FIX: unique temp name so two concurrent saves of the same session cannot
92
+ // clobber each other's temp file mid-write.
93
+ const tmp = `${target}.${process.pid}.${crypto.randomBytes(3).toString('hex')}.tmp`;
94
+ const json = JSON.stringify(serializableSession(session), null, 2);
95
+ fs.writeFileSync(tmp, json, 'utf8');
96
+ // Atomic replace. If this throws (EXDEV etc.) the temp is cleaned up below.
97
+ fs.renameSync(tmp, target);
98
+ // The saved transcript changed, so its cached metadata head is stale. Drop
99
+ // just this entry; the next scan re-reads the file (cheap, one file).
100
+ cacheFor(storeDir, false)?.delete(sessionFile(session.id, storeDir));
101
+ } catch (e) {
102
+ // FIX: never fail silently. Previously an empty catch {} in the TUI meant a
103
+ // serialization error (circular ref, disk full, permissions) lost the turn
104
+ // with no signal at all.
105
+ logSessionError('save', `${session.id}: ${e && e.message ? e.message : String(e)}`);
106
+ return session;
107
+ }
28
108
  return session;
29
109
  }
30
110
 
31
111
  export function loadSession(id, storeDir) {
32
- try { return JSON.parse(fs.readFileSync(sessionFile(id, storeDir), 'utf8')); }
33
- catch { return null; }
112
+ const file = sessionFile(id, storeDir);
113
+ let text;
114
+ try {
115
+ text = fs.readFileSync(file, 'utf8');
116
+ } catch (e) {
117
+ // ENOENT is "no such session" (normal). Anything else is a real read error.
118
+ if (e && e.code !== 'ENOENT') logSessionError('load', `${id}: ${e.message}`);
119
+ return null;
120
+ }
121
+ try {
122
+ return JSON.parse(text);
123
+ } catch (e) {
124
+ // FIX: a corrupt file is NOT the same as a missing session. Log it so the
125
+ // user can find the damaged file instead of silently "losing" the session.
126
+ logSessionError('parse', `${id}: ${e.message} (file: ${file})`);
127
+ return null;
128
+ }
34
129
  }
35
130
 
36
131
  // Most recent session for a workspace, or globally.
37
132
  // `opts.skipEmpty` ignores sessions with no messages: hncode creates an empty
38
133
  // session shell on every launch, so without this `--continue` would "resume"
39
134
  // a brand-new empty session instead of the user's last real conversation.
135
+ //
136
+ // Only metadata is read (see readSessionMeta): the full parse was 370 ms on 40
137
+ // realistic sessions, pure waste since the caller needs a title and a timestamp.
40
138
  export function latestSession(cwd, storeDir, opts = {}) {
41
- const d = dir(storeDir);
42
- let files;
43
- try { files = fs.readdirSync(d).filter((f) => f.endsWith('.json')); } catch { return null; }
44
- let best = null;
139
+ const entries = scanCached(storeDir);
140
+ const wantWs = cwd !== undefined ? path.resolve(cwd) : null;
141
+ let best = null; // { meta, file }
45
142
  let bestEmpty = null;
46
- for (const f of files) {
143
+ const stamp = (m) => (m && (m.updatedAt || m.createdAt)) || 0;
144
+ for (const { meta, hasMessages, file } of entries) {
145
+ if (wantWs !== null && meta.workspace !== wantWs) continue;
146
+ const up = stamp(meta);
147
+ if (!best || up > stamp(best.meta)) best = { meta, file };
148
+ // hasMessages comes from the same cache entry (single head-read per file),
149
+ // so we do not reopen anything to test emptiness.
150
+ if (hasMessages && (!bestEmpty || up > stamp(bestEmpty.meta))) bestEmpty = { meta, file };
151
+ }
152
+ const pick = opts.skipEmpty ? (bestEmpty || best) : best;
153
+ if (!pick) return null;
154
+ // The caller wants a usable session, so load the chosen one in full — one file,
155
+ // not forty.
156
+ const loaded = loadSession(pick.meta.id || path.basename(pick.file, '.json'), storeDir);
157
+ if (loaded) return loaded;
158
+ // If the full load failed (corrupt), fall back to the metadata we already have
159
+ // so the caller still gets an id/title/workspace instead of null.
160
+ return pick.meta;
161
+ }
162
+
163
+ // Enumerate the session directory, reading each file's metadata head ONCE per
164
+ // file per process (cached). Repeated calls — listSessions then latestSession,
165
+ // or the /sessions picker — reuse the parsed metadata instead of re-opening
166
+ // every file. Entries are deleted from the cache when saveSession/deleteSession
167
+ // touches that file, and stale paths are pruned here when the directory shrinks.
168
+ // Returns [{ file, meta, hasMessages }].
169
+ function scanCached(storeDir) {
170
+ const d = dir(storeDir);
171
+ const map = cacheFor(storeDir);
172
+ let names;
173
+ try { names = fs.readdirSync(d).filter((f) => f.endsWith('.json')); } catch { map.clear(); return []; }
174
+ // Prune cached paths that no longer exist on disk.
175
+ const live = new Set(names);
176
+ for (const p of map.keys()) if (!live.has(path.basename(p))) map.delete(p);
177
+ // Read only the files we have not cached yet.
178
+ const out = [];
179
+ for (const f of names) {
180
+ const full = path.join(d, f);
181
+ let got = map.get(full);
182
+ if (!got) {
183
+ got = _readMetaOnce(full);
184
+ if (got) map.set(full, got);
185
+ }
186
+ if (got) out.push({ file: full, meta: got.meta, hasMessages: got.hasMessages });
187
+ }
188
+ return out;
189
+ }
190
+
191
+ // Read ONLY a session's metadata (id/title/workspace/timestamps), never its
192
+ // messages — and never the whole FILE. Two costs had to go:
193
+ // * `JSON.parse` of the entire session: listing 40 realistic sessions measured
194
+ // 528 ms, `--continue` 370 ms, to read a title and a date;
195
+ // * even `readFileSync` of the whole file: a session with a long transcript is
196
+ // megabytes, and pulling it into a string is itself the stall.
197
+ //
198
+ // FIX: the previous version scanned for the bare `"messages"` key and stopped,
199
+ // which is wrong in two ways:
200
+ // 1. it assumes no metadata follows messages, but saveSession USED TO append
201
+ // `updatedAt` after `messages` (object key insertion order), so updatedAt
202
+ // was never seen and every session sorted as 0;
203
+ // 2. it cut the head mid-key, so the JSON.parse fixup was fragile.
204
+ // Now we stop at `"messages": [` (the array VALUE), not the key, so any key
205
+ // between the metadata block and the messages array is still included.
206
+ //
207
+ // Returns null when the file is not a readable session.
208
+ const META_HEAD_BYTES = 64 * 1024;
209
+ const META_MAX_BYTES = 4 * 1024 * 1024; // give up rather than scan a giant file
210
+
211
+ export function readSessionMeta(file) {
212
+ const res = _readMetaOnce(file);
213
+ return res ? res.meta : null;
214
+ }
215
+
216
+ // Metadata for `file`, plus whether it holds any message. Reads the file head
217
+ // ONE time: latestSession needs both the timestamp (to sort) and the
218
+ // non-empty rule (for --continue's skipEmpty). Doing them in two passes —
219
+ // readSessionMeta then hasMessages — opened every candidate file twice.
220
+ // Returns { meta, hasMessages } or null.
221
+ function _readMetaOnce(file) {
222
+ let fd;
223
+ try { fd = fs.openSync(file, 'r'); } catch { return null; }
224
+ try {
225
+ const buf = Buffer.allocUnsafe(META_HEAD_BYTES);
226
+ let text = '';
227
+ let read = 0;
228
+ let cut = -1;
229
+ // Grow in chunks until the `"messages"` VALUE starts, or the cap is hit.
230
+ for (;;) {
231
+ const n = fs.readSync(fd, buf, 0, buf.length, read);
232
+ if (n <= 0) break;
233
+ text += buf.toString('utf8', 0, n);
234
+ read += n;
235
+ cut = findMessagesValueStart(text);
236
+ if (cut >= 0 || read >= META_MAX_BYTES) break;
237
+ }
238
+ // Non-empty check from the SAME buffered head: true when a real "role" line
239
+ // follows the messages-array opening. Mirrors hasMessages().
240
+ const nonEmpty = !!(cut >= 0 && /"role"/.test(text.slice(cut)));
241
+ // Take everything BEFORE the messages array (which is the metadata block),
242
+ // then close the truncated JSON object.
243
+ const head = cut > 0 ? text.slice(0, cut) : text;
47
244
  try {
48
- const s = JSON.parse(fs.readFileSync(path.join(d, f), 'utf8'));
49
- if (cwd !== undefined && s.workspace !== path.resolve(cwd)) continue;
50
- if (!best || s.updatedAt > best.updatedAt) best = s;
51
- const hasMsgs = Array.isArray(s.messages) && s.messages.length > 0;
52
- if (hasMsgs && (!bestEmpty || s.updatedAt > bestEmpty.updatedAt)) bestEmpty = s;
53
- } catch {}
245
+ // `head` ends right after `"messages":` (no value), so append `[]}` to
246
+ // close it cleanly. Also handle the case where the array value already
247
+ // started but the head stops elsewhere.
248
+ const json = head.replace(/,\s*$/, '').replace(/"messages"\s*:\s*$/, '"messages": []')
249
+ + (head.trimEnd().endsWith('{') || head.trimEnd().endsWith(',') ? '}' : '}');
250
+ const parsed = JSON.parse(json);
251
+ delete parsed.messages;
252
+ return { meta: parsed, hasMessages: nonEmpty };
253
+ } catch {
254
+ // Fallback: parse as much of the file as we have, drop messages.
255
+ try {
256
+ const m = JSON.parse(text.slice(0, text.lastIndexOf('}') + 1));
257
+ delete m.messages;
258
+ return { meta: m, hasMessages: nonEmpty };
259
+ } catch {
260
+ return null;
261
+ }
262
+ }
263
+ } catch {
264
+ return null;
265
+ } finally {
266
+ try { fs.closeSync(fd); } catch {}
54
267
  }
55
- if (opts.skipEmpty) return bestEmpty || best;
56
- return best;
57
268
  }
58
269
 
270
+ // Locate the index of the character right after `"messages"` (whitespace and
271
+ // the `:` included) so the metadata head can be closed with `"messages": []}`.
272
+ // Returns -1 when the key is not present in `text`.
273
+ function findMessagesValueStart(text) {
274
+ const key = text.indexOf('"messages"');
275
+ if (key < 0) return -1;
276
+ let i = key + '"messages"'.length;
277
+ while (i < text.length && /\s/.test(text[i])) i++;
278
+ if (text[i] === ':') i++;
279
+ while (i < text.length && /\s/.test(text[i])) i++;
280
+ return i; // may point at `[` (or be end-of-text; caller handles both)
281
+ }
282
+
283
+ // Non-empty detection now lives in _readMetaOnce (it reads the file head once
284
+ // for both the metadata and the emptiness rule), so the old separate
285
+ // hasMessages() scan is gone — latestSession no longer reopens each candidate.
286
+
287
+ // The session list for the /sessions picker. Metadata only — see readSessionMeta
288
+ // for why. Entries carry no `messages`, which every caller already treats as
289
+ // "not loaded yet".
59
290
  export function listSessions(storeDir) {
60
- const d = dir(storeDir);
61
- let files;
62
- try { files = fs.readdirSync(d).filter((f) => f.endsWith('.json')); } catch { return []; }
63
291
  const out = [];
64
- for (const f of files) {
65
- try { out.push(JSON.parse(fs.readFileSync(path.join(d, f), 'utf8'))); } catch {}
292
+ for (const { meta } of scanCached(storeDir)) {
293
+ // FIX: fall back to createdAt so a legacy session written with a
294
+ // post-messages updatedAt still sorts reasonably instead of as 0.
295
+ if (!meta.updatedAt && meta.createdAt) meta.updatedAt = meta.createdAt;
296
+ out.push(meta);
66
297
  }
67
298
  out.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
68
299
  return out;
69
300
  }
70
301
 
71
302
  export function deleteSession(id, storeDir) {
303
+ // Drop the cached entry if present (before unlink, so the path is known).
304
+ try { cacheFor(storeDir, false)?.delete(sessionFile(id, storeDir)); } catch {}
72
305
  try { fs.unlinkSync(sessionFile(id, storeDir)); return true; } catch { return false; }
73
306
  }
@@ -0,0 +1,147 @@
1
+ // Subagent type catalog, shared by the Agent tool and AgentSwarm.
2
+ //
3
+ // Mirrors kimi-code's builtin agent profiles (session/agentLifecycle/profile/profiles.ts):
4
+ // coder - general software engineering; the only type with editing tools
5
+ // explore - fast read-only codebase exploration (kimi calls it "explore")
6
+ // plan - read-only implementation planning; no shell, no editing
7
+ //
8
+ // Kept in its own module so agent.js and agent-swarm.js can both import it
9
+ // without a cycle (agent-swarm used to import it from agent.js, which then
10
+ // imported agent-swarm back — the constant was read before it was initialised).
11
+
12
+ export const DEFAULT_SUBAGENT_TYPE = 'coder';
13
+
14
+ // Shared prefix kimi puts on every task-agent role prompt. It is what stops a
15
+ // subagent from addressing the end user directly or assuming the parent can see
16
+ // its context.
17
+ const TASK_AGENT_ROLE_PREFIX =
18
+ 'You are now running as a subagent. All the `user` messages are sent by the main agent. '
19
+ + 'The main agent cannot see your context, it can only see your last message when you finish the task. '
20
+ + 'You must treat the parent agent as your caller. Do not directly ask the end user questions. '
21
+ + 'If something is unclear, explain the ambiguity in your final summary to the parent agent. '
22
+ // A subagent has no Agent/AgentSwarm in its toolbelt (see DELEGATION_TOOLS), so it
23
+ // must not try: a delegated delegation would have nobody to report to and would
24
+ // nest without bound.
25
+ + 'You cannot delegate: you have no Agent or AgentSwarm tool. Do all of the work yourself.';
26
+
27
+ // Tools that let an agent hand work to ANOTHER agent. Subagents never get these:
28
+ // delegation belongs to the top-level turn the user is watching. kimi expresses
29
+ // this through each profile's explicit `tools` allowlist; we enforce it the same
30
+ // way plus a hard block, so a future profile that forgets to list its tools
31
+ // cannot silently re-enable nesting.
32
+ export const DELEGATION_TOOLS = ['Agent', 'AgentSwarm'];
33
+
34
+ export const SUBAGENT_TYPES = {
35
+ coder: {
36
+ label: 'Coder',
37
+ description: 'General software engineering agent — the only subagent type with file-editing tools; use it for any delegated task that must modify code.',
38
+ whenToUse:
39
+ 'Use this agent for non-trivial software engineering work that may require reading files, editing code, '
40
+ + 'running commands, and returning a compact but technically complete summary to the parent agent.',
41
+ // An explicit allowlist (not `null` = everything) so Agent/AgentSwarm are
42
+ // excluded. Mirrors kimi's CODER_TOOLS.
43
+ tools: [
44
+ 'Read', 'Write', 'Edit', 'Glob', 'Grep', 'FileLines', 'Bash',
45
+ 'TodoList', 'FetchURL', 'WebSearch', 'ReadMediaFile',
46
+ 'TaskList', 'TaskOutput', 'TaskStop', 'TaskWait',
47
+ ],
48
+ prompt:
49
+ `${TASK_AGENT_ROLE_PREFIX}\n\n`
50
+ + 'Your final message is the entire handoff — the parent sees nothing else from your run. '
51
+ + 'Make it technically complete: what you changed and why, the path of every file you touched, '
52
+ + 'how you verified the change (tests or commands run, with results), and anything left undone '
53
+ + 'or worth follow-up. If you are stopped before finishing, the parent receives only what '
54
+ + 'you have written so far, so keep the handoff current.',
55
+ },
56
+ explore: {
57
+ label: 'Explore',
58
+ description: 'Fast codebase exploration with prompt-enforced read-only behavior.',
59
+ whenToUse:
60
+ 'Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns '
61
+ + '(e.g. "src/**/*.yaml"), search code for keywords (e.g. "database connection"), or answer questions about '
62
+ + 'the codebase (e.g. "how does the auth module work?"). When calling this agent, specify the desired '
63
+ + 'thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "thorough" for '
64
+ + 'comprehensive analysis across multiple locations and naming conventions. Use this agent for any read-only '
65
+ + 'exploration that will clearly require more than 3 search queries. Prefer launching multiple explore agents '
66
+ + 'concurrently when investigating independent questions.',
67
+ // Bash is allowed for READ-ONLY commands (git log, ls, find) — the prompt
68
+ // forbids using it to create or modify anything, exactly as kimi does.
69
+ tools: ['Read', 'ReadMediaFile', 'Glob', 'Grep', 'Bash', 'WebSearch', 'FetchURL'],
70
+ prompt:
71
+ `${TASK_AGENT_ROLE_PREFIX}\n\n`
72
+ + 'You are a codebase exploration specialist. Your role is EXCLUSIVELY to search, read, and analyze existing '
73
+ + 'code and resources. You do NOT have access to file editing tools.\n\n'
74
+ + 'Your strengths:\n'
75
+ + '- Rapidly finding files using glob patterns\n'
76
+ + '- Searching code and text with powerful regex patterns\n'
77
+ + '- Reading and analyzing file contents\n'
78
+ + '- Running read-only shell commands (git log, git diff, ls, find, etc.)\n\n'
79
+ + 'Guidelines:\n'
80
+ + '- Use Glob for broad file pattern matching. Prefer patterns with a literal anchor (extension or subdirectory); '
81
+ + 'pure wildcards like `*` or `**/*` are allowed but usually truncate at the match cap.\n'
82
+ + '- Use Grep for searching file contents with regex\n'
83
+ + '- Use Read when you know the specific file path\n'
84
+ + '- Use Bash ONLY for read-only operations (ls, git status, git log, git diff, find)\n'
85
+ + '- NEVER use Bash for any file creation or modification commands\n'
86
+ + '- Use WebSearch or FetchURL when a question needs external context; the local codebase remains your primary domain\n'
87
+ + '- Adapt your search depth based on the thoroughness level specified by the caller\n'
88
+ + '- Wherever possible, spawn multiple parallel tool calls for grepping and reading files to maximize speed\n\n'
89
+ + 'You are meant to be a fast agent. Complete the search request efficiently and report your findings clearly '
90
+ + 'in a structured format.',
91
+ },
92
+ plan: {
93
+ label: 'Plan',
94
+ description: 'Read-only implementation planning and architecture design.',
95
+ whenToUse:
96
+ 'Use this agent when the parent agent needs a step-by-step implementation plan, key file identification, '
97
+ + 'and architectural trade-off analysis before code changes are made.',
98
+ // No Bash at all — a planning agent has no shell.
99
+ tools: ['Read', 'ReadMediaFile', 'Glob', 'Grep', 'WebSearch', 'FetchURL'],
100
+ prompt:
101
+ `${TASK_AGENT_ROLE_PREFIX}\n\n`
102
+ + 'Before designing your implementation plan, consider whether you fully understand the codebase areas '
103
+ + 'relevant to the task. If not, recommend the parent agent to use the explore agent '
104
+ + '(subagent_type="explore") to investigate key questions first. In your response, clearly state:\n'
105
+ + '1. What you already know from the information provided\n'
106
+ + '2. What questions remain unanswered that would benefit from explore agent investigation\n'
107
+ + '3. Your implementation plan (either preliminary if questions remain, or final if sufficient context exists)\n\n'
108
+ + 'You are a read-only planning agent: you can read and search files and consult the web, but you have no '
109
+ + 'shell and no file-editing tools. Where the general instructions tell you to make changes with tools, that '
110
+ + 'does not apply to you — do not attempt to run commands or modify files. Your deliverable is the plan '
111
+ + 'itself, returned as your final message.',
112
+ },
113
+ researcher: {
114
+ label: 'Researcher',
115
+ description: 'Read-only investigation; reports findings, changes nothing.',
116
+ whenToUse:
117
+ 'Use this agent for read-only investigation on the web or in the repository when you want findings '
118
+ + 'reported back without any chance of local modification.',
119
+ tools: ['Read', 'ReadMediaFile', 'Grep', 'Glob', 'WebSearch', 'FetchURL'],
120
+ prompt:
121
+ `${TASK_AGENT_ROLE_PREFIX}\n\n`
122
+ + 'You are a research subagent. Investigate with the read-only tools and report findings. '
123
+ + 'Do NOT modify anything.',
124
+ },
125
+ };
126
+
127
+ // The catalog as the Agent tool describes it: one line per type, description and
128
+ // the "when to use" guidance the model needs to pick correctly.
129
+ export function availableTypes() {
130
+ return Object.entries(SUBAGENT_TYPES)
131
+ .map(([id, t]) => `- ${id}: ${t.description}${t.whenToUse ? `\n ${t.whenToUse}` : ''}`)
132
+ .join('\n');
133
+ }
134
+
135
+ // The tool allowlist a subagent of `type` may run. Always excludes the delegation
136
+ // tools, whatever the profile declares, so nesting is impossible.
137
+ export function toolsForSubagent(type, parentFilter) {
138
+ const profile = SUBAGENT_TYPES[type] || SUBAGENT_TYPES[DEFAULT_SUBAGENT_TYPE];
139
+ const base = Array.isArray(profile.tools) ? profile.tools : null;
140
+ if (base) return base.filter((t) => !DELEGATION_TOOLS.includes(t));
141
+ // No explicit list: derive it from the parent's filter so a narrowed parent
142
+ // (Plan / Focus mode) stays narrowed in the child, minus delegation.
143
+ if (Array.isArray(parentFilter) && parentFilter.length) {
144
+ return parentFilter.filter((t) => !DELEGATION_TOOLS.includes(t));
145
+ }
146
+ return null; // null = agent.js exposes everything except DELEGATION_TOOLS
147
+ }