@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.
- package/CHANGELOG.md +49 -0
- package/README.md +12 -9
- package/dist/agent/compact.js +98 -18
- package/dist/agent/context-budget.js +180 -0
- package/dist/agent/evolution.js +37 -0
- package/dist/agent/failover.js +3 -4
- package/dist/agent/loop.js +39 -4
- package/dist/config.js +27 -90
- package/dist/cron/store.js +4 -3
- package/dist/desk.js +8 -14
- package/dist/fs-identity.js +11 -0
- package/dist/fs-open-flags.js +14 -0
- package/dist/fs-read.js +38 -22
- package/dist/fs-write.js +4 -3
- package/dist/gateway/outbound-files.js +7 -8
- package/dist/gateway/runtime-state.js +11 -20
- package/dist/gateway/weixin.js +44 -42
- package/dist/index.js +240 -54
- package/dist/org/review-chain.js +2 -2
- package/dist/org-fleet/enroll.js +17 -22
- package/dist/plugins/plugins.js +7 -9
- package/dist/profile/profile.js +29 -56
- package/dist/providers/qwen-oauth.js +6 -19
- package/dist/security/permissions.js +4 -3
- package/dist/security/private-state.js +324 -11
- package/dist/serve/server.js +65 -39
- package/dist/serve/sessions.js +12 -4
- package/dist/session/store.js +4 -0
- package/dist/session/task.js +70 -8
- package/dist/tools/memory.js +18 -3
- package/dist/tools/search.js +6 -2
- package/dist/tui/App.js +43 -17
- package/package.json +1 -1
package/dist/serve/server.js
CHANGED
|
@@ -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 {
|
|
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 |
|
|
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,
|
|
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 |
|
|
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
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
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
|
-
|
|
324
|
-
|
|
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 () =>
|
|
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
|
|
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: "
|
|
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
|
|
524
|
-
if (!
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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
|
}
|
package/dist/serve/sessions.js
CHANGED
|
@@ -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: [],
|
|
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,
|
|
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(),
|
package/dist/session/store.js
CHANGED
|
@@ -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
|
}
|
package/dist/session/task.js
CHANGED
|
@@ -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 =
|
|
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
|
-
{
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
]
|
|
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
|
}
|
package/dist/tools/memory.js
CHANGED
|
@@ -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
|
-
"
|
|
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
|
-
|
|
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(
|
|
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/tools/search.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
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
|
|
466
|
-
queueRef.current
|
|
467
|
-
|
|
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
|
-
//
|
|
496
|
-
// a
|
|
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
|
-
|
|
501
|
-
|
|
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
|
-
|
|
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.
|
|
645
|
-
//
|
|
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
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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]);
|