@hncode/hncode 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -67,6 +67,7 @@ hncode draws inspiration from several excellent projects:
67
67
 
68
68
  - **[Kimi Code](https://github.com/MoonshotAI/kimi-code)** - The original cyan-blue TUI coding agent that inspired this project's design and approach
69
69
  - **[Claude Code](https://github.com/anthropics/claude-code)** - The conversation flow and tool interaction patterns
70
+ - **[Codex](https://github.com/openai/codex)** - Several behaviours were shaped by reading its open-source implementation: the AGENTS.md loading spec, the rule against spawning subagents unprompted, the plan-tool state machine, the review rubric, and parts of the system prompt
70
71
 
71
72
  Special thanks to the communities behind these projects for pushing the boundaries of AI-assisted coding.
72
73
 
package/README_CN.md CHANGED
@@ -65,8 +65,10 @@ npx @hncode/hncode
65
65
 
66
66
  hncode 从以下几个优秀项目中汲取了灵感:
67
67
 
68
+ - **[Kimi Code](https://github.com/MoonshotAI/kimi-code)** - 原始的青色蓝调终端界面 AI 编程助手,启发了本项目的设计和理念
68
69
  - **[Kimi Code](https://github.com/MoonshotAI/kimi-code)** - 原始的青色蓝调终端界面 AI 编程助手,启发了本项目的设计和理念
69
70
  - **[Claude Code](https://github.com/anthropics/claude-code)** - 对话流程和工具交互模式
71
+ - **[Codex](https://github.com/openai/codex)** - 通过阅读其开源实现,本项目确定了几处行为:AGENTS.md 的加载规则、未获明确许可不得开启子代理、计划工具的状态流转约束、代码审查的判定标准,以及系统提示词的部分内容
70
72
 
71
73
  特别感谢这些项目背后的社区,它们推动了 AI 辅助编程的边界。
72
74
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hncode/hncode",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "hncode — A cyan-blue TUI AI coding agent with zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -0,0 +1,178 @@
1
+ // Background task registry — the single place tasks are created, listed, stopped
2
+ // and read. Mirrors kimi-code's task model:
3
+ //
4
+ // TaskInfo = base fields + a `kind`-discriminated payload
5
+ // kind 'process' -> { command, pid, exitCode } (Bash run_in_background)
6
+ // kind 'agent' -> { agentId, subagentType, model, thinkingEffort } (Agent)
7
+ // kind 'question' -> { questionCount, toolCallId } (AskUserQuestion bg)
8
+ //
9
+ // Statuses match kimi exactly so the UI can share one vocabulary:
10
+ // running | completed | failed | timed_out | killed | lost
11
+ //
12
+ // Bash used to keep its own ad-hoc objects ('done'/'failed', `id`, `start`), which
13
+ // is why the task panel could not show the same fields as kimi's. Everything now
14
+ // goes through `createTask`, so a task always has the same shape no matter which
15
+ // tool spawned it.
16
+
17
+ export const TERMINAL_STATUSES = new Set(['completed', 'failed', 'timed_out', 'killed', 'lost']);
18
+
19
+ export function isTerminal(status) {
20
+ return TERMINAL_STATUSES.has(status);
21
+ }
22
+
23
+ // `{prefix}-{8 chars}` like kimi's VALID_TASK_ID. Unique even for two tasks
24
+ // created in the same millisecond (a bare Date.now() collided).
25
+ let seq = 0;
26
+ export function newTaskId(prefix = 'task') {
27
+ seq = (seq + 1) % 1e9;
28
+ const rand = Math.random().toString(36).slice(2, 10).padEnd(8, '0');
29
+ const s = String(seq).padStart(4, '0');
30
+ return `${prefix}-${(Date.now().toString(36) + s).slice(-8)}-${rand}`;
31
+ }
32
+
33
+ // Decode a task's buffered chunks for display. Buffer chunks (Bash) are UTF-8;
34
+ // string chunks (Agent/AgentSwarm) are already text.
35
+ function decodeTaskOutput(chunks) {
36
+ const list = chunks || [];
37
+ if (!list.length) return '';
38
+ const allStrings = list.every((c) => typeof c === 'string');
39
+ if (allStrings) return list.join('');
40
+ try { return Buffer.concat(list.map((c) => (Buffer.isBuffer(c) ? c : Buffer.from(String(c))))).toString('utf8'); }
41
+ catch { return list.map((c) => String(c)).join(''); }
42
+ }
43
+
44
+ // The task store lives on the agent ctx (`ctx.tasks`), so every tool and the TUI
45
+ // see the same map.
46
+ function store(ctx) {
47
+ if (!ctx) return {};
48
+ if (!ctx.tasks) ctx.tasks = {};
49
+ return ctx.tasks;
50
+ }
51
+
52
+ /**
53
+ * Register a task. `kind` selects the payload group; the returned object is the
54
+ * live record (mutate `status`/`endedAt`/`stopReason` as it runs).
55
+ *
56
+ * options:
57
+ * description short human label
58
+ * detached true when it runs in the background (Ctrl+B or run_in_background)
59
+ * timeoutMs optional cap
60
+ * command/pid/exitCode (kind 'process')
61
+ * agentId/subagentType/model/thinkingEffort (kind 'agent')
62
+ * questionCount/toolCallId (kind 'question')
63
+ * output initial output text
64
+ */
65
+ export function createTask(ctx, kind, options = {}) {
66
+ const tasks = store(ctx);
67
+ const prefix = kind === 'process' ? 'bash' : kind === 'agent' ? 'agent' : 'question';
68
+ const task = {
69
+ taskId: newTaskId(prefix),
70
+ kind,
71
+ description: String(options.description || ''),
72
+ status: 'running',
73
+ detached: options.detached !== false,
74
+ startedAt: Date.now(),
75
+ endedAt: null,
76
+ stopReason: undefined,
77
+ timeoutMs: options.timeoutMs,
78
+ // Output is buffered here and exposed through `output` below. Kept as chunks
79
+ // so the decoder can run lazily (a chatty task costs nothing until read).
80
+ _chunks: options._chunks || [],
81
+ _bytes: 0,
82
+ };
83
+ if (kind === 'process') {
84
+ task.command = String(options.command || '');
85
+ task.pid = options.pid || 0;
86
+ task.exitCode = null;
87
+ } else if (kind === 'agent') {
88
+ task.agentId = options.agentId;
89
+ task.subagentType = options.subagentType;
90
+ task.model = options.model;
91
+ task.thinkingEffort = options.thinkingEffort;
92
+ } else if (kind === 'question') {
93
+ task.questionCount = options.questionCount || 0;
94
+ task.toolCallId = options.toolCallId;
95
+ }
96
+ // Every task exposes its buffered output through `output`. The chunks are
97
+ // stored raw and decoded lazily:
98
+ // * a process appends Buffer chunks -> decode as UTF-8
99
+ // * an agent/swarm appends strings -> join
100
+ // Callers may override this getter when they need a different decoding.
101
+ Object.defineProperty(task, 'output', {
102
+ enumerable: true,
103
+ configurable: true,
104
+ get() { return decodeTaskOutput(this._chunks); },
105
+ });
106
+ tasks[task.taskId] = task;
107
+ return task;
108
+ }
109
+
110
+ // Cap retained output per task; a runaway command must not exhaust memory.
111
+ export const MAX_TASK_OUTPUT_BYTES = 2 * 1024 * 1024;
112
+
113
+ // Append raw bytes/chunks to a task, trimming the oldest past the cap.
114
+ export function appendTaskOutput(task, chunk) {
115
+ if (!task || chunk == null) return;
116
+ task._chunks.push(chunk);
117
+ task._bytes = (task._bytes || 0) + (chunk.length || 0);
118
+ while (task._bytes > MAX_TASK_OUTPUT_BYTES && task._chunks.length > 1) {
119
+ task._bytes -= task._chunks[0].length || 0;
120
+ task._chunks.shift();
121
+ }
122
+ }
123
+
124
+ // Settle a task. `status` is one of the kimi statuses; a task that was already
125
+ // stopped keeps 'killed' (mirrors kimi: a deliberate stop must not be relabelled
126
+ // as a failure by the process's non-zero exit).
127
+ export function settleTask(task, status, extra = {}) {
128
+ if (!task) return;
129
+ // A task that already reached a TERMINAL state must not be relabelled: a
130
+ // deliberate stop ('killed') must not turn into 'failed' when the killed
131
+ // process reports a non-zero exit, and a 'completed' task must not be
132
+ // downgraded by a late error. Only a still-running task settles.
133
+ if (isTerminal(task.status)) {
134
+ if (extra.exitCode !== undefined && task.kind === 'process') task.exitCode = extra.exitCode;
135
+ return;
136
+ }
137
+ task.status = status;
138
+ task.endedAt = Date.now();
139
+ if (extra.stopReason !== undefined) task.stopReason = extra.stopReason;
140
+ if (extra.exitCode !== undefined && task.kind === 'process') task.exitCode = extra.exitCode;
141
+ }
142
+
143
+ export function listTasks(ctx) {
144
+ return Object.values(store(ctx));
145
+ }
146
+
147
+ export function getTask(ctx, id) {
148
+ return store(ctx)[id] || null;
149
+ }
150
+
151
+ // Newest-first for display (mirrors kimi's compareTasks: running first, then by
152
+ // most recent activity).
153
+ export function sortedTasks(ctx) {
154
+ const tasks = listTasks(ctx);
155
+ return tasks.slice().sort((a, b) => {
156
+ const at = isTerminal(a.status), bt = isTerminal(b.status);
157
+ if (at !== bt) return at ? 1 : -1;
158
+ if (!at) return a.startedAt - b.startedAt;
159
+ return (b.endedAt || b.startedAt) - (a.endedAt || a.startedAt);
160
+ });
161
+ }
162
+
163
+ // Human label for a status, matching kimi's STATUS_LABEL.
164
+ export const STATUS_LABEL = {
165
+ running: 'running',
166
+ completed: 'completed',
167
+ failed: 'failed',
168
+ timed_out: 'timed out',
169
+ killed: 'killed',
170
+ lost: 'lost',
171
+ };
172
+
173
+ // Colour token for a status (the TUI maps it to a theme colour).
174
+ export function statusColor(status) {
175
+ if (status === 'running') return 'green';
176
+ if (status === 'completed') return 'gray';
177
+ return 'red';
178
+ }
package/src/agent.js CHANGED
@@ -4,7 +4,9 @@
4
4
 
5
5
  import { LLM, setToolsList } from './llm.js';
6
6
  import { tools, getTool, llmTools } from './tools/index.js';
7
+ import { DELEGATION_TOOLS } from './subagent-types.js';
7
8
  import { estimateMessagesTokens } from './term.js';
9
+ import { runHooks } from './plugin.js';
8
10
 
9
11
  // ---- completion guard --------------------------------------------------------
10
12
  // A turn is only complete when the model ends it with an actual answer. Models
@@ -33,10 +35,40 @@ Tools:
33
35
  - If a tool fails, read the error and fix your approach. After 2 failed attempts on the same goal, stop and report the blocker.
34
36
  - Independent tool calls may run in parallel; never parallelize dependent ones.
35
37
 
38
+ Reasoning:
39
+ - Put ALL of your internal reasoning inside <think>...</think> tags, and always emit BOTH the opening
40
+ and the closing tag. Do not omit the tags, do not abbreviate them, and do not leave the block unclosed.
41
+ - Everything outside <think>...</think> is what the user reads: keep it to the answer itself — what you
42
+ did and what you found. Never repeat or summarise your reasoning there.
43
+ - Keep the tags exactly as written: <think> and </think>. Do not emit any other variant.
44
+ Acting:
45
+ - Unless the user is explicitly asking for a plan, asking a question about the code, or brainstorming,
46
+ assume they want the work DONE. Say less and do more: implement the change rather than describing it.
47
+ - Carry the task through implementation and verification in this turn. Do not stop at analysis, at a
48
+ partial fix, or at a list of things you would do next.
49
+ - New project, no prior context: be ambitious, use your judgement on structure.
50
+ Existing codebase: be surgical. Touch only what the task needs — no renames, no reorganising,
51
+ no drive-by fixes.
52
+ - Read the neighbours before you write: match the naming, error handling and comment density you see.
53
+ - Do not re-read a file after editing it, and do not re-run a search you already have results for.
54
+ A successful edit tool call already proves it worked.
55
+
36
56
  Scope:
37
57
  - Do only what was asked. Do not refactor or "improve" unrelated code.
58
+ - Do not fix unrelated bugs or broken tests — mention them instead; they are not yours to change.
38
59
  - If the request is ambiguous, ask one short clarifying question.
39
60
  - State any assumption you make.
61
+ - Might be a dirty git worktree. Never revert changes you did not make. Never run a destructive command
62
+ (git reset --hard, git checkout --) unless the user asked for it. If you notice an unexpected change
63
+ you did not make, stop and ask.
64
+
65
+ Verifying:
66
+ - Run the narrowest check that exercises your change first, then widen.
67
+ - In auto/yolo mode, run tests and lint yourself without asking.
68
+ - In ask mode, do not burn the user's time on slow test suites mid-task: say what you would run and
69
+ let them confirm.
70
+ - Do not add tests to a codebase that has none. Do not add a formatter that the project did not configure.
71
+ - If you could not verify something, say so plainly. Do not imply a test passed when you did not run it.
40
72
 
41
73
  Finishing:
42
74
  - Never stop silently after a tool call; end every turn with a written answer in the user's language.
@@ -56,10 +88,18 @@ export class Agent {
56
88
  // Ensure the LLM client knows the bare tool specs (the client wraps them per-protocol).
57
89
  // A toolFilter (array of tool names) limits which tools are exposed — used
58
90
  // by Plan mode (read-only subset) and Focus mode (minimal subset first).
59
- const filtered = Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
91
+ // `cfg.noDelegation` marks this as a SUBAGENT: delegation tools are removed
92
+ // even if the filter would have allowed them. The catalog already excludes
93
+ // them from every profile; this is the hard backstop that also covers the
94
+ // `null` (= everything) case, so a subagent can never spawn another agent.
95
+ const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
60
96
  ? tools.filter((t) => cfg.toolFilter.includes(t.name))
61
- : tools;
97
+ : tools
98
+ ).filter((t) => !(cfg.noDelegation && DELEGATION_TOOLS.includes(t.name)));
62
99
  setToolsList(filtered);
100
+ // A block for the direct-call path too: the model can still emit a tool call
101
+ // for a tool that is not exposed, so refuse it here rather than at execution.
102
+ this.noDelegation = !!cfg.noDelegation;
63
103
  // AbortSignal handed to every tool so a long-running one (Bash) is killed
64
104
  // when the user presses Esc / Ctrl-C — otherwise the command keeps running
65
105
  // after the turn was interrupted.
@@ -72,6 +112,12 @@ export class Agent {
72
112
  this.ctx.onOutput = (chunk) => {
73
113
  if (chunk && this._currentToolId) this.onEvent({ type: 'tool_output', id: this._currentToolId, chunk });
74
114
  };
115
+ // `fork` (the Agent tool) snapshots THIS conversation into the subagent. The
116
+ // tool reads `ctx._parentMessages`, which nothing ever set — so `fork: true`
117
+ // was accepted and then seeded the subagent with an empty history. Hand the
118
+ // tool a live getter over this agent's own messages instead of a copy, so the
119
+ // snapshot is taken at spawn time and reflects everything up to that point.
120
+ this.ctx._parentMessages = this.messages;
75
121
  this.llm = new LLM(cfg);
76
122
  this.stopRequested = false;
77
123
  this.onApproval = onApproval; // async (toolName, args) => boolean
@@ -97,14 +143,15 @@ export class Agent {
97
143
  }
98
144
 
99
145
  async run() {
146
+ // Plugin lifecycle: `onTurnStart` fires here and `onTurnEnd` in the finally
147
+ // block at the end of this method. Both are awaited and both swallow plugin
148
+ // errors (see runHooks), so a broken plugin cannot abort a turn.
149
+ await runHooks('onTurnStart', { messages: this.messages });
100
150
  let text = '';
101
151
  let toolCalls = [];
102
152
  let emittedEnd = false;
103
- // Reasoning text seen in the current step. A turn that ends with reasoning
104
- // but no answer is a truncated turn (see the completion guard below).
105
153
  let thought = '';
106
154
  let nudges = 0;
107
- // True when the most recent iteration still had tool calls pending; used to
108
155
  // decide whether running out of steps actually left the turn unfinished.
109
156
  let hadToolCallsOnFinalStep = false;
110
157
 
@@ -225,7 +272,12 @@ export class Agent {
225
272
  }
226
273
  }
227
274
 
275
+ // Plugin hooks around the model round-trip. `onBeforeRequest` sees the exact
276
+ // message array being sent (and may mutate it); `onAfterRequest` fires once
277
+ // the stream has been consumed.
278
+ await runHooks('onBeforeRequest', this.messages, this.cfg);
228
279
  await this.llm.request(this.messages, handle);
280
+ await runHooks('onAfterRequest', this.messages, this.cfg);
229
281
 
230
282
  // An abort ends the turn: keep whatever text arrived, then stop.
231
283
  if (this.stopRequested) {
@@ -238,11 +290,17 @@ export class Agent {
238
290
  const hasText = !!(text && text.trim());
239
291
  const hasCalls = toolCalls.length > 0;
240
292
  if (hasText || hasCalls) {
241
- this.messages.push({
293
+ const assistantMsg = {
242
294
  role: 'assistant',
243
295
  content: hasText ? text : null,
244
296
  toolCalls: hasCalls ? toolCalls : undefined,
245
- });
297
+ };
298
+ this.messages.push(assistantMsg);
299
+ // Plugin hook: one per assistant message committed to the history. Fired
300
+ // here (the single point where the model's reply lands) rather than at each
301
+ // of the eleven `messages.push` sites, so a plugin sees a coherent message
302
+ // instead of the intermediate bookkeeping pushes.
303
+ await runHooks('onNewMessage', assistantMsg, this.messages);
246
304
  }
247
305
 
248
306
  // ---- if tool calls exist, execute them ----
@@ -308,6 +366,17 @@ export class Agent {
308
366
  }
309
367
 
310
368
  const tool = getTool(tc.name);
369
+ // A subagent may not delegate. The tool is not exposed to it, but the model
370
+ // can still emit a call for a tool it was told about earlier, so refuse it
371
+ // here with an actionable message instead of running a nested agent.
372
+ if (this.noDelegation && DELEGATION_TOOLS.includes(tc.name)) {
373
+ result = `You CAN'T use ${tc.name}: subagents cannot delegate. Do the work yourself and report the result to the agent that started you.`;
374
+ this.onEvent({ type: 'tool_use', id: tc.id, name: tc.name, args: tc.args });
375
+ this.messages.push({ role: 'tool', toolCallId: tc.id, content: result });
376
+ this.onEvent({ type: 'tool_result', id: tc.id, name: tc.name, content: result });
377
+ allOk = false;
378
+ continue;
379
+ }
311
380
  if (modeName && !this.cfg.toolFilter.includes(tc.name)) {
312
381
  result = `You CAN'T use this tool on ${modeName} Mode. Allowed: ${this.cfg.toolFilter.join(', ')}.`;
313
382
  this.onEvent({ type: 'tool_use', id: tc.id, name: tc.name, args: tc.args });
@@ -322,6 +391,10 @@ export class Agent {
322
391
  } else {
323
392
  if (this.onToolStart) this.onToolStart(tc.name, tc.args);
324
393
  this.onEvent({ type: 'tool_use', id: tc.id, name: tc.name, args: tc.args });
394
+ // Plugin hook: onToolExecute fires BEFORE the tool runs and receives the
395
+ // mutable args object, so a plugin can inspect or adjust a call. Errors
396
+ // are swallowed by runHooks — a broken plugin must not break a turn.
397
+ await runHooks('onToolExecute', tc.name, tc.args, this.ctx);
325
398
  try {
326
399
  result = await tool.execute(tc.args, this.ctx);
327
400
  } catch (err) {
@@ -329,15 +402,27 @@ export class Agent {
329
402
  allOk = false;
330
403
  }
331
404
  this.ctx.lastResult = result;
405
+ // Plugin hook: onToolResult fires with the tool's output.
406
+ await runHooks('onToolResult', tc.name, result, this.ctx);
332
407
  // Surface the live TODO list so the TUI can render its panel.
333
408
  if (tc.name === 'TodoList') {
334
409
  this.onEvent({ type: 'todos', todos: this.ctx.todoState || [] });
335
410
  }
336
411
  }
337
412
  this.messages.push({ role: 'tool', toolCallId: tc.id, content: result });
338
- this.onEvent({ type: 'tool_result', id: tc.id, name: tc.name, content: result });
413
+ // An Edit reports the text it replaced on ctx (both the substring and the
414
+ // line-range path do). Forward it so the TUI can draw the +/- diff: the
415
+ // line-range mode has no `old_string` in its args, so without this the
416
+ // edit rendered with no diff at all. Cleared every time so a later tool
417
+ // cannot inherit a stale diff.
418
+ this.onEvent({
419
+ type: 'tool_result', id: tc.id, name: tc.name, content: result,
420
+ editDiff: this.ctx.lastEditDiff || null,
421
+ });
422
+ this.ctx.lastEditDiff = null;
339
423
  }
340
424
 
425
+
341
426
  // Did THIS iteration still have tool calls? If the loop runs out of budget
342
427
  // right after running them, the turn really is unfinished.
343
428
  hadToolCallsOnFinalStep = toolCalls.length > 0;
@@ -360,5 +445,8 @@ export class Agent {
360
445
  });
361
446
  }
362
447
  if (!emittedEnd && this.onEvent) this.onEvent({ type: 'done' });
448
+ // Plugin lifecycle: the turn is over (normally, interrupted, or out of steps).
449
+ // In a finally-equivalent position — every exit path above reaches here.
450
+ await runHooks('onTurnEnd', { messages: this.messages, stopped: this.stopRequested });
363
451
  }
364
452
  }
package/src/colors.js CHANGED
@@ -57,6 +57,13 @@ export const C = {
57
57
  reset: '\x1b[0m',
58
58
  bold: '\x1b[1m',
59
59
  dim: '\x1b[2m',
60
+ // Italic (SGR 3). Used for the BODY of a reasoning block — the prose reads as
61
+ // an aside rather than as the answer. Must be closed with SGR 23 (`endItalic`),
62
+ // NOT with `reset`, because resetting also drops the surrounding dim/gray that
63
+ // the same row depends on. Terminals without italic fall back to normal text,
64
+ // so this is a decoration, never load-bearing.
65
+ italic: '\x1b[3m',
66
+ endItalic: '\x1b[23m',
60
67
  muted: '\x1b[90m',
61
68
  teal: TEAL,
62
69
  cyan: CYAN,
@@ -74,6 +81,13 @@ export const C = {
74
81
  gray: '\x1b[90m',
75
82
  white: '\x1b[97m',
76
83
  orange: TRUECOLOR ? rgb(255, 140, 0) : '\x1b[38;5;208m',
84
+ // Cyan pushed slightly toward green — used for the Swarm mode badge so it reads
85
+ // as its own mode next to Plan (pure cyan) and Focus (blue) while staying in the
86
+ // theme's blue-green family. 38;5;43 is rgb(0,215,175): red at zero and green
87
+ // only 40 above blue, i.e. still clearly cyan with a green cast. 38;5;85 was the
88
+ // first attempt and read as plain GREEN — its red channel is lifted to 95 and
89
+ // green pins at 255, which is a spring green, not a green-tinted cyan.
90
+ spring: TRUECOLOR ? rgb(0, 215, 175) : '\x1b[38;5;43m',
77
91
  bgRed: '\x1b[41m',
78
92
  bgGreen: '\x1b[42m',
79
93
  // Selection highlight (mouse text selection) and the scrollbar gutter.
package/src/config.js CHANGED
@@ -62,6 +62,124 @@ export function hncodeConfigFile() {
62
62
  return process.env.HNCODE_CONFIG || path.join(home, '.hncode', 'config.toml');
63
63
  }
64
64
 
65
+ // ---- personal preferences (/personal) ----
66
+ // Free-form markdown the user wants applied on EVERY turn ("reply in Chinese",
67
+ // "I use pnpm", "never add a trailing newline", …). Two scopes are merged, the
68
+ // project one LAST so it can refine the global one:
69
+ // * global — ~/.hncode/PERSONAL.md (every workspace)
70
+ // * project — <workspace>/.hncode/PERSONAL.md (this workspace only)
71
+ // Both are OPTIONAL; a missing/empty file injects nothing. The files are read
72
+ // fresh on each turn, so an edit through /personal takes effect immediately.
73
+ export function personalPromptFile(scope, workspace) {
74
+ if (scope === 'global') return path.join(home, '.hncode', 'PERSONAL.md');
75
+ return path.join(workspace || process.cwd(), '.hncode', 'PERSONAL.md');
76
+ }
77
+
78
+ // Header placed before the injected preferences, so the model knows where the
79
+ // text came from and how much authority it has.
80
+ export const PERSONAL_PROMPT_HEADER =
81
+ 'USER PERSONAL PREFERENCES (persistent notes from the user; follow them unless\n'
82
+ + 'the current request says otherwise):';
83
+
84
+ // Raw file content for one scope (no header) — used by /personal's editor.
85
+ export function readPersonalPromptRaw(scope, workspace) {
86
+ try { return fs.readFileSync(personalPromptFile(scope, workspace), 'utf8'); } catch { return ''; }
87
+ }
88
+
89
+ // Save one scope. Empty text REMOVES the file (so nothing is injected).
90
+ export function writePersonalPrompt(scope, workspace, text) {
91
+ const file = personalPromptFile(scope, workspace);
92
+ const value = String(text == null ? '' : text);
93
+ if (!value.trim()) {
94
+ try { fs.unlinkSync(file); } catch { /* already gone */ }
95
+ return file;
96
+ }
97
+ fs.mkdirSync(path.dirname(file), { recursive: true });
98
+ fs.writeFileSync(file, value.replace(/\s+$/, '') + '\n', 'utf8');
99
+ return file;
100
+ }
101
+
102
+ export function readPersonalPrompt(workspace) {
103
+ const parts = [];
104
+ for (const scope of ['global', 'project']) {
105
+ try {
106
+ const t = fs.readFileSync(personalPromptFile(scope, workspace), 'utf8').trim();
107
+ if (t) parts.push(t);
108
+ } catch { /* no file for this scope: nothing to inject */ }
109
+ }
110
+ if (!parts.length) return '';
111
+ return PERSONAL_PROMPT_HEADER + '\n' + parts.join('\n\n');
112
+ }
113
+
114
+ // ---- AGENTS.md --------------------------------------------------------------
115
+ // Project instruction files, mirroring codex's AGENTS.md spec
116
+ // (codex-rs/core/gpt_5_2_prompt.md, "AGENTS.md spec"):
117
+ // * a file's scope is the whole directory tree rooted where it sits;
118
+ // * for every file you touch you must obey the AGENTS.md whose scope covers it;
119
+ // * more-deeply-nested files take precedence on conflict;
120
+ // * direct system/user instructions outrank all of them.
121
+ // Files are therefore collected from the workspace root DOWN to the working
122
+ // directory and injected outermost-first, so the deepest one is read last and
123
+ // naturally wins — the same precedence, without a merge step.
124
+ //
125
+ // This closes a real gap: /init WRITES an AGENTS.md and the prompt advertises it
126
+ // as "smarter agent context", but nothing ever read it back.
127
+ const AGENTS_MAX_BYTES = 32 * 1024;
128
+ const AGENTS_FILENAMES = ['AGENTS.md', 'agents.md', 'AGENTS.override.md'];
129
+
130
+ export const AGENTS_PROMPT_HEADER =
131
+ 'PROJECT INSTRUCTIONS (AGENTS.md). The scope of each file is the directory it sits in,\n'
132
+ + 'so a deeper file is more specific and takes precedence over a shallower one. They apply\n'
133
+ + 'to every file you touch under that directory. The current request and the user\'s direct\n'
134
+ + 'instructions still outrank anything here.'
135
+
136
+ // Every applicable AGENTS.md, outermost first. `dir` keeps the scope visible.
137
+ export function agentsFilesFor(cwd, workspaceRoot) {
138
+ const start = path.resolve(cwd || process.cwd());
139
+ const root = path.resolve(workspaceRoot || start);
140
+ const dirs = [];
141
+ let dir = start;
142
+ for (;;) {
143
+ dirs.push(dir);
144
+ const parent = path.dirname(dir);
145
+ // Stop at the filesystem root, or once we have walked past the workspace.
146
+ if (parent === dir || dir === root) break;
147
+ dir = parent;
148
+ }
149
+ dirs.reverse(); // outermost first
150
+ const found = [];
151
+ for (const d of dirs) {
152
+ for (const name of AGENTS_FILENAMES) {
153
+ const p = path.join(d, name);
154
+ try {
155
+ if (!fs.statSync(p).isFile()) continue;
156
+ found.push({ path: p, dir: d });
157
+ break; // one instruction file per directory
158
+ } catch { /* not present here */ }
159
+ }
160
+ }
161
+ return found;
162
+ }
163
+
164
+ // The block injected into the system prompt, or '' when the project has none.
165
+ export function readAgentsMd(cwd, workspaceRoot) {
166
+ const files = agentsFilesFor(cwd, workspaceRoot);
167
+ if (!files.length) return '';
168
+ const parts = [];
169
+ for (const f of files) {
170
+ try {
171
+ let text = fs.readFileSync(f.path, 'utf8').trim();
172
+ if (!text) continue;
173
+ if (Buffer.byteLength(text, 'utf8') > AGENTS_MAX_BYTES) {
174
+ text = Buffer.from(text, 'utf8').subarray(0, AGENTS_MAX_BYTES).toString('utf8') + '\n[truncated]';
175
+ }
176
+ parts.push(`--- ${f.path} ---\n${text}`);
177
+ } catch { /* unreadable: skip rather than fail the turn */ }
178
+ }
179
+ if (!parts.length) return '';
180
+ return AGENTS_PROMPT_HEADER + '\n\n' + parts.join('\n\n');
181
+ }
182
+
65
183
  function loadBaseToml() {
66
184
  // Only hncode's own config file participates.
67
185
  const f = hncodeConfigFile();
@@ -146,9 +264,19 @@ export function resolveConfig() {
146
264
  // Backwards-compat: read legacy cool_mode key if calm_mode is not set.
147
265
  : (root.calm_mode === true || root.calm_mode === 'true'
148
266
  || root.cool_mode === true || root.cool_mode === 'true'),
267
+ // SUBAGENT MODEL (/swarm-sub-agent): the model each subagent runs on. Empty
268
+ // (the default) means "follow this session's model", which is what the Agent
269
+ // and AgentSwarm tools fall back to. Set from config.toml so it persists.
270
+ subagentModel: process.env.HNCODE_SUBAGENT_MODEL || root.subagent_model || '',
149
271
  // Plugin directory (default: ~/.hncode/plugins). Set to "" or "false" to
150
272
  // disable plugin loading entirely.
151
273
  pluginDir: process.env.HNCODE_PLUGINS || root.plugins_dir || '',
274
+ // AUTO-UPDATE (/auto-update): when true, hncode checks npm for a newer
275
+ // version at startup and then every 30 minutes, installing in the background
276
+ // without interrupting the session.
277
+ autoUpdate: process.env.HNCODE_AUTO_UPDATE
278
+ ? /^(1|true|yes|on)$/i.test(process.env.HNCODE_AUTO_UPDATE)
279
+ : (root.auto_update === true || root.auto_update === 'true'),
152
280
  raw: root,
153
281
  };
154
282
  }
package/src/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import fs from 'node:fs';
6
- import { resolveConfig, effectiveSnapshot, saveConfig, resolveModelArg } from './config.js';
6
+ import { resolveConfig, effectiveSnapshot, saveConfig, resolveModelArg, readPersonalPrompt } from './config.js';
7
7
  import * as sess from './session.js';
8
8
  import { Agent, SYSTEM_PROMPT } from './agent.js';
9
9
  import { llmTools } from './tools/index.js';
@@ -171,7 +171,12 @@ async function runPrompt({ cfg, prompt, format, session, modelArg }) {
171
171
  }
172
172
  const messages = [];
173
173
  if (session && session.messages) for (const m of session.messages.slice(-30)) messages.push(m);
174
- messages.push({ role: 'system', content: SYSTEM_PROMPT });
174
+ // Same layering as the TUI: built-in prompt first, then the user's own
175
+ // system_prompt override if any, then the /personal notes, then calm mode.
176
+ let sysText = (cfg.systemPrompt && String(cfg.systemPrompt).trim()) || SYSTEM_PROMPT;
177
+ const personal = readPersonalPrompt(cfg.workspace);
178
+ if (personal) sysText += '\n\n' + personal;
179
+ messages.push({ role: 'system', content: sysText });
175
180
  const saved = session || { id: sess.newId(), title: prompt.slice(0, 60), workspace: path.resolve(process.cwd()), model: cfg.model, createdAt: Date.now(), messages: [] };
176
181
  const agent = new Agent({
177
182
  cfg,
package/src/plugin.js CHANGED
@@ -143,8 +143,18 @@ export async function loadPlugins(dir) {
143
143
  continue;
144
144
  }
145
145
 
146
- const meta = (mod.default && (mod.default.name || mod.default.meta)) || { name: f };
147
- const plugin = { id: f, name: meta.name || f, version: meta.version || '0.0.0' };
146
+ // Metadata comes from the default export. `name` there is a plain STRING
147
+ // (`export default { name: 'recorder' }`), so reading `meta.name` off it
148
+ // returned undefined and the plugin showed up as its FILENAME with version
149
+ // 0.0.0 — /plugins listed every plugin under the wrong name. Accept both
150
+ // shapes: a string name, or an object carrying { name, version }.
151
+ const def = (mod && mod.default) || {};
152
+ const meta = typeof def === 'string' ? { name: def } : def;
153
+ const plugin = {
154
+ id: f,
155
+ name: meta.name || meta.meta?.name || f,
156
+ version: meta.version || meta.meta?.version || '0.0.0',
157
+ };
148
158
  loadedPlugins.push(plugin);
149
159
  installFn(API);
150
160
  // eslint-disable-next-line no-console