gipity 1.0.425 → 1.0.427

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/knowledge.js CHANGED
@@ -20,7 +20,7 @@ Templates:
20
20
  - \`3d-world\` - Multiplayer world, 3D sandbox, shooter, exploration, virtual showroom (Three.js + Rapier + Colyseus)
21
21
  - \`api\` - Backend service, webhook, data pipeline, chatbot, cron job - no frontend
22
22
  - \`karaoke-captions\` - Forced-alignment app - karaoke captions, subtitle timing, language learning, dubbing alignment
23
- - \`outreach-agent\` - AI outreach / drip-email funnel - reach a list of people with personalized, human-approved emails that auto-send on a schedule and a self-improving agent that learns from your edits
23
+ - \`outreach-agent\` - AI outreach / drip-email / lifecycle funnel - move a list of people through stages (sign up, activate, pay) with personalized, human-approved emails on an auto-cadence and a self-improving agent that learns from your edits
24
24
  - \`paid-app\` - App that charges users money - SaaS subscription, paid membership, digital product store, "Pro" upgrade, paywalled content (Stripe one-time + subscriptions)
25
25
  - \`notify-demo\` - App that sends push notifications / alerts / reminders to users' phones or desktops - web push, PWA notifications, "notify me when..." features
26
26
  When unsure, default to \`web-simple\`. After adding the template, edit the generated files, then \`gipity deploy dev\`.
@@ -58,7 +58,7 @@ You are the developer. Write files in this directory - the Gipity Claude Code pl
58
58
 
59
59
  ## Use first-party services before reaching outside
60
60
 
61
- Gipity ships first-party services for what apps usually pull from third parties - auth, location/geocoding, LLM, image/audio/video generation, transcription, file uploads, realtime, web push notifications (Gipity Notify - no VAPID keys, no Firebase/OneSignal; works on iOS home-screen web apps), and email (send as the agent via \`gipity email send\`, or from a deployed app via a workflow \`notify\` step - no SMTP/SendGrid/Nodemailer). Before calling an external API or adding an npm package for one of these, check \`gipity skill list\` for a match. First-party services need no API keys, cost less, and keep data in-house. Reach outside only when the catalog has no equivalent - and say so when you do.
61
+ Gipity ships first-party services for what apps usually pull from third parties - auth, location/geocoding, LLM, image/audio/video generation, transcription, file uploads, realtime, web push notifications (Gipity Notify - no VAPID keys, no Firebase/OneSignal; works on iOS home-screen web apps), and email (send as the agent via \`gipity email send\`, or from a deployed app's function with the injected \`email()\` service - \`services: ['email']\`, no SMTP/SendGrid/Nodemailer). Before calling an external API or adding an npm package for one of these, check \`gipity skill list\` for a match. First-party services need no API keys, cost less, and keep data in-house. Reach outside only when the catalog has no equivalent - and say so when you do.
62
62
 
63
63
  ## Don't guess Gipity facts - look them up
64
64
 
@@ -11,7 +11,8 @@
11
11
  import { publicPost } from './api.js';
12
12
  import { getAuth, saveAuth } from './auth.js';
13
13
  import { prompt, decodeJwtExp } from './utils.js';
14
- import { success, error as clrError } from './colors.js';
14
+ import { success, error as clrError, muted } from './colors.js';
15
+ import { flushBugQueue } from './bug-queue.js';
15
16
  /** Prompt for email + 6-digit code, persist the tokens, and return the new
16
17
  * auth. Exits the process on empty input or a bad token (the caller can't
17
18
  * proceed without a session). */
@@ -37,6 +38,10 @@ export async function interactiveLogin() {
37
38
  const expiresAt = new Date(exp * 1000).toISOString();
38
39
  saveAuth({ accessToken: res.accessToken, refreshToken: res.refreshToken, email, expiresAt });
39
40
  console.log(` ${success(`Logged in (${email}).`)}`);
41
+ const delivered = await flushBugQueue().catch(() => 0);
42
+ if (delivered > 0) {
43
+ console.log(` ${muted(`Delivered ${delivered} queued bug report${delivered === 1 ? '' : 's'}.`)}`);
44
+ }
40
45
  return getAuth();
41
46
  }
42
47
  //# sourceMappingURL=login-flow.js.map
@@ -19,6 +19,11 @@ import { ensureRelayAgentToken } from './agent-token.js';
19
19
  import { redactEntries, redactString, normalizeSecrets } from './redact.js';
20
20
  import { getMachineId } from './machine-id.js';
21
21
  import { collectDiagnostics } from './diagnostics.js';
22
+ import { SessionPool, PoolFullError } from './session-pool.js';
23
+ import { getConfig } from '../config.js';
24
+ import { getAccountSlug } from '../api.js';
25
+ import { buildFreshWrap, buildResumeWrap, buildProjectContextBlock } from '../prompts.js';
26
+ import { fetchProjectStats } from '../commands/claude.js';
22
27
  // Re-exported so the existing `relay-bridge-abort.test.ts` keeps working.
23
28
  // New callers should import from device-http.js directly.
24
29
  export const bridgeAbort = bridgeAbortImpl;
@@ -41,6 +46,17 @@ const MAX_CONCURRENT_DISPATCHES = Math.max(1, parseInt(process.env.GIPITY_RELAY_
41
46
  // Cap how long the pre-Claude project sync (and the post-dispatch push-back) may
42
47
  // run before we kill it - a stalled sync must never hang a dispatch forever.
43
48
  const PROJECT_SYNC_TIMEOUT_MS = parseInt(process.env.GIPITY_RELAY_SYNC_TIMEOUT_MS || '120000', 10);
49
+ // ─── Phase 2: long-lived session pool (feature-flagged, default OFF) ─────
50
+ // When 'on', a conversation's follow-up messages are fed into a live Claude
51
+ // Code process (via the Agent SDK) instead of spawning `gipity claude -p`
52
+ // each time - faster follow-ups + clean interrupt, at ~300MB RSS per idle
53
+ // session. Bounded by a hot window + LRU cap; any pool error or a saturated
54
+ // pool falls back to the proven spawn path for that dispatch, so this can
55
+ // only make things faster, never break them. Rollback = restart with the
56
+ // flag unset.
57
+ const SESSION_POOL_ENABLED = process.env.GIPITY_RELAY_SESSION_POOL === 'on';
58
+ const SESSION_HOT_MS = parseInt(process.env.GIPITY_RELAY_SESSION_HOT_MS || String(5 * 60_000), 10);
59
+ const MAX_SESSIONS = Math.max(1, parseInt(process.env.GIPITY_RELAY_MAX_SESSIONS || '3', 10));
44
60
  /** System-prompt addendum enabling the interactive question card on the
45
61
  * relay path (AskUserQuestion is unavailable in -p mode). The model emits
46
62
  * a fenced `gipity-question` JSON block instead of asking in prose; the
@@ -227,6 +243,8 @@ export async function run(opts = {}) {
227
243
  return;
228
244
  ctx.shutdownReason = reason;
229
245
  ctx.abort.abort(reason);
246
+ // Close any live pool sessions so their claude subprocesses exit with us.
247
+ sessionPool?.shutdown();
230
248
  };
231
249
  process.on('SIGINT', () => shutdown('SIGINT'));
232
250
  process.on('SIGTERM', () => shutdown('SIGTERM'));
@@ -306,6 +324,14 @@ async function heartbeatLoop(ctx) {
306
324
  log('debug', 'diagnostics collection failed', { err: err?.message });
307
325
  }
308
326
  }
327
+ // Phase 2: report which conversations have a live (hot/running) session
328
+ // so the web indicator can show fast-follow-up readiness. Only when the
329
+ // pool is enabled and has live sessions - a bare ping otherwise.
330
+ if (SESSION_POOL_ENABLED) {
331
+ const sessions = getLiveSessionStates();
332
+ if (sessions.length > 0)
333
+ body.sessions = sessions.map(s => ({ conversation_guid: s.convGuid, state: s.state }));
334
+ }
309
335
  const r = await deviceFetch('POST', '/remote-devices/heartbeat', body, 10_000, ctx.abort.signal);
310
336
  if (r.status === 401) {
311
337
  log('warn', 'heartbeat 401 - device revoked, exiting clean');
@@ -356,7 +382,13 @@ async function heartbeatLoop(ctx) {
356
382
  await sleep(backoff, ctx.abort.signal);
357
383
  continue;
358
384
  }
359
- await sleep(HEARTBEAT_INTERVAL_MS, ctx.abort.signal);
385
+ // Sleep until the next tick OR a session-state poke (whichever first), so
386
+ // the indicator flips promptly on a session opening/closing.
387
+ await Promise.race([
388
+ sleep(HEARTBEAT_INTERVAL_MS, ctx.abort.signal),
389
+ new Promise(resolve => { heartbeatPoke = () => { heartbeatPoke = null; resolve(); }; }),
390
+ ]);
391
+ heartbeatPoke = null;
360
392
  }
361
393
  return 0;
362
394
  }
@@ -794,7 +826,8 @@ async function ack(shortGuid, status, error, metrics) {
794
826
  log('warn', 'ack network error', { shortGuid, err: err?.message });
795
827
  }
796
828
  }
797
- async function handleDispatch(d) {
829
+ async function handleDispatch(claimed) {
830
+ let d = claimed;
798
831
  log('info', 'dispatch claimed', { id: d.short_guid, project: d.project_slug, kind: d.kind });
799
832
  log('debug', 'dispatch payload', {
800
833
  id: d.short_guid,
@@ -821,7 +854,22 @@ async function handleDispatch(d) {
821
854
  // then --resume the same session, loading whatever made it to disk.
822
855
  // Two children on one session would corrupt the .jsonl - this is the
823
856
  // serialization point that prevents that.
824
- await killRunningForConv(d.conversation_guid);
857
+ const { killedSessionIds } = await killRunningForConv(d.conversation_guid);
858
+ // start→resume upgrade: a dispatch can arrive as kind='start' while the
859
+ // conversation ALREADY has a session locally - the server enqueued it
860
+ // before the first run's SessionStart reached it (fast follow-up). Spawning
861
+ // fresh would orphan that session and lose the first turn's context;
862
+ // resume the session the killed child announced (or the last one this conv
863
+ // was seen using) instead.
864
+ if (d.kind === 'start') {
865
+ const sid = killedSessionIds.find(isSafeSessionId) ?? lastSessionForConv(d.conversation_guid);
866
+ if (sid) {
867
+ log('info', 'upgrading start → resume (conv already has a local session)', {
868
+ id: d.short_guid, session_id: sid, from_kill: killedSessionIds.length > 0,
869
+ });
870
+ d = { ...d, kind: 'resume', remote_session_id: sid };
871
+ }
872
+ }
825
873
  // One ordered ingest queue per dispatch: markers, prompt echo, stream
826
874
  // entries, and the tail all flow through it in order, with backoff
827
875
  // retry instead of the old fire-and-forget loss on network errors.
@@ -876,6 +924,15 @@ async function handleDispatch(d) {
876
924
  }
877
925
  pushSystem('Project files synced.');
878
926
  }
927
+ // Phase 2: if the session pool is enabled, try to run this turn in a
928
+ // long-lived Claude Code session (fast follow-up + clean interrupt). Any
929
+ // failure - pool saturated, SDK error, wrap failure - falls through to the
930
+ // proven spawn path below, so the pool can only ever make things faster.
931
+ if (SESSION_POOL_ENABLED) {
932
+ const handled = await tryHandleViaPool(d, cwd, queue, pushSystem, flushQueue);
933
+ if (handled)
934
+ return;
935
+ }
879
936
  // Build argv for `gipity claude -p …` (or with --resume). No shell - argv
880
937
  // as array so the message string can't be interpreted as shell syntax.
881
938
  //
@@ -1044,6 +1101,205 @@ async function handleDispatch(d) {
1044
1101
  await ack(d.short_guid, 'error', `gipity claude exited with code ${exitCode}${stderrNote}`);
1045
1102
  }
1046
1103
  }
1104
+ /**
1105
+ * Wrap the user's raw message with the same project-context / resume framing
1106
+ * the `gipity claude` wrapper applies on the spawn path (see claude.ts). The
1107
+ * pool bypasses that wrapper, so it must replicate it or the model loses the
1108
+ * Gipity context. `resume` => short framing (context already loaded); fresh
1109
+ * => full context block (one stats call, cold path only).
1110
+ */
1111
+ async function wrapPoolMessage(d, cwd, resume) {
1112
+ const config = getConfig();
1113
+ let accountSlug = '';
1114
+ try {
1115
+ accountSlug = await getAccountSlug();
1116
+ }
1117
+ catch { /* best effort */ }
1118
+ const ctx = {
1119
+ projectName: config?.projectSlug ?? d.project_slug ?? 'this project',
1120
+ projectSlug: config?.projectSlug ?? d.project_slug ?? '',
1121
+ projectGuid: config?.projectGuid ?? d.project_guid ?? '',
1122
+ accountSlug,
1123
+ cwd,
1124
+ };
1125
+ if (resume)
1126
+ return buildResumeWrap(ctx, d.message);
1127
+ const stats = await fetchProjectStats(ctx.projectGuid, cwd);
1128
+ return buildFreshWrap(buildProjectContextBlock({ ...ctx, ...stats }), d.message);
1129
+ }
1130
+ /**
1131
+ * Phase 2 turn: run a dispatch in a long-lived pool session. Reuses the
1132
+ * caller's ingest `queue` + markers so the web live view is identical to the
1133
+ * spawn path. Returns true if it handled the dispatch (including acking it),
1134
+ * false if it declined (pool saturated / not-yet-created error) so the caller
1135
+ * falls back to a legacy spawn. Never throws.
1136
+ */
1137
+ async function tryHandleViaPool(d, cwd, queue, pushSystem, flushQueue) {
1138
+ let pool;
1139
+ try {
1140
+ pool = await getSessionPool();
1141
+ }
1142
+ catch (err) {
1143
+ log('warn', 'session pool unavailable - falling back to spawn', { id: d.short_guid, err: err?.message });
1144
+ return false;
1145
+ }
1146
+ const wasLive = pool.stateFor(d.conversation_guid) !== 'cold';
1147
+ // Resume framing when the pool already has a live session, OR the
1148
+ // conversation has a known Claude session id to resume into a fresh one.
1149
+ const resumeSessionId = d.kind === 'resume' && d.remote_session_id
1150
+ ? d.remote_session_id
1151
+ : lastSessionForConv(d.conversation_guid);
1152
+ const resume = wasLive || !!resumeSessionId;
1153
+ let message;
1154
+ try {
1155
+ message = await wrapPoolMessage(d, cwd, resume);
1156
+ }
1157
+ catch (err) {
1158
+ log('warn', 'pool message wrap failed - falling back to spawn', { id: d.short_guid, err: err?.message });
1159
+ return false;
1160
+ }
1161
+ // Prompt echo + "Running Claude Code" marker, matching the spawn path.
1162
+ const words = d.message.trim().split(/\s+/).filter(Boolean).length;
1163
+ const ts0 = new Date().toISOString();
1164
+ queue.push({ kind: 'prompt', prompt: d.message, ts: ts0, source_uuid: randomUUID() }, { kind: 'system', content: `Running Claude Code - ${words.toLocaleString('en-US')} words${wasLive ? ' (hot session)' : ''}`, ts: ts0, source_uuid: randomUUID() });
1165
+ // Per-turn stream plumbing, same shapes as spawnGipityClaude's splitter.
1166
+ const phases = new PhaseTracker();
1167
+ const deltaAcc = new DeltaAccumulator(getRelaySecrets);
1168
+ const deltaBatcher = new DeltaBatcher((flush) => {
1169
+ void postStreamDelta(d.conversation_guid, d.short_guid, flush);
1170
+ });
1171
+ const toolNames = new Map();
1172
+ const msgSeq = new Map();
1173
+ let attached = false;
1174
+ let finalResult = null;
1175
+ let contextTokens;
1176
+ const startedAt = Date.now();
1177
+ const buildProgress = (alive) => {
1178
+ const now = Date.now();
1179
+ const tool = phases.currentTool();
1180
+ const hint = tool?.hint ? redactString(tool.hint, getRelaySecrets()).slice(0, 200) : undefined;
1181
+ return {
1182
+ dispatch_guid: d.short_guid,
1183
+ proc_alive: alive,
1184
+ stdout_bytes_total: 0,
1185
+ stdout_bytes_delta: 0,
1186
+ stdout_idle_ms: Math.max(0, now - phases.lastEventAt),
1187
+ uptime_ms: Math.max(0, now - startedAt),
1188
+ phase: phases.phase,
1189
+ current_tool: tool?.name?.slice(0, 100),
1190
+ current_tool_hint: hint,
1191
+ tool_elapsed_ms: tool ? Math.max(0, now - tool.startedAt) : undefined,
1192
+ last_event_ms: Math.max(0, now - phases.lastEventAt),
1193
+ context_tokens: contextTokens,
1194
+ };
1195
+ };
1196
+ const onMessage = (msg) => {
1197
+ phases.note(msg);
1198
+ const usageIn = msg?.message?.usage?.input_tokens;
1199
+ if (typeof usageIn === 'number' && usageIn > (contextTokens ?? 0))
1200
+ contextTokens = usageIn;
1201
+ deltaBatcher.push(deltaAcc.note(msg));
1202
+ let entries = mapEventToEntries(msg, { toolNames, msgSeq });
1203
+ if (entries.length === 0)
1204
+ return;
1205
+ entries = entries.filter(e => {
1206
+ if (e.kind === 'attach') {
1207
+ if (attached)
1208
+ return false;
1209
+ attached = true;
1210
+ return true;
1211
+ }
1212
+ if (e.kind === 'result') {
1213
+ finalResult = e;
1214
+ return false;
1215
+ }
1216
+ return true;
1217
+ });
1218
+ if (entries.length === 0)
1219
+ return;
1220
+ const ts = new Date().toISOString();
1221
+ for (const e of entries) {
1222
+ e.ts = ts;
1223
+ if (!e.source_uuid)
1224
+ e.source_uuid = randomUUID();
1225
+ }
1226
+ queue.push(...entries);
1227
+ };
1228
+ const env = childEnv({ GIPITY_CONVERSATION_GUID: d.conversation_guid, GIPITY_CAPTURE: 'off' });
1229
+ const freshOptions = {
1230
+ cwd,
1231
+ permissionMode: 'bypassPermissions',
1232
+ allowDangerouslySkipPermissions: true,
1233
+ includePartialMessages: true,
1234
+ systemPrompt: { type: 'preset', preset: 'claude_code', append: GIPITY_QUESTION_PROTOCOL },
1235
+ env,
1236
+ pathToClaudeCodeExecutable: resolveCommand('claude'),
1237
+ ...(resumeSessionId ? { resume: resumeSessionId } : {}),
1238
+ ...(d.model ? { model: d.model } : {}),
1239
+ };
1240
+ poolDispatches.set(d.short_guid, d.conversation_guid);
1241
+ void postProgress(d.conversation_guid, buildProgress(true));
1242
+ const progressTimer = setInterval(() => {
1243
+ void postProgress(d.conversation_guid, buildProgress(true));
1244
+ }, 2000);
1245
+ progressTimer.unref?.();
1246
+ const t0 = Date.now();
1247
+ try {
1248
+ const result = await pool.runTurn({
1249
+ convGuid: d.conversation_guid,
1250
+ cwd,
1251
+ message,
1252
+ model: d.model,
1253
+ freshOptions,
1254
+ onMessage,
1255
+ });
1256
+ clearInterval(progressTimer);
1257
+ deltaBatcher.flush();
1258
+ if (finalResult)
1259
+ queue.push(finalResult);
1260
+ if (result.sessionId)
1261
+ recordSessionForConv(d.conversation_guid, result.sessionId);
1262
+ const dur = formatDuration(Date.now() - t0);
1263
+ void postProgress(d.conversation_guid, buildProgress(false));
1264
+ if (result.outcome === 'cancelled') {
1265
+ pushSystem(`Claude Code cancelled (${dur})`);
1266
+ await flushQueue();
1267
+ await ack(d.short_guid, 'cancelled');
1268
+ }
1269
+ else if (result.outcome === 'error') {
1270
+ pushSystem(`Claude Code failed (${dur}: ${result.error ?? 'session error'})`);
1271
+ await flushQueue();
1272
+ await ack(d.short_guid, 'error', `session pool: ${result.error ?? 'error'}`);
1273
+ }
1274
+ else {
1275
+ pushSystem(`Claude Code finished (${dur}${result.wasHot ? ', hot' : ''})`);
1276
+ await flushQueue();
1277
+ await ack(d.short_guid, 'done');
1278
+ }
1279
+ log('info', 'pool turn complete', { id: d.short_guid, outcome: result.outcome, hot: result.wasHot, ms: Date.now() - t0 });
1280
+ return true;
1281
+ }
1282
+ catch (err) {
1283
+ clearInterval(progressTimer);
1284
+ if (err instanceof PoolFullError) {
1285
+ // No idle session to evict - let the caller spawn a normal child. We
1286
+ // have NOT acked or pushed a marker beyond the prompt echo, so the
1287
+ // spawn path takes over cleanly.
1288
+ log('info', 'pool full - falling back to spawn for this dispatch', { id: d.short_guid });
1289
+ return false;
1290
+ }
1291
+ // A genuine pool error after the turn started: ack it here rather than
1292
+ // double-running via the spawn path (the turn may have partially executed).
1293
+ log('error', 'pool turn errored - acking error', { id: d.short_guid, err: err?.message });
1294
+ pushSystem(`Claude Code failed (session pool error: ${err?.message ?? 'unknown'})`);
1295
+ await flushQueue();
1296
+ await ack(d.short_guid, 'error', `session pool error: ${err?.message ?? 'unknown'}`);
1297
+ return true;
1298
+ }
1299
+ finally {
1300
+ poolDispatches.delete(d.short_guid);
1301
+ }
1302
+ }
1047
1303
  /**
1048
1304
  * Auto-resolve the cwd for a dispatched project. If `~/GipityProjects/<slug>/`
1049
1305
  * exists with a matching .gipity.json, use it. Otherwise create the dir,
@@ -1105,8 +1361,66 @@ async function resolveCwdForProject(d) {
1105
1361
  return { cwd: path, bootstrapped: true };
1106
1362
  }
1107
1363
  const running = new Map();
1364
+ /** Last session id seen per conversation, TTL-bounded. Belt for the window
1365
+ * where the server enqueued a `start` dispatch before the previous run's
1366
+ * SessionStart reached it (claim-time derivation upstream shrinks that
1367
+ * window to ingest lag; this map closes it locally, including the case
1368
+ * where the previous child already exited so there's nothing to kill). */
1369
+ const LAST_SESSION_TTL_MS = 10 * 60_000;
1370
+ const lastSessionByConv = new Map();
1371
+ function recordSessionForConv(convGuid, sessionId) {
1372
+ const now = Date.now();
1373
+ for (const [k, v] of lastSessionByConv) {
1374
+ if (now - v.at > LAST_SESSION_TTL_MS)
1375
+ lastSessionByConv.delete(k);
1376
+ }
1377
+ lastSessionByConv.set(convGuid, { sessionId, at: now });
1378
+ }
1379
+ function lastSessionForConv(convGuid) {
1380
+ const hit = lastSessionByConv.get(convGuid);
1381
+ if (!hit || Date.now() - hit.at > LAST_SESSION_TTL_MS)
1382
+ return null;
1383
+ return hit.sessionId;
1384
+ }
1385
+ // ─── Session pool wiring (Phase 2) ──────────────────────────────────────
1386
+ // Pool dispatch guid → conv guid, so the cancellation poller can interrupt a
1387
+ // live pool turn (which has no child process in `running`).
1388
+ const poolDispatches = new Map();
1389
+ let sessionPool;
1390
+ /** Real SDK-backed query factory. The SDK is loaded lazily so a daemon with
1391
+ * the flag OFF never imports it. */
1392
+ const realQueryFactory = (params) => {
1393
+ const query = globalThis.__gipitySdkQuery;
1394
+ if (!query)
1395
+ throw new Error('Agent SDK not loaded');
1396
+ return query(params);
1397
+ };
1398
+ async function getSessionPool() {
1399
+ if (sessionPool)
1400
+ return sessionPool;
1401
+ if (!globalThis.__gipitySdkQuery) {
1402
+ const mod = await import('@anthropic-ai/claude-agent-sdk');
1403
+ globalThis.__gipitySdkQuery = mod.query;
1404
+ }
1405
+ sessionPool = new SessionPool({
1406
+ queryFactory: realQueryFactory,
1407
+ log,
1408
+ hotWindowMs: SESSION_HOT_MS,
1409
+ maxSessions: MAX_SESSIONS,
1410
+ onStateChange: () => pokeHeartbeat(),
1411
+ });
1412
+ return sessionPool;
1413
+ }
1414
+ // Immediate-heartbeat signal: session-state changes fire this so the web
1415
+ // indicator flips hot/cold within a beat instead of waiting up to 60s.
1416
+ let heartbeatPoke = null;
1417
+ function pokeHeartbeat() { heartbeatPoke?.(); }
1418
+ /** Snapshot of live pool sessions for the session-state heartbeat. */
1419
+ export function getLiveSessionStates() {
1420
+ return sessionPool ? sessionPool.liveConversations() : [];
1421
+ }
1108
1422
  export function getRunningDispatchGuids() {
1109
- return [...running.keys()];
1423
+ return [...running.keys(), ...poolDispatches.keys()];
1110
1424
  }
1111
1425
  export function getRunningConvGuids() {
1112
1426
  return [...running.values()].map(e => e.convGuid);
@@ -1124,7 +1438,7 @@ const KILL_GRACE_MS = parseInt(process.env.GIPITY_RELAY_KILL_GRACE_MS || '10000'
1124
1438
  export async function killRunningForConv(convGuid) {
1125
1439
  const matches = [...running.values()].filter(e => e.convGuid === convGuid);
1126
1440
  if (matches.length === 0)
1127
- return;
1441
+ return { killedSessionIds: [] };
1128
1442
  for (const e of matches) {
1129
1443
  log('info', 'interrupting previous dispatch for conv', { conv: convGuid });
1130
1444
  try {
@@ -1155,6 +1469,7 @@ export async function killRunningForConv(convGuid) {
1155
1469
  await Promise.all(matches.map(e => e.exited));
1156
1470
  for (const t of graceTimers)
1157
1471
  clearTimeout(t);
1472
+ return { killedSessionIds: matches.map(e => e.sessionId).filter((s) => !!s) };
1158
1473
  }
1159
1474
  /** Spawn `gipity claude …` in `cwd` with `--output-format stream-json
1160
1475
  * --verbose` so every event (assistant messages, tool_use blocks,
@@ -1392,6 +1707,15 @@ export async function spawnGipityClaude(args, cwd, d, queue, meta) {
1392
1707
  return;
1393
1708
  entries = entries.filter(e => {
1394
1709
  if (e.kind === 'attach') {
1710
+ // Record the child's session id (init event) for kill-on-new-message
1711
+ // start→resume upgrades - on every attach, even deduped ones, since a
1712
+ // subagent respawn re-announces the same session.
1713
+ if (e.session_id && isSafeSessionId(e.session_id)) {
1714
+ const entry = running.get(d.short_guid);
1715
+ if (entry)
1716
+ entry.sessionId = e.session_id;
1717
+ recordSessionForConv(d.conversation_guid, e.session_id);
1718
+ }
1395
1719
  if (attached)
1396
1720
  return false;
1397
1721
  attached = true;
@@ -1535,19 +1859,26 @@ async function runDispatchSync(d, cwd) {
1535
1859
  running.delete(d.short_guid);
1536
1860
  }
1537
1861
  }
1538
- /** SIGTERM a specific running dispatch. Returns true if one was killed,
1539
- * false if no such child was running on this daemon. */
1862
+ /** Stop a specific running dispatch - SIGTERM its child (spawn path) or
1863
+ * cleanly interrupt its live pool turn (session-pool path). Returns true if
1864
+ * one was stopped, false if no such dispatch is running on this daemon. */
1540
1865
  export function killDispatch(shortGuid) {
1541
1866
  const entry = running.get(shortGuid);
1542
- if (!entry)
1543
- return false;
1544
- try {
1545
- entry.child.kill('SIGTERM');
1546
- return true;
1867
+ if (entry) {
1868
+ try {
1869
+ entry.child.kill('SIGTERM');
1870
+ return true;
1871
+ }
1872
+ catch {
1873
+ return false;
1874
+ }
1547
1875
  }
1548
- catch {
1549
- return false;
1876
+ const convGuid = poolDispatches.get(shortGuid);
1877
+ if (convGuid && sessionPool) {
1878
+ void sessionPool.interrupt(convGuid);
1879
+ return true;
1550
1880
  }
1881
+ return false;
1551
1882
  }
1552
1883
  // ─── Helpers ───────────────────────────────────────────────────────────
1553
1884
  function sleep(ms, signal) {