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/banner.js +2 -2
- package/dist/bug-queue.js +77 -0
- package/dist/colors.js +2 -2
- package/dist/commands/add.js +6 -1
- package/dist/commands/bug.js +28 -5
- package/dist/commands/claude.js +1 -1
- package/dist/commands/credits.js +46 -0
- package/dist/commands/doctor.js +41 -2
- package/dist/commands/email.js +77 -8
- package/dist/commands/generate.js +27 -1
- package/dist/commands/login.js +7 -0
- package/dist/commands/logs.js +4 -1
- package/dist/commands/page-eval.js +10 -0
- package/dist/commands/page-screenshot.js +16 -0
- package/dist/commands/sandbox.js +23 -5
- package/dist/commands/status.js +14 -0
- package/dist/commands/workflow.js +3 -1
- package/dist/index.js +26407 -969
- package/dist/knowledge.js +2 -2
- package/dist/login-flow.js +6 -1
- package/dist/relay/daemon.js +345 -14
- package/dist/relay/session-pool.js +299 -0
- package/dist/relay/stream-json.js +21 -4
- package/dist/sync.js +61 -13
- package/package.json +3 -2
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 -
|
|
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
|
|
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
|
|
package/dist/login-flow.js
CHANGED
|
@@ -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
|
package/dist/relay/daemon.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
/**
|
|
1539
|
-
*
|
|
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 (
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1867
|
+
if (entry) {
|
|
1868
|
+
try {
|
|
1869
|
+
entry.child.kill('SIGTERM');
|
|
1870
|
+
return true;
|
|
1871
|
+
}
|
|
1872
|
+
catch {
|
|
1873
|
+
return false;
|
|
1874
|
+
}
|
|
1547
1875
|
}
|
|
1548
|
-
|
|
1549
|
-
|
|
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) {
|