crewly 1.20.154 → 1.20.155

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.
Files changed (56) hide show
  1. package/config/skills/team-leader/delegate-task/execute.sh +17 -4
  2. package/dist/backend/backend/src/constants.d.ts +38 -0
  3. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  4. package/dist/backend/backend/src/constants.js +38 -0
  5. package/dist/backend/backend/src/constants.js.map +1 -1
  6. package/dist/backend/backend/src/controllers/monitoring/terminal.controller.d.ts +32 -0
  7. package/dist/backend/backend/src/controllers/monitoring/terminal.controller.d.ts.map +1 -1
  8. package/dist/backend/backend/src/controllers/monitoring/terminal.controller.js +122 -8
  9. package/dist/backend/backend/src/controllers/monitoring/terminal.controller.js.map +1 -1
  10. package/dist/backend/backend/src/index.d.ts.map +1 -1
  11. package/dist/backend/backend/src/index.js +13 -0
  12. package/dist/backend/backend/src/index.js.map +1 -1
  13. package/dist/backend/backend/src/services/agent/agent-registration.service.d.ts +15 -0
  14. package/dist/backend/backend/src/services/agent/agent-registration.service.d.ts.map +1 -1
  15. package/dist/backend/backend/src/services/agent/agent-registration.service.js +36 -0
  16. package/dist/backend/backend/src/services/agent/agent-registration.service.js.map +1 -1
  17. package/dist/backend/backend/src/services/agent/fresh-task-conversation.service.d.ts +153 -1
  18. package/dist/backend/backend/src/services/agent/fresh-task-conversation.service.d.ts.map +1 -1
  19. package/dist/backend/backend/src/services/agent/fresh-task-conversation.service.js +371 -22
  20. package/dist/backend/backend/src/services/agent/fresh-task-conversation.service.js.map +1 -1
  21. package/dist/backend/backend/src/services/event-bus/event-bus.service.d.ts.map +1 -1
  22. package/dist/backend/backend/src/services/event-bus/event-bus.service.js +6 -0
  23. package/dist/backend/backend/src/services/event-bus/event-bus.service.js.map +1 -1
  24. package/dist/backend/backend/src/services/task-pool/task-pool.service.d.ts.map +1 -1
  25. package/dist/backend/backend/src/services/task-pool/task-pool.service.js +2 -0
  26. package/dist/backend/backend/src/services/task-pool/task-pool.service.js.map +1 -1
  27. package/dist/backend/backend/src/services/v3/workitem-dispatch.subscriber.d.ts +47 -0
  28. package/dist/backend/backend/src/services/v3/workitem-dispatch.subscriber.d.ts.map +1 -1
  29. package/dist/backend/backend/src/services/v3/workitem-dispatch.subscriber.js +118 -3
  30. package/dist/backend/backend/src/services/v3/workitem-dispatch.subscriber.js.map +1 -1
  31. package/dist/backend/backend/src/types/event-bus.types.d.ts +5 -0
  32. package/dist/backend/backend/src/types/event-bus.types.d.ts.map +1 -1
  33. package/dist/backend/backend/src/types/event-bus.types.js.map +1 -1
  34. package/dist/backend/build-info.json +2 -2
  35. package/dist/cli/backend/src/constants.d.ts +38 -0
  36. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  37. package/dist/cli/backend/src/constants.js +38 -0
  38. package/dist/cli/backend/src/constants.js.map +1 -1
  39. package/dist/cli/backend/src/services/agent/agent-registration.service.d.ts +15 -0
  40. package/dist/cli/backend/src/services/agent/agent-registration.service.d.ts.map +1 -1
  41. package/dist/cli/backend/src/services/agent/agent-registration.service.js +36 -0
  42. package/dist/cli/backend/src/services/agent/agent-registration.service.js.map +1 -1
  43. package/dist/cli/backend/src/services/agent/fresh-task-conversation.service.d.ts +409 -0
  44. package/dist/cli/backend/src/services/agent/fresh-task-conversation.service.d.ts.map +1 -0
  45. package/dist/cli/backend/src/services/agent/fresh-task-conversation.service.js +961 -0
  46. package/dist/cli/backend/src/services/agent/fresh-task-conversation.service.js.map +1 -0
  47. package/dist/cli/backend/src/services/event-bus/event-bus.service.d.ts.map +1 -1
  48. package/dist/cli/backend/src/services/event-bus/event-bus.service.js +6 -0
  49. package/dist/cli/backend/src/services/event-bus/event-bus.service.js.map +1 -1
  50. package/dist/cli/backend/src/services/task-pool/task-pool.service.d.ts.map +1 -1
  51. package/dist/cli/backend/src/services/task-pool/task-pool.service.js +2 -0
  52. package/dist/cli/backend/src/services/task-pool/task-pool.service.js.map +1 -1
  53. package/dist/cli/backend/src/types/event-bus.types.d.ts +5 -0
  54. package/dist/cli/backend/src/types/event-bus.types.d.ts.map +1 -1
  55. package/dist/cli/backend/src/types/event-bus.types.js.map +1 -1
  56. package/package.json +1 -1
@@ -0,0 +1,961 @@
1
+ /**
2
+ * Fresh Task Conversation Service
3
+ *
4
+ * Claude Code agents keep one conversation forever, so a small new task
5
+ * re-reads hundreds of thousands of tokens of unrelated history on every
6
+ * turn (81% of measured usage came from turns over 300k context). This
7
+ * service starts a fresh conversation when a team member is handed a task
8
+ * whose ROOT differs from the last one it was given:
9
+ *
10
+ * 1. The old conversation is saved server-side — a handover file under
11
+ * `<CREWLY_HOME>/handover/` (same format the orchestrator uses at
12
+ * restart) and a concise copy through the memory `remember` path, which
13
+ * mirrors into the wiki. No agent turn is spent on it.
14
+ * 2. `/clear` is written to the PTY the same way context compaction writes
15
+ * its command (Escape, 200 ms, command + CR), then a short pause for the
16
+ * prompt to come back.
17
+ * 3. The dispatch goes on with a one-line note pointing at the handover.
18
+ * 4. In the background the new transcript Claude Code starts is found and
19
+ * its id stored, so a restart resumes the NEW conversation; if it cannot
20
+ * be found the stored id is cleared so a restart starts fresh instead of
21
+ * resuming the huge pre-clear one.
22
+ *
23
+ * Retries, verifications and reviews of the same task share its root
24
+ * (`<id>:retry:N`, `<id>:verify:<id>`, `<id>:review:…`), so they keep their
25
+ * context. Never the orchestrator, only Claude Code, only when the agent is
26
+ * idle and nobody else just wrote to it; any failure falls back to plain
27
+ * delivery — nothing here throws into dispatch.
28
+ *
29
+ * Idle-boundary context cap: one task can run for 1000+ turns, so a new root
30
+ * alone does not bound the context. A periodic sweep
31
+ * ({@link FreshTaskConversationService.startContextCapSweep}) saves and
32
+ * clears an idle Claude Code member whose last turn carried more than
33
+ * `CREWLY_MEMBER_CONTEXT_CAP_TOKENS` (300k by default, `0` disables), then
34
+ * writes one line naming its active WorkItem and the handover. Same safety
35
+ * rules — never the orchestrator, never mid-turn, never while a message is
36
+ * queued for or being delivered to it — plus at most one cap per 20 minutes.
37
+ *
38
+ * @module services/agent/fresh-task-conversation.service
39
+ */
40
+ import * as fs from 'fs';
41
+ import * as path from 'path';
42
+ import { LoggerService } from '../core/logger.service.js';
43
+ import { getCrewlyHomePath } from '../core/crewly-home.utils.js';
44
+ import { CREWLY_CONSTANTS, FRESH_TASK_CONVERSATION_CONSTANTS, ORC_CONVERSATION_CONSTANTS, ORCHESTRATOR_SESSION_NAME, RUNTIME_TYPES, } from '../../constants.js';
45
+ import { buildHandoverSummary, claudeTranscriptPath, lastTurnContextTokens } from './runtime-session-recovery.js';
46
+ import { getSessionStatePersistence } from '../session/session-state-persistence.js';
47
+ import { getSessionBackendSync } from '../session/session-backend.factory.js';
48
+ import { PtyActivityTrackerService } from './pty-activity-tracker.service.js';
49
+ import { SubAgentMessageQueue } from '../messaging/sub-agent-message-queue.service.js';
50
+ import { STANDING_ANSWERS_CONSTANTS } from '../../constants.js';
51
+ // ---------------------------------------------------------------------------
52
+ // Pure helpers
53
+ // ---------------------------------------------------------------------------
54
+ /**
55
+ * The root task id of a work item: its id with any follow-up suffix
56
+ * (`:retry:N`, `:verify:…`, `:review:…`) removed, so every follow-up of one
57
+ * task maps to the same root.
58
+ *
59
+ * @param workItemId - WorkItem id
60
+ * @returns The root id
61
+ *
62
+ * @example
63
+ * ```typescript
64
+ * rootWorkItemId('abc:retry:2'); // 'abc'
65
+ * rootWorkItemId('abc:verify:abc'); // 'abc'
66
+ * rootWorkItemId('abc:retry:1:retry:2'); // 'abc'
67
+ * ```
68
+ */
69
+ export function rootWorkItemId(workItemId) {
70
+ let cut = workItemId.length;
71
+ for (const marker of FRESH_TASK_CONVERSATION_CONSTANTS.ROOT_SUFFIX_MARKERS) {
72
+ const i = workItemId.indexOf(marker);
73
+ if (i > 0 && i < cut)
74
+ cut = i;
75
+ }
76
+ return workItemId.slice(0, cut);
77
+ }
78
+ /**
79
+ * Whether the feature is switched on by the environment.
80
+ *
81
+ * @param env - Environment (tests)
82
+ * @returns False when CREWLY_FRESH_TASK_CONVERSATION is 0 / false / off / no
83
+ */
84
+ export function freshTaskConversationEnvEnabled(env = process.env) {
85
+ const raw = env[FRESH_TASK_CONVERSATION_CONSTANTS.ENV_TOGGLE];
86
+ if (raw === undefined)
87
+ return true;
88
+ return !['0', 'false', 'off', 'no'].includes(raw.trim().toLowerCase());
89
+ }
90
+ /**
91
+ * Decide whether to start a fresh conversation before delivering a task.
92
+ *
93
+ * @param input - Decision inputs
94
+ * @returns The decision
95
+ */
96
+ export function decideFreshConversation(input) {
97
+ if (input.sessionName === ORCHESTRATOR_SESSION_NAME)
98
+ return { clear: false, reason: 'orchestrator' };
99
+ if (input.runtimeType !== RUNTIME_TYPES.CLAUDE_CODE)
100
+ return { clear: false, reason: 'not claude-code' };
101
+ if (!input.enabled)
102
+ return { clear: false, reason: 'disabled' };
103
+ if (!input.previousRoot)
104
+ return { clear: false, reason: 'first task' };
105
+ if (input.previousRoot === input.newRoot)
106
+ return { clear: false, reason: 'same task' };
107
+ if (input.busy)
108
+ return { clear: false, reason: 'busy' };
109
+ if (input.recentDelivery)
110
+ return { clear: false, reason: 'recent delivery' };
111
+ if (input.alreadyStarted)
112
+ return { clear: false, reason: 'already working on it' };
113
+ if (input.otherActiveRoots.some((r) => r !== input.newRoot))
114
+ return { clear: false, reason: 'other work in progress' };
115
+ return { clear: true, reason: 'new task' };
116
+ }
117
+ /**
118
+ * The line put in front of the task text after a fresh start.
119
+ *
120
+ * @param handoverPath - Handover file written for the old conversation
121
+ * @returns One line
122
+ */
123
+ export function freshConversationNote(handoverPath) {
124
+ return `Fresh conversation for this task — your earlier work is in ${handoverPath} and your wiki; read them only if this task needs it.`;
125
+ }
126
+ /**
127
+ * The context cap for members, from the environment.
128
+ *
129
+ * @param env - Environment (tests)
130
+ * @returns Tokens; 0 means the cap is off. Unset or invalid → the default.
131
+ */
132
+ export function memberContextCapTokens(env = process.env) {
133
+ const raw = env[FRESH_TASK_CONVERSATION_CONSTANTS.MEMBER_CONTEXT_CAP_ENV];
134
+ if (raw === undefined || raw.trim() === '')
135
+ return FRESH_TASK_CONVERSATION_CONSTANTS.MEMBER_CONTEXT_CAP_TOKENS;
136
+ const n = Number(raw);
137
+ if (!Number.isFinite(n) || n < 0)
138
+ return FRESH_TASK_CONVERSATION_CONSTANTS.MEMBER_CONTEXT_CAP_TOKENS;
139
+ return Math.floor(n);
140
+ }
141
+ /**
142
+ * Decide whether to cap (save + clear + re-orient) a member's conversation.
143
+ * Pure; the order of checks is also the order of the logged reasons.
144
+ *
145
+ * @param input - Decision inputs
146
+ * @returns The decision
147
+ */
148
+ export function decideContextCap(input) {
149
+ if (input.sessionName === ORCHESTRATOR_SESSION_NAME)
150
+ return { clear: false, reason: 'orchestrator' };
151
+ if (input.runtimeType !== RUNTIME_TYPES.CLAUDE_CODE)
152
+ return { clear: false, reason: 'not claude-code' };
153
+ if (input.capTokens <= 0)
154
+ return { clear: false, reason: 'cap off' };
155
+ if (!input.enabled)
156
+ return { clear: false, reason: 'disabled' };
157
+ if (input.contextTokens === null || input.contextTokens <= input.capTokens)
158
+ return { clear: false, reason: 'under cap' };
159
+ if (input.lastCapAt !== null &&
160
+ input.now - input.lastCapAt < FRESH_TASK_CONVERSATION_CONSTANTS.CONTEXT_CAP_MIN_INTERVAL_MS) {
161
+ return { clear: false, reason: 'rate limited' };
162
+ }
163
+ if (input.busy)
164
+ return { clear: false, reason: 'busy' };
165
+ if (input.quietMs === null || input.quietMs < FRESH_TASK_CONVERSATION_CONSTANTS.CONTEXT_CAP_MIN_QUIET_MS) {
166
+ return { clear: false, reason: 'not quiet long enough' };
167
+ }
168
+ if (input.deliveryActive)
169
+ return { clear: false, reason: 'delivery in progress' };
170
+ if (input.queuedMessages)
171
+ return { clear: false, reason: 'messages queued' };
172
+ // The new conversation is found by the WorkItem id in the re-orientation
173
+ // line; with no WorkItem there is nothing safe to track it by, and the
174
+ // next task's own prepare will start a fresh conversation anyway.
175
+ if (!input.activeWorkItemId)
176
+ return { clear: false, reason: 'no active work item' };
177
+ return { clear: true, reason: 'context over cap' };
178
+ }
179
+ /**
180
+ * The one line written after a context-cap clear. It must carry the WorkItem
181
+ * id: the new conversation id is found by it.
182
+ *
183
+ * @param args - WorkItem, handover file and the old context size
184
+ * @returns One line
185
+ */
186
+ export function contextCapReorientation(args) {
187
+ const title = args.workItem.title.length > 80 ? `${args.workItem.title.slice(0, 77)}...` : args.workItem.title;
188
+ return (`${FRESH_TASK_CONVERSATION_CONSTANTS.CONTEXT_CAP_TAG} Your conversation reached ${args.contextTokens.toLocaleString('en-US')} tokens, so it was saved and restarted. ` +
189
+ `You are on WorkItem ${args.workItem.id} ("${title}"). Your handover is in ${args.handoverPath} (also in your wiki) — read it, then continue that WorkItem where you left off.`);
190
+ }
191
+ /** Statuses that mean the agent is actively on a work item. */
192
+ const ACTIVE_STATUSES = new Set(['running', 'accepted', 'proposed']);
193
+ /**
194
+ * Default collaborators. Heavy services are imported lazily so importing this
195
+ * module (from the dispatch subscriber, the terminal controller) stays cheap.
196
+ *
197
+ * @returns Deps bound to the running backend
198
+ */
199
+ function defaultDeps() {
200
+ const getPersistence = () => {
201
+ try {
202
+ return getSessionStatePersistence();
203
+ }
204
+ catch {
205
+ return null;
206
+ }
207
+ };
208
+ return {
209
+ getSessionInfo: (sessionName) => {
210
+ const meta = getPersistence()?.getSessionMetadata(sessionName);
211
+ if (!meta)
212
+ return null;
213
+ return { runtimeType: meta.runtimeType, cwd: meta.cwd, sessionId: meta.claudeSessionId };
214
+ },
215
+ getClaimedSessionIds: (sessionName) => {
216
+ const p = getPersistence();
217
+ const ids = new Set();
218
+ if (!p)
219
+ return ids;
220
+ for (const name of p.getRegisteredSessions()) {
221
+ const id = p.getSessionId(name);
222
+ if (id && name !== sessionName)
223
+ ids.add(id);
224
+ }
225
+ return ids;
226
+ },
227
+ updateSessionId: (sessionName, id) => getPersistence()?.updateSessionId(sessionName, id),
228
+ clearSessionId: (sessionName) => getPersistence()?.clearSessionId(sessionName),
229
+ isBusy: async (sessionName) => {
230
+ const { StorageService } = await import('../core/storage.service.js');
231
+ const info = await StorageService.getInstance().findMemberBySessionName(sessionName).catch(() => null);
232
+ if (info?.member.workingStatus === CREWLY_CONSTANTS.WORKING_STATUSES.IN_PROGRESS)
233
+ return true;
234
+ const tracker = PtyActivityTrackerService.getInstance();
235
+ return tracker.hasActivity(sessionName) && tracker.getIdleTimeMs(sessionName) < FRESH_TASK_CONVERSATION_CONSTANTS.MIN_QUIET_MS;
236
+ },
237
+ settingEnabled: async () => {
238
+ try {
239
+ const { getSettingsService } = await import('../settings/settings.service.js');
240
+ return (await getSettingsService().getSettings()).general.freshConversationPerTask !== false;
241
+ }
242
+ catch {
243
+ return true;
244
+ }
245
+ },
246
+ getActiveItems: async (sessionName) => {
247
+ const { TaskPoolService } = await import('../task-pool/task-pool.service.js');
248
+ const all = await TaskPoolService.getInstance().getAllItems();
249
+ return all.filter((wi) => wi.target === sessionName && ACTIVE_STATUSES.has(wi.status));
250
+ },
251
+ writeToSession: (sessionName, data) => {
252
+ const session = getSessionBackendSync()?.getSession(sessionName);
253
+ if (!session)
254
+ return false;
255
+ session.write(data);
256
+ return true;
257
+ },
258
+ remember: async ({ agentId, projectPath, content, title }) => {
259
+ const { MemoryService } = await import('../memory/memory.service.js');
260
+ return MemoryService.getInstance().remember({
261
+ agentId,
262
+ projectPath,
263
+ content,
264
+ category: 'fact',
265
+ scope: 'agent',
266
+ metadata: { title },
267
+ });
268
+ },
269
+ crewlyHome: getCrewlyHomePath,
270
+ now: () => Date.now(),
271
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
272
+ env: process.env,
273
+ listSessions: () => getPersistence()?.getRegisteredSessions() ?? [],
274
+ hasQueuedMessages: (sessionName) => {
275
+ try {
276
+ return SubAgentMessageQueue.getInstance().hasPending(sessionName);
277
+ }
278
+ catch {
279
+ return true;
280
+ }
281
+ },
282
+ getQuietMs: (sessionName) => {
283
+ const tracker = PtyActivityTrackerService.getInstance();
284
+ return tracker.hasActivity(sessionName) ? tracker.getIdleTimeMs(sessionName) : null;
285
+ },
286
+ sendMessage: async (sessionName, text) => {
287
+ const session = getSessionBackendSync()?.getSession(sessionName);
288
+ if (!session)
289
+ return false;
290
+ // Same two-step write as the terminal controller's message mode:
291
+ // pasted text first, Enter separately so paste mode cannot swallow it.
292
+ session.write(`\x1b[200~${text}\x1b[201~`);
293
+ await new Promise((resolve) => setTimeout(resolve, Math.min(1000 + Math.ceil(text.length / 10), 5000)));
294
+ session.write('\r');
295
+ return true;
296
+ },
297
+ };
298
+ }
299
+ /**
300
+ * Singleton that starts a fresh Claude Code conversation per new task, and
301
+ * caps a long task's conversation at idle boundaries.
302
+ */
303
+ export class FreshTaskConversationService {
304
+ static instance = null;
305
+ logger;
306
+ deps;
307
+ state = null;
308
+ /** Per-session in-flight prepare (serialises prepares; waited on by terminal writes) */
309
+ inFlight = new Map();
310
+ /** Last time a message was written to a session (terminal write / deliver) */
311
+ lastDelivery = new Map();
312
+ /** Deliveries currently being written to a session (see {@link beginDelivery}) */
313
+ deliveriesInFlight = new Map();
314
+ /** Last context-cap clear per session (rate limit) */
315
+ lastCapAt = new Map();
316
+ /** Context-cap sweep timer */
317
+ capSweepTimer = null;
318
+ /** A sweep is running (sweeps never overlap) */
319
+ capSweepRunning = false;
320
+ constructor(deps) {
321
+ this.logger = LoggerService.getInstance().createComponentLogger('FreshTaskConversation');
322
+ this.deps = { ...defaultDeps(), ...(deps ?? {}) };
323
+ }
324
+ /**
325
+ * @returns The singleton
326
+ */
327
+ static getInstance() {
328
+ if (!FreshTaskConversationService.instance) {
329
+ FreshTaskConversationService.instance = new FreshTaskConversationService();
330
+ }
331
+ return FreshTaskConversationService.instance;
332
+ }
333
+ /**
334
+ * Replace the singleton with one using the given collaborators (tests).
335
+ *
336
+ * @param deps - Overrides for the default deps
337
+ * @returns The new singleton
338
+ */
339
+ static createForTesting(deps) {
340
+ FreshTaskConversationService.instance = new FreshTaskConversationService(deps);
341
+ return FreshTaskConversationService.instance;
342
+ }
343
+ /** Drop the singleton (tests). */
344
+ static resetInstance() {
345
+ FreshTaskConversationService.instance?.stopContextCapSweep();
346
+ FreshTaskConversationService.instance = null;
347
+ }
348
+ /**
349
+ * Mark a delivery to a session as in progress until the returned function
350
+ * is called. Neither a new-task clear nor a context-cap clear starts while
351
+ * one is running: it would wipe the message before the agent reads it.
352
+ *
353
+ * @param sessionName - Session being written to
354
+ * @returns Call when the delivery has finished (idempotent)
355
+ */
356
+ beginDelivery(sessionName) {
357
+ this.deliveriesInFlight.set(sessionName, (this.deliveriesInFlight.get(sessionName) ?? 0) + 1);
358
+ let done = false;
359
+ return () => {
360
+ if (done)
361
+ return;
362
+ done = true;
363
+ const left = (this.deliveriesInFlight.get(sessionName) ?? 1) - 1;
364
+ if (left > 0)
365
+ this.deliveriesInFlight.set(sessionName, left);
366
+ else
367
+ this.deliveriesInFlight.delete(sessionName);
368
+ this.lastDelivery.set(sessionName, this.deps.now());
369
+ };
370
+ }
371
+ /**
372
+ * Whether a delivery is being written to the session right now.
373
+ *
374
+ * @param sessionName - Session
375
+ * @returns True while a {@link beginDelivery} is open
376
+ */
377
+ isDelivering(sessionName) {
378
+ return (this.deliveriesInFlight.get(sessionName) ?? 0) > 0;
379
+ }
380
+ /**
381
+ * Record that a message is being written to a session. A clear right after
382
+ * would wipe it before the agent reads it, so {@link prepareForTask} skips
383
+ * clearing for a short window (this includes the dispatcher's own briefs,
384
+ * so two tasks dispatched back to back never clear each other).
385
+ *
386
+ * @param sessionName - Session written to
387
+ */
388
+ noteDelivery(sessionName) {
389
+ this.lastDelivery.set(sessionName, this.deps.now());
390
+ }
391
+ /**
392
+ * Wait (bounded) while a prepare — possibly a `/clear` — is running for the
393
+ * session, so a message written now is not wiped by it.
394
+ *
395
+ * @param sessionName - Session about to be written to
396
+ * @param maxMs - Upper bound on the wait
397
+ */
398
+ async waitIfClearing(sessionName, maxMs = FRESH_TASK_CONVERSATION_CONSTANTS.WAIT_IF_CLEARING_MAX_MS) {
399
+ const pending = this.inFlight.get(sessionName);
400
+ if (!pending)
401
+ return;
402
+ let timer;
403
+ await Promise.race([
404
+ pending.catch(() => undefined),
405
+ new Promise((resolve) => {
406
+ timer = setTimeout(resolve, maxMs);
407
+ timer.unref?.();
408
+ }),
409
+ ]);
410
+ if (timer)
411
+ clearTimeout(timer);
412
+ }
413
+ /**
414
+ * Root of the last task delivered to a session.
415
+ *
416
+ * @param sessionName - Session
417
+ * @returns Root id or null
418
+ */
419
+ getLastRoot(sessionName) {
420
+ return this.loadState().sessions[sessionName]?.root ?? null;
421
+ }
422
+ /**
423
+ * Called right before a task is written to an agent. Starts a fresh
424
+ * conversation when the task's root differs from the previous one and it
425
+ * is safe to; always records the new root. Never throws.
426
+ *
427
+ * @param sessionName - Target session
428
+ * @param workItem - Task being delivered
429
+ * @returns Whether it cleared, and the handover file when it did
430
+ */
431
+ async prepareForTask(sessionName, workItem) {
432
+ // A standing-refresh WorkItem re-raises the page an already-idle member
433
+ // was working on; it is not a new task for that member's conversation,
434
+ // so clearing here would /clear a member just to hand it a page refresh.
435
+ // Never record it as a root either — the next real task must still see
436
+ // whatever root preceded this refresh.
437
+ if (workItem.metadata?.['kind'] === STANDING_ANSWERS_CONSTANTS.WORKITEM_KIND) {
438
+ return { cleared: false };
439
+ }
440
+ const previous = this.inFlight.get(sessionName);
441
+ const run = (async () => {
442
+ if (previous)
443
+ await previous.catch(() => undefined);
444
+ try {
445
+ return await this.doPrepare(sessionName, workItem);
446
+ }
447
+ catch (err) {
448
+ this.logger.warn('Fresh-conversation prepare failed — delivering without clearing', {
449
+ sessionName,
450
+ workItemId: workItem.id,
451
+ error: err instanceof Error ? err.message : String(err),
452
+ });
453
+ return { cleared: false };
454
+ }
455
+ })();
456
+ this.inFlight.set(sessionName, run);
457
+ try {
458
+ return await run;
459
+ }
460
+ finally {
461
+ if (this.inFlight.get(sessionName) === run)
462
+ this.inFlight.delete(sessionName);
463
+ }
464
+ }
465
+ // -------------------------------------------------------------------------
466
+ // Idle-boundary context cap
467
+ // -------------------------------------------------------------------------
468
+ /**
469
+ * Start the periodic context-cap sweep (idempotent). The timer is unref'd
470
+ * so it never keeps the process alive.
471
+ *
472
+ * @param intervalMs - Sweep interval
473
+ */
474
+ startContextCapSweep(intervalMs = FRESH_TASK_CONVERSATION_CONSTANTS.CONTEXT_CAP_SWEEP_MS) {
475
+ if (this.capSweepTimer)
476
+ return;
477
+ this.capSweepTimer = setInterval(() => {
478
+ void this.runContextCapSweep();
479
+ }, intervalMs);
480
+ this.capSweepTimer.unref?.();
481
+ this.logger.info('Member context-cap sweep started', {
482
+ intervalMs,
483
+ capTokens: memberContextCapTokens(this.deps.env),
484
+ });
485
+ }
486
+ /** Stop the periodic context-cap sweep. */
487
+ stopContextCapSweep() {
488
+ if (this.capSweepTimer)
489
+ clearInterval(this.capSweepTimer);
490
+ this.capSweepTimer = null;
491
+ }
492
+ /**
493
+ * Check every registered session once. Sessions are handled one after
494
+ * another and sweeps never overlap. Never throws.
495
+ *
496
+ * @returns Per-session results (sessions skipped cheaply are omitted)
497
+ */
498
+ async runContextCapSweep() {
499
+ const results = {};
500
+ if (this.capSweepRunning)
501
+ return results;
502
+ if (memberContextCapTokens(this.deps.env) <= 0)
503
+ return results;
504
+ this.capSweepRunning = true;
505
+ try {
506
+ let sessions = [];
507
+ try {
508
+ sessions = this.deps.listSessions();
509
+ }
510
+ catch {
511
+ sessions = [];
512
+ }
513
+ for (const sessionName of sessions) {
514
+ if (sessionName === ORCHESTRATOR_SESSION_NAME)
515
+ continue;
516
+ results[sessionName] = await this.capContextIfNeeded(sessionName);
517
+ }
518
+ }
519
+ finally {
520
+ this.capSweepRunning = false;
521
+ }
522
+ return results;
523
+ }
524
+ /**
525
+ * If the member is idle between turns and its last turn carried more than
526
+ * the cap, save the conversation (handover file + wiki), `/clear` it and
527
+ * write one line naming its WorkItem and the handover. Runs in the same
528
+ * per-session chain as {@link prepareForTask}, so terminal writes wait for
529
+ * it. Never throws.
530
+ *
531
+ * @param sessionName - Member session
532
+ * @returns What happened
533
+ */
534
+ async capContextIfNeeded(sessionName) {
535
+ // Never queue behind (or start during) a prepare / another cap.
536
+ if (this.inFlight.has(sessionName))
537
+ return { capped: false, reason: 'clear in progress' };
538
+ const run = (async () => {
539
+ try {
540
+ const cap = await this.doCapContext(sessionName);
541
+ return { cleared: cap.capped, handoverPath: cap.handoverPath, cap };
542
+ }
543
+ catch (err) {
544
+ this.logger.warn('Context-cap check failed (non-fatal)', {
545
+ sessionName,
546
+ error: err instanceof Error ? err.message : String(err),
547
+ });
548
+ return { cleared: false, cap: { capped: false, reason: 'error' } };
549
+ }
550
+ })();
551
+ this.inFlight.set(sessionName, run);
552
+ try {
553
+ return (await run).cap;
554
+ }
555
+ finally {
556
+ if (this.inFlight.get(sessionName) === run)
557
+ this.inFlight.delete(sessionName);
558
+ }
559
+ }
560
+ async doCapContext(sessionName) {
561
+ const info = this.deps.getSessionInfo(sessionName);
562
+ const capTokens = memberContextCapTokens(this.deps.env);
563
+ const base = {
564
+ sessionName,
565
+ runtimeType: info?.runtimeType,
566
+ capTokens,
567
+ enabled: true,
568
+ contextTokens: Number.MAX_SAFE_INTEGER,
569
+ busy: false,
570
+ quietMs: Number.MAX_SAFE_INTEGER,
571
+ deliveryActive: false,
572
+ queuedMessages: false,
573
+ lastCapAt: null,
574
+ now: this.deps.now(),
575
+ activeWorkItemId: 'pending',
576
+ };
577
+ // Cheap checks first (orchestrator, runtime, cap off).
578
+ let decision = decideContextCap(base);
579
+ if (!decision.clear)
580
+ return { capped: false, reason: decision.reason };
581
+ if (!info?.cwd || !info.sessionId)
582
+ return { capped: false, reason: 'conversation id unknown' };
583
+ const transcript = claudeTranscriptPath({ sessionId: info.sessionId, cwd: info.cwd, claudeHome: this.deps.claudeHome });
584
+ const contextTokens = fs.existsSync(transcript) ? lastTurnContextTokens(transcript) : null;
585
+ const last = this.lastDelivery.get(sessionName);
586
+ const lastCapAt = this.lastCapAt.get(sessionName) ?? null;
587
+ decision = decideContextCap({ ...base, contextTokens, lastCapAt, now: this.deps.now() });
588
+ if (!decision.clear)
589
+ return { capped: false, reason: decision.reason };
590
+ const enabled = freshTaskConversationEnvEnabled(this.deps.env) && (await this.deps.settingEnabled());
591
+ const busy = enabled ? await this.deps.isBusy(sessionName).catch(() => true) : false;
592
+ const active = enabled && !busy ? await this.deps.getActiveItems(sessionName).catch(() => null) : [];
593
+ const current = this.pickCurrentItem(active ?? []);
594
+ decision = decideContextCap({
595
+ ...base,
596
+ contextTokens,
597
+ lastCapAt,
598
+ now: this.deps.now(),
599
+ enabled,
600
+ busy: busy || active === null,
601
+ quietMs: this.deps.getQuietMs(sessionName),
602
+ deliveryActive: this.isDelivering(sessionName) ||
603
+ (last !== undefined && this.deps.now() - last < FRESH_TASK_CONVERSATION_CONSTANTS.RECENT_DELIVERY_MS),
604
+ queuedMessages: this.deps.hasQueuedMessages(sessionName),
605
+ activeWorkItemId: current?.id ?? null,
606
+ });
607
+ if (!decision.clear || !current)
608
+ return { capped: false, reason: decision.reason };
609
+ // Last look right before the clear: anything written since the checks
610
+ // above means the agent is (about to be) working.
611
+ if (this.isDelivering(sessionName) || (await this.deps.isBusy(sessionName).catch(() => true))) {
612
+ return { capped: false, reason: 'busy' };
613
+ }
614
+ const saved = await this.saveAndClear(sessionName, { cwd: info.cwd, sessionId: info.sessionId }, {
615
+ lastTask: current.id,
616
+ why: `because it had grown past ${capTokens.toLocaleString('en-US')} tokens per turn (you are still on the same WorkItem)`,
617
+ });
618
+ if (!saved)
619
+ return { capped: false, reason: 'not cleared' };
620
+ this.lastCapAt.set(sessionName, this.deps.now());
621
+ const line = contextCapReorientation({ workItem: current, handoverPath: saved.handoverPath, contextTokens: contextTokens });
622
+ // Tracking matches on the WorkItem id in the first message, which the
623
+ // re-orientation line carries.
624
+ void this.trackNewConversation(sessionName, info.cwd, info.sessionId, saved.clearAt, current.id);
625
+ const sent = await this.deps.sendMessage(sessionName, line).catch(() => false);
626
+ this.lastDelivery.set(sessionName, this.deps.now());
627
+ this.logger.info('Capped a member conversation at an idle boundary', {
628
+ sessionName,
629
+ workItemId: current.id,
630
+ contextTokens,
631
+ capTokens,
632
+ oldSessionId: info.sessionId,
633
+ handover: saved.handoverPath,
634
+ reoriented: sent,
635
+ });
636
+ return { capped: true, reason: decision.reason, handoverPath: saved.handoverPath, workItemId: current.id };
637
+ }
638
+ /**
639
+ * The WorkItem a member is on: running before accepted before proposed,
640
+ * then the most recently started.
641
+ */
642
+ pickCurrentItem(items) {
643
+ const rank = { running: 0, accepted: 1, proposed: 2 };
644
+ const sorted = [...items].sort((a, b) => {
645
+ const r = (rank[a.status] ?? 9) - (rank[b.status] ?? 9);
646
+ if (r !== 0)
647
+ return r;
648
+ return (Date.parse(b.startedAt ?? '') || 0) - (Date.parse(a.startedAt ?? '') || 0);
649
+ });
650
+ return sorted[0] ?? null;
651
+ }
652
+ // -------------------------------------------------------------------------
653
+ // Internals
654
+ // -------------------------------------------------------------------------
655
+ async doPrepare(sessionName, workItem) {
656
+ const newRoot = rootWorkItemId(workItem.id);
657
+ const previousRoot = this.getLastRoot(sessionName);
658
+ const info = this.deps.getSessionInfo(sessionName);
659
+ // Cheap checks first; the async ones only when a clear is still possible.
660
+ let decision = decideFreshConversation({
661
+ sessionName,
662
+ runtimeType: info?.runtimeType,
663
+ previousRoot,
664
+ newRoot,
665
+ enabled: true,
666
+ busy: false,
667
+ recentDelivery: false,
668
+ otherActiveRoots: [],
669
+ });
670
+ if (decision.clear) {
671
+ const enabled = freshTaskConversationEnvEnabled(this.deps.env) && (await this.deps.settingEnabled());
672
+ const busy = enabled ? await this.deps.isBusy(sessionName).catch(() => true) : false;
673
+ const last = this.lastDelivery.get(sessionName);
674
+ const recentDelivery = this.isDelivering(sessionName) ||
675
+ (last !== undefined && this.deps.now() - last < FRESH_TASK_CONVERSATION_CONSTANTS.RECENT_DELIVERY_MS);
676
+ const active = enabled && !busy ? await this.deps.getActiveItems(sessionName).catch(() => null) : [];
677
+ decision = decideFreshConversation({
678
+ sessionName,
679
+ runtimeType: info?.runtimeType,
680
+ previousRoot,
681
+ newRoot,
682
+ enabled,
683
+ busy: busy || active === null,
684
+ recentDelivery,
685
+ otherActiveRoots: (active ?? []).filter((wi) => wi.id !== workItem.id).map((wi) => rootWorkItemId(wi.id)),
686
+ alreadyStarted: (active ?? []).some((wi) => rootWorkItemId(wi.id) === newRoot &&
687
+ !!wi.startedAt &&
688
+ this.deps.now() - Date.parse(wi.startedAt) > FRESH_TASK_CONVERSATION_CONSTANTS.ALREADY_STARTED_MS),
689
+ });
690
+ }
691
+ this.recordRoot(sessionName, newRoot);
692
+ if (!decision.clear) {
693
+ if (previousRoot && previousRoot !== newRoot) {
694
+ this.logger.debug('New task — keeping the current conversation', { sessionName, newRoot, previousRoot, reason: decision.reason });
695
+ }
696
+ return { cleared: false };
697
+ }
698
+ if (!info?.cwd || !info.sessionId) {
699
+ this.logger.info('New task but the conversation id is unknown — not clearing', { sessionName, newRoot });
700
+ return { cleared: false };
701
+ }
702
+ const saved = await this.saveAndClear(sessionName, { cwd: info.cwd, sessionId: info.sessionId }, {
703
+ lastTask: previousRoot,
704
+ why: 'when you were given a new task',
705
+ });
706
+ if (!saved)
707
+ return { cleared: false };
708
+ this.logger.info('Started a fresh conversation for a new task', {
709
+ sessionName,
710
+ previousRoot,
711
+ newRoot,
712
+ oldSessionId: info.sessionId,
713
+ handover: saved.handoverPath,
714
+ });
715
+ // 3. Learn the new conversation id in the background.
716
+ void this.trackNewConversation(sessionName, info.cwd, info.sessionId, saved.clearAt, workItem.id);
717
+ return { cleared: true, handoverPath: saved.handoverPath };
718
+ }
719
+ /**
720
+ * Save the current conversation (handover file + memory/wiki copy, no
721
+ * agent turn) and write `/clear`. Shared by the new-task and the
722
+ * context-cap paths.
723
+ *
724
+ * @param sessionName - Agent session
725
+ * @param conv - Its cwd and current conversation id
726
+ * @param handover - Task the old conversation was on, and why it closed
727
+ * @returns The handover file and the time `/clear` was written, or null
728
+ * when nothing was cleared (transcript missing, session gone)
729
+ */
730
+ async saveAndClear(sessionName, conv, handover) {
731
+ const transcript = claudeTranscriptPath({ sessionId: conv.sessionId, cwd: conv.cwd, claudeHome: this.deps.claudeHome });
732
+ if (!fs.existsSync(transcript)) {
733
+ this.logger.info('Transcript missing — not clearing', { sessionName, transcript });
734
+ return null;
735
+ }
736
+ // 1. Save the conversation (server-side, no agent turn).
737
+ const summary = buildHandoverSummary(transcript);
738
+ const handoverPath = this.writeHandover(sessionName, conv.sessionId, transcript, handover.lastTask, summary, handover.why);
739
+ const memoryText = this.conciseHandover(sessionName, handover.lastTask, handoverPath, summary);
740
+ void this.deps
741
+ .remember({
742
+ agentId: sessionName,
743
+ projectPath: conv.cwd,
744
+ content: memoryText,
745
+ title: `Conversation handover — ${sessionName} — task ${handover.lastTask}`,
746
+ })
747
+ .catch((err) => {
748
+ this.logger.debug('Handover memory write failed (non-fatal; the file is on disk)', {
749
+ sessionName,
750
+ error: err instanceof Error ? err.message : String(err),
751
+ });
752
+ });
753
+ // 2. Clear, exactly as context compaction writes its command.
754
+ const clearAt = this.deps.now();
755
+ if (!this.deps.writeToSession(sessionName, '\x1b')) {
756
+ this.logger.info('Session not found — not clearing', { sessionName });
757
+ return null;
758
+ }
759
+ await this.deps.sleep(FRESH_TASK_CONVERSATION_CONSTANTS.ESCAPE_DELAY_MS);
760
+ this.deps.writeToSession(sessionName, `${FRESH_TASK_CONVERSATION_CONSTANTS.CLEAR_COMMAND}\r`);
761
+ await this.deps.sleep(FRESH_TASK_CONVERSATION_CONSTANTS.POST_CLEAR_READY_MS);
762
+ return { handoverPath, clearAt };
763
+ }
764
+ /**
765
+ * Write the handover file (same shape as the orchestrator's restart handover).
766
+ *
767
+ * @returns Its path
768
+ */
769
+ writeHandover(sessionName, oldSessionId, transcript, previousRoot, body, why = 'when you were given a new task') {
770
+ const dir = path.join(this.deps.crewlyHome(), ORC_CONVERSATION_CONSTANTS.HANDOVER_DIR);
771
+ fs.mkdirSync(dir, { recursive: true });
772
+ const stamp = new Date(this.deps.now()).toISOString().replace(/[:.]/g, '-');
773
+ const file = path.join(dir, `${sessionName}-${stamp}.md`);
774
+ const tokens = lastTurnContextTokens(transcript);
775
+ fs.writeFileSync(file, [
776
+ `# Handover from your previous conversation`,
777
+ ``,
778
+ `Your previous conversation (${oldSessionId}) was closed ${why}; its last task was ${previousRoot}.` +
779
+ (tokens !== null ? ` It had grown to ${tokens.toLocaleString('en-US')} tokens per turn.` : ''),
780
+ `Everything Crewly tracks — tasks, teams, OKRs, wiki — is still there; this file keeps only the end of what was said.`,
781
+ `The full transcript: ${transcript}`,
782
+ ``,
783
+ body || '_Nothing readable was found at the end of the old conversation._',
784
+ ``,
785
+ ].join('\n'), 'utf-8');
786
+ return file;
787
+ }
788
+ /**
789
+ * The capped text stored through memory: where the handover is plus the
790
+ * newest part of what was said.
791
+ */
792
+ conciseHandover(sessionName, previousRoot, handoverPath, summary) {
793
+ const head = `${sessionName} finished its conversation on task ${previousRoot}; full handover: ${handoverPath}\n\n`;
794
+ const room = Math.max(0, FRESH_TASK_CONVERSATION_CONSTANTS.MEMORY_MAX_CHARS - head.length);
795
+ const tail = summary.length > room ? `…${summary.slice(summary.length - room + 1)}` : summary;
796
+ return head + (tail || '(nothing readable at the end of the conversation)');
797
+ }
798
+ /**
799
+ * After `/clear`, find the transcript Claude Code starts for this agent and
800
+ * store its id; if none shows up in time, clear the stored id so a restart
801
+ * starts fresh rather than resuming the pre-clear conversation.
802
+ *
803
+ * A candidate is a `.jsonl` in the agent's transcript directory that was
804
+ * STARTED after the clear (its first entry, not just a later write), is not
805
+ * the old id or claimed by another session, and contains this task's
806
+ * work-item id — the dispatch text written right after the clear carries
807
+ * it. Several agents share one cwd and their transcripts mention each
808
+ * other's session names, so matching on the name alone picked another
809
+ * agent's conversation (Atlas resumed Ella's, 2026-09-28).
810
+ */
811
+ async trackNewConversation(sessionName, cwd, oldId, clearAt, workItemId) {
812
+ try {
813
+ const dir = path.dirname(claudeTranscriptPath({ sessionId: oldId, cwd, claudeHome: this.deps.claudeHome }));
814
+ const deadline = clearAt + FRESH_TASK_CONVERSATION_CONSTANTS.NEW_SESSION_DETECT_MS;
815
+ while (this.deps.now() <= deadline) {
816
+ const found = this.findNewTranscript(dir, sessionName, oldId, clearAt, workItemId);
817
+ if (found) {
818
+ this.deps.updateSessionId(sessionName, found);
819
+ this.logger.info('Recorded the conversation id started by /clear', { sessionName, sessionId: found });
820
+ return;
821
+ }
822
+ await this.deps.sleep(FRESH_TASK_CONVERSATION_CONSTANTS.NEW_SESSION_POLL_MS);
823
+ }
824
+ this.deps.clearSessionId(sessionName);
825
+ this.logger.warn('New conversation id not found after /clear — cleared the stored id so a restart starts fresh', {
826
+ sessionName,
827
+ oldSessionId: oldId,
828
+ });
829
+ }
830
+ catch (err) {
831
+ this.logger.warn('New conversation id tracking failed — clearing the stored id', {
832
+ sessionName,
833
+ error: err instanceof Error ? err.message : String(err),
834
+ });
835
+ try {
836
+ this.deps.clearSessionId(sessionName);
837
+ }
838
+ catch {
839
+ // nothing more to do
840
+ }
841
+ }
842
+ }
843
+ /**
844
+ * One scan of the transcript directory.
845
+ *
846
+ * @returns The new conversation id, or null
847
+ */
848
+ findNewTranscript(dir, sessionName, oldId, clearAt, workItemId) {
849
+ let entries;
850
+ try {
851
+ entries = fs.readdirSync(dir).filter((f) => f.endsWith('.jsonl'));
852
+ }
853
+ catch {
854
+ return null;
855
+ }
856
+ const claimed = this.deps.getClaimedSessionIds(sessionName);
857
+ const candidates = [];
858
+ for (const name of entries) {
859
+ const id = name.slice(0, -'.jsonl'.length);
860
+ if (id === oldId || claimed.has(id))
861
+ continue;
862
+ const file = path.join(dir, name);
863
+ let mtime;
864
+ try {
865
+ mtime = fs.statSync(file).mtimeMs;
866
+ }
867
+ catch {
868
+ continue;
869
+ }
870
+ // One second of slack for filesystem timestamp granularity.
871
+ if (mtime < clearAt - 1_000)
872
+ continue;
873
+ if (!this.startedAfter(file, clearAt - 1_000))
874
+ continue;
875
+ if (!this.fileMentions(file, workItemId))
876
+ continue;
877
+ candidates.push({ id, mtime });
878
+ }
879
+ candidates.sort((a, b) => b.mtime - a.mtime);
880
+ return candidates[0]?.id ?? null;
881
+ }
882
+ /**
883
+ * Whether a transcript's first timestamped entry is at or after `since`,
884
+ * i.e. the conversation began after the clear rather than being an older
885
+ * one that happened to be written to recently.
886
+ */
887
+ startedAfter(file, since) {
888
+ try {
889
+ const fd = fs.openSync(file, 'r');
890
+ try {
891
+ const buf = Buffer.alloc(Math.min(64 * 1024, fs.fstatSync(fd).size));
892
+ fs.readSync(fd, buf, 0, buf.length, 0);
893
+ for (const line of buf.toString('utf-8').split('\n')) {
894
+ const m = /"timestamp":"([^"]+)"/.exec(line);
895
+ if (m)
896
+ return Date.parse(m[1]) >= since;
897
+ }
898
+ return false;
899
+ }
900
+ finally {
901
+ fs.closeSync(fd);
902
+ }
903
+ }
904
+ catch {
905
+ return false;
906
+ }
907
+ }
908
+ /** Whether the start of a transcript contains the given text. */
909
+ fileMentions(file, needle) {
910
+ const MAX_BYTES = 256 * 1024;
911
+ try {
912
+ const fd = fs.openSync(file, 'r');
913
+ try {
914
+ const buf = Buffer.alloc(Math.min(MAX_BYTES, fs.fstatSync(fd).size));
915
+ fs.readSync(fd, buf, 0, buf.length, 0);
916
+ return buf.toString('utf-8').includes(needle);
917
+ }
918
+ finally {
919
+ fs.closeSync(fd);
920
+ }
921
+ }
922
+ catch {
923
+ return false;
924
+ }
925
+ }
926
+ statePath() {
927
+ return path.join(this.deps.crewlyHome(), FRESH_TASK_CONVERSATION_CONSTANTS.STATE_FILE);
928
+ }
929
+ loadState() {
930
+ if (this.state)
931
+ return this.state;
932
+ try {
933
+ const parsed = JSON.parse(fs.readFileSync(this.statePath(), 'utf-8'));
934
+ this.state = { version: 1, sessions: parsed && typeof parsed.sessions === 'object' && parsed.sessions ? parsed.sessions : {} };
935
+ }
936
+ catch {
937
+ this.state = { version: 1, sessions: {} };
938
+ }
939
+ return this.state;
940
+ }
941
+ recordRoot(sessionName, root) {
942
+ const state = this.loadState();
943
+ if (state.sessions[sessionName]?.root === root)
944
+ return;
945
+ state.sessions[sessionName] = { root, at: new Date(this.deps.now()).toISOString() };
946
+ try {
947
+ const file = this.statePath();
948
+ fs.mkdirSync(path.dirname(file), { recursive: true });
949
+ const tmp = `${file}.${process.pid}.tmp`;
950
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2), 'utf-8');
951
+ fs.renameSync(tmp, file);
952
+ }
953
+ catch (err) {
954
+ this.logger.debug('Could not persist the last task root (non-fatal)', {
955
+ sessionName,
956
+ error: err instanceof Error ? err.message : String(err),
957
+ });
958
+ }
959
+ }
960
+ }
961
+ //# sourceMappingURL=fresh-task-conversation.service.js.map