tokenmaw 0.3.0 → 0.4.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.
@@ -1,14 +1,36 @@
1
- import { randomUUID } from 'node:crypto';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { resolve } from 'node:path';
3
3
  import { chatStream } from '../backend.js';
4
4
  import { executeTool, getToolPolicy, toolRegistry } from '../infra/tools.js';
5
- import { AgentRegistry, matchesAgentSelector } from './agent-registry.js';
5
+ import { AgentRegistry, loadWorkspaceContext, matchesAgentSelector } from './agent-registry.js';
6
6
  import { AgentRuntimeStore } from './agent-store.js';
7
+ import { CrossProcessLockManager, LockConflictError } from './file-lock.js';
7
8
  import { FileLockManager } from './locks.js';
8
9
  import { recordTimeline } from './session-timeline.js';
10
+ const WORKSPACE_CONTEXT_LABEL = 'AGENTS.md';
9
11
  function now() {
10
12
  return new Date().toISOString();
11
13
  }
14
+ function mergeUsage(previous, next) {
15
+ const result = { ...(previous ?? {}) };
16
+ for (const key of ['inputTokens', 'outputTokens', 'reasoningTokens', 'cachedInputTokens', 'cacheCreationInputTokens']) {
17
+ if (next[key] !== undefined)
18
+ result[key] = (result[key] ?? 0) + next[key];
19
+ }
20
+ return result;
21
+ }
22
+ function asidePrefix() {
23
+ return 'Additional context noted earlier (aside):';
24
+ }
25
+ function mergeAgentUsage(previous, usage, firstTokenMs, durationMs, requests = 1) {
26
+ const merged = mergeUsage(previous, usage ?? {});
27
+ return { ...merged, requests: (previous?.requests ?? 0) + requests, turns: (previous?.turns ?? 0) + 1, firstTokenMs, lastTurnMs: durationMs };
28
+ }
29
+ function sessionChildren(instances, parentId) {
30
+ return [...instances.values()].filter((instance) => instance.parentInstanceId === parentId);
31
+ }
32
+ /** Tools whose success constitutes real progress (state changed on disk). */
33
+ const PROGRESS_TOOLS = new Set(['edit_file', 'write_file']);
12
34
  // ── Context compaction ───────────────────────────────────────────────────────
13
35
  const COMPACT_SYSTEM_PROMPT = 'You are a context compaction assistant. Produce a faithful, information-dense digest of the archived conversation so a coding agent can continue the work without the original messages. Never invent facts; keep file paths, ids, decisions, and pending work exact.';
14
36
  const DEFAULT_COMPACT_KEEP_RECENT = 12;
@@ -103,6 +125,11 @@ export class AgentRuntime {
103
125
  modelStream;
104
126
  maxConcurrentTurns;
105
127
  maxAgentDepth;
128
+ maxChildrenPerTurn;
129
+ maxSteps;
130
+ projectContext;
131
+ readVersions = new Map();
132
+ failureCounts = new Map();
106
133
  defaultModel;
107
134
  sessions = new Map();
108
135
  instances = new Map();
@@ -113,19 +140,35 @@ export class AgentRuntime {
113
140
  running = new Set();
114
141
  controllers = new Map();
115
142
  idleWaiters = new Set();
116
- fileLocks = new FileLockManager();
143
+ fileLocks;
144
+ sessionLocks;
145
+ sessionLockHandles = new Map();
146
+ sessionLockHolders = new Map();
117
147
  ready;
118
148
  shuttingDown = false;
119
149
  constructor(options) {
120
150
  this.workspaceRoot = resolve(options.workspaceRoot ?? process.cwd());
121
151
  this.registry = options.registry ?? new AgentRegistry({ workspaceRoot: this.workspaceRoot });
122
152
  this.store = options.store ?? new AgentRuntimeStore();
153
+ // Locks live next to session state so tests (which pass a tmp store) and
154
+ // alternate CODER_DATA_HOME deployments never touch the default lock dir.
155
+ const lockDir = resolve(this.store.runtimeDir, 'locks');
156
+ this.fileLocks = new FileLockManager(lockDir);
157
+ this.sessionLocks = new CrossProcessLockManager(lockDir);
123
158
  this.resolveModel = options.resolveModel;
124
159
  this.defaultModel = options.defaultModel;
125
160
  this.modelStream = options.modelStream ?? ((config, system, messages, tools, signal) => (chatStream(config, system, messages, tools, signal)));
126
161
  this.maxConcurrentTurns = Math.max(1, options.maxConcurrentTurns ?? Number(process.env.AGENT_MAX_CONCURRENT_TURNS ?? 4));
127
162
  this.maxAgentDepth = Math.max(1, options.maxAgentDepth ?? Number(process.env.AGENT_MAX_DEPTH ?? 4));
128
- this.ready = Promise.all([this.registry.load(), this.store.init()]).then(() => this.validateSpecs());
163
+ this.maxChildrenPerTurn = Math.max(1, options.maxChildrenPerTurn ?? Number(process.env.AGENT_MAX_CHILDREN_PER_TURN ?? 3));
164
+ this.maxSteps = options.maxSteps;
165
+ const contextPromise = options.projectContext !== undefined
166
+ ? Promise.resolve(options.projectContext)
167
+ : loadWorkspaceContext(this.workspaceRoot);
168
+ this.ready = Promise.all([this.registry.load(), this.store.init(), contextPromise]).then(([, , context]) => {
169
+ this.projectContext = context;
170
+ this.validateSpecs();
171
+ });
129
172
  }
130
173
  whenReady() {
131
174
  return this.ready;
@@ -164,11 +207,123 @@ export class AgentRuntime {
164
207
  listAgentSpecs() {
165
208
  return this.registry.list();
166
209
  }
210
+ /** Absolute workspace root tools resolve relative paths against (/cd target). */
211
+ workspace() {
212
+ return this.workspaceRoot;
213
+ }
214
+ /** Switch the workspace for this runtime (/cd). Resolves `path` against the
215
+ * current root, revalidates it, reloads project agent specs and AGENTS.md,
216
+ * and re-reads project context so every following turn runs in the new root.
217
+ * Ongoing turns keep their already-composed prompts; the change lands on the
218
+ * next turn. */
219
+ async changeWorkspace(path, options = {}) {
220
+ await this.ready;
221
+ const previousRoot = this.workspaceRoot;
222
+ const target = resolve(previousRoot, path.trim());
223
+ let stat;
224
+ try {
225
+ stat = await import('node:fs/promises').then((fs) => fs.stat(target));
226
+ }
227
+ catch {
228
+ throw new Error(`cd: no such directory: ${path}`);
229
+ }
230
+ if (!stat.isDirectory())
231
+ throw new Error(`cd: not a directory: ${target}`);
232
+ if (target === previousRoot)
233
+ return { from: previousRoot, to: target };
234
+ this.workspaceRoot = target;
235
+ this.registry.setProjectDir(target);
236
+ this.projectContext = await loadWorkspaceContext(target);
237
+ this.readVersions.clear();
238
+ this.failureCounts.clear();
239
+ try {
240
+ await this.registry.load();
241
+ this.validateSpecs();
242
+ }
243
+ catch (error) {
244
+ // Roll back so the runtime never stays half-switched between roots.
245
+ this.workspaceRoot = previousRoot;
246
+ this.registry.setProjectDir(previousRoot);
247
+ this.projectContext = await loadWorkspaceContext(previousRoot).catch(() => undefined);
248
+ throw error instanceof Error ? error : new Error(String(error));
249
+ }
250
+ this.emit({ type: 'workspace_changed', sessionId: options.sessionId, workspaceRoot: target, previousRoot });
251
+ return { from: previousRoot, to: target };
252
+ }
253
+ // ── Cross-process session access (single writer, many read-only viewers) ───
254
+ sessionLockTarget(sessionId) {
255
+ return this.store.sessionPath(sessionId);
256
+ }
257
+ sessionLockTimeout() {
258
+ return Math.max(250, Number(process.env.AGENT_SESSION_LOCK_TIMEOUT_MS ?? 5_000));
259
+ }
260
+ /**
261
+ * Try to become the session's cross-process writer. Returns `writable: false`
262
+ * (instead of throwing) when another process owns the session; the caller
263
+ * opens the session in read-only mode.
264
+ */
265
+ async ensureSessionLock(sessionId) {
266
+ if (this.sessionLockHandles.has(sessionId))
267
+ return { writable: true };
268
+ try {
269
+ const handle = await this.sessionLocks.acquire(this.sessionLockTarget(sessionId), {
270
+ timeoutMs: this.sessionLockTimeout(),
271
+ purpose: `session:${sessionId}`,
272
+ session: sessionId,
273
+ });
274
+ this.sessionLockHandles.set(sessionId, handle);
275
+ this.sessionLockHolders.delete(sessionId);
276
+ return { writable: true };
277
+ }
278
+ catch (error) {
279
+ if (!(error instanceof LockConflictError))
280
+ throw error;
281
+ const holder = error.holder ?? await this.sessionLocks.holder(this.sessionLockTarget(sessionId));
282
+ if (holder)
283
+ this.sessionLockHolders.set(sessionId, holder);
284
+ return { writable: false, holder };
285
+ }
286
+ }
287
+ async releaseSessionLock(sessionId) {
288
+ const handle = this.sessionLockHandles.get(sessionId);
289
+ if (!handle)
290
+ return;
291
+ this.sessionLockHandles.delete(sessionId);
292
+ this.sessionLockHolders.delete(sessionId);
293
+ await handle.release();
294
+ }
295
+ /** Cross-process access state for UI status display. */
296
+ sessionAccess(sessionId) {
297
+ if (this.sessionLockHandles.has(sessionId))
298
+ return { writable: true };
299
+ if (!this.sessions.has(sessionId))
300
+ return { writable: true }; // never opened here; no restriction known
301
+ const holder = this.sessionLockHolders.get(sessionId);
302
+ if (!holder)
303
+ return { writable: false };
304
+ return { writable: false, holderPid: holder.pid, holderSession: holder.session };
305
+ }
306
+ /** Gate for mutating entry points. Opens the session, then self-heals a
307
+ * read-only state when the previous writer has since exited; otherwise
308
+ * fails with guidance (review or /fork). */
309
+ async requireSessionWrite(sessionId) {
310
+ await this.openSession(sessionId);
311
+ if (this.sessionLockHandles.has(sessionId))
312
+ return;
313
+ const access = await this.ensureSessionLock(sessionId);
314
+ if (access.writable)
315
+ return;
316
+ const holder = access.holder ?? this.sessionLockHolders.get(sessionId);
317
+ const who = holder ? `pid ${holder.pid}${holder.session ? ` (session ${holder.session})` : ''}` : 'another process';
318
+ throw new Error(`Session is read-only: it is currently being written by ${who}. ` +
319
+ 'You can keep reviewing the conversation; to continue working from this point, run /fork to get your own writable copy.');
320
+ }
167
321
  async openSession(sessionId = `session-${Date.now()}`) {
168
322
  await this.ready;
169
323
  const current = this.sessions.get(sessionId);
170
324
  if (current)
171
325
  return cloneSession(current);
326
+ const access = await this.ensureSessionLock(sessionId);
172
327
  const persisted = await this.store.load(sessionId);
173
328
  if (persisted) {
174
329
  const session = persisted.session;
@@ -180,11 +335,20 @@ export class AgentRuntime {
180
335
  }
181
336
  this.instances.set(instance.instanceId, instance);
182
337
  }
183
- for (const instance of persisted.instances.filter((item) => item.status === 'queued'))
184
- this.enqueue(instance.instanceId);
338
+ // Read-only viewers never schedule recovered turns: the writer process
339
+ // that crashed owned that queue, and re-running it from here would race.
340
+ if (access.writable) {
341
+ for (const instance of persisted.instances.filter((item) => item.status === 'queued'))
342
+ this.enqueue(instance.instanceId);
343
+ }
185
344
  this.emit({ type: 'session_opened', session: cloneSession(session) });
186
345
  return cloneSession(session);
187
346
  }
347
+ if (!access.writable) {
348
+ const holder = access.holder;
349
+ const who = holder ? `pid ${holder.pid}${holder.session ? ` (session ${holder.session})` : ''}` : 'another process';
350
+ throw new Error(`Session ${sessionId} is currently being written by ${who}. Pick a new session id, or wait for the other process to exit.`);
351
+ }
188
352
  if (!this.registry.get('main'))
189
353
  throw new Error('No main agent spec found');
190
354
  const createdAt = now();
@@ -231,15 +395,37 @@ export class AgentRuntime {
231
395
  this.queued.delete(id);
232
396
  }
233
397
  this.sessions.delete(sessionId);
234
- await this.store.remove(sessionId);
398
+ await this.releaseSessionLock(sessionId);
399
+ // Deleting is itself a cross-process mutation: refuse when another
400
+ // process currently owns the session.
401
+ try {
402
+ const handle = await this.sessionLocks.acquire(this.sessionLockTarget(sessionId), {
403
+ timeoutMs: 2_000,
404
+ purpose: `session:${sessionId}:delete`,
405
+ session: sessionId,
406
+ });
407
+ try {
408
+ await this.store.remove(sessionId);
409
+ }
410
+ finally {
411
+ await handle.release();
412
+ }
413
+ }
414
+ catch (error) {
415
+ if (error instanceof LockConflictError) {
416
+ throw new Error(`Session ${sessionId} cannot be removed while another process is using it (pid ${error.holder?.pid ?? 'unknown'}).`);
417
+ }
418
+ throw error;
419
+ }
235
420
  }
236
421
  async clearSession(sessionId) {
237
- if (!this.sessions.has(sessionId))
238
- await this.openSession(sessionId);
422
+ await this.requireSessionWrite(sessionId);
239
423
  await this.cancelSession(sessionId);
240
424
  const session = this.sessions.get(sessionId);
241
425
  session.messages = [];
242
426
  session.timeline = [];
427
+ session.pendingAsides = [];
428
+ session.goal = undefined;
243
429
  session.updatedAt = now();
244
430
  const main = this.instances.get(session.mainInstanceId);
245
431
  if (main) {
@@ -255,6 +441,8 @@ export class AgentRuntime {
255
441
  await this.persistSession(sessionId);
256
442
  }
257
443
  async cancelSession(sessionId) {
444
+ if (!this.sessionLockHandles.has(sessionId))
445
+ await this.requireSessionWrite(sessionId);
258
446
  const session = this.sessions.get(sessionId);
259
447
  if (!session)
260
448
  return;
@@ -273,15 +461,81 @@ export class AgentRuntime {
273
461
  await this.persistSession(sessionId);
274
462
  this.notifyIdleWaiters();
275
463
  }
464
+ /** Queue an aside (/aside): folds into the next submitted message without starting a turn. */
465
+ async addAside(sessionId, content) {
466
+ const text = content.trim();
467
+ if (!text)
468
+ throw new Error('Aside cannot be empty');
469
+ await this.requireSessionWrite(sessionId);
470
+ const session = this.sessions.get(sessionId);
471
+ const hadAsides = (session.pendingAsides?.length ?? 0) > 0;
472
+ (session.pendingAsides ??= []).push(text);
473
+ session.updatedAt = now();
474
+ await this.persistSession(sessionId);
475
+ this.emit({ type: 'system_message', sessionId, message: { messageId: randomUUID(), role: 'system', content: hadAsides
476
+ ? `Noted — another aside is already queued; both will be included with your next message.`
477
+ : `Noted. This will be included with your next message without starting a turn.`, createdAt: now() } });
478
+ return { queued: true, detail: hadAsides
479
+ ? 'Queued behind one earlier aside; both will be included with the next message.'
480
+ : 'Queued. It will be included with the next message without starting a turn.' };
481
+ }
482
+ /** Set or clear the standing session goal (/goal). Injected into every agent's prompt until cleared. */
483
+ async setSessionGoal(sessionId, goal) {
484
+ const text = goal.trim();
485
+ await this.requireSessionWrite(sessionId);
486
+ const session = this.sessions.get(sessionId);
487
+ session.goal = text || undefined;
488
+ session.updatedAt = now();
489
+ await this.persistSession(sessionId);
490
+ this.emit({ type: 'system_message', sessionId, message: { messageId: randomUUID(), role: 'system',
491
+ content: text ? `Session goal set: ${text}` : 'Session goal cleared.', createdAt: now() } });
492
+ return { set: Boolean(text), detail: text ? `Goal set. It now applies to every agent in this session: ${text}` : 'Goal cleared.' };
493
+ }
494
+ /** Emit a session-scoped timeline notice that never reaches the model. */
495
+ async emitSystemNotice(sessionId, content) {
496
+ if (!this.sessions.has(sessionId))
497
+ await this.openSession(sessionId);
498
+ this.emit({ type: 'system_message', sessionId, message: { messageId: randomUUID(), role: 'system', content, createdAt: now() } });
499
+ }
500
+ /** Fork a session into a new persisted copy (used by /fork and /btw side conversations).
501
+ * Works from a read-only session: the copy is taken from the persisted file
502
+ * and the fork's lock is held while copying so a concurrent opener of the
503
+ * same new id either waits or loses the race cleanly. */
504
+ async forkSession(sessionId, newSessionId = `session-${Date.now()}`) {
505
+ await this.openSession(sessionId);
506
+ await this.persistSession(sessionId);
507
+ const handle = await this.sessionLocks.acquire(this.sessionLockTarget(newSessionId), {
508
+ timeoutMs: this.sessionLockTimeout(),
509
+ purpose: `session:${newSessionId}:fork`,
510
+ session: newSessionId,
511
+ });
512
+ try {
513
+ await this.store.copySession(sessionId, newSessionId);
514
+ }
515
+ finally {
516
+ await handle.release();
517
+ }
518
+ const detail = `Forked into ${newSessionId}. /sessions lists both; the original stays untouched.`;
519
+ this.emit({ type: 'system_message', sessionId, message: { messageId: randomUUID(), role: 'system', content: detail, createdAt: now() } });
520
+ return { sessionId: newSessionId, detail };
521
+ }
276
522
  async submitMessage(sessionId, content) {
277
523
  const text = content.trim();
278
524
  if (!text)
279
525
  throw new Error('Message cannot be empty');
280
- await this.openSession(sessionId);
526
+ await this.requireSessionWrite(sessionId);
281
527
  const session = this.sessions.get(sessionId);
282
528
  const main = this.instances.get(session.mainInstanceId);
283
529
  const turnId = randomUUID();
284
- const message = { messageId: randomUUID(), role: 'user', content: text, createdAt: now(), turnId };
530
+ const queuedAsides = session.pendingAsides ?? [];
531
+ session.pendingAsides = [];
532
+ const composed = queuedAsides.length
533
+ ? `${text}
534
+
535
+ ${asidePrefix()}
536
+ ${queuedAsides.map((aside, index) => `${index + 1}. ${aside}`).join('\n')}`
537
+ : text;
538
+ const message = { messageId: randomUUID(), role: 'user', content: composed, createdAt: now(), turnId };
285
539
  session.messages.push(message);
286
540
  session.updatedAt = message.createdAt;
287
541
  if (main.status === 'running' || main.status === 'waiting') {
@@ -289,9 +543,20 @@ export class AgentRuntime {
289
543
  }
290
544
  if (main.status === 'cancelled')
291
545
  main.status = 'idle';
292
- this.deliver(main, text, undefined, turnId);
546
+ // The model must see the folded asides, so deliver the composed message.
547
+ // The TUI renders the asides as separate system entries from the emitted
548
+ // system_message events above, while this user message keeps them inline.
549
+ this.deliver(main, composed, undefined, turnId);
293
550
  await this.persistSession(sessionId);
294
551
  this.emit({ type: 'user_message', sessionId, message: { ...message } });
552
+ if (queuedAsides.length) {
553
+ // Timeline-only notices; the user message itself already carries the
554
+ // asides inline for the model.
555
+ this.emit({ type: 'system_message', sessionId, message: { messageId: randomUUID(), role: 'system', content: `Aside${queuedAsides.length > 1 ? 's' : ''} included with your message:`, createdAt: now() } });
556
+ for (const aside of queuedAsides) {
557
+ this.emit({ type: 'system_message', sessionId, message: { messageId: randomUUID(), role: 'system', content: `· ${aside}`, createdAt: now() } });
558
+ }
559
+ }
295
560
  this.enqueue(main.instanceId);
296
561
  return turnId;
297
562
  }
@@ -309,8 +574,13 @@ export class AgentRuntime {
309
574
  const ancestors = this.ancestorAgentIds(parent);
310
575
  if (ancestors.has(agentId))
311
576
  throw new Error(`Agent call cycle rejected: ${agentId} already exists in the ancestor chain`);
577
+ const turnId = parent.activeTurnId;
578
+ const childrenThisTurn = sessionChildren(this.instances, parent.instanceId).filter((child) => child.parentTurnId === turnId).length;
579
+ if (childrenThisTurn >= this.maxChildrenPerTurn)
580
+ throw new Error(`Maximum of ${this.maxChildrenPerTurn} child agents per turn reached; reuse an existing agent or continue directly.`);
312
581
  const session = this.sessions.get(parent.sessionId);
313
582
  const child = this.newInstance(parent.sessionId, agentId, parent.instanceId, parent.depth + 1);
583
+ child.parentTurnId = turnId;
314
584
  this.instances.set(child.instanceId, child);
315
585
  parent.childInstanceIds.push(child.instanceId);
316
586
  parent.updatedAt = now();
@@ -467,6 +737,9 @@ export class AgentRuntime {
467
737
  for (const sessionId of this.sessions.keys())
468
738
  await this.persistSession(sessionId);
469
739
  await this.store.flush();
740
+ for (const sessionId of [...this.sessionLockHandles.keys()]) {
741
+ await this.releaseSessionLock(sessionId).catch(() => undefined);
742
+ }
470
743
  }
471
744
  newInstance(sessionId, agentId, parentInstanceId, depth = 0, createdAt = now()) {
472
745
  if (!this.registry.get(agentId))
@@ -567,11 +840,15 @@ export class AgentRuntime {
567
840
  }
568
841
  systemPrompt(instance, spec) {
569
842
  const catalog = this.registry.allowedAgents(spec);
570
- const relatedInstances = this.listInstances(instance.sessionId)
571
- .filter((candidate) => candidate.instanceId !== instance.instanceId && candidate.status !== 'cancelled')
572
- .map((candidate) => `- ${candidate.agentId} (${candidate.instanceId}): ${candidate.status}${candidate.lastOutput ? ` ${candidate.lastOutput.slice(0, 180)}` : ''}`);
843
+ // Keep the system prefix stable between model calls. Injecting every sibling's
844
+ // live status here invalidates provider prompt caches and burns input tokens;
845
+ // child results are delivered through the mailbox and remain visible in the
846
+ // normal conversation context.
847
+ const session = this.sessions.get(instance.sessionId);
573
848
  return [
574
849
  spec.instructions,
850
+ ...(session?.goal ? ['', `Standing goal for this session (highest priority; stay aligned with it unless the user says otherwise):`, session.goal] : []),
851
+ ...(this.projectContext ? ['', `Project context (${WORKSPACE_CONTEXT_LABEL}):`, this.projectContext] : []),
575
852
  '',
576
853
  'Runtime contract:',
577
854
  `- You are agent "${spec.id}" in workspace ${this.workspaceRoot}.`,
@@ -584,9 +861,7 @@ export class AgentRuntime {
584
861
  catalog.length
585
862
  ? `Available agents:\n${catalog.map((agent) => `- ${agent.id}: ${agent.description}`).join('\n')}`
586
863
  : 'Available agents: none.',
587
- relatedInstances.length
588
- ? `Existing instances in this session:\n${relatedInstances.join('\n')}`
589
- : 'Existing instances in this session: none.',
864
+ 'Existing instances are communicated through mailbox messages. Reuse an existing related agent when possible; do not spawn duplicates.',
590
865
  ].join('\n');
591
866
  }
592
867
  toolsFor(instance, spec) {
@@ -784,6 +1059,7 @@ export class AgentRuntime {
784
1059
  instance.activeTurnId = turnId;
785
1060
  instance.status = 'running';
786
1061
  instance.updatedAt = now();
1062
+ this.failureCounts.delete(instance.instanceId);
787
1063
  this.controllers.set(instance.instanceId, controller);
788
1064
  this.absorbMailbox(instance);
789
1065
  this.emit({ type: 'instance_updated', instance: cloneInstance(instance) });
@@ -794,7 +1070,12 @@ export class AgentRuntime {
794
1070
  throw new Error('No model configured. Use /provider or /model first.');
795
1071
  const tools = this.toolsFor(instance, spec);
796
1072
  let finalOutput = '';
797
- for (let step = 0; step < 32; step += 1) {
1073
+ const turnStartedAt = Date.now();
1074
+ let turnUsage;
1075
+ let firstTokenMs;
1076
+ let requestCount = 0;
1077
+ const stepLimit = Math.max(1, this.maxSteps ?? (instance.parentInstanceId ? 48 : 64));
1078
+ for (let step = 0; step < stepLimit; step += 1) {
798
1079
  if (controller.signal.aborted || instance.activeTurnId !== turnId)
799
1080
  return;
800
1081
  if (instance.pendingCompact || this.shouldAutoCompact(instance.messages, config)) {
@@ -809,6 +1090,7 @@ export class AgentRuntime {
809
1090
  catch { /* Compaction is best-effort; trimMessages remains the fallback. */ }
810
1091
  }
811
1092
  const messages = this.trimMessages(instance.messages, config);
1093
+ requestCount += 1;
812
1094
  let text = '';
813
1095
  let thinking = '';
814
1096
  const responseItems = [];
@@ -823,12 +1105,16 @@ export class AgentRuntime {
823
1105
  this.emit({ type: 'thinking_delta', sessionId: session.sessionId, instanceId: instance.instanceId, turnId, text: chunk.thinking });
824
1106
  }
825
1107
  if (chunk.content) {
1108
+ if (firstTokenMs === undefined)
1109
+ firstTokenMs = Date.now() - turnStartedAt;
826
1110
  text += chunk.content;
827
1111
  finalOutput += chunk.content;
828
1112
  if (!instance.parentInstanceId) {
829
1113
  this.emit({ type: 'assistant_delta', sessionId: session.sessionId, instanceId: instance.instanceId, turnId, text: chunk.content });
830
1114
  }
831
1115
  }
1116
+ if (chunk.usage)
1117
+ turnUsage = mergeUsage(turnUsage, chunk.usage);
832
1118
  if (chunk.toolCalls?.length)
833
1119
  calls.push(...chunk.toolCalls);
834
1120
  }
@@ -850,6 +1136,31 @@ export class AgentRuntime {
850
1136
  return;
851
1137
  instance.messages.push({ role: 'tool', content: output, tool_use_id: call.id });
852
1138
  this.emit({ type: 'tool_finished', instanceId: instance.instanceId, turnId, tool: call.function.name, output });
1139
+ const fingerprint = createHash('sha256')
1140
+ .update(call.function.name)
1141
+ .update('\0')
1142
+ .update(JSON.stringify(args))
1143
+ .update('\0')
1144
+ .update(output)
1145
+ .digest('hex');
1146
+ const failed = /^Error:/i.test(output) || /PolicyError/.test(output);
1147
+ if (failed) {
1148
+ // Progress, not recency, resets the failure chain: counts are kept
1149
+ // per fingerprint, so interleaved successful reads (which prove
1150
+ // nothing changed) cannot launder a repeating failure.
1151
+ const counts = this.failureCounts.get(instance.instanceId) ?? new Map();
1152
+ const repeats = (counts.get(fingerprint) ?? 0) + 1;
1153
+ counts.set(fingerprint, repeats);
1154
+ this.failureCounts.set(instance.instanceId, counts);
1155
+ if (repeats >= 3) {
1156
+ throw new Error(`Doom loop detected: ${call.function.name} produced the same failure ${repeats} times without progress. Change approach or inspect the diagnostic before retrying.`);
1157
+ }
1158
+ }
1159
+ else if (PROGRESS_TOOLS.has(call.function.name)) {
1160
+ // Only a state-changing success (an actual write) counts as
1161
+ // progress; read-only successes leave the failure chain intact.
1162
+ this.failureCounts.delete(instance.instanceId);
1163
+ }
853
1164
  }
854
1165
  if (instance.pendingCompact && !controller.signal.aborted && instance.activeTurnId === turnId) {
855
1166
  try {
@@ -861,12 +1172,15 @@ export class AgentRuntime {
861
1172
  }
862
1173
  catch { /* Compaction is best-effort. */ }
863
1174
  }
864
- if (step === 31)
865
- throw new Error('Agent reached the 32-step limit. Review the activity and send a follow-up to continue.');
1175
+ if (step === stepLimit - 1)
1176
+ throw new Error(`Agent reached the ${stepLimit}-step safety limit. Review the activity and send a follow-up to continue.`);
866
1177
  }
867
1178
  if (controller.signal.aborted || instance.activeTurnId !== turnId)
868
1179
  return;
869
1180
  instance.lastOutput = finalOutput.trim() || instance.lastOutput;
1181
+ const endedAt = now();
1182
+ instance.usage = mergeAgentUsage(instance.usage, turnUsage, firstTokenMs, Date.now() - turnStartedAt, requestCount);
1183
+ instance.lastTurn = { startedAt: new Date(turnStartedAt).toISOString(), endedAt, durationMs: Date.now() - turnStartedAt, usage: turnUsage };
870
1184
  instance.lastError = undefined;
871
1185
  instance.status = 'idle';
872
1186
  instance.activeTurnId = undefined;
@@ -949,6 +1263,19 @@ export class AgentRuntime {
949
1263
  signal,
950
1264
  policy: getToolPolicy(),
951
1265
  acquireWriteLock: (path) => this.fileLocks.acquire(path),
1266
+ requirePriorRead: true,
1267
+ getReadVersion: (path) => this.readVersions.get(instance.instanceId)?.get(resolve(path)),
1268
+ recordReadVersion: (path, version) => {
1269
+ let versions = this.readVersions.get(instance.instanceId);
1270
+ if (!versions) {
1271
+ versions = new Map();
1272
+ this.readVersions.set(instance.instanceId, versions);
1273
+ }
1274
+ versions.set(resolve(path), version);
1275
+ },
1276
+ recordWriteVersion: (path, _version) => {
1277
+ this.readVersions.get(instance.instanceId)?.delete(resolve(path));
1278
+ },
952
1279
  });
953
1280
  }
954
1281
  catch (error) {
@@ -979,6 +1306,10 @@ export class AgentRuntime {
979
1306
  const session = this.sessions.get(sessionId);
980
1307
  if (!session)
981
1308
  return;
1309
+ // Read-only viewers never persist: the session file belongs to the
1310
+ // cross-process writer holding the session lock.
1311
+ if (!this.sessionLockHandles.has(sessionId))
1312
+ return;
982
1313
  const snapshot = {
983
1314
  version: 1,
984
1315
  session: cloneSession(session),
@@ -31,6 +31,10 @@ export class AgentRuntimeStore {
31
31
  constructor(baseDir = process.env.CODER_DATA_HOME?.trim() || resolve(homedir(), '.coder')) {
32
32
  this.dir = resolve(baseDir, 'runtime');
33
33
  }
34
+ /** Root directory for runtime state (sessions, archives, locks, instances). */
35
+ get runtimeDir() {
36
+ return this.dir;
37
+ }
34
38
  async init() {
35
39
  await mkdir(this.dir, { recursive: true });
36
40
  }
@@ -38,6 +42,10 @@ export class AgentRuntimeStore {
38
42
  validSessionId(sessionId);
39
43
  return resolve(this.dir, `${sessionId}.json`);
40
44
  }
45
+ /** Absolute path of the persisted session snapshot (cross-process lock target). */
46
+ sessionPath(sessionId) {
47
+ return this.path(sessionId);
48
+ }
41
49
  async save(snapshot) {
42
50
  const path = this.path(snapshot.session.sessionId);
43
51
  const payload = `${JSON.stringify(snapshot, null, 2)}\n`;
@@ -75,6 +83,21 @@ export class AgentRuntimeStore {
75
83
  return undefined;
76
84
  }
77
85
  }
86
+ /** Duplicate one persisted session file under a new session id (used by /fork). */
87
+ async copySession(sourceSessionId, newSessionId) {
88
+ const source = await this.load(sourceSessionId);
89
+ if (!source)
90
+ throw new Error(`Session ${sourceSessionId} is not persisted yet.`);
91
+ const snapshot = {
92
+ version: 1,
93
+ session: { ...source.session, sessionId: newSessionId },
94
+ instances: source.instances.map((instance) => ({ ...instance, sessionId: newSessionId })),
95
+ };
96
+ await this.save(snapshot);
97
+ for (const archive of await this.loadArchives(sourceSessionId)) {
98
+ await this.saveArchive(newSessionId, archive.instanceId, archive.seq, archive.messages);
99
+ }
100
+ }
78
101
  async list() {
79
102
  await Promise.all([...writes.values()].map((write) => write.catch(() => undefined)));
80
103
  let files = [];