negotium 0.6.13 → 0.6.17

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 (31) hide show
  1. package/dist/agent-helpers.js +760 -142
  2. package/dist/agent-helpers.js.map +13 -12
  3. package/dist/{chunk-sr48tc2c.js → chunk-ax6tqx0s.js} +2 -2
  4. package/dist/{chunk-sr48tc2c.js.map → chunk-ax6tqx0s.js.map} +2 -2
  5. package/dist/hosted-agent.js +64 -3
  6. package/dist/hosted-agent.js.map +4 -4
  7. package/dist/main.js +783 -297
  8. package/dist/main.js.map +18 -17
  9. package/dist/mcp-factories.js +850 -261
  10. package/dist/mcp-factories.js.map +13 -12
  11. package/dist/registry.js +1 -1
  12. package/dist/runtime/cron/background-sessions.ts +5 -2
  13. package/dist/runtime/src/agents/archiver.ts +8 -5
  14. package/dist/runtime/src/agents/claude-provider.ts +88 -1
  15. package/dist/runtime/src/agents/idle-compact.ts +178 -0
  16. package/dist/runtime/src/node-host.ts +8 -1
  17. package/dist/runtime/src/runtime/background-sessions.ts +21 -8
  18. package/dist/runtime/src/runtime/turn-event-stream.ts +54 -1
  19. package/dist/runtime/src/runtime/turn-runner.ts +63 -0
  20. package/dist/runtime/src/topics/lifecycle.ts +2 -0
  21. package/dist/runtime/src/topics/session.ts +60 -3
  22. package/dist/runtime/src/version.ts +1 -1
  23. package/dist/types/packages/core/src/agents/archiver.d.ts +2 -2
  24. package/dist/types/packages/core/src/agents/idle-compact.d.ts +28 -0
  25. package/dist/types/packages/core/src/runtime/background-sessions.d.ts +10 -2
  26. package/dist/types/packages/core/src/runtime/turn-event-stream.d.ts +14 -0
  27. package/dist/types/packages/core/src/runtime/turn-runner.d.ts +2 -0
  28. package/dist/types/packages/core/src/topics/session.d.ts +11 -0
  29. package/dist/types/packages/core/src/version.d.ts +1 -1
  30. package/dist/types/packages/module-cron/src/background-sessions.d.ts +1 -1
  31. package/package.json +1 -1
package/dist/registry.js CHANGED
@@ -133,7 +133,7 @@ var codexRegistryOperations = {
133
133
  return { sessionId: threadId, rolloutPath };
134
134
  },
135
135
  async forkSession({ parentSessionId }) {
136
- const { forkCodexSession } = await import("./chunk-sr48tc2c.js");
136
+ const { forkCodexSession } = await import("./chunk-ax6tqx0s.js");
137
137
  return await forkCodexSession(parentSessionId);
138
138
  },
139
139
  async cleanupRollouts({ sessionIds }) {
@@ -27,11 +27,14 @@ function latestRun(jobs: CronJobRecord[]): DisplayRun | undefined {
27
27
  .sort((left, right) => right.run.scheduledAt.localeCompare(left.run.scheduledAt))[0];
28
28
  }
29
29
 
30
- export function listCronBackgroundSessions(userId: string): BackgroundSessionDto[] {
30
+ export function listCronBackgroundSessions(
31
+ userId: string,
32
+ allUsers = false,
33
+ ): BackgroundSessionDto[] {
31
34
  const jobsByTopic = new Map<string, CronJobRecord[]>();
32
35
  for (const job of listCronJobs()) {
33
36
  const topic = getTopic(job.topicId);
34
- if (!topic || !isParticipant(topic, userId)) continue;
37
+ if (!topic || !(allUsers || isParticipant(topic, userId))) continue;
35
38
  const jobs = jobsByTopic.get(job.topicId) ?? [];
36
39
  jobs.push(job);
37
40
  jobsByTopic.set(job.topicId, jobs);
@@ -149,7 +149,7 @@ export interface ArchiverHost {
149
149
 
150
150
  export interface ArchiverRuntime {
151
151
  runArchiverTurn(params: RunArchiverTurnParams): boolean;
152
- listActiveMemoryArchiverSessions(userId: string): BackgroundSessionDto[];
152
+ listActiveMemoryArchiverSessions(userId: string, allUsers?: boolean): BackgroundSessionDto[];
153
153
  }
154
154
 
155
155
  export function createArchiverRuntime(host: ArchiverHost): ArchiverRuntime {
@@ -169,7 +169,7 @@ export function createArchiverRuntime(host: ArchiverHost): ArchiverRuntime {
169
169
  }
170
170
  };
171
171
 
172
- const listSessions = (userId: string): BackgroundSessionDto[] => {
172
+ const listSessions = (userId: string, allUsers = false): BackgroundSessionDto[] => {
173
173
  const now = host.config.now().getTime();
174
174
  for (const [id, session] of activeSessions) {
175
175
  if (session.expiresAt !== undefined && session.expiresAt <= now) {
@@ -180,7 +180,7 @@ export function createArchiverRuntime(host: ArchiverHost): ArchiverRuntime {
180
180
  }
181
181
  }
182
182
  return [...activeSessions.values()]
183
- .filter((session) => session.userId === userId)
183
+ .filter((session) => allUsers || session.userId === userId)
184
184
  .map(({ userId: _userId, expiresAt: _expiresAt, expiryTimer: _expiryTimer, ...session }) => ({
185
185
  ...session,
186
186
  steps: [...session.steps],
@@ -660,6 +660,9 @@ export function runArchiverTurn(params: RunArchiverTurnParams): boolean {
660
660
  return defaultArchiverRuntime.runArchiverTurn(params);
661
661
  }
662
662
 
663
- export function listActiveMemoryArchiverSessions(userId: string): BackgroundSessionDto[] {
664
- return defaultArchiverRuntime.listActiveMemoryArchiverSessions(userId);
663
+ export function listActiveMemoryArchiverSessions(
664
+ userId: string,
665
+ allUsers = false,
666
+ ): BackgroundSessionDto[] {
667
+ return defaultArchiverRuntime.listActiveMemoryArchiverSessions(userId, allUsers);
665
668
  }
@@ -43,6 +43,11 @@ const CLAUDE_DEFAULT_DISALLOWED_TOOLS = [
43
43
  "TaskUpdate",
44
44
  "TaskList",
45
45
  "TaskGet",
46
+ // Monitor spawns a long-running background watcher tied to the CLI's own
47
+ // interactive session/notification loop, which our headless spawned turns
48
+ // don't have — it doesn't reliably deliver events back into a Negotium
49
+ // topic. Use the background-bash MCP tools (run/watch) instead.
50
+ "Monitor",
46
51
  // Native scheduling/cron tools duplicate Negotium's own runtime MCP surface
47
52
  // (`mcp__runtime__schedule_self`, the `cron-manager` server) and bypass its
48
53
  // persistence/host wiring if used directly.
@@ -215,6 +220,48 @@ type SpawnClaudeCodeResult = ReturnType<SpawnClaudeCodeProcess>;
215
220
 
216
221
  const CLAUDE_ABORT_SIGKILL_DELAY_MS = 2500;
217
222
 
223
+ type ClaudeProcessExit = { code: number | null; signal: NodeJS.Signals | null };
224
+
225
+ // The Agent SDK can keep its stdout reader open for several minutes after its
226
+ // CLI child has gone away (CLAUDE_CODE_STREAM_CLOSE_TIMEOUT defaults to five
227
+ // minutes in our host environment). Keep a per-query hook so the provider
228
+ // can abort that stuck reader and emit a normal terminal error instead of
229
+ // leaving the room lease occupied indefinitely.
230
+ const claudeProcessExitListeners = new WeakMap<
231
+ AbortSignal,
232
+ Set<(exit: ClaudeProcessExit) => void>
233
+ >();
234
+
235
+ function watchClaudeProcessExit(signal: AbortSignal): {
236
+ exited: Promise<ClaudeProcessExit>;
237
+ dispose: () => void;
238
+ } {
239
+ let listener: ((exit: ClaudeProcessExit) => void) | undefined;
240
+ const exited = new Promise<ClaudeProcessExit>((resolve) => {
241
+ listener = resolve;
242
+ const listeners = claudeProcessExitListeners.get(signal) ?? new Set();
243
+ listeners.add(listener);
244
+ claudeProcessExitListeners.set(signal, listeners);
245
+ });
246
+ return {
247
+ exited,
248
+ dispose: () => {
249
+ if (!listener) return;
250
+ const listeners = claudeProcessExitListeners.get(signal);
251
+ listeners?.delete(listener);
252
+ if (listeners?.size === 0) claudeProcessExitListeners.delete(signal);
253
+ listener = undefined;
254
+ },
255
+ };
256
+ }
257
+
258
+ function notifyClaudeProcessExit(signal: AbortSignal, exit: ClaudeProcessExit): void {
259
+ const listeners = claudeProcessExitListeners.get(signal);
260
+ if (!listeners) return;
261
+ claudeProcessExitListeners.delete(signal);
262
+ for (const listener of listeners) listener(exit);
263
+ }
264
+
218
265
  function signalProcessTree(pid: number, signal: NodeJS.Signals): boolean {
219
266
  // Negative pid = "signal every process in this process group". Falls back
220
267
  // to single-pid signaling if the group call fails (e.g. on platforms where
@@ -285,6 +332,7 @@ export function spawnClaudeCodeProcessWithTreeKill(
285
332
  clearKillTimer();
286
333
  options.signal.removeEventListener("abort", onAbort);
287
334
  logger.debug({ pid: child.pid, code, signal }, "Claude Code process exited");
335
+ notifyClaudeProcessExit(options.signal, { code, signal });
288
336
  });
289
337
 
290
338
  // Consume the child's 'error' event. Without a listener, a spawn-level error
@@ -304,6 +352,7 @@ export function spawnClaudeCodeProcessWithTreeKill(
304
352
  },
305
353
  "Claude Code process error event",
306
354
  );
355
+ notifyClaudeProcessExit(options.signal, { code: null, signal: null });
307
356
  });
308
357
 
309
358
  return {
@@ -380,6 +429,22 @@ export async function* claudeProvider(opts: AgentQueryOptions): AsyncGenerator<U
380
429
  // spawns); the `settings` flag layer below covers it a second time.
381
430
  cleanEnv.CLAUDE_CODE_DISABLE_WORKFLOWS = "1";
382
431
 
432
+ // Use a private signal for the SDK. A premature CLI exit must wake its
433
+ // reader, but must not look like a user-initiated abort to the turn runner.
434
+ const sdkAbortController = new AbortController();
435
+ const onCallerAbort = () => sdkAbortController.abort();
436
+ if (opts.abortController?.signal.aborted) sdkAbortController.abort();
437
+ else opts.abortController?.signal.addEventListener("abort", onCallerAbort, { once: true });
438
+ const processExitWatch = watchClaudeProcessExit(sdkAbortController.signal);
439
+ let unexpectedProcessExit: ClaudeProcessExit | undefined;
440
+ void processExitWatch.exited.then((exit) => {
441
+ // An exit caused by an explicit stop/supersede is expected. Its caller
442
+ // signal is the authoritative distinction from a CLI crash.
443
+ if (opts.abortController?.signal.aborted) return;
444
+ unexpectedProcessExit = exit;
445
+ sdkAbortController.abort();
446
+ });
447
+
383
448
  const queryOptions: Options = {
384
449
  ...(claudeExecutable ? { pathToClaudeCodeExecutable: claudeExecutable } : {}),
385
450
  spawnClaudeCodeProcess: spawnClaudeCodeProcessWithTreeKill,
@@ -390,7 +455,7 @@ export async function* claudeProvider(opts: AgentQueryOptions): AsyncGenerator<U
390
455
  env: cleanEnv,
391
456
  mcpServers: hostedMcpServers(opts) as Options["mcpServers"],
392
457
  ...(claudeBuiltInTools(opts) ? { tools: claudeBuiltInTools(opts) } : {}),
393
- abortController: opts.abortController,
458
+ abortController: sdkAbortController,
394
459
  // `AskUserQuestion` is Claude Code's built-in clarification tool, but it
395
460
  // expects the SDK CLI to render a TUI prompt and read stdin for the
396
461
  // answer — neither exists in this headless Telegram bridge. Without
@@ -746,12 +811,34 @@ export async function* claudeProvider(opts: AgentQueryOptions): AsyncGenerator<U
746
811
  }
747
812
  }
748
813
  }
814
+ if (unexpectedProcessExit) {
815
+ const detail = unexpectedProcessExit.signal
816
+ ? `signal ${unexpectedProcessExit.signal}`
817
+ : unexpectedProcessExit.code === null
818
+ ? "before it could start"
819
+ : `exit code ${unexpectedProcessExit.code}`;
820
+ logger.error({ detail }, "claudeProvider: CLI exited before terminal SDK event");
821
+ yield { type: "error", content: `Claude CLI exited unexpectedly (${detail}).` };
822
+ }
749
823
  } catch (e) {
750
824
  // Abort = user moved on; don't surface as an error message. The handler
751
825
  // catches the abort signal separately via abortReason, so swallowing
752
826
  // here matches both maestro and codex behavior.
827
+ if (unexpectedProcessExit) {
828
+ const detail = unexpectedProcessExit.signal
829
+ ? `signal ${unexpectedProcessExit.signal}`
830
+ : unexpectedProcessExit.code === null
831
+ ? "before it could start"
832
+ : `exit code ${unexpectedProcessExit.code}`;
833
+ logger.error({ err: e, detail }, "claudeProvider: CLI exited before terminal SDK event");
834
+ yield { type: "error", content: `Claude CLI exited unexpectedly (${detail}).` };
835
+ return;
836
+ }
753
837
  if (isAbortError(e) || opts.abortController?.signal.aborted) return;
754
838
  logger.error({ err: e }, "claudeProvider: SDK iteration failed");
755
839
  yield { type: "error", content: errMsg(e) };
840
+ } finally {
841
+ processExitWatch.dispose();
842
+ opts.abortController?.signal.removeEventListener("abort", onCallerAbort);
756
843
  }
757
844
  }
@@ -0,0 +1,178 @@
1
+ import { logger } from "#platform/logger";
2
+ import { getRoomQuery } from "#query/active-rooms";
3
+ import { getTopic } from "#storage/api-topics";
4
+ import { getRuntimeTurnLease } from "#storage/runtime-leases";
5
+ import { getTopicStats } from "#storage/token-stats";
6
+ import type { RestartTopicSessionResult } from "#topics/session";
7
+
8
+ const DEFAULT_IDLE_DELAY_MS = 6 * 60 * 60 * 1000;
9
+ // Mirrors the 80%-occupancy usage alert in `#runtime/turn-event-stream` at a
10
+ // lower bar: an idle topic gets auto-compacted once its *last known* context
11
+ // usage (as the provider itself reported it) has crossed the halfway mark.
12
+ const DEFAULT_MIN_CONTEXT_PERCENT = 50;
13
+
14
+ type IdleCompactStatus =
15
+ | "scheduled"
16
+ | "disabled"
17
+ | "busy"
18
+ | "topic-not-found"
19
+ | "not-ai-invited"
20
+ | "mention-only-channel"
21
+ | "no-owner"
22
+ | "below-threshold"
23
+ | "compacted"
24
+ | "failed";
25
+
26
+ export interface IdleCompactOptions {
27
+ isBusy?: (topicId: string) => boolean;
28
+ onBusy?: (topicId: string, userId: string) => void;
29
+ minContextPercent?: number;
30
+ /** Injected for tests; defaults to the real `getTopicStats`. */
31
+ getStats?: typeof getTopicStats;
32
+ /** Injected for tests; defaults to the real `compactTopicSession`. */
33
+ compact?: (topicId: string, userId: string, reason: string) => Promise<RestartTopicSessionResult>;
34
+ }
35
+
36
+ const timers = new Map<string, ReturnType<typeof setTimeout>>();
37
+
38
+ /** Cancel a pending idle-compact timer, e.g. before a topic is reset or deleted. */
39
+ export function cancelIdleCompactForTopic(topicId: string): boolean {
40
+ const timer = timers.get(topicId);
41
+ if (!timer) return false;
42
+ clearTimeout(timer);
43
+ timers.delete(topicId);
44
+ return true;
45
+ }
46
+
47
+ function envFlagEnabled(name: string, fallback: boolean): boolean {
48
+ const raw = process.env[name]?.trim().toLowerCase();
49
+ if (!raw) return fallback;
50
+ return !["0", "false", "off", "no"].includes(raw);
51
+ }
52
+
53
+ function envPositiveInt(name: string, fallback: number): number {
54
+ const value = Number.parseInt(process.env[name] ?? "", 10);
55
+ return Number.isFinite(value) && value > 0 ? value : fallback;
56
+ }
57
+
58
+ export function idleCompactDelayMs(): number {
59
+ return envPositiveInt("NEGOTIUM_IDLE_COMPACT_DELAY_MS", DEFAULT_IDLE_DELAY_MS);
60
+ }
61
+
62
+ /** Percentage (0–100) of the provider's context window an idle topic must reach before it is auto-compacted. */
63
+ export function idleCompactMinContextPercent(): number {
64
+ return envPositiveInt("NEGOTIUM_IDLE_COMPACT_MIN_CONTEXT_PERCENT", DEFAULT_MIN_CONTEXT_PERCENT);
65
+ }
66
+
67
+ export function idleCompactEnabled(): boolean {
68
+ return envFlagEnabled("NEGOTIUM_IDLE_COMPACT_ENABLED", true);
69
+ }
70
+
71
+ /**
72
+ * Schedule a check, `idleCompactDelayMs()` after the topic's last turn, that
73
+ * compacts the session if (and only if) it is still idle and its context has
74
+ * grown past the percent-of-window threshold. Runs alongside the memory
75
+ * idle-archiver — this only trims what the model sees next turn; it does not
76
+ * touch memory.
77
+ */
78
+ export function scheduleIdleCompactForTopic(topicId: string, userId: string): IdleCompactStatus {
79
+ if (!idleCompactEnabled()) return "disabled";
80
+
81
+ const topic = getTopic(topicId);
82
+ if (!topic) return "topic-not-found";
83
+ if (!topic.agent) return "not-ai-invited";
84
+ if (topic.aiMode === "mention") return "mention-only-channel";
85
+
86
+ const existing = timers.get(topicId);
87
+ if (existing) clearTimeout(existing);
88
+
89
+ const timer = setTimeout(() => {
90
+ timers.delete(topicId);
91
+ void runIdleCompactForTopic(topicId, userId);
92
+ }, idleCompactDelayMs());
93
+ timer.unref?.();
94
+ timers.set(topicId, timer);
95
+ return "scheduled";
96
+ }
97
+
98
+ export async function runIdleCompactForTopic(
99
+ topicId: string,
100
+ userId: string,
101
+ options: IdleCompactOptions = {},
102
+ ): Promise<IdleCompactStatus> {
103
+ if (!idleCompactEnabled()) return "disabled";
104
+
105
+ const busy = options.isBusy
106
+ ? options.isBusy(topicId)
107
+ : Boolean(getRoomQuery(topicId) || getRuntimeTurnLease(topicId));
108
+ if (busy) {
109
+ if (options.onBusy) options.onBusy(topicId, userId);
110
+ else scheduleIdleCompactForTopic(topicId, userId);
111
+ return "busy";
112
+ }
113
+
114
+ const topic = getTopic(topicId);
115
+ if (!topic) return "topic-not-found";
116
+ if (!topic.agent) return "not-ai-invited";
117
+ if (topic.aiMode === "mention") return "mention-only-channel";
118
+
119
+ const owner = topic.participants.find((participant) => participant.role === "owner")?.userId;
120
+ if (!owner) return "no-owner";
121
+
122
+ const stats = (options.getStats ?? getTopicStats)(owner, topicId);
123
+ const currentSession = stats.currentSession;
124
+ if (!currentSession || currentSession.contextWindow <= 0) {
125
+ logger.debug({ topicId }, "idle-compact: no provider-reported context usage yet, skipping");
126
+ return "below-threshold";
127
+ }
128
+ const percent = (currentSession.contextTokens / currentSession.contextWindow) * 100;
129
+ const minPercent = options.minContextPercent ?? idleCompactMinContextPercent();
130
+ if (percent < minPercent) {
131
+ logger.debug(
132
+ { topicId, percent: Math.round(percent), minPercent },
133
+ "idle-compact: skipped below context-usage threshold",
134
+ );
135
+ return "below-threshold";
136
+ }
137
+
138
+ const compact =
139
+ options.compact ??
140
+ (async (id: string, actorId: string, reason: string) => {
141
+ // `preemptive: false`: idle-compact must never abort or cancel a turn
142
+ // already in flight, local or remote — only a human explicitly running
143
+ // `/compact` gets to interrupt work. See `compactTopicSession`.
144
+ const { compactTopicSession } = await import("#topics/session");
145
+ return compactTopicSession(id, actorId, reason, { preemptive: false });
146
+ });
147
+
148
+ try {
149
+ const result = await compact(topicId, owner, "idle-compact");
150
+ if (result.busy) {
151
+ logger.debug(
152
+ { topicId, percent: Math.round(percent) },
153
+ "idle-compact: topic became busy, rescheduling",
154
+ );
155
+ if (options.onBusy) options.onBusy(topicId, owner);
156
+ else scheduleIdleCompactForTopic(topicId, owner);
157
+ return "busy";
158
+ }
159
+ if (result.isError) {
160
+ logger.warn(
161
+ { topicId, percent: Math.round(percent), text: result.text },
162
+ "idle-compact: failed",
163
+ );
164
+ return "failed";
165
+ }
166
+ logger.info(
167
+ { topicId, percent: Math.round(percent) },
168
+ "idle-compact: compacted an idle topic's context",
169
+ );
170
+ return "compacted";
171
+ } catch (error) {
172
+ logger.warn(
173
+ { err: error, topicId, percent: Math.round(percent) },
174
+ "idle-compact: unexpected failure while compacting an idle topic",
175
+ );
176
+ return "failed";
177
+ }
178
+ }
@@ -77,8 +77,15 @@ export {
77
77
  getLastMessagePreviews,
78
78
  listApiMessages,
79
79
  listThreadMessages,
80
+ setApiMessageReactions,
80
81
  softDeleteApiMessage,
82
+ updateApiMessageText,
81
83
  } from "#storage/api-messages";
84
+ export {
85
+ getApiTopicConfig,
86
+ setApiTopicConfig,
87
+ type TopicConfig,
88
+ } from "#storage/api-topic-config";
82
89
  export { getTopic, upsertTopic } from "#storage/api-topics";
83
90
  export { getGlobalAiName, setGlobalAiName } from "#storage/app-settings";
84
91
  export { readDecisions, writeDecisionGraphSvg } from "#storage/decisions";
@@ -103,6 +110,6 @@ export {
103
110
  export { ensurePersonalGeneral } from "#topics/personal-general";
104
111
  export { compactTopicSession } from "#topics/session";
105
112
  export { TopicUpdateConflictError, updateTopicSettings } from "#topics/update";
106
- export type { AgentKind } from "#types";
113
+ export type { AgentKind, EffortLevel } from "#types";
107
114
  export type { AiMode, AttachmentDto, TopicDto, TopicSurface } from "#types/api";
108
115
  export { NEGOTIUM_VERSION } from "#version";
@@ -6,7 +6,10 @@ import { listRuntimeTurnLeases } from "#storage/runtime-leases";
6
6
  import type { BackgroundSessionDto } from "#types/api";
7
7
  import { COMPLETED_BACKGROUND_SESSION_RETENTION_MS } from "./background-session-policy";
8
8
 
9
- export type BackgroundSessionProvider = (userId: string) => BackgroundSessionDto[];
9
+ export type BackgroundSessionProvider = (
10
+ userId: string,
11
+ allUsers?: boolean,
12
+ ) => BackgroundSessionDto[];
10
13
 
11
14
  const providers = new Set<BackgroundSessionProvider>();
12
15
  interface TransientBackgroundSessionRecord extends BackgroundSessionDto {
@@ -220,8 +223,19 @@ export function backgroundSessionProgress(
220
223
  return { status, steps };
221
224
  }
222
225
 
223
- export function listBackgroundSessionsForUser(userId: string): BackgroundSessionDto[] {
224
- const memory = listActiveMemoryArchiverSessions(userId);
226
+ /**
227
+ * `allUsers` widens every section — memory archiver, compact/transient, and
228
+ * cron — past the caller's own participation. Meant for single-owner
229
+ * surfaces (terminal, Telegram) where "my view" and "everything on this
230
+ * node" are the same person anyway, so idle background upkeep (and compact,
231
+ * manual or idle-triggered) triggered under a different local userId is
232
+ * still visible.
233
+ */
234
+ export function listBackgroundSessionsForUser(
235
+ userId: string,
236
+ allUsers = false,
237
+ ): BackgroundSessionDto[] {
238
+ const memory = listActiveMemoryArchiverSessions(userId, allUsers);
225
239
  const now = Date.now();
226
240
  for (const [id, session] of transientSessions) {
227
241
  if (session.expiresAt !== undefined && session.expiresAt <= now) {
@@ -230,12 +244,12 @@ export function listBackgroundSessionsForUser(userId: string): BackgroundSession
230
244
  }
231
245
  }
232
246
  const transient = [...transientSessions.values()]
233
- .filter((session) => session.userId === userId)
247
+ .filter((session) => allUsers || session.userId === userId)
234
248
  .map(({ userId: _userId, expiresAt: _expiresAt, expiryTimer: _expiryTimer, ...session }) => ({
235
249
  ...session,
236
250
  steps: [...session.steps],
237
251
  }));
238
- const provided = [...providers].flatMap((provider) => provider(userId));
252
+ const provided = [...providers].flatMap((provider) => provider(userId, allUsers));
239
253
  const providedCronTopicIds = new Set(
240
254
  provided
241
255
  .filter((session) => session.kind === "cron")
@@ -246,9 +260,8 @@ export function listBackgroundSessionsForUser(userId: string): BackgroundSession
246
260
  .filter((lease) => lease.origin.startsWith("cron:"))
247
261
  .flatMap((lease): BackgroundSessionDto[] => {
248
262
  const topic = getTopic(lease.topicId);
249
- const isParticipant = topic?.participants.some(
250
- (participant) => participant.userId === userId,
251
- );
263
+ const isParticipant =
264
+ allUsers || topic?.participants.some((participant) => participant.userId === userId);
252
265
  if (!topic || !isParticipant || providedCronTopicIds.has(lease.topicId)) {
253
266
  return [];
254
267
  }
@@ -4,6 +4,7 @@ import { isAbsolute, resolve } from "node:path";
4
4
  import { estimateTextTokens } from "#agents/compaction-support";
5
5
  import { cleanupAgentFork } from "#agents/fork";
6
6
  import { scheduleIdleArchiveForTopic } from "#agents/idle-archiver";
7
+ import { scheduleIdleCompactForTopic } from "#agents/idle-compact";
7
8
  import { cancelPendingAskUserQuestions } from "#agents/mcp-tools/ask-user";
8
9
  import {
9
10
  settleSubagentFailure,
@@ -83,7 +84,18 @@ export type StreamAgentOutcome =
83
84
  | { kind: "aborted" }
84
85
  | { kind: "budget-capped"; reason: "cost"; usage?: TokenUsage }
85
86
  | { kind: "session-expired"; error: string }
86
- | { kind: "provider-error"; error: string };
87
+ | { kind: "provider-error"; error: string }
88
+ /**
89
+ * The provider's terminal result carried no assistant text at all — the
90
+ * "silent turn" bug: a session-level race (e.g. a queued background-task
91
+ * notification flushing into the CLI session at the same instant a new
92
+ * user turn is dispatched) makes the underlying CLI self-interrupt within
93
+ * ~1-2s and report a trivial zero-usage "success". Distinct from
94
+ * provider-error so the caller can retry once before surfacing a failure —
95
+ * a manual resend has reliably produced a normal reply in every observed
96
+ * case.
97
+ */
98
+ | { kind: "empty-response"; error: string };
87
99
 
88
100
  export interface TurnEventStreamHooks {
89
101
  appendSystemMessage: (topicId: string, text: string) => MessageDto;
@@ -146,6 +158,7 @@ export async function runTurnEventStream(
146
158
  const peerBridge = execution?.peerBridge ?? control.injectParams?.peerBridge;
147
159
  let errorOccurred = false;
148
160
  let terminalEmitted = false;
161
+ const streamStartedAt = Date.now();
149
162
  let lastEventType: UnifiedEvent["type"] | null = null;
150
163
  let pendingSawDelta = false;
151
164
  let accumulatedText = "";
@@ -632,6 +645,13 @@ export async function runTurnEventStream(
632
645
  break;
633
646
  case "result":
634
647
  if (event.usage) recordEventUsage(event.usage);
648
+ // Most providers emit assistant text before the terminal result, but
649
+ // accept result-only providers too. Without this fallback a valid
650
+ // final payload was indistinguishable from the silent-turn bug.
651
+ if (!accumulatedText.trim() && event.content.trim()) {
652
+ accumulatedText = event.content;
653
+ pendingText = event.content;
654
+ }
635
655
  {
636
656
  const usage: MessageDto["usage"] = event.usage
637
657
  ? {
@@ -654,8 +674,26 @@ export async function runTurnEventStream(
654
674
  }
655
675
  }
656
676
  }
677
+ if (!accumulatedText.trim()) {
678
+ const error = "Provider completed without an assistant response";
679
+ terminalEmitted = true;
680
+ // Silent turns (asks, subagent calls) delivered their error to the
681
+ // caller synchronously below and have no user-facing chat to
682
+ // retry into, so they skip the retry path the caller applies to
683
+ // "empty-response" and go straight to a reported provider-error.
684
+ outcome = silent
685
+ ? { kind: "provider-error", error }
686
+ : { kind: "empty-response", error };
687
+ logger.warn(
688
+ { topicId, queryId, agentType, model, silent },
689
+ "ai: provider completed without assistant text",
690
+ );
691
+ if (silent) void deliverAskError(queryId, topicTitle, error);
692
+ return outcome;
693
+ }
657
694
  if (!silent) {
658
695
  scheduleIdleArchiveForTopic(topicId, execution?.actorUserId ?? userId);
696
+ scheduleIdleCompactForTopic(topicId, execution?.actorUserId ?? userId);
659
697
  hub.broadcastDone(
660
698
  topicId,
661
699
  queryId,
@@ -873,6 +911,7 @@ export async function runTurnEventStream(
873
911
  const discardIncompleteSegments =
874
912
  outcome.kind === "session-expired" ||
875
913
  outcome.kind === "provider-error" ||
914
+ outcome.kind === "empty-response" ||
876
915
  (!terminalEmitted && !abortController.signal.aborted);
877
916
  if (discardIncompleteSegments) discardVisibleAssistantMessages();
878
917
  // Release the room slot — but only if a newer turn hasn't already taken it
@@ -891,6 +930,7 @@ export async function runTurnEventStream(
891
930
  if (
892
931
  forkHandle &&
893
932
  outcome.kind !== "session-expired" &&
933
+ outcome.kind !== "empty-response" &&
894
934
  (!forkRequestId || !interSessionQueue.hasRequest(topicId, forkRequestId))
895
935
  ) {
896
936
  cleanupAgentFork(forkHandle);
@@ -903,12 +943,25 @@ export async function runTurnEventStream(
903
943
  if (
904
944
  roomId === topicId &&
905
945
  outcome.kind !== "session-expired" &&
946
+ outcome.kind !== "empty-response" &&
906
947
  !getRoomQuery(topicId) &&
907
948
  !hasReplacementUserTurn
908
949
  ) {
909
950
  const next = takeDeferredInject(topicId);
910
951
  if (next) redispatchInject(next);
911
952
  }
953
+ logger.info(
954
+ {
955
+ topicId,
956
+ queryId,
957
+ outcomeKind: outcome.kind,
958
+ assistantMessageCount: visibleMessageIds.length,
959
+ assistantTextChars: accumulatedText.length,
960
+ durationMs: Date.now() - streamStartedAt,
961
+ lastEventType,
962
+ },
963
+ "ai: turn settled",
964
+ );
912
965
  }
913
966
  return outcome;
914
967
  }
@@ -734,6 +734,8 @@ export interface StartAiTurnParams extends AiTurnExecutionOptions {
734
734
  /** Durable rows whose envelopes are already present in _userMessages. */
735
735
  _durableRequestIds?: string[];
736
736
  _sessionRetried?: boolean;
737
+ /** Internal only. Caps the empty-provider-response retry to a single attempt. */
738
+ _emptyResponseRetried?: boolean;
737
739
  }
738
740
 
739
741
  export interface TriggerTopicAiTurnOptions extends AiTurnExecutionOptions {
@@ -1028,6 +1030,7 @@ export function startAiTurn(params: StartAiTurnParams): string | null {
1028
1030
  const peerBridge = params.peerBridge;
1029
1031
  const askReplySources = params.askReplySources;
1030
1032
  const sessionRetried = params._sessionRetried === true;
1033
+ const emptyResponseRetried = params._emptyResponseRetried === true;
1031
1034
  const queryId = params._queryId ?? randomUUID();
1032
1035
  const roomId = turnConcurrency === "isolated" ? isolatedTurnRoomId(topicId, queryId) : topicId;
1033
1036
  const currentRuntimeEpoch = getRuntimeTopicEpoch(topic.id);
@@ -1822,6 +1825,66 @@ export function startAiTurn(params: StartAiTurnParams): string | null {
1822
1825
  }
1823
1826
  }
1824
1827
 
1828
+ if (outcome.kind === "empty-response") {
1829
+ if (emptyResponseRetried) {
1830
+ // Already retried once and still got nothing back — stop masking it
1831
+ // and report a normal provider failure.
1832
+ outcome = { kind: "provider-error", error: outcome.error };
1833
+ } else {
1834
+ // Observed cause: a queued background-task notification flushes into
1835
+ // the CLI session at the same instant this turn is dispatched, and
1836
+ // the CLI self-interrupts within ~1-2s with a zero-usage "success".
1837
+ // A same-session retry has reliably produced a real reply in every
1838
+ // case seen so far, so retry once before surfacing a failure.
1839
+ if (!silent) WsHub.get().broadcastAborted(topicId, queryId, "stopped");
1840
+ logger.info(
1841
+ { topicId, prevQueryId: queryId, agent: agentKind },
1842
+ "ai: retrying query after empty provider response",
1843
+ );
1844
+ startAiTurn({
1845
+ topic,
1846
+ userId,
1847
+ vaultUserId,
1848
+ prompt,
1849
+ _userMessages: userMessages,
1850
+ _conversationPrompts: conversationPrompts,
1851
+ _loggedUserMessageCount: loggedUserMessageCount,
1852
+ _durableRequestIds: durableRequestIds,
1853
+ attachments,
1854
+ allowAutoContinue,
1855
+ origin,
1856
+ onDispatched,
1857
+ requestId,
1858
+ depth,
1859
+ silent,
1860
+ contextId,
1861
+ agentOverride,
1862
+ modelOverride,
1863
+ effortOverride,
1864
+ sessionId,
1865
+ sessionScope,
1866
+ turnConcurrency,
1867
+ forkHandle,
1868
+ prepareSession,
1869
+ cwd,
1870
+ sessionName,
1871
+ sessionType,
1872
+ visualTools,
1873
+ fileDeliveryTools,
1874
+ onSessionId,
1875
+ onSessionReset,
1876
+ bridgeSessionFromHistory,
1877
+ onSettled,
1878
+ peerBridge,
1879
+ askReplySources,
1880
+ _runtimeEpoch: runtimeEpoch,
1881
+ _sessionRetried: sessionRetried,
1882
+ _emptyResponseRetried: true,
1883
+ });
1884
+ return;
1885
+ }
1886
+ }
1887
+
1825
1888
  if (outcome.kind === "budget-capped") {
1826
1889
  const error = "The job reached its cost limit";
1827
1890
  if (!silent) {