shraga 0.1.89 → 0.1.91

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.
@@ -26,7 +26,7 @@ import { mountFeatures, registerFeature, resumeFeatureSession, collectFeatureFla
26
26
  import { registerSpaCatchAll } from './spa-catchall.ts';
27
27
  import { slackFeature } from './slack/feature.ts';
28
28
  import { dataPath } from './paths.ts';
29
- import { getAllSessions, getSession, getSessionHistory, upsertSession, appendMessage, saveConversation, loadConversation, setSessionDirectives, getAutoApprove, setAutoApprove, getSessionsByScheduleId, getSessionsVisibleTo, isSessionVisibleTo, setRunStatus, incrementRetryCount, getRunningSessions, getActiveLockCount, updateScheduledSessionStatus, setShuttingDown, backfillSessionVisibility, writePartial, readPartial, clearPartial, registerLivePartial, unregisterLivePartial, readLivePartial, acquireSessionLock, releaseSessionLock, replaceSessionLock, isSessionLocked, getSessionAbortController, forkSession, generateSessionTitle, type ConvBlock, type ConvMessage, type SessionMeta } from './sessions.ts';
29
+ import { getAllSessions, getSession, getSessionHistory, upsertSession, appendMessage, saveConversation, loadConversation, setSessionDirectives, getAutoApprove, setAutoApprove, getSessionsByScheduleId, getSessionsVisibleTo, isSessionVisibleTo, setRunStatus, incrementRetryCount, getRunningSessions, getActiveLockCount, updateScheduledSessionStatus, setShuttingDown, backfillSessionVisibility, writePartial, readPartial, clearPartial, registerLivePartial, unregisterLivePartial, readLivePartial, acquireSessionLock, releaseSessionLock, replaceSessionLock, isSessionLocked, getSessionAbortController, forkSession, generateSessionTitle, toListItem, pageSessions, isOwnSession, type ConvBlock, type ConvMessage, type SessionMeta } from './sessions.ts';
30
30
  import { setBroadcaster } from './session-bus.ts';
31
31
  import * as scheduler from './scheduler/index.ts';
32
32
  import { initPolls } from './polls.ts';
@@ -249,10 +249,45 @@ app.get('/api/claude-usage', requireAuth, async (_req, res) => {
249
249
  res.json(usage);
250
250
  });
251
251
 
252
+ /**
253
+ * Conversation list — PAGED and TRIMMED.
254
+ *
255
+ * It used to serialize the entire session index: 6.76 MB and ~226 ms of blocking work per call on
256
+ * the Circles box, most of it `triggeredSkills` + `seenSlackTs`, which are server bookkeeping the
257
+ * browser never reads. The full record is still one `/api/sessions/:id/meta` away.
258
+ *
259
+ * Two modes, both returning `{ sessions, nextCursor }`:
260
+ * ?filter=mine|all&limit=&before=<cursor> — a page of the list, newest first.
261
+ * ?ids=a,b,c — exactly these sessions, at any age.
262
+ *
263
+ * `ids` is what keeps the client's filters honest instead of "searches the first page only": the
264
+ * unread map arrives over the socket in full (per-user, `unread_sync`), so the Sidebar knows every
265
+ * unread id and hydrates the ones outside the loaded window by id. The active session is hydrated
266
+ * the same way. `mine` cannot be done that way — it is a predicate over the whole 12k index — so
267
+ * it is a server-side filter applied BEFORE paging.
268
+ */
269
+ const SESSIONS_PAGE_MAX = 200;
252
270
  app.get('/api/sessions', requireAuth, async (req, res) => {
253
271
  const user = (req as any).user;
254
272
  // Exclude PTY-only sessions — a standalone/terminal-first shell is not a conversation.
255
- res.json(getSessionsVisibleTo(user.uid, user.isOwner, user.email).filter((s) => s.kind !== 'terminal'));
273
+ const all = getSessionsVisibleTo(user.uid, user.isOwner, user.email).filter((s) => s.kind !== 'terminal');
274
+
275
+ const email = String(user.email ?? '').toLowerCase();
276
+ const mine = (s: SessionMeta) => isOwnSession(s, user.uid, email);
277
+
278
+ const idsParam = String(req.query.ids ?? '').trim();
279
+ if (idsParam) {
280
+ const want = new Set(idsParam.split(',').map((s) => s.trim()).filter(Boolean).slice(0, SESSIONS_PAGE_MAX));
281
+ // Deliberately NOT narrowed to `mine`: this is how the ACTIVE conversation gets a row, and the
282
+ // open session must render under either filter. Each row carries `mine` so the client can scope
283
+ // the parts that should be scoped (the unread view and its dot) without losing the active one.
284
+ return void res.json({ sessions: all.filter((s) => want.has(s.sessionId)).map((s) => toListItem(s, mine(s))), nextCursor: null });
285
+ }
286
+
287
+ const scoped = String(req.query.filter ?? 'all') === 'mine' ? all.filter(mine) : all;
288
+
289
+ const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), SESSIONS_PAGE_MAX);
290
+ res.json(pageSessions(scoped, { limit, before: String(req.query.before ?? '') || undefined, mine }));
256
291
  });
257
292
 
258
293
  app.get('/api/sessions/:id/meta', requireAuth, async (req, res) => {
@@ -23,7 +23,7 @@ import { getUserContextBlock } from './user-context.ts';
23
23
  import { collectTurnContext } from './turn-context.ts';
24
24
  import { DATA_DIR, dataPath } from './paths.ts';
25
25
  import * as contacts from './contacts.ts';
26
- import { resolveAndGetEngine } from './engine/index.ts';
26
+ import { resolveAndGetEngine, ModelUnavailableError } from './engine/index.ts';
27
27
 
28
28
  const CONFIG_PATH = dataPath('agent-config.json');
29
29
 
@@ -213,7 +213,19 @@ export async function* streamChat(opts: {
213
213
  context?: Record<string, string>;
214
214
  }): AsyncGenerator<WsEvent> {
215
215
  const config = getAgentConfig();
216
- const { prompt: cleanPrompt, directives: parsed } = parseDirectives(opts.prompt);
216
+ const { prompt: cleanPrompt, directives: parsed, unresolvedModel } = parseDirectives(opts.prompt);
217
+
218
+ // An explicit model selection that resolves to nothing is a HARD stop, for the same reason an
219
+ // unregistered engine is: continuing would run the turn on `config.model` — a different model
220
+ // than the caller picked — with only a console.warn to show for it. Surfaced as a turn `error`
221
+ // event (not a throw) so WS, Slack, scheduler, MCP and webhook all report it identically and a
222
+ // scheduled run is marked failed instead of quietly succeeding on the wrong model.
223
+ if (unresolvedModel) {
224
+ const message = new ModelUnavailableError(unresolvedModel).message;
225
+ console.error(`[stream] ${message} (user=${opts.uid} session=${opts.sessionId ?? 'new'})`);
226
+ yield { type: 'error', message };
227
+ return;
228
+ }
217
229
 
218
230
  const sessionMeta = opts.sessionId ? getSession(opts.sessionId) : undefined;
219
231
  const directives: Directives = { ...sessionMeta?.directives, ...parsed };
@@ -107,6 +107,9 @@ export class DataSync {
107
107
  private pullPending = false;
108
108
  private ready = false;
109
109
  private warnedDisabled = false;
110
+ /** key -> fingerprint+timestamp of the last DM sent for that guard, so a STANDING condition
111
+ * alerts once instead of on every sync cycle. See alertOnce(). */
112
+ private alerted = new Map<string, { fingerprint: string; at: number }>();
110
113
 
111
114
  constructor(opts?: Partial<DataSyncOptions>) {
112
115
  this.options = { ...new DataSyncOptions(), ...opts };
@@ -243,7 +246,16 @@ export class DataSync {
243
246
  * shrink (e.g. contacts.json 111→5 lines — a legit-looking normalization that wiped shared
244
247
  * data). Notifies owner and aborts. */
245
248
  private async guardMassDeletions(context: string): Promise<boolean> {
246
- const fileThreshold = parseInt(process.env.DATA_SYNC_DELETIONS_BLOCK || '10', 10);
249
+ // An untrackIgnored() pass is NOT a deletion: it runs `git rm --cached` over files that match
250
+ // the repo's OWN .gitignore (a personal global gitignore is excluded upstream) and that still
251
+ // exist on disk. Nothing is lost — the files simply stop being shared, which is the entire
252
+ // point of the call. Judging it by the 10-file deletion threshold made a normal .gitignore
253
+ // addition (pane-sessions/, 173 files) an unclearable block: every flush retried it, alerted,
254
+ // and `git reset HEAD` also discarded whatever real work was staged alongside.
255
+ const untracking = context === 'untrackIgnored';
256
+ const fileThreshold = untracking
257
+ ? parseInt(process.env.DATA_SYNC_UNTRACK_BLOCK || '500', 10)
258
+ : parseInt(process.env.DATA_SYNC_DELETIONS_BLOCK || '10', 10);
247
259
  const shrinkThreshold = parseInt(process.env.DATA_SYNC_SHRINK_BLOCK || '50', 10);
248
260
  try {
249
261
  // (1) Mass FILE deletions.
@@ -252,7 +264,7 @@ export class DataSync {
252
264
  if (deleted.length > fileThreshold) {
253
265
  console.error(`${TAG} 🚫 BLOCKED mass deletion (${context}): ${deleted.length} file(s) — threshold is ${fileThreshold}`);
254
266
  const list = deleted.slice(0, 20).map(f => `• ${f}`).join('\n');
255
- await this.notifyOwners(
267
+ await this.alertOnce(`deletions:${context}`, deleted.join('\n'),
256
268
  `🚫 BLOCKED mass deletion in data/ (${context}): ${deleted.length} file(s) staged for deletion (threshold: ${fileThreshold})\n\n${list}` +
257
269
  (deleted.length > 20 ? `\n…and ${deleted.length - 20} more` : '') +
258
270
  `\n\nCommit was aborted. Manual intervention needed.`,
@@ -273,13 +285,16 @@ export class DataSync {
273
285
  }
274
286
  if (shrunk.length) {
275
287
  console.error(`${TAG} 🚫 BLOCKED large content shrink (${context}): ${shrunk.length} file(s) — net-removal threshold is ${shrinkThreshold}`);
276
- await this.notifyOwners(
288
+ await this.alertOnce(`shrink:${context}`, shrunk.join('\n'),
277
289
  `🚫 BLOCKED large content shrink in data/ (${context}): a tracked file lost more than ${shrinkThreshold} net lines (guards against wiping shared data like contacts.json)\n\n${shrunk.slice(0, 20).join('\n')}` +
278
290
  `\n\nCommit was aborted. If intended, raise DATA_SYNC_SHRINK_BLOCK or commit manually.`,
279
291
  );
280
292
  await this.git('reset', 'HEAD').catch(() => {});
281
293
  return true;
282
294
  }
295
+ // Condition cleared — a future recurrence is news again, so let it alert.
296
+ this.clearAlert(`deletions:${context}`);
297
+ this.clearAlert(`shrink:${context}`);
283
298
  return false;
284
299
  } catch (err) {
285
300
  console.warn(`${TAG} Destructive-change check failed:`, (err as Error).message);
@@ -678,6 +693,31 @@ export class DataSync {
678
693
  return violations;
679
694
  }
680
695
 
696
+ /**
697
+ * DM owners about a guard trip, but only when the condition is NEW. A blocked commit is not a
698
+ * one-off: the same staged change is re-attempted on every flush, so a standing condition used to
699
+ * DM on every cycle (measured 2026-09-08: 173 files ignored-but-tracked produced an identical
700
+ * "BLOCKED mass deletion" DM ~15×/hour, indefinitely). Alert fatigue is a correctness bug — it
701
+ * buries the one alert that matters. Re-alerts only when the fingerprint CHANGES or after
702
+ * DATA_SYNC_ALERT_REPEAT_MS (default 6h), and every trip is still logged locally.
703
+ */
704
+ private async alertOnce(key: string, fingerprint: string, text: string): Promise<void> {
705
+ const repeatMs = parseInt(process.env.DATA_SYNC_ALERT_REPEAT_MS || '', 10) || 6 * 60 * 60 * 1000;
706
+ const prev = this.alerted.get(key);
707
+ const now = Date.now();
708
+ if (prev && prev.fingerprint === fingerprint && now - prev.at < repeatMs) {
709
+ console.warn(`${TAG} alert suppressed (unchanged since ${new Date(prev.at).toISOString()}): ${key}`);
710
+ return;
711
+ }
712
+ this.alerted.set(key, { fingerprint, at: now });
713
+ await this.notifyOwners(text);
714
+ }
715
+
716
+ /** Clear the repeat-suppression for a guard once its condition is gone, so a RECURRENCE alerts. */
717
+ private clearAlert(key: string): void {
718
+ this.alerted.delete(key);
719
+ }
720
+
681
721
  private async notifyOwners(text: string): Promise<void> {
682
722
  if (!this.options.notify) {
683
723
  // Not the authoritative instance — log it so a dev run still surfaces the problem locally,
@@ -9,6 +9,11 @@ export interface Directives {
9
9
  export interface ParsedPrompt {
10
10
  prompt: string;
11
11
  directives: Directives;
12
+ /** An EXPLICIT model selection that no alias and no registered engine could resolve. Reported
13
+ * instead of applied: dropping it (the old behaviour) ran the turn on `config.model` — a
14
+ * DIFFERENT model than the caller asked for, with nothing but a `console.warn` to show for it.
15
+ * The caller turns this into a turn error; see `streamChat`. */
16
+ unresolvedModel?: string;
12
17
  }
13
18
 
14
19
  /** Model used when neither directives nor config specify one. Always passed
@@ -91,8 +96,19 @@ export function parseDirectives(text: string): ParsedPrompt {
91
96
 
92
97
  const directives: Directives = {};
93
98
  let positionalIndex = 0;
99
+ let unresolvedModel: string | undefined;
100
+
101
+ const tokens = raw.split(',');
102
+ // Is this group PROVABLY a directive group (some token is a known `key:value`)? A bare positional
103
+ // that resolves to nothing is otherwise indistinguishable from prose — `[some bracketed text] hi`
104
+ // is a legitimate prompt and must never fail a turn. With a keyed sibling present the group is
105
+ // unambiguously directives, so an unresolvable leading model token there IS a dropped selection.
106
+ const provenDirectiveGroup = tokens.some((t) => {
107
+ const i = t.indexOf(':');
108
+ return i !== -1 && DIRECTIVE_KEYS.includes(t.slice(0, i).trim().toLowerCase());
109
+ });
94
110
 
95
- for (const token of raw.split(',')) {
111
+ for (const token of tokens) {
96
112
  const t = token.trim();
97
113
  if (!t) continue;
98
114
 
@@ -100,7 +116,7 @@ export function parseDirectives(text: string): ParsedPrompt {
100
116
  if (colonIdx !== -1) {
101
117
  const key = t.slice(0, colonIdx).trim().toLowerCase();
102
118
  const val = t.slice(colonIdx + 1).trim().toLowerCase();
103
- applyDirective(directives, key, val);
119
+ unresolvedModel = applyDirective(directives, key, val) ?? unresolvedModel;
104
120
  } else {
105
121
  const val = t.toLowerCase();
106
122
  if (positionalIndex === 0 && resolveModelToken(directives, val)) {
@@ -112,6 +128,7 @@ export function parseDirectives(text: string): ParsedPrompt {
112
128
  } else if (['nothink', 'nothinking'].includes(val)) {
113
129
  directives.thinking = 'disabled';
114
130
  } else if (positionalIndex === 0) {
131
+ if (provenDirectiveGroup && !isQualifiedModel(val)) unresolvedModel ??= t;
115
132
  console.warn(isQualifiedModel(val)
116
133
  ? `[directives] Provider-qualified model needs key form: "[model:${t}]"`
117
134
  : `[directives] Unknown model alias: "${t}"`);
@@ -120,16 +137,19 @@ export function parseDirectives(text: string): ParsedPrompt {
120
137
  }
121
138
  }
122
139
 
123
- return { prompt, directives };
140
+ return { prompt, directives, unresolvedModel };
124
141
  }
125
142
 
126
- function applyDirective(d: Directives, key: string, val: string) {
143
+ /** Returns the token of an EXPLICIT model selection it could not resolve, else undefined. */
144
+ function applyDirective(d: Directives, key: string, val: string): string | undefined {
127
145
  switch (key) {
128
146
  case 'model':
129
147
  if (resolveModelToken(d, val)) break;
130
- if (isQualifiedModel(val)) d.model = val;
131
- else console.warn(`[directives] Unknown model alias: "${val}"`);
132
- break;
148
+ // A `provider/model` id is already concrete — hand it to the engine, which owns the verdict.
149
+ if (isQualifiedModel(val)) { d.model = val; break; }
150
+ // Nothing knows this token. `[model:x]` is unambiguously a selection (never prose), so the
151
+ // turn must not quietly continue on the instance default model.
152
+ return val;
133
153
  case 'turns':
134
154
  if (/^\d+$/.test(val)) d.turns = parseInt(val, 10);
135
155
  else console.warn(`[directives] Invalid turns value: "${val}"`);
@@ -149,4 +169,5 @@ function applyDirective(d: Directives, key: string, val: string) {
149
169
  default:
150
170
  console.warn(`[directives] Unknown directive key: "${key}"`);
151
171
  }
172
+ return undefined;
152
173
  }
@@ -64,6 +64,23 @@ export class EngineUnavailableError extends Error {
64
64
  }
65
65
  }
66
66
 
67
+ /** An explicitly selected model that neither the alias table nor any REGISTERED engine could
68
+ * resolve. Same contract as EngineUnavailableError: the run stops instead of silently continuing
69
+ * on the instance default, which is a different model than the caller chose. */
70
+ export class ModelUnavailableError extends Error {
71
+ constructor(public readonly model: string) {
72
+ super(
73
+ `Model "${model}" could not be resolved on this server. It is not a known alias, not a ` +
74
+ `provider-qualified id (\`provider/model\`), and no registered engine advertises it. ` +
75
+ `Registered engines: ${getAvailableEngines().join(', ') || 'none'} — an engine registers only when enabled ` +
76
+ `at boot (AGENT_ENGINES must list it; the native cursor engine also needs CURSOR_API_KEY), ` +
77
+ `so a model owned by an absent engine cannot resolve. Check the server env and startup log. ` +
78
+ `The run was stopped rather than silently re-routed to the instance's default model.`,
79
+ );
80
+ this.name = 'ModelUnavailableError';
81
+ }
82
+ }
83
+
67
84
  export function resolveAndGetEngine(directives?: { engine?: string }, agentConfig?: { engine?: string }) {
68
85
  const name = resolveEngine(directives, agentConfig);
69
86
  // An optional engine may be unregistered on a given boot (add-on not loaded, missing API key or
@@ -1,4 +1,4 @@
1
- import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, readdirSync, unlinkSync } from 'node:fs';
1
+ import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, readdirSync, unlinkSync, statSync, renameSync } from 'node:fs';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { homedir } from 'node:os';
@@ -44,18 +44,146 @@ export interface SessionMeta {
44
44
  forkedFrom?: string;
45
45
  }
46
46
 
47
+ // ── Index cache ──────────────────────────────────────────────────────────────
48
+ // The index is ~9 MB / 12.8k records on the Circles box. Every mutation used to readFileSync +
49
+ // JSON.parse the whole file and then stringify + write it back, SYNCHRONOUSLY (~260 ms per
50
+ // mutation on a 2-vCPU host, and a turn does several), so the event loop stalled on bookkeeping.
51
+ //
52
+ // Single-writer, deliberately: the HTTP server, its scheduler and the MCP surface all run in ONE
53
+ // bun process (`src/server/index.ts` → boot.ts, which mounts the MCP in-process; the only spawned
54
+ // children are vendor MCP sidecars and background-job shells, none of which import this module).
55
+ // data-sync cannot touch it either — `sessions.json` is in the data repo's canonical .gitignore,
56
+ // so its pull/reset/stash never sees the file. The one out-of-band writer is the manual
57
+ // `defaults/scripts/backfill-slack-usernames.ts`, and this cache does NOT make that safe:
58
+ //
59
+ // The mtime guard below only runs when the cache is CLEAN. `loadIndex()` short-circuits on
60
+ // `if (dirty) return cache` first, so while a flush is pending the server neither sees an
61
+ // external edit nor survives it — the next flushIndex() writes the whole in-memory cache over
62
+ // the file, and the backfill's work is gone. The guard buys exactly one thing over the old
63
+ // read-every-time behaviour: an external edit made while nothing is pending IS picked up.
64
+ // Fixing the pending-flush case needs a merge or a lock, i.e. a multi-process protocol, which
65
+ // this file deliberately does not have. flushIndex() detects the clobber and logs it loudly
66
+ // instead of losing it silently — run the backfill with the server stopped.
67
+ let cache: SessionMeta[] | null = null;
68
+ let cacheMtimeMs = -1;
69
+ let dirty = false;
70
+ let flushTimer: ReturnType<typeof setTimeout> | null = null;
71
+
72
+ /**
73
+ * Coalescing window for the 9 MB write.
74
+ *
75
+ * Durability trade, stated explicitly: a crash can lose at most this much session BOOKKEEPING —
76
+ * runStatus, lastModified, seenSlackTs, triggeredSkills. Conversation content is not in this file
77
+ * (it lives in `data/conversations/`), so no user-visible message can be lost here. Every exit
78
+ * path we control flushes synchronously (`process.on('exit')` — reached by the SIGTERM/SIGINT
79
+ * graceful shutdown, which ends in process.exit — plus setShuttingDown() at the top of the drain),
80
+ * so the exposure is a SIGKILL or power loss inside a 250 ms window. Taken over the alternative:
81
+ * a ~260 ms sync stall on every single mutation.
82
+ */
83
+ const FLUSH_DEBOUNCE_MS = 250;
84
+
47
85
  function loadIndex(): SessionMeta[] {
48
- if (!existsSync(SESSIONS_PATH)) return [];
86
+ if (cache) {
87
+ if (dirty) return cache; // memory is ahead of disk — we are the authority
88
+ try {
89
+ if (statSync(SESSIONS_PATH).mtimeMs === cacheMtimeMs) return cache;
90
+ } catch {
91
+ return cache; // file vanished mid-run; keep serving what we have
92
+ }
93
+ }
94
+ if (!existsSync(SESSIONS_PATH)) return (cache = []);
49
95
  try {
50
- return JSON.parse(readFileSync(SESSIONS_PATH, 'utf-8'));
51
- } catch {
52
- return [];
96
+ cache = JSON.parse(readFileSync(SESSIONS_PATH, 'utf-8'));
97
+ cacheMtimeMs = statSync(SESSIONS_PATH).mtimeMs;
98
+ } catch (err) {
99
+ console.error('[sessions] index unreadable, serving an empty list:', (err as Error).message);
100
+ cache = [];
53
101
  }
102
+ return cache!;
54
103
  }
55
104
 
56
105
  function saveIndex(sessions: SessionMeta[]): void {
57
- mkdirSync(DATA_DIR, { recursive: true });
58
- writeFileSync(SESSIONS_PATH, JSON.stringify(sessions, null, 2));
106
+ cache = sessions;
107
+ dirty = true;
108
+ if (!flushTimer) scheduleFlush();
109
+ }
110
+
111
+ function scheduleFlush(): void {
112
+ const t = setTimeout(flushIndex, FLUSH_DEBOUNCE_MS);
113
+ (t as { unref?: () => void }).unref?.(); // never hold the process open for bookkeeping
114
+ flushTimer = t;
115
+ }
116
+
117
+ /**
118
+ * Write the index to disk now. Atomic (tmp + rename): the old truncate-in-place write left a
119
+ * half-written 9 MB file if it was interrupted, and loadIndex's catch turned that into an EMPTY
120
+ * session list — a silent total loss of the index.
121
+ */
122
+ export function flushIndex(): void {
123
+ if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
124
+ if (!dirty || !cache) return;
125
+ dirty = false;
126
+ const tmp = `${SESSIONS_PATH}.tmp`;
127
+ try {
128
+ // Someone wrote the file behind us while this flush was pending (see the cache note above).
129
+ // We cannot merge without a multi-process protocol, so at least never lose it silently.
130
+ try {
131
+ const onDisk = statSync(SESSIONS_PATH).mtimeMs;
132
+ if (cacheMtimeMs >= 0 && onDisk !== cacheMtimeMs) {
133
+ console.error(`[sessions] index changed on disk (mtime ${onDisk} != ${cacheMtimeMs}) while a flush was pending — overwriting that external edit`);
134
+ }
135
+ } catch { /* file absent: nothing to clobber */ }
136
+ mkdirSync(DATA_DIR, { recursive: true });
137
+ writeFileSync(tmp, JSON.stringify(cache)); // no pretty-print: 9 MB nobody reads by hand
138
+ renameSync(tmp, SESSIONS_PATH);
139
+ cacheMtimeMs = statSync(SESSIONS_PATH).mtimeMs;
140
+ } catch (err) {
141
+ console.error('[sessions] index flush failed, retrying on the next tick:', (err as Error).message);
142
+ dirty = true;
143
+ scheduleFlush();
144
+ }
145
+ }
146
+
147
+ // Covers the graceful-shutdown paths (SIGTERM/SIGINT end in process.exit) and any normal exit,
148
+ // without adding signal listeners that would change the process's default signal semantics.
149
+ process.on('exit', () => flushIndex());
150
+
151
+ /**
152
+ * The subset of SessionMeta the conversation list actually renders. `triggeredSkills` (381 KB) and
153
+ * `seenSlackTs` (174 KB) are server bookkeeping and must never be shipped to a browser; the full
154
+ * record is still available per-session from /api/sessions/:id/meta.
155
+ */
156
+ export interface SessionListItem {
157
+ sessionId: string;
158
+ title: string;
159
+ userName: string;
160
+ lastModified: number;
161
+ slackContext?: SessionMeta['slackContext'];
162
+ runStatus?: SessionMeta['runStatus'];
163
+ lastStopReason?: SessionMeta['lastStopReason'];
164
+ scheduleRunStatus?: SessionMeta['scheduleRunStatus'];
165
+ /** Owned by (or explicitly shared with) the caller. Stamped per-request: the trimmed row drops
166
+ * `uid`/`visibleTo`, so without it the client cannot scope anything by "mine". */
167
+ mine?: boolean;
168
+ }
169
+
170
+ /** The `mine` predicate, shared by the list filter and the per-row stamp so they cannot drift. */
171
+ export function isOwnSession(s: SessionMeta, uid: string, email: string): boolean {
172
+ return s.uid === uid || !!s.visibleTo?.includes(email);
173
+ }
174
+
175
+ export function toListItem(s: SessionMeta, mine?: boolean): SessionListItem {
176
+ return {
177
+ sessionId: s.sessionId,
178
+ title: s.title,
179
+ userName: s.userName,
180
+ lastModified: s.lastModified,
181
+ ...(s.slackContext ? { slackContext: s.slackContext } : {}),
182
+ ...(s.runStatus ? { runStatus: s.runStatus } : {}),
183
+ ...(s.lastStopReason ? { lastStopReason: s.lastStopReason } : {}),
184
+ ...(s.scheduleRunStatus ? { scheduleRunStatus: s.scheduleRunStatus } : {}),
185
+ ...(mine === undefined ? {} : { mine }),
186
+ };
59
187
  }
60
188
 
61
189
  function summarize(prompt: string): string {
@@ -126,8 +254,33 @@ export function addTriggeredSkills(sessionId: string, names: string[]): void {
126
254
  saveIndex(sessions);
127
255
  }
128
256
 
257
+ /**
258
+ * Stable pagination cursor: lastModified alone is not unique across 12k sessions, and a paging
259
+ * cursor that only carries the timestamp drops or repeats every record in a tie.
260
+ */
261
+ export function sessionCursor(s: SessionMeta | SessionListItem): string {
262
+ return `${String(s.lastModified).padStart(16, '0')}.${s.sessionId}`;
263
+ }
264
+
265
+ /**
266
+ * One page of an already-sorted (getAllSessions order) list, newest first. `before` is the cursor
267
+ * from the previous page's `nextCursor`; it is composite (lastModified + sessionId) because
268
+ * lastModified alone is not unique and a timestamp-only cursor drops or repeats records on a tie.
269
+ */
270
+ export function pageSessions(list: SessionMeta[], opts: { limit: number; before?: string; mine?: (s: SessionMeta) => boolean }): { sessions: SessionListItem[]; nextCursor: string | null } {
271
+ const rest = opts.before ? list.filter((s) => sessionCursor(s) < opts.before!) : list;
272
+ const page = rest.slice(0, opts.limit);
273
+ return {
274
+ sessions: page.map((s) => toListItem(s, opts.mine?.(s))),
275
+ nextCursor: rest.length > page.length ? sessionCursor(page[page.length - 1]) : null,
276
+ };
277
+ }
278
+
129
279
  export function getAllSessions(): SessionMeta[] {
130
- return loadIndex().sort((a, b) => b.lastModified - a.lastModified);
280
+ // Copy before sorting: loadIndex() now returns the live cache array, and an in-place sort here
281
+ // would silently reorder the store for every other caller. Sorted by the same composite key the
282
+ // pagination cursor uses, so `cursor < before` is exactly consistent with this order.
283
+ return [...loadIndex()].sort((a, b) => b.lastModified - a.lastModified || (a.sessionId < b.sessionId ? 1 : a.sessionId > b.sessionId ? -1 : 0));
131
284
  }
132
285
 
133
286
  export function isSessionVisibleTo(s: SessionMeta, uid: string, isOwner = false, email?: string): boolean {
@@ -184,7 +337,11 @@ export function setSessionDirectives(sessionId: string, directives: NonNullable<
184
337
  const sessions = loadIndex();
185
338
  const s = sessions.find((s) => s.sessionId === sessionId);
186
339
  if (s) {
187
- s.directives = directives;
340
+ // COPY, don't alias. streamChat() stores its working `directives` object here and then keeps
341
+ // mutating it for the rest of the turn (the per-skill turn budget is gap-filled afterwards).
342
+ // While the index was re-parsed from disk on every read that leak was invisible; against an
343
+ // in-memory store it would silently PIN a skill's 250-turn budget onto the session forever.
344
+ s.directives = { ...directives };
188
345
  saveIndex(sessions);
189
346
  }
190
347
  }
@@ -269,7 +426,7 @@ export function setAutoApprove(uid: string, value: boolean): void {
269
426
  }
270
427
 
271
428
  let _shuttingDown = false;
272
- export function setShuttingDown(): void { _shuttingDown = true; }
429
+ export function setShuttingDown(): void { _shuttingDown = true; flushIndex(); }
273
430
 
274
431
  export function setRunStatus(sessionId: string, status: 'running' | 'idle', origin?: 'web' | 'slack' | 'scheduler' | 'gmail', stopReason?: SessionMeta['lastStopReason']): void {
275
432
  if (_shuttingDown && status === 'idle') return;