@nanhara/hara 0.123.1 → 0.124.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.
@@ -10,7 +10,7 @@ import { homedir } from "node:os";
10
10
  import { isAbsolute, join, relative, sep } from "node:path";
11
11
  import "../tools/all.js"; // register the full built-in toolset — serve must work as a standalone entry
12
12
  import { runAgent } from "../agent/loop.js";
13
- import { COMPACT_SYSTEM, buildFileRestore, workingSetFromSummary } from "../agent/compact.js";
13
+ import { COMPACT_SYSTEM, buildFileRestore, compactedConversationHistory, compactedHistoryTokenEstimate, compactionSourceHistory, normalizeCompactionSummary, recentHistoryForCompaction, workingSetFromSummary, } from "../agent/compact.js";
14
14
  import { rewindTo } from "../agent/rewind.js";
15
15
  import { analyzeContext } from "../agent/context-report.js";
16
16
  import { clearTouched, recentTouched } from "../agent/touched.js";
@@ -32,7 +32,7 @@ import { INTERJECT_PREFIX, disposeReminderScope } from "../agent/reminders.js";
32
32
  import { SessionHub, realStore } from "./sessions.js";
33
33
  import { parseFrame, rpcResult, rpcError, rpcNotify, ERR, PROTOCOL_VERSION } from "./protocol.js";
34
34
  import { readModelContextFileSync } from "../fs-read.js";
35
- import { createTaskExecution, continueTaskExecution, finishTaskExecution, newSteerInteraction, newTurnInteraction, recordTaskSteering, taskExecutionContext, } from "../session/task.js";
35
+ import { consumePendingTaskSteering, createTaskExecution, continueTaskExecution, finishTaskExecution, newSteerInteraction, newTurnInteraction, recordTaskSteering, requestsTaskContinuation, taskExecutionContext, } from "../session/task.js";
36
36
  const APPROVAL_TIMEOUT_MS = 300_000; // an unanswered approval denies after 5 min (never hangs a turn)
37
37
  const COMPACT_TIMEOUT_MS = 60_000;
38
38
  const SHUTDOWN_GRACE_MS = 2_000;
@@ -301,31 +301,58 @@ export async function startServe(opts, deps) {
301
301
  throw error;
302
302
  }
303
303
  }
304
+ /** Move accepted steering from the task inbox into a write-ahead transcript snapshot. The caller either
305
+ * appends the returned messages to live history or returns them to runAgent for that append. A crash can
306
+ * therefore recover pending inbox state or consumed transcript state, never lose an acknowledged input. */
307
+ const materializePendingSteering = (s) => {
308
+ const consumed = consumePendingTaskSteering(s.task);
309
+ if (!consumed)
310
+ return [];
311
+ const messages = consumed.entries.map((entry) => ({
312
+ role: "user",
313
+ content: `${INTERJECT_PREFIX}\n\n${entry.content}`,
314
+ }));
315
+ hub.saveSnapshot(s, [...s.history, ...messages], consumed.task);
316
+ s.task = consumed.task;
317
+ s.history.push(...messages);
318
+ return messages;
319
+ };
304
320
  /** Run one turn on a session, streaming events to all authed clients. */
305
321
  const runTurn = async (s, text, images, forceNewTask = false) => {
306
322
  const sessionId = s.meta.id;
307
323
  s.busy = true;
308
324
  const turnAbort = new AbortController();
309
325
  s.abort = turnAbort;
310
- s.pendingInput = [];
311
- const interaction = !forceNewTask && s.resumeTaskPending && s.task
312
- ? newSteerInteraction(s.task.turnId)
313
- : newTurnInteraction();
314
- if (interaction.kind === "steer") {
315
- const continued = continueTaskExecution(s.task, interaction);
316
- if (!continued.ok) {
317
- s.abort = null;
318
- s.busy = false;
319
- throw new Error(continued.reason);
326
+ let interaction;
327
+ let executionContext;
328
+ try {
329
+ const recoveredSteering = materializePendingSteering(s);
330
+ interaction = !forceNewTask && s.task && s.task.status !== "completed" &&
331
+ (recoveredSteering.length > 0 || requestsTaskContinuation(text))
332
+ ? newSteerInteraction(s.task.turnId)
333
+ : newTurnInteraction();
334
+ if (interaction.kind === "steer") {
335
+ const continued = continueTaskExecution(s.task, interaction);
336
+ if (!continued.ok)
337
+ throw new Error(continued.reason);
338
+ s.task = continued.task;
320
339
  }
321
- s.task = continued.task;
340
+ else {
341
+ s.task = createTaskExecution(text, interaction.turnId);
342
+ // Checklists belong to executions, not to the surrounding conversation thread. An unrelated task
343
+ // must not inherit old pending todos and be forced back into paused state after a successful turn.
344
+ s.meta.todos = [];
345
+ }
346
+ executionContext = taskExecutionContext(s.task, interaction, s.meta.todos ?? []);
347
+ hub.save(s); // crash-safe running identity before provider/tool side effects
322
348
  }
323
- else {
324
- s.task = createTaskExecution(text, interaction.turnId);
349
+ catch (error) {
350
+ // Initialization happens before the main turn try/finally. Release the session here as well so a
351
+ // transient snapshot/config error cannot wedge it in a permanently busy, non-interruptible state.
352
+ s.abort = null;
353
+ s.busy = false;
354
+ throw error;
325
355
  }
326
- s.resumeTaskPending = false;
327
- const executionContext = taskExecutionContext(s.task, interaction);
328
- hub.save(s); // crash-safe running identity before provider/tool side effects
329
356
  broadcast("event.turn_start", { sessionId, taskId: s.task.id, turnId: s.task.turnId });
330
357
  const historyStart = s.history.length;
331
358
  const before = { input: s.stats.input, output: s.stats.output };
@@ -400,7 +427,10 @@ export async function startServe(opts, deps) {
400
427
  memory: memoryDigest(s.meta.cwd),
401
428
  continuationSession: s.continuationSession,
402
429
  executionContext,
403
- pendingInput: async () => s.pendingInput.splice(0),
430
+ pendingInput: async () => {
431
+ materializePendingSteering(s); // helper updates the shared live history after its write-ahead save
432
+ return [];
433
+ },
404
434
  stats: s.stats,
405
435
  signal: turnAbort.signal,
406
436
  onProviderTurn: (turn) => {
@@ -430,15 +460,12 @@ export async function startServe(opts, deps) {
430
460
  });
431
461
  // A steer may land after the agent's final in-loop drain but before the logical turn returns. Keep
432
462
  // it in the same task/run instead of making the client retry it as an unrelated session.send.
433
- const trailing = s.pendingInput.splice(0);
463
+ const trailing = materializePendingSteering(s);
434
464
  if (!trailing.length || turnAbort.signal.aborted || outcome.status !== "completed")
435
465
  break;
436
- s.history.push(...trailing);
437
- hub.save(s);
438
466
  } while (true);
439
467
  s.meta.todos = serializeTodos(sessionId);
440
468
  s.task = finishTaskExecution(s.task, outcome, s.meta.todos, turnAbort.signal.aborted);
441
- s.resumeTaskPending = Boolean(s.task && s.task.status !== "completed");
442
469
  hub.save(s);
443
470
  const usage = { input: s.stats.input - before.input, output: s.stats.output - before.output };
444
471
  // context watermark rides along with every turn end (codex thread/tokenUsage/updated pattern) —
@@ -462,7 +489,6 @@ export async function startServe(opts, deps) {
462
489
  catch (error) {
463
490
  if (s.task?.status === "running") {
464
491
  s.task = finishTaskExecution(s.task, { status: "error", error: error instanceof Error ? error.message : String(error) }, s.meta.todos ?? [], turnAbort.signal.aborted);
465
- s.resumeTaskPending = Boolean(s.task && s.task.status !== "completed");
466
492
  hub.save(s);
467
493
  }
468
494
  throw error;
@@ -482,6 +508,7 @@ export async function startServe(opts, deps) {
482
508
  * cwd because serve is multi-session (recentTouched is process-wide and must not leak across projects). */
483
509
  const compactSession = async (s, controller) => {
484
510
  const timeoutMs = Math.max(1, Math.min(deps.compactTimeoutMs ?? COMPACT_TIMEOUT_MS, COMPACT_TIMEOUT_MS));
511
+ const recent = recentHistoryForCompaction(s.history);
485
512
  const r = await new Promise((resolve, reject) => {
486
513
  let settled = false;
487
514
  let timedOut = false;
@@ -511,7 +538,7 @@ export async function startServe(opts, deps) {
511
538
  throw new Error(timedOut ? "compaction timed out" : "compaction interrupted");
512
539
  return s.provider.turn({
513
540
  system: COMPACT_SYSTEM,
514
- history: [...s.history, { role: "user", content: "Summarize our conversation so far per the instructions." }],
541
+ history: [...compactionSourceHistory(s.history), { role: "user", content: "Create the bounded execution checkpoint now." }],
515
542
  tools: [],
516
543
  onText: () => { },
517
544
  signal: controller.signal,
@@ -520,9 +547,10 @@ export async function startServe(opts, deps) {
520
547
  });
521
548
  if (controller.signal.aborted || r.stop === "error")
522
549
  return null;
523
- const summary = r.text.trim();
524
- if (!summary)
550
+ const rawSummary = r.text.trim();
551
+ if (!rawSummary)
525
552
  return null;
553
+ const summary = normalizeCompactionSummary(rawSummary);
526
554
  const workingSet = workingSetFromSummary(summary);
527
555
  const touched = recentTouched(20, s.meta.id).filter((file) => {
528
556
  const rel = relative(s.meta.cwd, file);
@@ -541,13 +569,12 @@ export async function startServe(opts, deps) {
541
569
  if (controller.signal.aborted)
542
570
  return null;
543
571
  s.meta.workingSet = workingSet;
572
+ const compacted = compactedConversationHistory(summary, recent, restore);
544
573
  s.history.length = 0;
545
- s.history.push({ role: "user", content: `Summary of our conversation so far (continue from here):\n\n${summary}` });
546
- if (restore)
547
- s.history.push({ role: "user", content: restore });
574
+ s.history.push(...compacted);
548
575
  s.stats.input += r.usage?.input ?? 0;
549
576
  s.stats.output += r.usage?.output ?? 0;
550
- s.stats.lastInput = r.usage?.input ?? 0; // ctx% now reflects the (small) summary
577
+ s.stats.lastInput = compactedHistoryTokenEstimate(compacted);
551
578
  hub.save(s);
552
579
  return summary;
553
580
  };
@@ -679,8 +706,7 @@ export async function startServe(opts, deps) {
679
706
  if (!recorded.ok)
680
707
  return reply(rpcError(id, ERR.BUSY, recorded.reason));
681
708
  s.task = recorded.task;
682
- s.pendingInput.push({ role: "user", content: `${INTERJECT_PREFIX}\n\n${expanded}` });
683
- hub.save(s); // task audit is durable even before the running loop reaches its next drain
709
+ hub.save(s); // executable inbox entry is durable before ACK
684
710
  return reply(rpcResult(id, { accepted: true, taskId: s.task.id, turnId: s.task.turnId }));
685
711
  }
686
712
  case "session.interrupt": {
@@ -969,8 +995,6 @@ export async function startServe(opts, deps) {
969
995
  s.history.length = 0;
970
996
  s.history.push(...next);
971
997
  s.task = undefined;
972
- s.resumeTaskPending = false;
973
- s.pendingInput = [];
974
998
  hub.save(s);
975
999
  return reply(rpcResult(id, { sessionId: s.meta.id, history: historyForClient(s.history) }));
976
1000
  }
@@ -48,7 +48,7 @@ export class SessionHub {
48
48
  const lock = this.store.acquire(meta.id); // fresh UUID, but filesystem errors must still fail closed
49
49
  if (!lock.ok)
50
50
  throw new Error(`could not acquire session lock for ${meta.id}${lock.pid ? ` (held by pid ${lock.pid})` : ""}`);
51
- const s = { meta, history: [], resumeTaskPending: false, pendingInput: [], provider: o.provider, approval: o.approval, autoApprove: new Set(), stats: { input: 0, output: 0 }, projectContext: o.projectContext, continuationSession: false, busy: false, configuring: false, pendingProviderTurns: 0, pendingToolRuns: 0, abort: null };
51
+ const s = { meta, history: [], provider: o.provider, approval: o.approval, autoApprove: new Set(), stats: { input: 0, output: 0 }, projectContext: o.projectContext, continuationSession: false, busy: false, configuring: false, pendingProviderTurns: 0, pendingToolRuns: 0, abort: null };
52
52
  try {
53
53
  this.sessions.set(meta.id, s);
54
54
  this.store.save(meta, []); // an empty newly-created thread must survive restart and appear in lists
@@ -78,7 +78,7 @@ export class SessionHub {
78
78
  // Credential/provider routing is live, while the model remains the session's explicit pin.
79
79
  prior.meta.provider = o.provider.id;
80
80
  const task = recoverTaskExecution(prior.task);
81
- const s = { meta: prior.meta, history: [...prior.history], task, resumeTaskPending: Boolean(task && task.status !== "completed"), pendingInput: [], provider: o.provider, approval: o.approval, autoApprove: new Set(), stats: { input: 0, output: 0 }, projectContext: o.projectContext, continuationSession: prior.history.length > 0, busy: false, configuring: false, pendingProviderTurns: 0, pendingToolRuns: 0, abort: null, effort: prior.meta.effort };
81
+ const s = { meta: prior.meta, history: [...prior.history], task, provider: o.provider, approval: o.approval, autoApprove: new Set(), stats: { input: 0, output: 0 }, projectContext: o.projectContext, continuationSession: prior.history.length > 0, busy: false, configuring: false, pendingProviderTurns: 0, pendingToolRuns: 0, abort: null, effort: prior.meta.effort };
82
82
  this.sessions.set(id, s);
83
83
  keepLock = true; // live session owns it until delete/releaseAll
84
84
  return { session: s };
@@ -118,6 +118,16 @@ export class SessionHub {
118
118
  }
119
119
  this.store.save(s.meta, s.history, s.task);
120
120
  }
121
+ /** Persist a projected transcript/task transition without mutating the live arrays first. Steering uses
122
+ * this write-ahead snapshot so an accepted inbox item is durable in history before runAgent observes it. */
123
+ saveSnapshot(s, history, task) {
124
+ if (!s.meta.title) {
125
+ const first = history.find((m) => m.role === "user");
126
+ if (first && "content" in first && typeof first.content === "string")
127
+ s.meta.title = deriveTitle(first.content);
128
+ }
129
+ this.store.save(s.meta, history, task);
130
+ }
121
131
  /** Rename a session (live or on-disk). Returns false when the id is unknown. */
122
132
  rename(id, title) {
123
133
  const live = this.sessions.get(id);
@@ -175,8 +185,6 @@ export class SessionHub {
175
185
  meta,
176
186
  history: [...src.history],
177
187
  task: forkTaskExecution(src.task),
178
- resumeTaskPending: Boolean(src.task),
179
- pendingInput: [],
180
188
  provider: o.provider,
181
189
  approval: o.approval,
182
190
  autoApprove: new Set(),
@@ -370,6 +370,10 @@ function redactedSessionCopy(data) {
370
370
  target.id = source.id;
371
371
  target.turnId = source.turnId;
372
372
  target.createdAt = source.createdAt;
373
+ if (source.deliveryState !== undefined)
374
+ target.deliveryState = source.deliveryState;
375
+ if (source.consumedAt !== undefined)
376
+ target.consumedAt = source.consumedAt;
373
377
  }
374
378
  }
375
379
  }
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  export const TASK_SCHEMA_VERSION = 1;
3
3
  export const MAX_TASK_OBJECTIVE_CHARS = 4096;
4
- export const MAX_TASK_STEERING_CHARS = 4096;
4
+ export const MAX_TASK_STEERING_CHARS = 24_000;
5
5
  export const MAX_TASK_STEERING_ENTRIES = 24;
6
6
  function iso(at = new Date()) {
7
7
  return typeof at === "string" ? at : at.toISOString();
@@ -63,13 +63,56 @@ export function recordTaskSteering(task, expectedTurnId, content, at = new Date(
63
63
  if (task.turnId !== expectedTurnId) {
64
64
  return { ok: false, reason: `stale steer for turn ${expectedTurnId}; active turn is ${task.turnId}` };
65
65
  }
66
+ const normalized = content.replace(/\r\n?/g, "\n").trim() || "(image-only steering)";
67
+ if (normalized.length > MAX_TASK_STEERING_CHARS) {
68
+ return { ok: false, reason: `steering input is too large (${normalized.length} chars; maximum ${MAX_TASK_STEERING_CHARS})` };
69
+ }
66
70
  const now = iso(at);
67
71
  const steering = [
68
72
  ...(task.steering ?? []),
69
- { id: randomUUID(), turnId: expectedTurnId, content: boundedText(content, MAX_TASK_STEERING_CHARS), createdAt: now },
70
- ].slice(-MAX_TASK_STEERING_ENTRIES);
73
+ {
74
+ id: randomUUID(),
75
+ turnId: expectedTurnId,
76
+ content: normalized,
77
+ createdAt: now,
78
+ deliveryState: "pending",
79
+ },
80
+ ];
81
+ // Never silently evict accepted-but-undelivered input. Prefer dropping the oldest consumed/legacy audit
82
+ // entry; if all slots are pending, apply backpressure and let the caller surface a retryable queue-full
83
+ // error instead of acknowledging data that cannot be retained.
84
+ if (steering.length > MAX_TASK_STEERING_ENTRIES) {
85
+ const removable = steering.findIndex((entry) => entry.deliveryState !== "pending");
86
+ if (removable < 0)
87
+ return { ok: false, reason: `task steering inbox is full (${MAX_TASK_STEERING_ENTRIES}); wait for the running turn to consume it` };
88
+ steering.splice(removable, 1);
89
+ }
71
90
  return { ok: true, task: { ...task, steering, updatedAt: now } };
72
91
  }
92
+ /** Mark every accepted inbox entry consumed in one immutable transition. Callers persist the projected
93
+ * transcript plus this returned task before exposing the messages to the agent loop, making delivery
94
+ * crash-safe and exactly-once. Legacy entries without deliveryState remain audit-only. */
95
+ export function consumePendingTaskSteering(task, at = new Date()) {
96
+ if (!task?.steering?.some((entry) => entry.deliveryState === "pending"))
97
+ return null;
98
+ const now = iso(at);
99
+ const entries = task.steering.filter((entry) => entry.deliveryState === "pending").map((entry) => ({ ...entry }));
100
+ const steering = task.steering.map((entry) => entry.deliveryState === "pending"
101
+ ? { ...entry, deliveryState: "consumed", consumedAt: now }
102
+ : entry);
103
+ return { task: { ...task, steering, updatedAt: now }, entries };
104
+ }
105
+ export function hasPendingTaskSteering(task) {
106
+ return !!task?.steering?.some((entry) => entry.deliveryState === "pending");
107
+ }
108
+ /** Idle messages start a new task by default. Only an explicit continuation phrase resumes an unfinished
109
+ * execution, matching Codex's separation between steering an active turn and starting the next task. */
110
+ export function requestsTaskContinuation(text) {
111
+ const value = text.trim().toLocaleLowerCase();
112
+ if (!value)
113
+ return false;
114
+ return /^(?:\/(?:continue|resume)(?:\s|$)|(?:continue|resume|go\s+on)(?:[\s,.:;!?,。:;!?]|$)|(?:继续|接着|接着做|继续处理)(?:[\s,.:;!?,。:;!?]|$))/.test(value);
115
+ }
73
116
  export function finishTaskExecution(task, outcome, todos = [], interrupted = false, at = new Date()) {
74
117
  if (!task)
75
118
  return undefined;
@@ -106,14 +149,18 @@ export function forkTaskExecution(task, at = new Date()) {
106
149
  startedAt: now,
107
150
  endedAt: now,
108
151
  lastOutcome: "interrupted",
109
- steering: task.steering?.slice(-MAX_TASK_STEERING_ENTRIES).map((entry) => ({ ...entry })),
152
+ // A fork copies audit context, never ownership of an executable inbox item. Pending steering remains
153
+ // pending only in the source session; replaying it in both branches would violate exactly-once delivery.
154
+ steering: task.steering?.slice(-MAX_TASK_STEERING_ENTRIES).map((entry) => entry.deliveryState === "pending"
155
+ ? { ...entry, deliveryState: "consumed", consumedAt: now }
156
+ : { ...entry }),
110
157
  };
111
158
  }
112
- export function taskExecutionContext(task, interaction) {
159
+ export function taskExecutionContext(task, interaction, todos = []) {
113
160
  const steeringNote = interaction.kind === "steer"
114
161
  ? "This interaction steers the existing task. Refine execution without replacing its objective."
115
162
  : "This interaction created a new task.";
116
- return [
163
+ const lines = [
117
164
  "# Task execution (authoritative; separate from conversation history)",
118
165
  `Task ID: ${task.id}`,
119
166
  `Turn ID: ${task.turnId}`,
@@ -121,7 +168,17 @@ export function taskExecutionContext(task, interaction) {
121
168
  `Interaction: ${interaction.kind}`,
122
169
  steeringNote,
123
170
  "Conversation messages provide evidence and refinements, but the task objective above remains authoritative until an explicit new task starts.",
124
- ].join("\n");
171
+ ];
172
+ if (todos.length) {
173
+ lines.push("## Persisted execution checkpoint", ...todos.slice(0, 24).map((todo) => {
174
+ const mark = todo.status === "done" ? "done" : todo.status === "in_progress" ? "in progress" : "pending";
175
+ return `- [${mark}] ${todo.text.replace(/\s+/g, " ").trim().slice(0, 240)}`;
176
+ }));
177
+ if (todos.length > 24)
178
+ lines.push(`- … ${todos.length - 24} additional item(s) omitted; call todo_write to inspect/update the full list.`);
179
+ lines.push("Treat this checklist as the recovery cursor: continue from the first unfinished item, verify current workspace state, and update it as work changes.");
180
+ }
181
+ return lines.join("\n");
125
182
  }
126
183
  export function formatTaskExecution(task) {
127
184
  if (!task)
@@ -154,8 +211,13 @@ export function isTaskExecution(value) {
154
211
  if (!entry || typeof entry !== "object" || Array.isArray(entry))
155
212
  return false;
156
213
  const steering = entry;
214
+ const deliveryValid = steering.deliveryState === undefined
215
+ ? steering.consumedAt === undefined
216
+ : steering.deliveryState === "pending"
217
+ ? steering.consumedAt === undefined
218
+ : steering.deliveryState === "consumed" && validTimestamp(steering.consumedAt);
157
219
  return validId(steering.id) && validId(steering.turnId) &&
158
220
  typeof steering.content === "string" && steering.content.length > 0 && steering.content.length <= MAX_TASK_STEERING_CHARS &&
159
- validTimestamp(steering.createdAt);
221
+ validTimestamp(steering.createdAt) && deliveryValid;
160
222
  });
161
223
  }
@@ -11,6 +11,9 @@ import { globalSkillsDir, skillsDir, invalidateSkillsCache } from "../skills/ski
11
11
  import { sensitiveFileError } from "../security/sensitive-files.js";
12
12
  import { readModelContextFileSync, readVerifiedRegularFileSnapshot } from "../fs-read.js";
13
13
  import { atomicWriteText, bindAtomicWritePath } from "../fs-write.js";
14
+ export const MAX_MEMORY_ENTRY_CHARS = 4_000;
15
+ export const MAX_SKILL_DESCRIPTION_CHARS = 500;
16
+ export const MAX_SKILL_BODY_CHARS = 24_000;
14
17
  const asTarget = (v) => (["memory", "user", "log"].includes(v) ? v : "memory");
15
18
  const asScope = (v) => (v === "global" ? "global" : "project");
16
19
  registerTool({
@@ -58,7 +61,8 @@ registerTool({
58
61
  registerTool({
59
62
  name: "memory_write",
60
63
  description: "Persist a durable fact/decision/preference to memory so future sessions recall it. Save proactively " +
61
- "when you learn something worth keeping: project conventions, the user's preferences, a tricky solution.",
64
+ "only with evidence: use target=log for tentative/one-off observations; promote stable verified project " +
65
+ "conventions/decisions to memory and explicit user preferences to user. Include a short source/evidence phrase.",
62
66
  input_schema: {
63
67
  type: "object",
64
68
  properties: {
@@ -74,6 +78,9 @@ registerTool({
74
78
  const content = String(input.content ?? "").trim();
75
79
  if (!content)
76
80
  return "Error: empty content.";
81
+ if (content.length > MAX_MEMORY_ENTRY_CHARS) {
82
+ return `Error: memory entry is too large (${content.length} chars; maximum ${MAX_MEMORY_ENTRY_CHARS}). Save one concise durable fact, not a transcript or dump.`;
83
+ }
77
84
  const scan = scanMemory(content);
78
85
  if (!scan.ok)
79
86
  return `Blocked: this looks unsafe to store (${scan.hits.join(", ")}). Rephrase without secrets/injection text.`;
@@ -109,12 +116,20 @@ registerTool({
109
116
  .slice(0, 48);
110
117
  if (!slug)
111
118
  return "Error: invalid name.";
112
- let description = String(input.description ?? "").replace(/\s+/g, " ").trim();
119
+ const rawDescription = String(input.description ?? "");
120
+ const rawBody = String(input.body ?? "");
121
+ if (rawDescription.length > MAX_SKILL_DESCRIPTION_CHARS) {
122
+ return `Error: skill description is too large (${rawDescription.length} chars; maximum ${MAX_SKILL_DESCRIPTION_CHARS}).`;
123
+ }
124
+ if (rawBody.length > MAX_SKILL_BODY_CHARS) {
125
+ return `Error: skill body is too large (${rawBody.length} chars; maximum ${MAX_SKILL_BODY_CHARS}). Save a focused reusable procedure, not a transcript or file dump.`;
126
+ }
127
+ let description = rawDescription.replace(/\s+/g, " ").trim();
113
128
  if (!description)
114
129
  return "Error: a description is required (it's how the skill gets surfaced).";
115
130
  // sanitize on capture: generalize local paths/emails, then redact secrets; block only on residue.
116
131
  description = scrubLocal(description, ctx.cwd);
117
- let body = scrubLocal(String(input.body ?? ""), ctx.cwd);
132
+ let body = scrubLocal(rawBody, ctx.cwd);
118
133
  const rd = redactSecrets(description);
119
134
  const rb = redactSecrets(body);
120
135
  description = rd.text;
package/dist/tui/App.js CHANGED
@@ -20,7 +20,7 @@ import { clearTodos, currentTodos, onTodosChange } from "../tools/todo.js";
20
20
  import { onTurnPhase, turnPhase } from "../agent/phase.js";
21
21
  import { listJobs, onJobsChange } from "../exec/jobs.js";
22
22
  import { ModelPicker } from "./model-picker.js";
23
- import { newSteerInteraction, newTurnInteraction } from "../session/task.js";
23
+ import { newSteerInteraction, newTurnInteraction, requestsTaskContinuation } from "../session/task.js";
24
24
  let _id = 0;
25
25
  const nid = () => ++_id;
26
26
  const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
@@ -290,7 +290,7 @@ function StatusRow({ working, todos, queued }) {
290
290
  // Pre-first-token honesty (codex-parity): "waiting for the model" reads very differently from a
291
291
  // generic "working" when the network is slow — the user knows the request is out, not dead.
292
292
  const verb = (phase === "waiting" ? `waiting for the model… ${elapsedSec}s · esc to interrupt` : spinnerVerb(todos, elapsedSec)) + bgTag;
293
- return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", children: frames[frame % frames.length] }), _jsx(Text, { dimColor: true, children: ` ${verb} · ⏎ steers task${queued ? ` (${queued})` : ""}` })] }));
293
+ return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", children: frames[frame % frames.length] }), _jsx(Text, { dimColor: true, children: ` ${verb} · ⏎ steers · /next queues${queued ? ` (${queued})` : ""}` })] }));
294
294
  }
295
295
  // Short per-mode descriptions for the ONE-ROW mode line (the old two-row ModeBar's long sentences
296
296
  // don't fit inline). Full behavior is documented in /help; this line is a switching aid, not a manual.
@@ -462,9 +462,12 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
462
462
  const drainQueue = useCallback(() => {
463
463
  if (!queueRef.current.length)
464
464
  return [];
465
- const batch = queueRef.current;
466
- queueRef.current = [];
467
- setPool([]);
465
+ const barrier = queueRef.current.findIndex((item) => item.mode === "next");
466
+ const batch = (barrier < 0 ? queueRef.current : queueRef.current.slice(0, barrier));
467
+ if (!batch.length)
468
+ return [];
469
+ queueRef.current = barrier < 0 ? [] : queueRef.current.slice(barrier);
470
+ setPool(queueRef.current.map((item) => `${item.mode === "next" ? "next: " : ""}${item.line.trim() || "🖼 (image)"}`));
468
471
  if (batch.some((b) => b.images?.length))
469
472
  noteVisionIfNeeded();
470
473
  for (const b of batch)
@@ -492,13 +495,24 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
492
495
  if ((!t && !images?.length) || prompt)
493
496
  return; // nothing to send, or a choice is pending
494
497
  if (working) {
495
- // Bind every type-ahead message to the exact live turn. A stale message must never silently become
496
- // a new task after another turn starts (Codex's expected_turn_id steering invariant).
498
+ // Enter steers the live turn; `/next …` creates an explicit queue barrier for a separate next task.
499
+ // Once such a barrier exists, later type-ahead stays behind it instead of leaking backward.
497
500
  const expectedTurnId = activeTurnRef.current;
498
501
  if (!expectedTurnId)
499
502
  return;
500
- queueRef.current.push({ line, images, expectedTurnId });
501
- setPool(queueRef.current.map((q) => q.line.trim() || "🖼 (image)"));
503
+ const next = /^\/(?:next|queue)(?:\s+([\s\S]+))?$/.exec(t);
504
+ if (next && !next[1]?.trim() && !images?.length) {
505
+ pushCurrent("notice", "usage: /next <message> (queues a separate task after the current turn)");
506
+ return;
507
+ }
508
+ const behindBarrier = queueRef.current.some((item) => item.mode === "next");
509
+ if (next || behindBarrier) {
510
+ queueRef.current.push({ mode: "next", line: next?.[1]?.trim() || line, images });
511
+ }
512
+ else {
513
+ queueRef.current.push({ mode: "steer", line, images, expectedTurnId });
514
+ }
515
+ setPool(queueRef.current.map((item) => `${item.mode === "next" ? "next: " : ""}${item.line.trim() || "🖼 (image)"}`));
502
516
  return;
503
517
  }
504
518
  const interaction = forcedInteraction ?? newTurnInteraction();
@@ -506,7 +520,8 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
506
520
  // Fold the previous turn's checklist NOW, at the natural boundary (a new task begins). The old
507
521
  // 30s-idle timer yanked the input box UP by the panel's height while the user was reading/typing
508
522
  // (anti-bob); folding on submit means the shrink coincides with the user's own action.
509
- if (interaction.kind === "turn" && currentTodos().length) {
523
+ const controlCommand = /^\/[a-z][\w-]*(?:\s|$)/i.test(t);
524
+ if (interaction.kind === "turn" && !controlCommand && !requestsTaskContinuation(t) && currentTodos().length) {
510
525
  const list = currentTodos();
511
526
  const done = list.filter((td) => td.status === "done").length;
512
527
  setHistory((h) => [...h, { id: nid(), kind: "notice", text: ` ✓ Todos: ${done}/${list.length} done` }]);
@@ -641,9 +656,8 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
641
656
  if (activeTurnRef.current === interaction.turnId)
642
657
  activeTurnRef.current = null;
643
658
  }, [working, prompt, askText, onSubmit, pushCurrent, model, exit, drainQueue, noteVisionIfNeeded]);
644
- // A message can arrive after runAgent's final pending-input drain but before the view flips to idle. Keep
645
- // its original expectedTurnId and run it as a STEER continuation of that task never as an ordinary new
646
- // task inferred from queue timing.
659
+ // A message can arrive after runAgent's final pending-input drain but before the view flips to idle. Late
660
+ // steer groups retain expectedTurnId; explicit `/next` groups start ordinary task turns in FIFO order.
647
661
  useEffect(() => {
648
662
  if (working || prompt || askText || drainingRef.current || !queueRef.current.length)
649
663
  return;
@@ -651,10 +665,22 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
651
665
  const batch = queueRef.current;
652
666
  queueRef.current = [];
653
667
  setPool([]);
654
- const line = batch.map((b) => b.line).join("\n\n");
655
- const images = batch.flatMap((b) => b.images ?? []);
656
- const expectedTurnId = batch[0].expectedTurnId;
657
- void Promise.resolve(handleSubmit(line, images.length ? images : undefined, newSteerInteraction(expectedTurnId))).finally(() => {
668
+ void (async () => {
669
+ for (let at = 0; at < batch.length;) {
670
+ const mode = batch[at].mode;
671
+ let end = at + 1;
672
+ while (end < batch.length && batch[end].mode === mode)
673
+ end++;
674
+ const group = batch.slice(at, end);
675
+ const line = group.map((item) => item.line).join("\n\n");
676
+ const images = group.flatMap((item) => item.images ?? []);
677
+ const forced = mode === "steer"
678
+ ? newSteerInteraction(group[0].expectedTurnId)
679
+ : undefined;
680
+ await handleSubmit(line, images.length ? images : undefined, forced);
681
+ at = end;
682
+ }
683
+ })().finally(() => {
658
684
  drainingRef.current = false;
659
685
  });
660
686
  }, [working, prompt, askText, handleSubmit]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.123.1",
3
+ "version": "0.124.0",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "runtime-bootstrap.cjs"