tokenmaw 0.3.0 → 0.4.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/dist/responses.js CHANGED
@@ -66,7 +66,13 @@ export async function* responsesStream(config, instructions, messages, tools, si
66
66
  throw new Error(event.response?.error?.message ?? event.message ?? event.response?.incomplete_details?.reason ?? `Responses: ${event.type}`);
67
67
  }
68
68
  if (event.type === 'response.completed') {
69
- yield { content: null, done: true };
69
+ const usage = event.response?.usage;
70
+ yield { content: null, done: true, usage: usage ? {
71
+ inputTokens: usage.input_tokens,
72
+ outputTokens: usage.output_tokens,
73
+ cachedInputTokens: usage.input_tokens_details?.cached_tokens,
74
+ reasoningTokens: usage.output_tokens_details?.reasoning_tokens,
75
+ } : undefined };
70
76
  return;
71
77
  }
72
78
  }
@@ -1,6 +1,6 @@
1
1
  import { readdir, readFile } from 'node:fs/promises';
2
2
  import { homedir } from 'node:os';
3
- import { relative, resolve, sep } from 'node:path';
3
+ import { join, relative, resolve, sep } from 'node:path';
4
4
  function unquote(value) {
5
5
  const trimmed = value.trim();
6
6
  if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
@@ -86,6 +86,25 @@ async function markdownFiles(root) {
86
86
  function specId(root, file) {
87
87
  return relative(root, file).split(sep).join('/').replace(/\.md$/i, '');
88
88
  }
89
+ /** Names checked in order when loading project context. The lowercase form
90
+ * covers case-insensitive filesystems; the capitalized forms follow the
91
+ * agents.md convention used by other coding agents. */
92
+ const WORKSPACE_CONTEXT_FILENAMES = ['AGENTS.md', 'AGENT.md', 'agents.md'];
93
+ /** Loads an optional AGENTS.md-style project context document from the
94
+ * workspace root. Plain Markdown with no frontmatter; a missing, empty, or
95
+ * unreadable file is not an error — the convention is opt-in per workspace. */
96
+ export async function loadWorkspaceContext(root) {
97
+ for (const name of WORKSPACE_CONTEXT_FILENAMES) {
98
+ try {
99
+ const content = (await readFile(join(root, name), 'utf8')).replace(/^\uFEFF/, '').trim();
100
+ if (content)
101
+ return content;
102
+ // An empty file counts as absent; keep looking at the remaining names.
103
+ }
104
+ catch { /* try the next candidate name */ }
105
+ }
106
+ return undefined;
107
+ }
89
108
  function matchesSelector(id, selector) {
90
109
  if (selector === '*')
91
110
  return true;
@@ -96,15 +115,26 @@ function matchesSelector(id, selector) {
96
115
  export class AgentRegistry {
97
116
  specs = new Map();
98
117
  roots;
118
+ builtinDir;
119
+ userDir;
120
+ projectDir;
99
121
  constructor(options = {}) {
100
122
  const workspaceRoot = resolve(options.workspaceRoot ?? process.cwd());
101
- const builtinDir = resolve(options.builtinDir ?? resolve(import.meta.dirname, '..', '..', 'agents'));
123
+ this.builtinDir = resolve(options.builtinDir ?? resolve(import.meta.dirname, '..', '..', 'agents'));
124
+ this.userDir = resolve(options.userDir ?? resolve(homedir(), '.coder', 'agents'));
125
+ this.projectDir = options.projectDir ? resolve(options.projectDir) : undefined;
102
126
  this.roots = [
103
- { path: builtinDir, scope: 'builtin' },
104
- { path: resolve(options.userDir ?? resolve(homedir(), '.coder', 'agents')), scope: 'user' },
105
- { path: resolve(options.projectDir ?? resolve(workspaceRoot, '.coder', 'agents')), scope: 'project' },
127
+ { path: this.builtinDir, scope: 'builtin' },
128
+ { path: this.userDir, scope: 'user' },
129
+ { path: this.projectDir ?? resolve(workspaceRoot, '.coder', 'agents'), scope: 'project' },
106
130
  ];
107
131
  }
132
+ /** Point the project-scope spec root at another workspace (<root>/.coder/agents).
133
+ * Takes effect on the next load(); used by /cd when switching workspaces. */
134
+ setProjectDir(workspaceRoot) {
135
+ this.projectDir = resolve(workspaceRoot, '.coder', 'agents');
136
+ this.roots[this.roots.length - 1] = { path: this.projectDir, scope: 'project' };
137
+ }
108
138
  async load() {
109
139
  this.specs.clear();
110
140
  for (const root of this.roots) {
@@ -1,14 +1,33 @@
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 mergeAgentUsage(previous, usage, firstTokenMs, durationMs, requests = 1) {
23
+ const merged = mergeUsage(previous, usage ?? {});
24
+ return { ...merged, requests: (previous?.requests ?? 0) + requests, turns: (previous?.turns ?? 0) + 1, firstTokenMs, lastTurnMs: durationMs };
25
+ }
26
+ function sessionChildren(instances, parentId) {
27
+ return [...instances.values()].filter((instance) => instance.parentInstanceId === parentId);
28
+ }
29
+ /** Tools whose success constitutes real progress (state changed on disk). */
30
+ const PROGRESS_TOOLS = new Set(['edit_file', 'write_file']);
12
31
  // ── Context compaction ───────────────────────────────────────────────────────
13
32
  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
33
  const DEFAULT_COMPACT_KEEP_RECENT = 12;
@@ -103,6 +122,11 @@ export class AgentRuntime {
103
122
  modelStream;
104
123
  maxConcurrentTurns;
105
124
  maxAgentDepth;
125
+ maxChildrenPerTurn;
126
+ maxSteps;
127
+ projectContext;
128
+ readVersions = new Map();
129
+ failureCounts = new Map();
106
130
  defaultModel;
107
131
  sessions = new Map();
108
132
  instances = new Map();
@@ -113,19 +137,35 @@ export class AgentRuntime {
113
137
  running = new Set();
114
138
  controllers = new Map();
115
139
  idleWaiters = new Set();
116
- fileLocks = new FileLockManager();
140
+ fileLocks;
141
+ sessionLocks;
142
+ sessionLockHandles = new Map();
143
+ sessionLockHolders = new Map();
117
144
  ready;
118
145
  shuttingDown = false;
119
146
  constructor(options) {
120
147
  this.workspaceRoot = resolve(options.workspaceRoot ?? process.cwd());
121
148
  this.registry = options.registry ?? new AgentRegistry({ workspaceRoot: this.workspaceRoot });
122
149
  this.store = options.store ?? new AgentRuntimeStore();
150
+ // Locks live next to session state so tests (which pass a tmp store) and
151
+ // alternate CODER_DATA_HOME deployments never touch the default lock dir.
152
+ const lockDir = resolve(this.store.runtimeDir, 'locks');
153
+ this.fileLocks = new FileLockManager(lockDir);
154
+ this.sessionLocks = new CrossProcessLockManager(lockDir);
123
155
  this.resolveModel = options.resolveModel;
124
156
  this.defaultModel = options.defaultModel;
125
157
  this.modelStream = options.modelStream ?? ((config, system, messages, tools, signal) => (chatStream(config, system, messages, tools, signal)));
126
158
  this.maxConcurrentTurns = Math.max(1, options.maxConcurrentTurns ?? Number(process.env.AGENT_MAX_CONCURRENT_TURNS ?? 4));
127
159
  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());
160
+ this.maxChildrenPerTurn = Math.max(1, options.maxChildrenPerTurn ?? Number(process.env.AGENT_MAX_CHILDREN_PER_TURN ?? 3));
161
+ this.maxSteps = options.maxSteps;
162
+ const contextPromise = options.projectContext !== undefined
163
+ ? Promise.resolve(options.projectContext)
164
+ : loadWorkspaceContext(this.workspaceRoot);
165
+ this.ready = Promise.all([this.registry.load(), this.store.init(), contextPromise]).then(([, , context]) => {
166
+ this.projectContext = context;
167
+ this.validateSpecs();
168
+ });
129
169
  }
130
170
  whenReady() {
131
171
  return this.ready;
@@ -164,11 +204,123 @@ export class AgentRuntime {
164
204
  listAgentSpecs() {
165
205
  return this.registry.list();
166
206
  }
207
+ /** Absolute workspace root tools resolve relative paths against (/cd target). */
208
+ workspace() {
209
+ return this.workspaceRoot;
210
+ }
211
+ /** Switch the workspace for this runtime (/cd). Resolves `path` against the
212
+ * current root, revalidates it, reloads project agent specs and AGENTS.md,
213
+ * and re-reads project context so every following turn runs in the new root.
214
+ * Ongoing turns keep their already-composed prompts; the change lands on the
215
+ * next turn. */
216
+ async changeWorkspace(path, options = {}) {
217
+ await this.ready;
218
+ const previousRoot = this.workspaceRoot;
219
+ const target = resolve(previousRoot, path.trim());
220
+ let stat;
221
+ try {
222
+ stat = await import('node:fs/promises').then((fs) => fs.stat(target));
223
+ }
224
+ catch {
225
+ throw new Error(`cd: no such directory: ${path}`);
226
+ }
227
+ if (!stat.isDirectory())
228
+ throw new Error(`cd: not a directory: ${target}`);
229
+ if (target === previousRoot)
230
+ return { from: previousRoot, to: target };
231
+ this.workspaceRoot = target;
232
+ this.registry.setProjectDir(target);
233
+ this.projectContext = await loadWorkspaceContext(target);
234
+ this.readVersions.clear();
235
+ this.failureCounts.clear();
236
+ try {
237
+ await this.registry.load();
238
+ this.validateSpecs();
239
+ }
240
+ catch (error) {
241
+ // Roll back so the runtime never stays half-switched between roots.
242
+ this.workspaceRoot = previousRoot;
243
+ this.registry.setProjectDir(previousRoot);
244
+ this.projectContext = await loadWorkspaceContext(previousRoot).catch(() => undefined);
245
+ throw error instanceof Error ? error : new Error(String(error));
246
+ }
247
+ this.emit({ type: 'workspace_changed', sessionId: options.sessionId, workspaceRoot: target, previousRoot });
248
+ return { from: previousRoot, to: target };
249
+ }
250
+ // ── Cross-process session access (single writer, many read-only viewers) ───
251
+ sessionLockTarget(sessionId) {
252
+ return this.store.sessionPath(sessionId);
253
+ }
254
+ sessionLockTimeout() {
255
+ return Math.max(250, Number(process.env.AGENT_SESSION_LOCK_TIMEOUT_MS ?? 5_000));
256
+ }
257
+ /**
258
+ * Try to become the session's cross-process writer. Returns `writable: false`
259
+ * (instead of throwing) when another process owns the session; the caller
260
+ * opens the session in read-only mode.
261
+ */
262
+ async ensureSessionLock(sessionId) {
263
+ if (this.sessionLockHandles.has(sessionId))
264
+ return { writable: true };
265
+ try {
266
+ const handle = await this.sessionLocks.acquire(this.sessionLockTarget(sessionId), {
267
+ timeoutMs: this.sessionLockTimeout(),
268
+ purpose: `session:${sessionId}`,
269
+ session: sessionId,
270
+ });
271
+ this.sessionLockHandles.set(sessionId, handle);
272
+ this.sessionLockHolders.delete(sessionId);
273
+ return { writable: true };
274
+ }
275
+ catch (error) {
276
+ if (!(error instanceof LockConflictError))
277
+ throw error;
278
+ const holder = error.holder ?? await this.sessionLocks.holder(this.sessionLockTarget(sessionId));
279
+ if (holder)
280
+ this.sessionLockHolders.set(sessionId, holder);
281
+ return { writable: false, holder };
282
+ }
283
+ }
284
+ async releaseSessionLock(sessionId) {
285
+ const handle = this.sessionLockHandles.get(sessionId);
286
+ if (!handle)
287
+ return;
288
+ this.sessionLockHandles.delete(sessionId);
289
+ this.sessionLockHolders.delete(sessionId);
290
+ await handle.release();
291
+ }
292
+ /** Cross-process access state for UI status display. */
293
+ sessionAccess(sessionId) {
294
+ if (this.sessionLockHandles.has(sessionId))
295
+ return { writable: true };
296
+ if (!this.sessions.has(sessionId))
297
+ return { writable: true }; // never opened here; no restriction known
298
+ const holder = this.sessionLockHolders.get(sessionId);
299
+ if (!holder)
300
+ return { writable: false };
301
+ return { writable: false, holderPid: holder.pid, holderSession: holder.session };
302
+ }
303
+ /** Gate for mutating entry points. Opens the session, then self-heals a
304
+ * read-only state when the previous writer has since exited; otherwise
305
+ * fails with guidance (review or /fork). */
306
+ async requireSessionWrite(sessionId) {
307
+ await this.openSession(sessionId);
308
+ if (this.sessionLockHandles.has(sessionId))
309
+ return;
310
+ const access = await this.ensureSessionLock(sessionId);
311
+ if (access.writable)
312
+ return;
313
+ const holder = access.holder ?? this.sessionLockHolders.get(sessionId);
314
+ const who = holder ? `pid ${holder.pid}${holder.session ? ` (session ${holder.session})` : ''}` : 'another process';
315
+ throw new Error(`Session is read-only: it is currently being written by ${who}. ` +
316
+ 'You can keep reviewing the conversation; to continue working from this point, run /fork to get your own writable copy.');
317
+ }
167
318
  async openSession(sessionId = `session-${Date.now()}`) {
168
319
  await this.ready;
169
320
  const current = this.sessions.get(sessionId);
170
321
  if (current)
171
322
  return cloneSession(current);
323
+ const access = await this.ensureSessionLock(sessionId);
172
324
  const persisted = await this.store.load(sessionId);
173
325
  if (persisted) {
174
326
  const session = persisted.session;
@@ -180,11 +332,20 @@ export class AgentRuntime {
180
332
  }
181
333
  this.instances.set(instance.instanceId, instance);
182
334
  }
183
- for (const instance of persisted.instances.filter((item) => item.status === 'queued'))
184
- this.enqueue(instance.instanceId);
335
+ // Read-only viewers never schedule recovered turns: the writer process
336
+ // that crashed owned that queue, and re-running it from here would race.
337
+ if (access.writable) {
338
+ for (const instance of persisted.instances.filter((item) => item.status === 'queued'))
339
+ this.enqueue(instance.instanceId);
340
+ }
185
341
  this.emit({ type: 'session_opened', session: cloneSession(session) });
186
342
  return cloneSession(session);
187
343
  }
344
+ if (!access.writable) {
345
+ const holder = access.holder;
346
+ const who = holder ? `pid ${holder.pid}${holder.session ? ` (session ${holder.session})` : ''}` : 'another process';
347
+ throw new Error(`Session ${sessionId} is currently being written by ${who}. Pick a new session id, or wait for the other process to exit.`);
348
+ }
188
349
  if (!this.registry.get('main'))
189
350
  throw new Error('No main agent spec found');
190
351
  const createdAt = now();
@@ -231,15 +392,36 @@ export class AgentRuntime {
231
392
  this.queued.delete(id);
232
393
  }
233
394
  this.sessions.delete(sessionId);
234
- await this.store.remove(sessionId);
395
+ await this.releaseSessionLock(sessionId);
396
+ // Deleting is itself a cross-process mutation: refuse when another
397
+ // process currently owns the session.
398
+ try {
399
+ const handle = await this.sessionLocks.acquire(this.sessionLockTarget(sessionId), {
400
+ timeoutMs: 2_000,
401
+ purpose: `session:${sessionId}:delete`,
402
+ session: sessionId,
403
+ });
404
+ try {
405
+ await this.store.remove(sessionId);
406
+ }
407
+ finally {
408
+ await handle.release();
409
+ }
410
+ }
411
+ catch (error) {
412
+ if (error instanceof LockConflictError) {
413
+ throw new Error(`Session ${sessionId} cannot be removed while another process is using it (pid ${error.holder?.pid ?? 'unknown'}).`);
414
+ }
415
+ throw error;
416
+ }
235
417
  }
236
418
  async clearSession(sessionId) {
237
- if (!this.sessions.has(sessionId))
238
- await this.openSession(sessionId);
419
+ await this.requireSessionWrite(sessionId);
239
420
  await this.cancelSession(sessionId);
240
421
  const session = this.sessions.get(sessionId);
241
422
  session.messages = [];
242
423
  session.timeline = [];
424
+ session.goal = undefined;
243
425
  session.updatedAt = now();
244
426
  const main = this.instances.get(session.mainInstanceId);
245
427
  if (main) {
@@ -255,6 +437,8 @@ export class AgentRuntime {
255
437
  await this.persistSession(sessionId);
256
438
  }
257
439
  async cancelSession(sessionId) {
440
+ if (!this.sessionLockHandles.has(sessionId))
441
+ await this.requireSessionWrite(sessionId);
258
442
  const session = this.sessions.get(sessionId);
259
443
  if (!session)
260
444
  return;
@@ -273,11 +457,51 @@ export class AgentRuntime {
273
457
  await this.persistSession(sessionId);
274
458
  this.notifyIdleWaiters();
275
459
  }
460
+ /** Set or clear the standing session goal (/goal). Injected into every agent's prompt until cleared. */
461
+ async setSessionGoal(sessionId, goal) {
462
+ const text = goal.trim();
463
+ await this.requireSessionWrite(sessionId);
464
+ const session = this.sessions.get(sessionId);
465
+ session.goal = text || undefined;
466
+ session.updatedAt = now();
467
+ await this.persistSession(sessionId);
468
+ this.emit({ type: 'system_message', sessionId, message: { messageId: randomUUID(), role: 'system',
469
+ content: text ? `Session goal set: ${text}` : 'Session goal cleared.', createdAt: now() } });
470
+ return { set: Boolean(text), detail: text ? `Goal set. It now applies to every agent in this session: ${text}` : 'Goal cleared.' };
471
+ }
472
+ /** Emit a session-scoped timeline notice that never reaches the model. */
473
+ async emitSystemNotice(sessionId, content) {
474
+ if (!this.sessions.has(sessionId))
475
+ await this.openSession(sessionId);
476
+ this.emit({ type: 'system_message', sessionId, message: { messageId: randomUUID(), role: 'system', content, createdAt: now() } });
477
+ }
478
+ /** Fork a session into a new persisted copy (used by /fork and /btw side conversations).
479
+ * Works from a read-only session: the copy is taken from the persisted file
480
+ * and the fork's lock is held while copying so a concurrent opener of the
481
+ * same new id either waits or loses the race cleanly. */
482
+ async forkSession(sessionId, newSessionId = `session-${Date.now()}`) {
483
+ await this.openSession(sessionId);
484
+ await this.persistSession(sessionId);
485
+ const handle = await this.sessionLocks.acquire(this.sessionLockTarget(newSessionId), {
486
+ timeoutMs: this.sessionLockTimeout(),
487
+ purpose: `session:${newSessionId}:fork`,
488
+ session: newSessionId,
489
+ });
490
+ try {
491
+ await this.store.copySession(sessionId, newSessionId);
492
+ }
493
+ finally {
494
+ await handle.release();
495
+ }
496
+ const detail = `Forked into ${newSessionId}. /sessions lists both; the original stays untouched.`;
497
+ this.emit({ type: 'system_message', sessionId, message: { messageId: randomUUID(), role: 'system', content: detail, createdAt: now() } });
498
+ return { sessionId: newSessionId, detail };
499
+ }
276
500
  async submitMessage(sessionId, content) {
277
501
  const text = content.trim();
278
502
  if (!text)
279
503
  throw new Error('Message cannot be empty');
280
- await this.openSession(sessionId);
504
+ await this.requireSessionWrite(sessionId);
281
505
  const session = this.sessions.get(sessionId);
282
506
  const main = this.instances.get(session.mainInstanceId);
283
507
  const turnId = randomUUID();
@@ -309,8 +533,13 @@ export class AgentRuntime {
309
533
  const ancestors = this.ancestorAgentIds(parent);
310
534
  if (ancestors.has(agentId))
311
535
  throw new Error(`Agent call cycle rejected: ${agentId} already exists in the ancestor chain`);
536
+ const turnId = parent.activeTurnId;
537
+ const childrenThisTurn = sessionChildren(this.instances, parent.instanceId).filter((child) => child.parentTurnId === turnId).length;
538
+ if (childrenThisTurn >= this.maxChildrenPerTurn)
539
+ throw new Error(`Maximum of ${this.maxChildrenPerTurn} child agents per turn reached; reuse an existing agent or continue directly.`);
312
540
  const session = this.sessions.get(parent.sessionId);
313
541
  const child = this.newInstance(parent.sessionId, agentId, parent.instanceId, parent.depth + 1);
542
+ child.parentTurnId = turnId;
314
543
  this.instances.set(child.instanceId, child);
315
544
  parent.childInstanceIds.push(child.instanceId);
316
545
  parent.updatedAt = now();
@@ -467,6 +696,9 @@ export class AgentRuntime {
467
696
  for (const sessionId of this.sessions.keys())
468
697
  await this.persistSession(sessionId);
469
698
  await this.store.flush();
699
+ for (const sessionId of [...this.sessionLockHandles.keys()]) {
700
+ await this.releaseSessionLock(sessionId).catch(() => undefined);
701
+ }
470
702
  }
471
703
  newInstance(sessionId, agentId, parentInstanceId, depth = 0, createdAt = now()) {
472
704
  if (!this.registry.get(agentId))
@@ -567,11 +799,15 @@ export class AgentRuntime {
567
799
  }
568
800
  systemPrompt(instance, spec) {
569
801
  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)}` : ''}`);
802
+ // Keep the system prefix stable between model calls. Injecting every sibling's
803
+ // live status here invalidates provider prompt caches and burns input tokens;
804
+ // child results are delivered through the mailbox and remain visible in the
805
+ // normal conversation context.
806
+ const session = this.sessions.get(instance.sessionId);
573
807
  return [
574
808
  spec.instructions,
809
+ ...(session?.goal ? ['', `Standing goal for this session (highest priority; stay aligned with it unless the user says otherwise):`, session.goal] : []),
810
+ ...(this.projectContext ? ['', `Project context (${WORKSPACE_CONTEXT_LABEL}):`, this.projectContext] : []),
575
811
  '',
576
812
  'Runtime contract:',
577
813
  `- You are agent "${spec.id}" in workspace ${this.workspaceRoot}.`,
@@ -584,9 +820,7 @@ export class AgentRuntime {
584
820
  catalog.length
585
821
  ? `Available agents:\n${catalog.map((agent) => `- ${agent.id}: ${agent.description}`).join('\n')}`
586
822
  : 'Available agents: none.',
587
- relatedInstances.length
588
- ? `Existing instances in this session:\n${relatedInstances.join('\n')}`
589
- : 'Existing instances in this session: none.',
823
+ 'Existing instances are communicated through mailbox messages. Reuse an existing related agent when possible; do not spawn duplicates.',
590
824
  ].join('\n');
591
825
  }
592
826
  toolsFor(instance, spec) {
@@ -784,6 +1018,7 @@ export class AgentRuntime {
784
1018
  instance.activeTurnId = turnId;
785
1019
  instance.status = 'running';
786
1020
  instance.updatedAt = now();
1021
+ this.failureCounts.delete(instance.instanceId);
787
1022
  this.controllers.set(instance.instanceId, controller);
788
1023
  this.absorbMailbox(instance);
789
1024
  this.emit({ type: 'instance_updated', instance: cloneInstance(instance) });
@@ -794,7 +1029,12 @@ export class AgentRuntime {
794
1029
  throw new Error('No model configured. Use /provider or /model first.');
795
1030
  const tools = this.toolsFor(instance, spec);
796
1031
  let finalOutput = '';
797
- for (let step = 0; step < 32; step += 1) {
1032
+ const turnStartedAt = Date.now();
1033
+ let turnUsage;
1034
+ let firstTokenMs;
1035
+ let requestCount = 0;
1036
+ const stepLimit = Math.max(1, this.maxSteps ?? (instance.parentInstanceId ? 48 : 64));
1037
+ for (let step = 0; step < stepLimit; step += 1) {
798
1038
  if (controller.signal.aborted || instance.activeTurnId !== turnId)
799
1039
  return;
800
1040
  if (instance.pendingCompact || this.shouldAutoCompact(instance.messages, config)) {
@@ -809,6 +1049,7 @@ export class AgentRuntime {
809
1049
  catch { /* Compaction is best-effort; trimMessages remains the fallback. */ }
810
1050
  }
811
1051
  const messages = this.trimMessages(instance.messages, config);
1052
+ requestCount += 1;
812
1053
  let text = '';
813
1054
  let thinking = '';
814
1055
  const responseItems = [];
@@ -823,12 +1064,16 @@ export class AgentRuntime {
823
1064
  this.emit({ type: 'thinking_delta', sessionId: session.sessionId, instanceId: instance.instanceId, turnId, text: chunk.thinking });
824
1065
  }
825
1066
  if (chunk.content) {
1067
+ if (firstTokenMs === undefined)
1068
+ firstTokenMs = Date.now() - turnStartedAt;
826
1069
  text += chunk.content;
827
1070
  finalOutput += chunk.content;
828
1071
  if (!instance.parentInstanceId) {
829
1072
  this.emit({ type: 'assistant_delta', sessionId: session.sessionId, instanceId: instance.instanceId, turnId, text: chunk.content });
830
1073
  }
831
1074
  }
1075
+ if (chunk.usage)
1076
+ turnUsage = mergeUsage(turnUsage, chunk.usage);
832
1077
  if (chunk.toolCalls?.length)
833
1078
  calls.push(...chunk.toolCalls);
834
1079
  }
@@ -850,6 +1095,31 @@ export class AgentRuntime {
850
1095
  return;
851
1096
  instance.messages.push({ role: 'tool', content: output, tool_use_id: call.id });
852
1097
  this.emit({ type: 'tool_finished', instanceId: instance.instanceId, turnId, tool: call.function.name, output });
1098
+ const fingerprint = createHash('sha256')
1099
+ .update(call.function.name)
1100
+ .update('\0')
1101
+ .update(JSON.stringify(args))
1102
+ .update('\0')
1103
+ .update(output)
1104
+ .digest('hex');
1105
+ const failed = /^Error:/i.test(output) || /PolicyError/.test(output);
1106
+ if (failed) {
1107
+ // Progress, not recency, resets the failure chain: counts are kept
1108
+ // per fingerprint, so interleaved successful reads (which prove
1109
+ // nothing changed) cannot launder a repeating failure.
1110
+ const counts = this.failureCounts.get(instance.instanceId) ?? new Map();
1111
+ const repeats = (counts.get(fingerprint) ?? 0) + 1;
1112
+ counts.set(fingerprint, repeats);
1113
+ this.failureCounts.set(instance.instanceId, counts);
1114
+ if (repeats >= 3) {
1115
+ 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.`);
1116
+ }
1117
+ }
1118
+ else if (PROGRESS_TOOLS.has(call.function.name)) {
1119
+ // Only a state-changing success (an actual write) counts as
1120
+ // progress; read-only successes leave the failure chain intact.
1121
+ this.failureCounts.delete(instance.instanceId);
1122
+ }
853
1123
  }
854
1124
  if (instance.pendingCompact && !controller.signal.aborted && instance.activeTurnId === turnId) {
855
1125
  try {
@@ -861,12 +1131,15 @@ export class AgentRuntime {
861
1131
  }
862
1132
  catch { /* Compaction is best-effort. */ }
863
1133
  }
864
- if (step === 31)
865
- throw new Error('Agent reached the 32-step limit. Review the activity and send a follow-up to continue.');
1134
+ if (step === stepLimit - 1)
1135
+ throw new Error(`Agent reached the ${stepLimit}-step safety limit. Review the activity and send a follow-up to continue.`);
866
1136
  }
867
1137
  if (controller.signal.aborted || instance.activeTurnId !== turnId)
868
1138
  return;
869
1139
  instance.lastOutput = finalOutput.trim() || instance.lastOutput;
1140
+ const endedAt = now();
1141
+ instance.usage = mergeAgentUsage(instance.usage, turnUsage, firstTokenMs, Date.now() - turnStartedAt, requestCount);
1142
+ instance.lastTurn = { startedAt: new Date(turnStartedAt).toISOString(), endedAt, durationMs: Date.now() - turnStartedAt, usage: turnUsage };
870
1143
  instance.lastError = undefined;
871
1144
  instance.status = 'idle';
872
1145
  instance.activeTurnId = undefined;
@@ -949,6 +1222,19 @@ export class AgentRuntime {
949
1222
  signal,
950
1223
  policy: getToolPolicy(),
951
1224
  acquireWriteLock: (path) => this.fileLocks.acquire(path),
1225
+ requirePriorRead: true,
1226
+ getReadVersion: (path) => this.readVersions.get(instance.instanceId)?.get(resolve(path)),
1227
+ recordReadVersion: (path, version) => {
1228
+ let versions = this.readVersions.get(instance.instanceId);
1229
+ if (!versions) {
1230
+ versions = new Map();
1231
+ this.readVersions.set(instance.instanceId, versions);
1232
+ }
1233
+ versions.set(resolve(path), version);
1234
+ },
1235
+ recordWriteVersion: (path, _version) => {
1236
+ this.readVersions.get(instance.instanceId)?.delete(resolve(path));
1237
+ },
952
1238
  });
953
1239
  }
954
1240
  catch (error) {
@@ -979,6 +1265,10 @@ export class AgentRuntime {
979
1265
  const session = this.sessions.get(sessionId);
980
1266
  if (!session)
981
1267
  return;
1268
+ // Read-only viewers never persist: the session file belongs to the
1269
+ // cross-process writer holding the session lock.
1270
+ if (!this.sessionLockHandles.has(sessionId))
1271
+ return;
982
1272
  const snapshot = {
983
1273
  version: 1,
984
1274
  session: cloneSession(session),