@crouton-kit/humanloop 0.3.37 → 0.3.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/browser/server.d.ts +15 -5
  2. package/dist/browser/server.js +110 -14
  3. package/dist/cli.js +0 -0
  4. package/dist/conversation/reader.d.ts +15 -1
  5. package/dist/conversation/reader.js +320 -30
  6. package/dist/editor/roundtrip.d.ts +5 -0
  7. package/dist/editor/roundtrip.js +36 -0
  8. package/dist/inbox/completion.js +1 -27
  9. package/dist/inbox/controller.d.ts +36 -1
  10. package/dist/inbox/controller.js +335 -28
  11. package/dist/inbox/convention.d.ts +7 -0
  12. package/dist/inbox/convention.js +32 -0
  13. package/dist/inbox/deck-adapter.d.ts +10 -2
  14. package/dist/inbox/deck-adapter.js +23 -12
  15. package/dist/inbox/deck-schema.d.ts +2 -0
  16. package/dist/inbox/deck-schema.js +4 -4
  17. package/dist/inbox/followup.d.ts +57 -0
  18. package/dist/inbox/followup.js +117 -0
  19. package/dist/inbox/registry.d.ts +2 -0
  20. package/dist/inbox/registry.js +6 -2
  21. package/dist/inbox/tickets.js +8 -2
  22. package/dist/inbox/tui.d.ts +1 -1
  23. package/dist/inbox/tui.js +58 -25
  24. package/dist/index.d.ts +3 -1
  25. package/dist/index.js +1 -0
  26. package/dist/render/termrender.js +25 -26
  27. package/dist/tui/app.d.ts +1 -1
  28. package/dist/tui/app.js +80 -88
  29. package/dist/tui/input.d.ts +5 -1
  30. package/dist/tui/input.js +41 -15
  31. package/dist/tui/log.d.ts +2 -0
  32. package/dist/tui/log.js +53 -0
  33. package/dist/tui/render.js +42 -23
  34. package/dist/tui/terminal.d.ts +1 -0
  35. package/dist/tui/terminal.js +5 -0
  36. package/dist/tui/tmux.js +44 -22
  37. package/dist/types.d.ts +39 -2
  38. package/dist/visuals/conversation.d.ts +7 -0
  39. package/dist/visuals/conversation.js +16 -0
  40. package/dist/visuals/generate.d.ts +3 -1
  41. package/dist/visuals/generate.js +8 -6
  42. package/dist/web/assets/{index-DhbBiRqS.js → index-YFwgZZMg.js} +2 -2
  43. package/dist/web/index.html +1 -1
  44. package/package.json +7 -2
@@ -9,12 +9,18 @@ export interface WebServerOpts {
9
9
  * exactly once per server instance, only for the first accepted submit
10
10
  * (later submits get a 409 and never re-fire this). `responsePath`/
11
11
  * `completedAt` are already persisted to disk — the caller converges the
12
- * host surface, it must NOT write the result again. Fired only after the
13
- * submit's own HTTP 200 has finished flushing to its socket, so it is
14
- * safe for this callback to `stop()` the server (which force-closes all
15
- * open sockets) without racing the ack the browser is waiting on.
12
+ * host surface, it must NOT write the result again. For a connected caller,
13
+ * fires only after its HTTP 200 finishes, so this callback can `stop()` the
14
+ * server without racing that ack. If the client disconnects after accepted
15
+ * persistence, it fires without waiting for an impossible finish.
16
16
  */
17
- onSubmit?: (responses: InteractionResponse[], completedAt: string, responsePath: string) => void;
17
+ onSubmit?: (responses: InteractionResponse[], completedAt: string, responsePath: string) => void | Promise<void>;
18
+ /** Controller-owned finalization for a claimed inbox ticket. When supplied,
19
+ * this server never writes response.json itself. */
20
+ finalize?: (responses: InteractionResponse[]) => Promise<{
21
+ completedAt: string;
22
+ responsePath: string;
23
+ }>;
18
24
  }
19
25
  export interface ReviewWebServerOpts {
20
26
  /** Shared review job dir — carries review.vim and progress.json. */
@@ -47,6 +53,10 @@ export interface WebServerHandle {
47
53
  * acks (closed, network hiccup) can't hang take-back forever. Safe to call
48
54
  * with zero open sockets (e.g. the browser was never opened). */
49
55
  requestTakeBack(): Promise<void>;
56
+ /** The accepted submit currently completing its HTTP ack and owner convergence,
57
+ * if one has crossed the server boundary. `true` means the normal submit
58
+ * finish path completed; `false` means finalization was rejected. */
59
+ pendingSubmitLifecycle?(): Promise<boolean> | undefined;
50
60
  /** Stop listening, close all sockets (WS and HTTP), and tear down. */
51
61
  stop(): Promise<void>;
52
62
  }
@@ -4,7 +4,7 @@ import { existsSync } from 'node:fs';
4
4
  import { join, extname, dirname, basename } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { WebSocketServer } from 'ws';
7
- import { deckPath, readJson, writeResponse, clearProgress } from '../inbox/convention.js';
7
+ import { deckPath, progressPath, readJson, writeResponse, clearProgress } from '../inbox/convention.js';
8
8
  import { buildDraftFeedbackResult, buildFinalFeedbackResult, parseFeedbackComments, readReviewDraft, serializeFeedbackResult, writeReviewDraft, writeSubmitFlag, } from '../editor/feedback.js';
9
9
  // See $CRTR_CONTEXT_DIR/phase1-server-contract.md for the deck HTTP/WS API this
10
10
  // module implements — this file is the reference implementation for the deck
@@ -118,6 +118,12 @@ async function startDeckServer(opts) {
118
118
  let deck = opts.deck;
119
119
  const dir = opts.dir;
120
120
  let submitted = null;
121
+ let submitting = false;
122
+ // Created synchronously when an accepted request enters its finalizer, then
123
+ // kept pending through response flush and async owner convergence. A
124
+ // take-back can therefore serialize with the complete submit lifecycle,
125
+ // rather than racing only its persistence portion.
126
+ let pendingSubmitLifecycle;
121
127
  const scaffold = createServerScaffold();
122
128
  scaffold.setHandler(async (req, res) => {
123
129
  const url = req.url ?? '/';
@@ -132,7 +138,9 @@ async function startDeckServer(opts) {
132
138
  const onDisk = readJson(deckPath(dir));
133
139
  if (onDisk !== null)
134
140
  deck = onDisk;
135
- sendJson(res, 200, { dir, deck });
141
+ const progress = readJson(progressPath(dir));
142
+ const responses = Array.isArray(progress?.responses) ? progress.responses : [];
143
+ sendJson(res, 200, { dir, deck, responses });
136
144
  return;
137
145
  }
138
146
  if (url === '/api/submit' && req.method === 'POST') {
@@ -156,22 +164,107 @@ async function startDeckServer(opts) {
156
164
  sendJson(res, 409, { ok: false, error: 'already_submitted', ...submitted });
157
165
  return;
158
166
  }
167
+ if (submitting) {
168
+ sendJson(res, 409, { ok: false, error: 'submit_in_progress' });
169
+ return;
170
+ }
171
+ submitting = true;
159
172
  const responses = parsed.responses;
160
- const completedAt = new Date().toISOString();
161
- // Canonical write — the exact helper the terminal path uses, so nothing
162
- // downstream of response.json can tell which surface produced it.
163
- const responsePath = writeResponse(dir, responses, completedAt, deck);
173
+ let resolveSubmitLifecycle;
174
+ let submitLifecycleSettled = false;
175
+ const settleSubmitLifecycle = (didSubmit) => {
176
+ if (submitLifecycleSettled)
177
+ return;
178
+ submitLifecycleSettled = true;
179
+ resolveSubmitLifecycle(didSubmit);
180
+ };
181
+ pendingSubmitLifecycle = new Promise((resolve) => { resolveSubmitLifecycle = resolve; });
182
+ let completedAt;
183
+ let responsePath;
184
+ // Convergence (owner onSubmit) must run exactly once for a persisted
185
+ // success, regardless of whether the client is still connected. The
186
+ // `finish` gate below only exists to preserve ack-ordering for a live
187
+ // caller; once the client is gone there is no ordering to protect.
188
+ let convergenceStarted = false;
189
+ const runConvergence = () => {
190
+ if (convergenceStarted)
191
+ return;
192
+ convergenceStarted = true;
193
+ void Promise.resolve(opts.onSubmit?.(responses, completedAt, responsePath)).then(() => settleSubmitLifecycle(true), () => settleSubmitLifecycle(false));
194
+ };
195
+ // Observe client disconnect BEFORE awaiting the finalizer. If the
196
+ // client leaves while finalization is still pending we must NOT declare
197
+ // the accepted submit lost — the finalizer may still persist, in which
198
+ // case convergence must run. Defer judgment until the finalizer settles.
199
+ let finalizePending = true;
200
+ let successReady = false;
201
+ let responseFinished = false;
202
+ let clientGone = false;
203
+ const onResponseFinished = () => {
204
+ responseFinished = true;
205
+ if (successReady && submitted !== null)
206
+ runConvergence();
207
+ };
208
+ const onClientGone = () => {
209
+ // `writableFinished` only means end() was called; a peer can vanish
210
+ // before the `finish` event. Only finish proves the live caller got
211
+ // through the response boundary.
212
+ if (responseFinished)
213
+ return;
214
+ if (finalizePending || !successReady) {
215
+ clientGone = true; // decide once finalization and convergence broadcast settle
216
+ return;
217
+ }
218
+ if (submitted !== null) {
219
+ // Accepted submit persisted but the client left before its body
220
+ // flushed — converge once; no live caller remains to protect.
221
+ runConvergence();
222
+ }
223
+ else {
224
+ // The finalizer rejected: the submit was genuinely lost.
225
+ settleSubmitLifecycle(false);
226
+ }
227
+ };
228
+ req.once('aborted', onClientGone);
229
+ res.once('finish', onResponseFinished);
230
+ res.once('close', onClientGone);
231
+ res.once('error', onClientGone);
232
+ try {
233
+ if (opts.finalize !== undefined) {
234
+ ({ completedAt, responsePath } = await opts.finalize(responses));
235
+ }
236
+ else {
237
+ // Standalone browser handoff retains its direct local persistence.
238
+ completedAt = new Date().toISOString();
239
+ responsePath = writeResponse(dir, responses, completedAt, deck);
240
+ clearProgress(dir);
241
+ }
242
+ }
243
+ catch (error) {
244
+ finalizePending = false;
245
+ submitting = false;
246
+ settleSubmitLifecycle(false);
247
+ sendJson(res, 400, { error: 'invalid_submit', message: error instanceof Error ? error.message : String(error) });
248
+ return;
249
+ }
250
+ finalizePending = false;
164
251
  submitted = { responsePath, completedAt };
165
- clearProgress(dir);
166
- // Await the flush BEFORE registering the finish callback that can
167
- // trigger caller-side stop() — that ordering is what closes the race
168
- // between a lost WS frame and the teardown it precedes.
252
+ // Broadcast convergence before permitting owner cleanup, so connected
253
+ // observers receive this state transition before the server can close.
169
254
  await scaffold.broadcast({ type: 'converged' });
255
+ successReady = true;
256
+ if (clientGone) {
257
+ // The client disconnected during finalization; converge now rather
258
+ // than waiting for a `finish` that will never fire. The 200 is
259
+ // best-effort — the socket may already be closed.
260
+ runConvergence();
261
+ sendJson(res, 200, { ok: true, responsePath, completedAt });
262
+ return;
263
+ }
170
264
  // Ack-ordering guarantee: the HTTP caller must always receive its 200
171
- // body before any lifecycle teardown can close its socket.
172
- res.once('finish', () => {
173
- opts.onSubmit?.(responses, completedAt, responsePath);
174
- });
265
+ // body before any lifecycle teardown can close its socket. The finish
266
+ // observer registered above converges then; `onClientGone` converges if
267
+ // the peer disappears before finish.
175
268
  sendJson(res, 200, { ok: true, responsePath, completedAt });
176
269
  return;
177
270
  }
@@ -192,6 +285,9 @@ async function startDeckServer(opts) {
192
285
  async requestTakeBack() {
193
286
  await scaffold.broadcast({ type: 'taken-back' });
194
287
  },
288
+ pendingSubmitLifecycle() {
289
+ return pendingSubmitLifecycle;
290
+ },
195
291
  stop() {
196
292
  return scaffold.stop();
197
293
  },
package/dist/cli.js CHANGED
File without changes
@@ -2,5 +2,19 @@ export interface ConversationMessage {
2
2
  role: 'user' | 'assistant';
3
3
  content: string;
4
4
  }
5
- export declare function readConversation(sessionId: string): ConversationMessage[];
5
+ export type ConversationReadErrorCode = 'session_not_found' | 'session_ambiguous' | 'session_unreadable' | 'session_id_mismatch' | 'conversation_empty';
6
+ export declare class ConversationReadError extends Error {
7
+ readonly code: ConversationReadErrorCode;
8
+ constructor(code: ConversationReadErrorCode);
9
+ }
10
+ export interface ConversationReaderOptions {
11
+ /** Claude SQLite path override for a real-store fixture; production uses Claude's standard store. */
12
+ claudeDbPath?: string;
13
+ }
14
+ /** Read messages from Claude's local conversation store. */
15
+ export declare function readConversation(sessionId: string, options?: ConversationReaderOptions): ConversationMessage[];
16
+ /** Resolve a pi session by exact header id and return its active useful context. */
17
+ export declare function readPiConversationText(sessionId: string): Promise<string>;
18
+ /** Resolve by exact membership across provider stores, never by session-id shape. */
19
+ export declare function readConversationText(sessionId: string, options?: ConversationReaderOptions): Promise<string>;
6
20
  export declare function findRecentSessionId(cwd?: string): string | null;
@@ -1,56 +1,346 @@
1
- import { execSync } from 'child_process';
2
- import { homedir } from 'os';
3
- import { join } from 'path';
4
- import { existsSync } from 'fs';
5
- const CLAUDE_DB_PATH = join(homedir(), '.claude', '__store.db');
6
- export function readConversation(sessionId) {
7
- if (!existsSync(CLAUDE_DB_PATH)) {
8
- throw new Error(`Claude database not found at ${CLAUDE_DB_PATH}`);
1
+ import { execFileSync, execSync } from 'node:child_process';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ export class ConversationReadError extends Error {
6
+ code;
7
+ constructor(code) {
8
+ super(code);
9
+ this.code = code;
10
+ this.name = 'ConversationReadError';
9
11
  }
12
+ }
13
+ const CLAUDE_DB_PATH = join(homedir(), '.claude', '__store.db');
14
+ const MAX_SESSION_ID_LENGTH = 256;
15
+ const piSessionIndex = new Map();
16
+ /** Read messages from Claude's local conversation store. */
17
+ export function readConversation(sessionId, options = {}) {
18
+ const claudeDbPath = options.claudeDbPath ?? CLAUDE_DB_PATH;
19
+ if (!existsSync(claudeDbPath))
20
+ throw new Error('Claude conversation store unavailable');
10
21
  const query = `
11
22
  SELECT bm.message_type,
12
23
  COALESCE(um.message, am.message) AS content
13
24
  FROM base_messages bm
14
25
  LEFT JOIN user_messages um ON bm.uuid = um.uuid
15
26
  LEFT JOIN assistant_messages am ON bm.uuid = am.uuid
16
- WHERE bm.session_id = '${sessionId.replace(/'/g, "''")}'
27
+ WHERE bm.session_id = '${escapeSqlString(sessionId)}'
17
28
  ORDER BY bm.timestamp ASC;
18
29
  `;
19
- const raw = execSync(`sqlite3 -json "${CLAUDE_DB_PATH}" "${query.replace(/"/g, '\\"')}"`, {
20
- encoding: 'utf8',
21
- maxBuffer: 50 * 1024 * 1024,
22
- });
30
+ const raw = runSqlite(claudeDbPath, query);
23
31
  if (!raw.trim())
24
32
  return [];
25
33
  const rows = JSON.parse(raw);
26
- const messages = [];
27
- for (const row of rows) {
28
- if (!row.content)
34
+ return rows.flatMap((row) => row.content && (row.message_type === 'user' || row.message_type === 'assistant')
35
+ ? [{ role: row.message_type, content: row.content }]
36
+ : []);
37
+ }
38
+ /** Resolve a pi session by exact header id and return its active useful context. */
39
+ export async function readPiConversationText(sessionId) {
40
+ if (sessionId.length === 0 || sessionId.length > MAX_SESSION_ID_LENGTH)
41
+ throw new ConversationReadError('session_not_found');
42
+ const pi = await import('@earendil-works/pi-coding-agent').catch(() => {
43
+ throw new ConversationReadError('session_unreadable');
44
+ });
45
+ const paths = await resolvePiSessionPaths(pi.SessionManager, sessionId);
46
+ if (paths.length === 0)
47
+ throw new ConversationReadError('session_not_found');
48
+ if (paths.length !== 1)
49
+ throw new ConversationReadError('session_ambiguous');
50
+ let entries;
51
+ try {
52
+ // parseSessionEntries is the package-root parser. Reading only the SDK-discovered
53
+ // path keeps an untrusted deck id from becoming a filesystem locator.
54
+ const raw = readFileSync(paths[0], 'utf8');
55
+ rejectMalformedCompleteJsonlRecords(raw);
56
+ entries = pi.parseSessionEntries(raw);
57
+ }
58
+ catch {
59
+ throw new ConversationReadError('session_unreadable');
60
+ }
61
+ const header = entries[0];
62
+ if (!isMatchingHeader(header, sessionId))
63
+ throw new ConversationReadError('session_id_mismatch');
64
+ if (entries.length < 2)
65
+ throw new ConversationReadError('conversation_empty');
66
+ try {
67
+ pi.migrateSessionEntries(entries);
68
+ const sessionEntries = entries.slice(1).filter((entry) => entry.type !== 'session');
69
+ const context = pi.buildSessionContext(sessionEntries);
70
+ const text = context.messages.map(serializePiMessage).filter((part) => part !== '').join('\n\n').trim();
71
+ if (text === '')
72
+ throw new ConversationReadError('conversation_empty');
73
+ return text;
74
+ }
75
+ catch (error) {
76
+ if (error instanceof ConversationReadError)
77
+ throw error;
78
+ throw new ConversationReadError('session_unreadable');
79
+ }
80
+ }
81
+ async function resolvePiSessionPaths(SessionManager, sessionId) {
82
+ const root = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), '.pi', 'agent');
83
+ let index = piSessionIndex.get(root);
84
+ if (index === undefined) {
85
+ index = await buildPiSessionIndex(SessionManager);
86
+ piSessionIndex.set(root, index);
87
+ }
88
+ let paths = index.get(sessionId) ?? [];
89
+ // A popup can outlive session creation. Refresh once on a miss, then keep the
90
+ // process-local cache for subsequent visuals instead of rescanning every transcript.
91
+ if (paths.length === 0) {
92
+ index = await buildPiSessionIndex(SessionManager);
93
+ piSessionIndex.set(root, index);
94
+ paths = index.get(sessionId) ?? [];
95
+ }
96
+ return paths;
97
+ }
98
+ async function buildPiSessionIndex(SessionManager) {
99
+ let sessions;
100
+ try {
101
+ sessions = await SessionManager.listAll();
102
+ }
103
+ catch {
104
+ throw new ConversationReadError('session_unreadable');
105
+ }
106
+ const index = new Map();
107
+ for (const session of sessions) {
108
+ const paths = index.get(session.id);
109
+ if (paths === undefined)
110
+ index.set(session.id, [session.path]);
111
+ else
112
+ paths.push(session.path);
113
+ }
114
+ return index;
115
+ }
116
+ /** Resolve by exact membership across provider stores, never by session-id shape. */
117
+ export async function readConversationText(sessionId, options = {}) {
118
+ if (sessionId.length === 0 || sessionId.length > MAX_SESSION_ID_LENGTH)
119
+ throw new ConversationReadError('session_not_found');
120
+ const claudeDbPath = options.claudeDbPath ?? CLAUDE_DB_PATH;
121
+ const pi = await import('@earendil-works/pi-coding-agent').catch(() => {
122
+ throw new ConversationReadError('session_unreadable');
123
+ });
124
+ const piPaths = await resolvePiSessionPaths(pi.SessionManager, sessionId);
125
+ const claudeMatches = findClaudeSessionMembership(sessionId, claudeDbPath);
126
+ const matches = (piPaths.length === 1 ? 1 : 0) + (claudeMatches ? 1 : 0);
127
+ if (piPaths.length > 1 || matches > 1)
128
+ throw new ConversationReadError('session_ambiguous');
129
+ if (matches === 0)
130
+ throw new ConversationReadError('session_not_found');
131
+ if (claudeMatches) {
132
+ try {
133
+ const text = readConversation(sessionId, { claudeDbPath }).map((message) => `${message.role}: ${message.content}`).join('\n\n').trim();
134
+ if (text === '')
135
+ throw new ConversationReadError('conversation_empty');
136
+ return text;
137
+ }
138
+ catch (error) {
139
+ if (error instanceof ConversationReadError)
140
+ throw error;
141
+ throw new ConversationReadError('session_unreadable');
142
+ }
143
+ }
144
+ return readPiConversationText(sessionId);
145
+ }
146
+ function findClaudeSessionMembership(sessionId, claudeDbPath) {
147
+ if (!existsSync(claudeDbPath))
148
+ return false;
149
+ try {
150
+ return runSqlite(claudeDbPath, `SELECT 1 AS found FROM base_messages WHERE session_id = '${escapeSqlString(sessionId)}' LIMIT 1;`).trim() !== '';
151
+ }
152
+ catch {
153
+ throw new ConversationReadError('session_unreadable');
154
+ }
155
+ }
156
+ function runSqlite(dbPath, query) {
157
+ return execFileSync('sqlite3', ['-json', dbPath, query], { encoding: 'utf8', maxBuffer: 50 * 1024 * 1024 });
158
+ }
159
+ function escapeSqlString(value) { return value.replace(/'/g, "''"); }
160
+ /** Reject corrupt records while allowing only an unterminated JSON-object prefix being concurrently written. */
161
+ function rejectMalformedCompleteJsonlRecords(raw) {
162
+ const lines = raw.split('\n');
163
+ for (const line of lines.slice(0, -1)) {
164
+ if (line.trim() === '')
29
165
  continue;
30
- if (row.message_type === 'user' || row.message_type === 'assistant') {
31
- messages.push({
32
- role: row.message_type,
33
- content: row.content,
34
- });
166
+ JSON.parse(line);
167
+ }
168
+ const tail = lines.at(-1);
169
+ if (!raw.endsWith('\n') && tail !== '' && !isIncompleteJsonObjectPrefix(tail))
170
+ JSON.parse(tail);
171
+ }
172
+ function isIncompleteJsonObjectPrefix(source) {
173
+ let offset = 0;
174
+ const whitespace = () => { while (/\s/.test(source[offset] ?? ''))
175
+ offset += 1; };
176
+ const string = () => {
177
+ if (source[offset++] !== '"')
178
+ return 'malformed';
179
+ while (offset < source.length) {
180
+ const character = source[offset++];
181
+ if (character === '"')
182
+ return 'complete';
183
+ if (character < ' ')
184
+ return 'malformed';
185
+ if (character === '\\') {
186
+ if (offset === source.length)
187
+ return 'incomplete';
188
+ const escape = source[offset++];
189
+ if (!'"\\/bfnrtu'.includes(escape))
190
+ return 'malformed';
191
+ if (escape === 'u') {
192
+ for (let count = 0; count < 4; count += 1) {
193
+ if (offset === source.length)
194
+ return 'incomplete';
195
+ if (!/[0-9a-f]/i.test(source[offset++]))
196
+ return 'malformed';
197
+ }
198
+ }
199
+ }
200
+ }
201
+ return 'incomplete';
202
+ };
203
+ const value = () => {
204
+ whitespace();
205
+ const character = source[offset];
206
+ if (character === undefined)
207
+ return 'incomplete';
208
+ if (character === '"')
209
+ return string();
210
+ if (character === '{')
211
+ return object();
212
+ if (character === '[')
213
+ return array();
214
+ for (const literal of ['true', 'false', 'null']) {
215
+ if (source.slice(offset, offset + literal.length) === literal) {
216
+ offset += literal.length;
217
+ return 'complete';
218
+ }
219
+ if (literal.startsWith(source.slice(offset)))
220
+ return 'incomplete';
221
+ }
222
+ if (character === '-' || /[0-9]/.test(character)) {
223
+ const match = source.slice(offset).match(/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/);
224
+ if (match === null)
225
+ return character === '-' ? 'incomplete' : 'malformed';
226
+ offset += match[0].length;
227
+ return 'complete';
228
+ }
229
+ return 'malformed';
230
+ };
231
+ const object = () => {
232
+ offset += 1;
233
+ whitespace();
234
+ if (source[offset] === '}') {
235
+ offset += 1;
236
+ return 'complete';
35
237
  }
238
+ while (true) {
239
+ if (offset === source.length)
240
+ return 'incomplete';
241
+ const key = string();
242
+ if (key !== 'complete')
243
+ return key;
244
+ whitespace();
245
+ if (offset === source.length)
246
+ return 'incomplete';
247
+ if (source[offset++] !== ':')
248
+ return 'malformed';
249
+ const item = value();
250
+ if (item !== 'complete')
251
+ return item;
252
+ whitespace();
253
+ if (offset === source.length)
254
+ return 'incomplete';
255
+ const separator = source[offset++];
256
+ if (separator === '}')
257
+ return 'complete';
258
+ if (separator !== ',')
259
+ return 'malformed';
260
+ whitespace();
261
+ }
262
+ };
263
+ const array = () => {
264
+ offset += 1;
265
+ whitespace();
266
+ if (source[offset] === ']') {
267
+ offset += 1;
268
+ return 'complete';
269
+ }
270
+ while (true) {
271
+ const item = value();
272
+ if (item !== 'complete')
273
+ return item;
274
+ whitespace();
275
+ if (offset === source.length)
276
+ return 'incomplete';
277
+ const separator = source[offset++];
278
+ if (separator === ']')
279
+ return 'complete';
280
+ if (separator !== ',')
281
+ return 'malformed';
282
+ whitespace();
283
+ }
284
+ };
285
+ const result = value();
286
+ whitespace();
287
+ return result === 'incomplete' && offset === source.length && source.trimStart().startsWith('{');
288
+ }
289
+ function isMatchingHeader(entry, sessionId) {
290
+ return entry?.type === 'session' && entry.id === sessionId;
291
+ }
292
+ function serializePiMessage(message) {
293
+ switch (message.role) {
294
+ case 'user': return labeledText('user', message.content);
295
+ case 'assistant': {
296
+ const blocks = Array.isArray(message.content) ? message.content : [];
297
+ const text = blocks.flatMap((block) => {
298
+ if (!isRecord(block))
299
+ return [];
300
+ if (block.type === 'text' && typeof block.text === 'string')
301
+ return [block.text];
302
+ if (block.type === 'toolCall' && typeof block.name === 'string')
303
+ return [`tool call ${block.name}: ${safeJson(block.arguments)}`];
304
+ return [];
305
+ }).filter((part) => part.trim() !== '');
306
+ return text.length === 0 ? '' : `assistant: ${text.join('\n')}`;
307
+ }
308
+ case 'toolResult': return labeledText(`tool result${typeof message.toolName === 'string' ? ` ${message.toolName}` : ''}`, message.content);
309
+ case 'compactionSummary': return typeof message.summary === 'string' && message.summary.trim() !== '' ? `compaction summary: ${message.summary}` : '';
310
+ case 'branchSummary': return typeof message.summary === 'string' && message.summary.trim() !== '' ? `branch summary: ${message.summary}` : '';
311
+ default: return '';
312
+ }
313
+ }
314
+ function labeledText(label, content) {
315
+ const text = textFromContent(content);
316
+ return text === '' ? '' : `${label}: ${text}`;
317
+ }
318
+ function textFromContent(content) {
319
+ if (typeof content === 'string')
320
+ return content.trim();
321
+ if (!Array.isArray(content))
322
+ return '';
323
+ return content.flatMap((block) => isRecord(block) && block.type === 'text' && typeof block.text === 'string' ? [block.text] : []).join('\n').trim();
324
+ }
325
+ function safeJson(value) {
326
+ try {
327
+ return JSON.stringify(value);
328
+ }
329
+ catch {
330
+ return '[unserializable arguments]';
36
331
  }
37
- return messages;
38
332
  }
333
+ function isRecord(value) { return typeof value === 'object' && value !== null; }
39
334
  export function findRecentSessionId(cwd) {
40
335
  if (!existsSync(CLAUDE_DB_PATH))
41
336
  return null;
42
- const whereClause = cwd
43
- ? `WHERE cwd = '${cwd.replace(/'/g, "''")}'`
44
- : '';
337
+ const whereClause = cwd ? `WHERE cwd = '${cwd.replace(/'/g, "''")}'` : '';
45
338
  const query = `SELECT DISTINCT session_id FROM base_messages ${whereClause} ORDER BY timestamp DESC LIMIT 1;`;
46
339
  try {
47
- const raw = execSync(`sqlite3 -json "${CLAUDE_DB_PATH}" "${query.replace(/"/g, '\\"')}"`, {
48
- encoding: 'utf8',
49
- });
340
+ const raw = execSync(`sqlite3 -json "${CLAUDE_DB_PATH}" "${query.replace(/"/g, '\\"')}"`, { encoding: 'utf8' });
50
341
  if (!raw.trim())
51
342
  return null;
52
- const rows = JSON.parse(raw);
53
- return rows[0]?.session_id ?? null;
343
+ return JSON.parse(raw)[0]?.session_id ?? null;
54
344
  }
55
345
  catch {
56
346
  return null;
@@ -0,0 +1,5 @@
1
+ /** Run $EDITOR against a temporary buffer. Terminal ownership stays with the host. */
2
+ export declare function editBufferInEditor(buffer: string): {
3
+ text: string;
4
+ error?: string;
5
+ };
@@ -0,0 +1,36 @@
1
+ import { readFileSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ /** Run $EDITOR against a temporary buffer. Terminal ownership stays with the host. */
7
+ export function editBufferInEditor(buffer) {
8
+ const tmpFile = join(tmpdir(), `hl-input-${randomUUID()}.txt`);
9
+ const editor = process.env.EDITOR || 'vi';
10
+ try {
11
+ writeFileSync(tmpFile, buffer);
12
+ // $EDITOR may be a shell fragment such as "code --wait".
13
+ const result = spawnSync('/bin/sh', ['-c', `${editor} "$1"`, 'sh', tmpFile], { stdio: 'inherit' });
14
+ if (result.error)
15
+ return { text: buffer, error: `$EDITOR ("${editor}") failed to launch: ${result.error.message}` };
16
+ if (result.signal !== null)
17
+ return { text: buffer, error: `$EDITOR ("${editor}") was killed by signal ${result.signal}` };
18
+ if (result.status === 127 || result.status === 126)
19
+ return { text: buffer, error: `$EDITOR ("${editor}") failed to launch (shell exit ${result.status})` };
20
+ if (result.status !== 0)
21
+ return { text: buffer, error: `$EDITOR ("${editor}") exited with status ${result.status}` };
22
+ let text = readFileSync(tmpFile, 'utf8');
23
+ if (text.endsWith('\n') && !buffer.endsWith('\n'))
24
+ text = text.slice(0, -1);
25
+ return { text };
26
+ }
27
+ catch (error) {
28
+ return { text: buffer, error: `$EDITOR round-trip failed: ${error instanceof Error ? error.message : String(error)}` };
29
+ }
30
+ finally {
31
+ try {
32
+ unlinkSync(tmpFile);
33
+ }
34
+ catch { /* best-effort cleanup */ }
35
+ }
36
+ }