@agenthippo-ai/agenthippo 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,37 +2,93 @@
2
2
  /*---------------------------------------------------------------------------------------------
3
3
  * Copyright (c) AgentHippo.ai. All rights reserved.
4
4
  *--------------------------------------------------------------------------------------------*/
5
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
6
+ if (k2 === undefined) k2 = k;
7
+ var desc = Object.getOwnPropertyDescriptor(m, k);
8
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
9
+ desc = { enumerable: true, get: function() { return m[k]; } };
10
+ }
11
+ Object.defineProperty(o, k2, desc);
12
+ }) : (function(o, m, k, k2) {
13
+ if (k2 === undefined) k2 = k;
14
+ o[k2] = m[k];
15
+ }));
16
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
17
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
18
+ }) : function(o, v) {
19
+ o["default"] = v;
20
+ });
21
+ var __importStar = (this && this.__importStar) || (function () {
22
+ var ownKeys = function(o) {
23
+ ownKeys = Object.getOwnPropertyNames || function (o) {
24
+ var ar = [];
25
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
26
+ return ar;
27
+ };
28
+ return ownKeys(o);
29
+ };
30
+ return function (mod) {
31
+ if (mod && mod.__esModule) return mod;
32
+ var result = {};
33
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
34
+ __setModuleDefault(result, mod);
35
+ return result;
36
+ };
37
+ })();
5
38
  Object.defineProperty(exports, "__esModule", { value: true });
6
39
  exports.isAwsCliAvailable = isAwsCliAvailable;
7
40
  exports.resolveS3StateConfig = resolveS3StateConfig;
41
+ exports.resolveRestoreBootMode = resolveRestoreBootMode;
42
+ exports.safeSessionId = safeSessionId;
43
+ exports.sessionRemoteUri = sessionRemoteUri;
44
+ exports.shardBelongsToSession = shardBelongsToSession;
45
+ exports.hasLocalSessionState = hasLocalSessionState;
46
+ exports.listSessionShardFiles = listSessionShardFiles;
47
+ exports.stageSessionPushDir = stageSessionPushDir;
48
+ exports.mergeSessionStagingIntoState = mergeSessionStagingIntoState;
8
49
  exports.restoreState = restoreState;
9
50
  exports.pushState = pushState;
51
+ exports.pushSessionState = pushSessionState;
52
+ exports.ensureSessionLocal = ensureSessionLocal;
53
+ exports.clearRestoreAttemptCache = clearRestoreAttemptCache;
10
54
  exports.makeStatePusher = makeStatePusher;
55
+ exports.providerSessionShardName = providerSessionShardName;
11
56
  /**
12
- * Object-store state sync (serve-v3 Q1, shared SessionStore).
57
+ * Object-store state sync (serve-v3 Q1 + serve-v4 P1.2).
13
58
  *
14
- * Persists the durable state root (/state) to an S3-compatible object store so
15
- * sessions survive scale-to-zero / a fresh instance even where there is NO
16
- * filesystem mount (Cloudflare R2, Databricks → S3/DBFS), and so a sticky-routed
17
- * fleet replica can reload an affinity-owned session.
59
+ * Persists the durable state root to an S3-compatible object store so sessions
60
+ * survive scale-to-zero / a fresh instance (Cloudflare R2, MinIO, GCS, AWS S3)
61
+ * and so a sticky-routed fleet can restore-on-miss after resharding.
18
62
  *
19
- * Strategy (agreed): boot restore + per-turn async-delta push (debounced, off
20
- * the response path) + a SIGTERM flush. Uses `aws s3 sync` (delta by default;
21
- * S3-compatible via --endpoint-url → MinIO / R2 / GCS / S3) so we add no SDK to
22
- * the binary. Single-writer-per-session safety comes from sticky-by-session_id.
63
+ * Strategy:
64
+ * - Per-session async-delta push (debounced, off the response path) →
65
+ * s3://…/sessions/<sessionId>/
66
+ * - Restore-on-miss before a turn when the session is not local
67
+ * - Optional boot restore of the whole prefix (`AGENTHIPPO_STATE_RESTORE_BOOT=all`)
68
+ * - SIGTERM flush of pending session pushes
69
+ *
70
+ * Uses `aws s3 sync` (S3-compatible via --endpoint-url). No SDK in the binary.
71
+ * Last-write-wins per session prefix — no CAS. Mid-reshard may lose one turn of
72
+ * context; sticky routing makes that rare.
23
73
  *
24
74
  * Config (no-op unless AGENTHIPPO_STATE_S3_URI is set):
25
- * AGENTHIPPO_STATE_S3_URI s3://bucket/prefix
26
- * AGENTHIPPO_STATE_S3_ENDPOINT http://minio:9000 (for non-AWS S3)
27
- * AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_REGION (standard aws creds)
75
+ * AGENTHIPPO_STATE_S3_URI s3://bucket/prefix
76
+ * AGENTHIPPO_STATE_S3_ENDPOINT http://minio:9000 (non-AWS S3)
77
+ * AGENTHIPPO_STATE_RESTORE_BOOT all|none (default all for single-instance compat;
78
+ * multi-replica templates set none)
79
+ * AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_REGION
28
80
  */
81
+ const crypto_1 = require("crypto");
29
82
  const child_process_1 = require("child_process");
83
+ const fsp = __importStar(require("fs/promises"));
84
+ const path = __importStar(require("path"));
30
85
  const util_1 = require("util");
31
86
  const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
87
+ /** In-process cache: skip repeat S3 pulls for the same session on this replica. */
88
+ const restoreAttempted = new Set();
32
89
  /**
33
90
  * Verify the `aws` CLI is callable. State sync shells out to it, so when S3 is
34
91
  * configured a missing binary means silent data loss — callers should fail fast.
35
- * Resolves true when available, false otherwise.
36
92
  */
37
93
  async function isAwsCliAvailable() {
38
94
  try {
@@ -54,6 +110,27 @@ function resolveS3StateConfig(env = process.env) {
54
110
  endpoint: env.AGENTHIPPO_STATE_S3_ENDPOINT?.trim() || undefined,
55
111
  };
56
112
  }
113
+ /**
114
+ * Boot restore mode. Default `all` preserves single-instance behavior.
115
+ * Multi-replica templates set `none` and rely on restore-on-miss (P1.2).
116
+ */
117
+ function resolveRestoreBootMode(env = process.env) {
118
+ const raw = env.AGENTHIPPO_STATE_RESTORE_BOOT?.trim().toLowerCase();
119
+ if (raw === 'none') {
120
+ return 'none';
121
+ }
122
+ return 'all';
123
+ }
124
+ /** Sanitize a client session id for use as an S3 path segment / local dir name. */
125
+ function safeSessionId(sessionId) {
126
+ const trimmed = sessionId.trim();
127
+ const safe = trimmed.replace(/[<>:"/\\|?*\x00-\x1F]+/g, '_').replace(/\.\./g, '_').slice(0, 160);
128
+ return safe || 'session';
129
+ }
130
+ /** Remote prefix for one session: s3://bucket/prefix/sessions/<id> */
131
+ function sessionRemoteUri(cfg, sessionId) {
132
+ return `${cfg.uri}/sessions/${safeSessionId(sessionId)}`;
133
+ }
57
134
  function syncArgs(cfg, from, to) {
58
135
  const args = ['s3', 'sync', from, to, '--no-progress'];
59
136
  if (cfg.endpoint) {
@@ -61,6 +138,126 @@ function syncArgs(cfg, from, to) {
61
138
  }
62
139
  return args;
63
140
  }
141
+ function attemptKey(stateDir, sessionId) {
142
+ return `${path.resolve(stateDir)}\0${sessionId}`;
143
+ }
144
+ /** Conversation key format: engine:chatSessionId:pack:signature */
145
+ function shardBelongsToSession(shardKey, chatSessionId, value) {
146
+ if (value?.chatSessionId && value.chatSessionId === chatSessionId) {
147
+ return true;
148
+ }
149
+ const parts = shardKey.split(':');
150
+ return parts.length >= 2 && parts[1] === chatSessionId;
151
+ }
152
+ /**
153
+ * True when this replica already has provider-session state for the chat session.
154
+ * Used to skip restore-on-miss on the hot path.
155
+ */
156
+ async function hasLocalSessionState(stateDir, sessionId) {
157
+ const dir = path.join(stateDir, 'provider-sessions');
158
+ let names;
159
+ try {
160
+ names = await fsp.readdir(dir);
161
+ }
162
+ catch {
163
+ return false;
164
+ }
165
+ for (const name of names) {
166
+ if (!name.endsWith('.json')) {
167
+ continue;
168
+ }
169
+ try {
170
+ const raw = await fsp.readFile(path.join(dir, name), 'utf8');
171
+ const parsed = JSON.parse(raw);
172
+ if (typeof parsed.key === 'string' && shardBelongsToSession(parsed.key, sessionId, parsed.value)) {
173
+ return true;
174
+ }
175
+ }
176
+ catch {
177
+ // skip unreadable
178
+ }
179
+ }
180
+ return false;
181
+ }
182
+ /** Collect provider-session shard filenames that belong to this chat session. */
183
+ async function listSessionShardFiles(stateDir, sessionId) {
184
+ const dir = path.join(stateDir, 'provider-sessions');
185
+ let names;
186
+ try {
187
+ names = await fsp.readdir(dir);
188
+ }
189
+ catch {
190
+ return [];
191
+ }
192
+ const out = [];
193
+ for (const name of names) {
194
+ if (!name.endsWith('.json')) {
195
+ continue;
196
+ }
197
+ try {
198
+ const raw = await fsp.readFile(path.join(dir, name), 'utf8');
199
+ const parsed = JSON.parse(raw);
200
+ if (typeof parsed.key === 'string' && shardBelongsToSession(parsed.key, sessionId, parsed.value)) {
201
+ out.push(name);
202
+ }
203
+ }
204
+ catch {
205
+ // skip
206
+ }
207
+ }
208
+ return out;
209
+ }
210
+ async function pathExists(p) {
211
+ try {
212
+ await fsp.access(p);
213
+ return true;
214
+ }
215
+ catch {
216
+ return false;
217
+ }
218
+ }
219
+ async function copyDirIfExists(src, dest) {
220
+ if (!(await pathExists(src))) {
221
+ return;
222
+ }
223
+ await fsp.mkdir(dest, { recursive: true });
224
+ await fsp.cp(src, dest, { recursive: true, force: true });
225
+ }
226
+ /**
227
+ * Build a local staging tree for session S:
228
+ * provider-sessions/<shards for S>
229
+ * agents/ (replica snapshot — sticky-by-user keeps this scoped)
230
+ */
231
+ async function stageSessionPushDir(stateDir, sessionId, stagingDir) {
232
+ await fsp.rm(stagingDir, { recursive: true, force: true });
233
+ await fsp.mkdir(stagingDir, { recursive: true });
234
+ const shards = await listSessionShardFiles(stateDir, sessionId);
235
+ const psDest = path.join(stagingDir, 'provider-sessions');
236
+ if (shards.length > 0) {
237
+ await fsp.mkdir(psDest, { recursive: true });
238
+ for (const name of shards) {
239
+ await fsp.copyFile(path.join(stateDir, 'provider-sessions', name), path.join(psDest, name));
240
+ }
241
+ }
242
+ await copyDirIfExists(path.join(stateDir, 'agents'), path.join(stagingDir, 'agents'));
243
+ const hasAgents = await pathExists(path.join(stagingDir, 'agents'));
244
+ return shards.length > 0 || hasAgents;
245
+ }
246
+ /** Merge a restored staging tree back into stateDir. */
247
+ async function mergeSessionStagingIntoState(stagingDir, stateDir) {
248
+ const psSrc = path.join(stagingDir, 'provider-sessions');
249
+ if (await pathExists(psSrc)) {
250
+ const psDest = path.join(stateDir, 'provider-sessions');
251
+ await fsp.mkdir(psDest, { recursive: true });
252
+ for (const name of await fsp.readdir(psSrc)) {
253
+ if (!name.endsWith('.json')) {
254
+ continue;
255
+ }
256
+ await fsp.copyFile(path.join(psSrc, name), path.join(psDest, name));
257
+ }
258
+ }
259
+ await copyDirIfExists(path.join(stagingDir, 'agents'), path.join(stateDir, 'agents'));
260
+ }
64
261
  /** Download state from the bucket into stateDir on boot. An empty bucket is a no-op. */
65
262
  async function restoreState(cfg, stateDir, log) {
66
263
  try {
@@ -68,46 +265,130 @@ async function restoreState(cfg, stateDir, log) {
68
265
  log?.(`State restored from ${cfg.uri}`);
69
266
  }
70
267
  catch (err) {
71
- // First boot / empty bucket / transient — don't block startup.
72
268
  log?.(`State restore skipped: ${err instanceof Error ? err.message : String(err)}`);
73
269
  }
74
270
  }
75
- /** Upload changed files from stateDir to the bucket (delta). */
271
+ /** Upload changed files from stateDir to the bucket (full-tree delta — legacy / flush fallback). */
76
272
  async function pushState(cfg, stateDir, log) {
77
273
  try {
78
274
  await execFileAsync('aws', syncArgs(cfg, stateDir, cfg.uri));
79
275
  }
80
276
  catch (err) {
81
- // A failed push means sessions aren't durable — surface it unconditionally,
82
- // not just under --verbose, so the operator notices state isn't persisting.
83
277
  console.error(`[state-sync] push to ${cfg.uri} failed: ${err instanceof Error ? err.message : String(err)}`);
84
278
  log?.(`State push failed: ${err instanceof Error ? err.message : String(err)}`);
85
279
  }
86
280
  }
281
+ /** Push one session's scoped tree to s3://…/sessions/<id>/. */
282
+ async function pushSessionState(cfg, stateDir, sessionId, log) {
283
+ const safe = safeSessionId(sessionId);
284
+ const staging = path.join(stateDir, '.push-staging', safe);
285
+ try {
286
+ const hasContent = await stageSessionPushDir(stateDir, sessionId, staging);
287
+ if (!hasContent) {
288
+ log?.(`State push skipped for session ${safe}: nothing local`);
289
+ return;
290
+ }
291
+ const remote = sessionRemoteUri(cfg, sessionId);
292
+ await execFileAsync('aws', syncArgs(cfg, staging, remote));
293
+ log?.(`State pushed session ${safe} → ${remote}`);
294
+ }
295
+ catch (err) {
296
+ console.error(`[state-sync] push session ${safe} failed: ${err instanceof Error ? err.message : String(err)}`);
297
+ log?.(`State push failed for ${safe}: ${err instanceof Error ? err.message : String(err)}`);
298
+ }
299
+ finally {
300
+ await fsp.rm(staging, { recursive: true, force: true }).catch(() => undefined);
301
+ }
302
+ }
303
+ /**
304
+ * On a request whose session_id has no local provider-session state, pull
305
+ * s3://…/sessions/<id>/ before runner.run(). No-ops if already local or already
306
+ * attempted this process (avoids re-pulling brand-new sessions every turn).
307
+ */
308
+ async function ensureSessionLocal(cfg, stateDir, sessionId, log) {
309
+ const key = attemptKey(stateDir, sessionId);
310
+ if (restoreAttempted.has(key)) {
311
+ return;
312
+ }
313
+ if (await hasLocalSessionState(stateDir, sessionId)) {
314
+ restoreAttempted.add(key);
315
+ return;
316
+ }
317
+ const safe = safeSessionId(sessionId);
318
+ const staging = path.join(stateDir, '.restore-staging', safe);
319
+ try {
320
+ await fsp.rm(staging, { recursive: true, force: true });
321
+ await fsp.mkdir(staging, { recursive: true });
322
+ const remote = sessionRemoteUri(cfg, sessionId);
323
+ await execFileAsync('aws', syncArgs(cfg, remote, staging));
324
+ await mergeSessionStagingIntoState(staging, stateDir);
325
+ log?.(`State restored session ${safe} from ${remote}`);
326
+ }
327
+ catch (err) {
328
+ // Empty prefix / first turn / transient — continue; runner creates fresh state.
329
+ log?.(`State restore-on-miss skipped for ${safe}: ${err instanceof Error ? err.message : String(err)}`);
330
+ }
331
+ finally {
332
+ restoreAttempted.add(key);
333
+ await fsp.rm(staging, { recursive: true, force: true }).catch(() => undefined);
334
+ }
335
+ }
336
+ /** Test helper — clear the restore-attempted cache. */
337
+ function clearRestoreAttemptCache() {
338
+ restoreAttempted.clear();
339
+ }
87
340
  /**
88
- * Debounced, non-overlapping pusher. `schedule()` after each turn (cheap, off the
89
- * response path); coalesces bursts; `flush()` awaits a final push (SIGTERM).
341
+ * Debounced, non-overlapping per-session pusher.
342
+ * `schedule(sessionId)` after each turn; `flush()` awaits pending pushes (SIGTERM).
90
343
  */
91
344
  function makeStatePusher(cfg, stateDir, log, waitMs = 1000) {
92
345
  let timer = null;
93
346
  let running = false;
94
- let pending = false;
95
347
  let inflightPromise = null;
348
+ const pendingSessions = new Set();
349
+ let pendingFull = false;
96
350
  const runNow = () => {
97
351
  running = true;
98
- pending = false;
99
- inflightPromise = pushState(cfg, stateDir, log).then(() => {
352
+ const sessions = [...pendingSessions];
353
+ const doFull = pendingFull;
354
+ pendingSessions.clear();
355
+ pendingFull = false;
356
+ inflightPromise = (async () => {
357
+ if (doFull) {
358
+ await pushState(cfg, stateDir, log);
359
+ }
360
+ for (const id of sessions) {
361
+ await pushSessionState(cfg, stateDir, id, log);
362
+ }
363
+ })()
364
+ .catch((err) => {
365
+ console.error(`[state-sync] push batch failed: ${err instanceof Error ? err.message : String(err)}`);
366
+ })
367
+ .finally(() => {
100
368
  running = false;
101
369
  inflightPromise = null;
102
- if (pending) {
103
- schedule();
370
+ if (pendingSessions.size > 0 || pendingFull) {
371
+ // Re-arm debounce without treating this as a legacy full-push schedule().
372
+ if (!timer) {
373
+ timer = setTimeout(() => {
374
+ timer = null;
375
+ void runNow();
376
+ }, waitMs);
377
+ timer.unref?.();
378
+ }
104
379
  }
105
380
  });
106
381
  return inflightPromise;
107
382
  };
108
- const schedule = () => {
383
+ const schedule = (sessionId) => {
384
+ if (sessionId?.trim()) {
385
+ pendingSessions.add(sessionId.trim());
386
+ }
387
+ else {
388
+ // Legacy no-arg call → full-tree push (boot-compat / callers that omit id).
389
+ pendingFull = true;
390
+ }
109
391
  if (running) {
110
- pending = true;
111
392
  return;
112
393
  }
113
394
  if (timer) {
@@ -124,12 +405,19 @@ function makeStatePusher(cfg, stateDir, log, waitMs = 1000) {
124
405
  clearTimeout(timer);
125
406
  timer = null;
126
407
  }
127
- // Wait for any in-flight push to complete before the final push,
128
- // so SIGTERM never runs two concurrent aws s3 sync processes.
129
408
  if (inflightPromise) {
130
409
  await inflightPromise;
131
410
  }
132
- await pushState(cfg, stateDir, log);
411
+ if (pendingSessions.size > 0 || pendingFull) {
412
+ await runNow();
413
+ if (inflightPromise) {
414
+ await inflightPromise;
415
+ }
416
+ }
133
417
  };
134
418
  return { schedule, flush };
135
419
  }
420
+ /** Stable hash helper exposed for tests (matches DirectorySessionStore naming). */
421
+ function providerSessionShardName(conversationKey) {
422
+ return `${(0, crypto_1.createHash)('sha256').update(conversationKey).digest('hex').slice(0, 40)}.json`;
423
+ }
@@ -283,8 +283,11 @@ function createHeadlessRunner(config) {
283
283
  }
284
284
  return customEngine;
285
285
  };
286
- // Shared session event writer for Layer 2 analytics
287
- const sessionEventWriter = new sessionEvents_1.SessionEventWriter();
286
+ // Shared session event writer for Layer 2 analytics. Rooted under storagePath (not the
287
+ // os.homedir() default) so tool-call/warrant events land on the same durable volume as
288
+ // session state — a container's home directory is ephemeral, storagePath honors
289
+ // AGENTHIPPO_HOME / --state-dir and is what actually survives a restart or scale event.
290
+ const sessionEventWriter = new sessionEvents_1.SessionEventWriter(path.join(config.storagePath, 'analytics', 'sessions'));
288
291
  return {
289
292
  async run(options) {
290
293
  const result = {
package/out/mcp.js CHANGED
@@ -271,8 +271,15 @@ function resolveMcpEnvVar(varName, envSources = {}) {
271
271
  const aliases = getDataTokenEnvAliases();
272
272
  const isDataTokenVar = varName === 'dataToken' || aliases.includes(varName);
273
273
  if (tokenFile && isDataTokenVar) {
274
- // Token lives in the per-turn file (AGENTHIPPO_DATA_TOKEN_FILE); don't bake into spawn env.
275
- return '';
274
+ // Token lives in the per-turn file (AGENTHIPPO_DATA_TOKEN_FILE); never bake the OBO token
275
+ // into spawn env. The explicit ${credential:dataToken} placeholder resolves to empty
276
+ // (AgentHippo-aware servers read the file). A plain ${env:ALIAS} reference falls through to
277
+ // any operator-provided ambient value so a stock server that reads the var directly still
278
+ // works, instead of being clobbered to an empty string.
279
+ if (varName === 'dataToken') {
280
+ return '';
281
+ }
282
+ return process.env[varName] ?? envSources[varName] ?? '';
276
283
  }
277
284
  if (varName === 'dataToken') {
278
285
  return cred?.dataToken?.trim() ?? '';
@@ -465,7 +472,9 @@ function generateCodexMcpConfig(servers, options) {
465
472
  // Use separate [mcp_servers.name.env] section — codex binary requires this form
466
473
  lines.push('', `[mcp_servers.${s.name}.env]`);
467
474
  for (const [k, v] of Object.entries(s.env)) {
468
- lines.push(`${k} = ${JSON.stringify(v)}`);
475
+ // Quote the key: env-var names may contain '.', spaces, etc. that are not valid
476
+ // TOML bare keys (a bare `my.token` would parse as a nested table).
477
+ lines.push(`${JSON.stringify(k)} = ${JSON.stringify(v)}`);
469
478
  }
470
479
  }
471
480
  return lines.join('\n');
@@ -96,13 +96,19 @@ async function handleRemoteServeChat(deps, args) {
96
96
  try {
97
97
  syncService.incrementActiveAgentTurns();
98
98
  await observableEmitter.emitTurnStart();
99
- await (0, serveClient_js_1.streamRemoteServeChat)({
99
+ const result = await (0, serveClient_js_1.streamRemoteServeChat)({
100
100
  baseUrl: remoteUrl,
101
101
  message,
102
102
  sessionId: remoteSessionId,
103
103
  signal: abortController.signal,
104
104
  auth,
105
105
  }, observableEmitter);
106
+ if (!result.ok) {
107
+ // streamRemoteServeChat already surfaced the failure to the emitter (error + done);
108
+ // record the turn as failed and propagate to the caller without re-emitting.
109
+ await observableEmitter.emitTurnEnd(false);
110
+ return { error: result.error ?? 'Remote serve failed' };
111
+ }
106
112
  await observableEmitter.emitTurnEnd(true);
107
113
  return { streaming: true };
108
114
  }
@@ -77,7 +77,7 @@ async function streamRemoteServeChat(options, emitter) {
77
77
  const message = err instanceof Error ? err.message : 'Remote serve connection failed';
78
78
  await emitter.error(message);
79
79
  await emitter.done();
80
- return;
80
+ return { ok: false, error: message };
81
81
  }
82
82
  if (!response.ok) {
83
83
  clearTimers();
@@ -93,29 +93,39 @@ async function streamRemoteServeChat(options, emitter) {
93
93
  }
94
94
  await emitter.error(errorMessage);
95
95
  await emitter.done();
96
- return;
96
+ return { ok: false, error: errorMessage };
97
97
  }
98
98
  if (!response.body) {
99
99
  clearTimers();
100
- await emitter.error('Remote serve returned empty body');
100
+ const message = 'Remote serve returned empty body';
101
+ await emitter.error(message);
101
102
  await emitter.done();
102
- return;
103
+ return { ok: false, error: message };
103
104
  }
104
105
  try {
105
- await consumeServeSse(response.body, emitter, resetIdle);
106
+ const streamError = await consumeServeSse(response.body, emitter, resetIdle);
107
+ return streamError ? { ok: false, error: streamError } : { ok: true };
106
108
  }
107
109
  finally {
108
110
  clearTimers();
109
111
  }
110
112
  }
113
+ /**
114
+ * Consume the SSE stream. Returns the message of an `error` event if one was seen
115
+ * (the turn failed even though the stream ended cleanly), otherwise undefined.
116
+ */
111
117
  async function consumeServeSse(body, emitter, resetIdle) {
112
118
  const reader = body.getReader();
113
119
  const decoder = new TextDecoder();
114
120
  let buffer = '';
115
121
  let doneEmitted = false;
122
+ let streamError;
116
123
  const markDone = () => {
117
124
  doneEmitted = true;
118
125
  };
126
+ const markError = (message) => {
127
+ streamError = message;
128
+ };
119
129
  try {
120
130
  while (true) {
121
131
  const { value, done } = await reader.read();
@@ -125,24 +135,25 @@ async function consumeServeSse(body, emitter, resetIdle) {
125
135
  // Any data on the wire means the turn is progressing — push the idle window out.
126
136
  resetIdle();
127
137
  buffer += decoder.decode(value, { stream: true });
128
- buffer = await drainServeSseBuffer(buffer, emitter, markDone);
138
+ buffer = await drainServeSseBuffer(buffer, emitter, markDone, markError);
129
139
  if (doneEmitted) {
130
- return;
140
+ return streamError;
131
141
  }
132
142
  }
133
143
  buffer += decoder.decode();
134
144
  if (buffer.trim()) {
135
- await drainServeSseBuffer(`${buffer}\n`, emitter, markDone);
145
+ await drainServeSseBuffer(`${buffer}\n`, emitter, markDone, markError);
136
146
  }
137
147
  if (!doneEmitted) {
138
148
  await emitter.done();
139
149
  }
150
+ return streamError;
140
151
  }
141
152
  finally {
142
153
  reader.releaseLock();
143
154
  }
144
155
  }
145
- async function drainServeSseBuffer(buffer, emitter, markDone) {
156
+ async function drainServeSseBuffer(buffer, emitter, markDone, markError) {
146
157
  const lines = buffer.split('\n');
147
158
  const remainder = lines.pop() ?? '';
148
159
  for (const line of lines) {
@@ -160,7 +171,7 @@ async function drainServeSseBuffer(buffer, emitter, markDone) {
160
171
  catch {
161
172
  continue;
162
173
  }
163
- const finished = await dispatchServeEvent(event, emitter);
174
+ const finished = await dispatchServeEvent(event, emitter, markError);
164
175
  if (finished) {
165
176
  markDone();
166
177
  return '';
@@ -168,7 +179,7 @@ async function drainServeSseBuffer(buffer, emitter, markDone) {
168
179
  }
169
180
  return remainder;
170
181
  }
171
- async function dispatchServeEvent(event, emitter) {
182
+ async function dispatchServeEvent(event, emitter, markError) {
172
183
  const type = typeof event.type === 'string' ? event.type : '';
173
184
  switch (type) {
174
185
  case 'thinking':
@@ -204,9 +215,12 @@ async function dispatchServeEvent(event, emitter) {
204
215
  }
205
216
  return false;
206
217
  }
207
- case 'error':
208
- await emitter.error(typeof event.message === 'string' ? event.message : 'Remote serve error');
218
+ case 'error': {
219
+ const message = typeof event.message === 'string' ? event.message : 'Remote serve error';
220
+ markError(message);
221
+ await emitter.error(message);
209
222
  return false;
223
+ }
210
224
  case 'done':
211
225
  await emitter.done();
212
226
  return true;
@@ -79,6 +79,7 @@ exports.PROVIDERS = {
79
79
  description: 'local adapter for agents to work with Spotlight and all models from OpenAI, Anthropic, Ollama etc.',
80
80
  showInAddApiKeyUI: true,
81
81
  isGateway: true, // Can route to any backend
82
+ deferMissingApiKey: true, // base-url configured; key may be empty in dev
82
83
  },
83
84
  ollama: {
84
85
  id: 'ollama',
@@ -102,6 +103,8 @@ exports.PROVIDERS = {
102
103
  showInAddApiKeyUI: false,
103
104
  isGateway: true,
104
105
  staticHeaders: { 'x-agenthippo-source': 'chat-ui' },
106
+ credentialRefreshCommand: 'agentide.auth.refreshToken', // refresh stale sign-in JWT on 401
107
+ deferMissingApiKey: true, // key is the sign-in JWT; route branch prompts sign-in
105
108
  },
106
109
  };
107
110
  // -----------------------------------------------------------------------------