@nanhara/hara 0.123.1 → 0.124.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.
@@ -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,9 @@ 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 { optionalPosixOpenFlag } from "../fs-open-flags.js";
36
+ import { sameOpenedFileIdentity } from "../fs-identity.js";
37
+ import { consumePendingTaskSteering, createTaskExecution, continueTaskExecution, finishTaskExecution, newSteerInteraction, newTurnInteraction, recordTaskSteering, requestsTaskContinuation, taskExecutionContext, } from "../session/task.js";
36
38
  const APPROVAL_TIMEOUT_MS = 300_000; // an unanswered approval denies after 5 min (never hangs a turn)
37
39
  const COMPACT_TIMEOUT_MS = 60_000;
38
40
  const SHUTDOWN_GRACE_MS = 2_000;
@@ -54,7 +56,7 @@ const ensurePrivateDiscoveryDir = (dir) => {
54
56
  mkdirSync(dir, { recursive: true, mode: 0o700 });
55
57
  let fd;
56
58
  try {
57
- fd = openSync(dir, fsConstants.O_RDONLY | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW);
59
+ fd = openSync(dir, fsConstants.O_RDONLY | optionalPosixOpenFlag("O_DIRECTORY") | optionalPosixOpenFlag("O_NOFOLLOW"));
58
60
  const st = fstatSync(fd);
59
61
  if (!st.isDirectory())
60
62
  throw new Error(`${dir} must be a private directory, not a symlink`);
@@ -151,7 +153,7 @@ const writeDiscovery = async (dir, path, record) => {
151
153
  const temp = join(dir, `.serve.json.${process.pid}.${record.instanceId}.tmp`);
152
154
  let fd;
153
155
  try {
154
- fd = openSync(temp, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY | fsConstants.O_NOFOLLOW, 0o600);
156
+ fd = openSync(temp, "wx", 0o600);
155
157
  fchmodSync(fd, 0o600);
156
158
  writeFileSync(fd, `${JSON.stringify(record, null, 2)}\n`, "utf8");
157
159
  fsyncSync(fd);
@@ -177,7 +179,7 @@ const removeOwnedDiscovery = async (dir, path, record) => {
177
179
  await withDiscoveryLock(dir, record.instanceId, () => {
178
180
  let fd;
179
181
  try {
180
- fd = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
182
+ fd = openSync(path, fsConstants.O_RDONLY | optionalPosixOpenFlag("O_NOFOLLOW"));
181
183
  const opened = fstatSync(fd);
182
184
  if (!opened.isFile() || opened.size > 64 * 1024)
183
185
  return;
@@ -191,7 +193,7 @@ const removeOwnedDiscovery = async (dir, path, record) => {
191
193
  // Re-check the directory entry against the already-open, verified inode. Cooperating writers are
192
194
  // serialized by the lock; this check also refuses an uncooperative symlink/replacement race.
193
195
  const linked = lstatSync(path);
194
- if (!linked.isFile() || linked.isSymbolicLink() || linked.dev !== opened.dev || linked.ino !== opened.ino)
196
+ if (!linked.isFile() || linked.isSymbolicLink() || !sameOpenedFileIdentity(linked, opened))
195
197
  return;
196
198
  unlinkSync(path);
197
199
  syncDirectory(dir);
@@ -301,31 +303,58 @@ export async function startServe(opts, deps) {
301
303
  throw error;
302
304
  }
303
305
  }
306
+ /** Move accepted steering from the task inbox into a write-ahead transcript snapshot. The caller either
307
+ * appends the returned messages to live history or returns them to runAgent for that append. A crash can
308
+ * therefore recover pending inbox state or consumed transcript state, never lose an acknowledged input. */
309
+ const materializePendingSteering = (s) => {
310
+ const consumed = consumePendingTaskSteering(s.task);
311
+ if (!consumed)
312
+ return [];
313
+ const messages = consumed.entries.map((entry) => ({
314
+ role: "user",
315
+ content: `${INTERJECT_PREFIX}\n\n${entry.content}`,
316
+ }));
317
+ hub.saveSnapshot(s, [...s.history, ...messages], consumed.task);
318
+ s.task = consumed.task;
319
+ s.history.push(...messages);
320
+ return messages;
321
+ };
304
322
  /** Run one turn on a session, streaming events to all authed clients. */
305
323
  const runTurn = async (s, text, images, forceNewTask = false) => {
306
324
  const sessionId = s.meta.id;
307
325
  s.busy = true;
308
326
  const turnAbort = new AbortController();
309
327
  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);
328
+ let interaction;
329
+ let executionContext;
330
+ try {
331
+ const recoveredSteering = materializePendingSteering(s);
332
+ interaction = !forceNewTask && s.task && s.task.status !== "completed" &&
333
+ (recoveredSteering.length > 0 || requestsTaskContinuation(text))
334
+ ? newSteerInteraction(s.task.turnId)
335
+ : newTurnInteraction();
336
+ if (interaction.kind === "steer") {
337
+ const continued = continueTaskExecution(s.task, interaction);
338
+ if (!continued.ok)
339
+ throw new Error(continued.reason);
340
+ s.task = continued.task;
320
341
  }
321
- s.task = continued.task;
342
+ else {
343
+ s.task = createTaskExecution(text, interaction.turnId);
344
+ // Checklists belong to executions, not to the surrounding conversation thread. An unrelated task
345
+ // must not inherit old pending todos and be forced back into paused state after a successful turn.
346
+ s.meta.todos = [];
347
+ }
348
+ executionContext = taskExecutionContext(s.task, interaction, s.meta.todos ?? []);
349
+ hub.save(s); // crash-safe running identity before provider/tool side effects
322
350
  }
323
- else {
324
- s.task = createTaskExecution(text, interaction.turnId);
351
+ catch (error) {
352
+ // Initialization happens before the main turn try/finally. Release the session here as well so a
353
+ // transient snapshot/config error cannot wedge it in a permanently busy, non-interruptible state.
354
+ s.abort = null;
355
+ s.busy = false;
356
+ throw error;
325
357
  }
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
358
  broadcast("event.turn_start", { sessionId, taskId: s.task.id, turnId: s.task.turnId });
330
359
  const historyStart = s.history.length;
331
360
  const before = { input: s.stats.input, output: s.stats.output };
@@ -400,7 +429,10 @@ export async function startServe(opts, deps) {
400
429
  memory: memoryDigest(s.meta.cwd),
401
430
  continuationSession: s.continuationSession,
402
431
  executionContext,
403
- pendingInput: async () => s.pendingInput.splice(0),
432
+ pendingInput: async () => {
433
+ materializePendingSteering(s); // helper updates the shared live history after its write-ahead save
434
+ return [];
435
+ },
404
436
  stats: s.stats,
405
437
  signal: turnAbort.signal,
406
438
  onProviderTurn: (turn) => {
@@ -430,15 +462,12 @@ export async function startServe(opts, deps) {
430
462
  });
431
463
  // A steer may land after the agent's final in-loop drain but before the logical turn returns. Keep
432
464
  // 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);
465
+ const trailing = materializePendingSteering(s);
434
466
  if (!trailing.length || turnAbort.signal.aborted || outcome.status !== "completed")
435
467
  break;
436
- s.history.push(...trailing);
437
- hub.save(s);
438
468
  } while (true);
439
469
  s.meta.todos = serializeTodos(sessionId);
440
470
  s.task = finishTaskExecution(s.task, outcome, s.meta.todos, turnAbort.signal.aborted);
441
- s.resumeTaskPending = Boolean(s.task && s.task.status !== "completed");
442
471
  hub.save(s);
443
472
  const usage = { input: s.stats.input - before.input, output: s.stats.output - before.output };
444
473
  // context watermark rides along with every turn end (codex thread/tokenUsage/updated pattern) —
@@ -462,7 +491,6 @@ export async function startServe(opts, deps) {
462
491
  catch (error) {
463
492
  if (s.task?.status === "running") {
464
493
  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
494
  hub.save(s);
467
495
  }
468
496
  throw error;
@@ -482,6 +510,7 @@ export async function startServe(opts, deps) {
482
510
  * cwd because serve is multi-session (recentTouched is process-wide and must not leak across projects). */
483
511
  const compactSession = async (s, controller) => {
484
512
  const timeoutMs = Math.max(1, Math.min(deps.compactTimeoutMs ?? COMPACT_TIMEOUT_MS, COMPACT_TIMEOUT_MS));
513
+ const recent = recentHistoryForCompaction(s.history);
485
514
  const r = await new Promise((resolve, reject) => {
486
515
  let settled = false;
487
516
  let timedOut = false;
@@ -511,7 +540,7 @@ export async function startServe(opts, deps) {
511
540
  throw new Error(timedOut ? "compaction timed out" : "compaction interrupted");
512
541
  return s.provider.turn({
513
542
  system: COMPACT_SYSTEM,
514
- history: [...s.history, { role: "user", content: "Summarize our conversation so far per the instructions." }],
543
+ history: [...compactionSourceHistory(s.history), { role: "user", content: "Create the bounded execution checkpoint now." }],
515
544
  tools: [],
516
545
  onText: () => { },
517
546
  signal: controller.signal,
@@ -520,9 +549,10 @@ export async function startServe(opts, deps) {
520
549
  });
521
550
  if (controller.signal.aborted || r.stop === "error")
522
551
  return null;
523
- const summary = r.text.trim();
524
- if (!summary)
552
+ const rawSummary = r.text.trim();
553
+ if (!rawSummary)
525
554
  return null;
555
+ const summary = normalizeCompactionSummary(rawSummary);
526
556
  const workingSet = workingSetFromSummary(summary);
527
557
  const touched = recentTouched(20, s.meta.id).filter((file) => {
528
558
  const rel = relative(s.meta.cwd, file);
@@ -541,13 +571,12 @@ export async function startServe(opts, deps) {
541
571
  if (controller.signal.aborted)
542
572
  return null;
543
573
  s.meta.workingSet = workingSet;
574
+ const compacted = compactedConversationHistory(summary, recent, restore);
544
575
  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 });
576
+ s.history.push(...compacted);
548
577
  s.stats.input += r.usage?.input ?? 0;
549
578
  s.stats.output += r.usage?.output ?? 0;
550
- s.stats.lastInput = r.usage?.input ?? 0; // ctx% now reflects the (small) summary
579
+ s.stats.lastInput = compactedHistoryTokenEstimate(compacted);
551
580
  hub.save(s);
552
581
  return summary;
553
582
  };
@@ -679,8 +708,7 @@ export async function startServe(opts, deps) {
679
708
  if (!recorded.ok)
680
709
  return reply(rpcError(id, ERR.BUSY, recorded.reason));
681
710
  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
711
+ hub.save(s); // executable inbox entry is durable before ACK
684
712
  return reply(rpcResult(id, { accepted: true, taskId: s.task.id, turnId: s.task.turnId }));
685
713
  }
686
714
  case "session.interrupt": {
@@ -969,8 +997,6 @@ export async function startServe(opts, deps) {
969
997
  s.history.length = 0;
970
998
  s.history.push(...next);
971
999
  s.task = undefined;
972
- s.resumeTaskPending = false;
973
- s.pendingInput = [];
974
1000
  hub.save(s);
975
1001
  return reply(rpcResult(id, { sessionId: s.meta.id, history: historyForClient(s.history) }));
976
1002
  }
@@ -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;
@@ -290,7 +290,8 @@ function execute() {
290
290
 
291
291
  const readBuffer = Buffer.allocUnsafe(cfg.maxFileBytes + 1);
292
292
  function sameIdentity(info, candidate) {
293
- return String(info.dev) === candidate.dev && String(info.ino) === candidate.ino;
293
+ return String(info.ino) === candidate.ino
294
+ && (process.platform === "win32" || String(info.dev) === candidate.dev);
294
295
  }
295
296
  function safeReadText(candidate) {
296
297
  const absolute = candidate.path;
@@ -298,7 +299,10 @@ function execute() {
298
299
  try {
299
300
  const before = fs.lstatSync(absolute, { bigint: true });
300
301
  if (!before.isFile() || before.nlink !== 1n || !sameIdentity(before, candidate)) return null;
301
- fd = fs.openSync(absolute, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0) | (fs.constants.O_NOFOLLOW || 0));
302
+ const posixFlags = process.platform === "win32"
303
+ ? 0
304
+ : (fs.constants.O_NONBLOCK || 0) | (fs.constants.O_NOFOLLOW || 0);
305
+ fd = fs.openSync(absolute, fs.constants.O_RDONLY | posixFlags);
302
306
  const info = fs.fstatSync(fd, { bigint: true });
303
307
  if (!info.isFile() || info.nlink !== 1n || !sameIdentity(info, candidate) || info.size > BigInt(cfg.maxFileBytes)) return null;
304
308
  let total = 0;
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.1",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "runtime-bootstrap.cjs"