svamp-cli 0.2.113 → 0.2.115

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.
@@ -1,7 +1,7 @@
1
1
  import{createRequire as _pkgrollCR}from"node:module";const require=_pkgrollCR(import.meta.url);import os$1, { homedir as homedir$1 } from 'os';
2
2
  import fs, { mkdir as mkdir$1, readdir as readdir$1, readFile, writeFile as writeFile$1, rename, unlink } from 'fs/promises';
3
- import { readFileSync as readFileSync$1, mkdirSync as mkdirSync$1, writeFileSync as writeFileSync$1, renameSync as renameSync$1, existsSync as existsSync$1, rmSync as rmSync$1, unlinkSync as unlinkSync$1, copyFileSync, readdirSync as readdirSync$1, watch, rmdirSync } from 'fs';
4
- import path__default, { join as join$1, dirname as dirname$1, basename as basename$1, resolve } from 'path';
3
+ import { readFileSync as readFileSync$1, mkdirSync as mkdirSync$1, writeFileSync as writeFileSync$1, renameSync as renameSync$1, existsSync as existsSync$1, realpathSync, rmSync as rmSync$1, copyFileSync, unlinkSync as unlinkSync$1, readdirSync as readdirSync$1, watch, rmdirSync } from 'fs';
4
+ import path__default, { join as join$1, resolve, dirname as dirname$1, basename as basename$1 } from 'path';
5
5
  import { fileURLToPath } from 'url';
6
6
  import { execFile, spawn as spawn$1, execSync as execSync$1, spawnSync } from 'child_process';
7
7
  import { randomUUID as randomUUID$1 } from 'crypto';
@@ -10,8 +10,8 @@ import { existsSync, readFileSync, mkdirSync, readdirSync, writeFileSync, rename
10
10
  import { exec, execSync, spawn, execFile as execFile$1, execFileSync } from 'node:child_process';
11
11
  import { promisify } from 'util';
12
12
  import { join, basename, dirname } from 'node:path';
13
- import os, { homedir, platform } from 'node:os';
14
13
  import { EventEmitter } from 'node:events';
14
+ import os, { homedir, platform } from 'node:os';
15
15
  import { ndJsonStream, ClientSideConnection } from '@agentclientprotocol/sdk';
16
16
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
17
17
  import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
@@ -1388,22 +1388,36 @@ function generateSkillBody(channel, ctx) {
1388
1388
  const bindNote = mode === "stateless" ? `**Stateless channel** \u2014 each call spawns a fresh, isolated one-shot session (cold-start: a few seconds), runs, replies, and closes. No shared memory between calls.` : mode === "fixed" ? `Routes to a **fixed session** (\`${rSession}\`); reachable only while that session is live.` : rSession ? `Routes to **session \`${rSession}\`** (the one this instruction was copied from); reachable only while it is live, else a fresh stateless run answers.` : `**Dynamic channel** \u2014 pass \`session: "<id>"\` to target a specific live session, else a fresh stateless run answers.`;
1389
1389
  const name = channel.skill?.name || channel.name;
1390
1390
  const desc = channel.skill?.description || channel.description || `Send a message to the "${channel.name}" channel.`;
1391
- const replyNote = isAgent ? `This is a **WISE Agent** channel: \`send()\` runs a fast assistant against the session's tools/skills and returns its answer synchronously in the result \`reply\`.` : isQueue ? `This is an **async** channel: \`send()\` returns a \`correlationId\` and the agent replies later \u2014 poll \`receive()\` (or GET /receive \xB7 /events) for replies addressed to you.` : `Delivery is fire-and-forget \u2014 the message lands in the agent's inbox, tagged with your verified identity (\`from\`).`;
1391
+ const replyNote = isAgent ? `This is a **WISE Agent** channel: \`send()\` runs a fast assistant against the session's tools/skills and returns its answer synchronously in the result \`reply\`.` : isQueue ? `This is an **async** channel: \`send()\` usually returns \`{ status: "queued", correlationId }\` and the agent replies later \u2014 poll \`receive()\` (or GET /receive \xB7 /events) for replies addressed to you. **But it may also answer synchronously** with \`{ status: "completed", reply }\` (e.g. when the target session is gone and a fresh one-shot run handles the call) \u2014 always check \`status\` first and use \`reply\` directly when present; \`receive()\` has nothing to return for a one-shot.` : `Delivery is fire-and-forget \u2014 the message lands in the agent's inbox, tagged with your verified identity (\`from\`).`;
1392
1392
  const queueSection = isQueue ? `
1393
1393
 
1394
- ## Getting the reply (async)
1395
- \`send()\` returns \`{ correlationId }\`. The agent answers later; retrieve replies addressed
1396
- to you by **long-polling** \`receive\` with a cursor you advance each call:
1394
+ ## Getting the reply
1395
+ \`send()\` returns one of two shapes \u2014 **check \`status\` first**:
1396
+ - \`{ status: "completed", reply, correlationId }\` \u2014 answered synchronously (e.g. a
1397
+ dynamic channel whose target session is gone falls back to a fresh one-shot run).
1398
+ Use \`reply\` directly; do **not** poll \`receive()\` \u2014 there is no persistent session
1399
+ to poll and it will return an error.
1400
+ - \`{ status: "queued", correlationId }\` \u2014 a live session will answer later; long-poll
1401
+ \`receive\` for replies addressed to you, advancing \`cursor\` each call:
1397
1402
  \`\`\`js
1398
- const { correlationId } = await get_service("${svc}").send({ channel: "${channel.id}", message: "\u2026", from: "your-name" });
1399
- let cursor = 0;
1403
+ const res = await get_service("${svc}").send({ channel: "${channel.id}", message: "\u2026", from: "your-name" });
1404
+ if (res.status === "completed") return res.reply; // answered now \u2014 done
1405
+ let cursor = 0; // else poll for the async reply
1400
1406
  while (true) {
1401
1407
  const r = await get_service("${svc}").receive({ channel: "${channel.id}", key: "${key}", cursor, wait: 25 });
1402
1408
  cursor = r.cursor;
1403
- for (const reply of r.replies) if (reply.correlationId === correlationId) return reply.body;
1409
+ for (const reply of r.replies) if (reply.correlationId === res.correlationId) return reply.body;
1404
1410
  }
1405
1411
  \`\`\`
1406
- **HTTP:** \`POST ${recvUrl}\` with \`{"kwargs": {"channel": "${channel.id}", "key": "${key}", "cursor": 0, "wait": 25}}\` (long-poll), or stream \`GET <channel-http>/channel/${channel.id}/events?key=${key}\` (SSE).` : "";
1412
+ **HTTP:** \`POST ${recvUrl}\` with \`{"kwargs": {"channel": "${channel.id}", "key": "${key}", "cursor": 0, "wait": 25}}\` (long-poll), or stream \`GET <channel-http>/channel/${channel.id}/events?key=${key}\` (SSE).
1413
+
1414
+ ### Agent-to-agent: get the reply in your own inbox
1415
+ If **you are a Svamp agent session**, pass \`reply_to: { session: "<your-session-id>" }\`
1416
+ to \`send()\`. The reply is then delivered straight to **your session's inbox**
1417
+ (\`inbox reply\`-able, persistent) \u2014 symmetric agent\u2194agent messaging. Without
1418
+ \`reply_to.session\` you are treated as an **external caller**: the reply goes to this
1419
+ channel's outbox and you must poll \`receive()\` (above) for it \u2014 it will **not** appear
1420
+ in your inbox.` : "";
1407
1421
  const rpcLine = hyphaOpen ? `**Hypha RPC** \u2014 preferred. Your verified Hypha identity is accepted, no key needed:` : `**Hypha RPC** \u2014 verified identity:`;
1408
1422
  return `---
1409
1423
  name: ${name}
@@ -1449,7 +1463,10 @@ function resolveSender(channel, input = {}) {
1449
1463
  }
1450
1464
  if (id.mode === "per-key") {
1451
1465
  const caller = (id.callers || []).find((c) => c.key && c.key === key);
1452
- if (!caller) return { error: "invalid or missing key" };
1466
+ if (!caller) {
1467
+ const acceptsHypha = Array.isArray(id.hypha_allow) && id.hypha_allow.length > 0;
1468
+ return { error: acceptsHypha ? "invalid or missing key \u2014 supply a caller key issued by the channel owner, or call with an authenticated Hypha identity (this channel accepts hypha_allow callers; anonymous/keyless requests are rejected)" : "invalid or missing key \u2014 this channel requires a caller key issued by the channel owner" };
1469
+ }
1453
1470
  return { sender: { name: caller.name, kind: caller.kind, verified: true } };
1454
1471
  }
1455
1472
  if (id.mode === "caller-supplied") {
@@ -1482,6 +1499,138 @@ function renderMessage(channel, { sender = {}, body = {}, query = {}, callId, no
1482
1499
  return renderTemplate(channel.template || DEFAULT_TEMPLATE, ctx);
1483
1500
  }
1484
1501
 
1502
+ const MAX_PER_CHANNEL = 200;
1503
+ const TTL_MS = 60 * 60 * 1e3;
1504
+ class ChannelOutbox {
1505
+ file;
1506
+ byChannel = /* @__PURE__ */ new Map();
1507
+ seqByChannel = /* @__PURE__ */ new Map();
1508
+ emitter = new EventEmitter();
1509
+ constructor(projectDir) {
1510
+ const dir = join(projectDir, ".svamp", "channels");
1511
+ this.file = join(dir, "_outbox.jsonl");
1512
+ this.emitter.setMaxListeners(0);
1513
+ try {
1514
+ mkdirSync(dir, { recursive: true });
1515
+ } catch {
1516
+ }
1517
+ this._load();
1518
+ }
1519
+ _load() {
1520
+ if (!existsSync(this.file)) return;
1521
+ const cutoff = Date.now() - TTL_MS;
1522
+ try {
1523
+ for (const line of readFileSync(this.file, "utf8").split("\n")) {
1524
+ if (!line.trim()) continue;
1525
+ let rec = null;
1526
+ try {
1527
+ rec = JSON.parse(line);
1528
+ } catch {
1529
+ continue;
1530
+ }
1531
+ if (!rec || rec.ts < cutoff) continue;
1532
+ const { channelId, ...reply } = rec;
1533
+ const arr = this.byChannel.get(channelId) || [];
1534
+ arr.push(reply);
1535
+ this.byChannel.set(channelId, arr);
1536
+ this.seqByChannel.set(channelId, Math.max(this.seqByChannel.get(channelId) || 0, reply.seq));
1537
+ }
1538
+ for (const [ch, arr] of this.byChannel) if (arr.length > MAX_PER_CHANNEL) this.byChannel.set(ch, arr.slice(-MAX_PER_CHANNEL));
1539
+ } catch {
1540
+ }
1541
+ }
1542
+ _evict(channelId) {
1543
+ const cutoff = Date.now() - TTL_MS;
1544
+ let arr = this.byChannel.get(channelId);
1545
+ if (!arr) return;
1546
+ if (arr.some((r) => r.ts < cutoff)) arr = arr.filter((r) => r.ts >= cutoff);
1547
+ if (arr.length > MAX_PER_CHANNEL) arr = arr.slice(-MAX_PER_CHANNEL);
1548
+ this.byChannel.set(channelId, arr);
1549
+ }
1550
+ /** Append a reply addressed to `to`. Assigns seq + ts, persists, and wakes waiters. */
1551
+ append(channelId, r) {
1552
+ const seq = (this.seqByChannel.get(channelId) || 0) + 1;
1553
+ this.seqByChannel.set(channelId, seq);
1554
+ const reply = { seq, ts: Date.now(), to: r.to, body: r.body, ...r.correlationId ? { correlationId: r.correlationId } : {} };
1555
+ const arr = this.byChannel.get(channelId) || [];
1556
+ arr.push(reply);
1557
+ this.byChannel.set(channelId, arr);
1558
+ this._evict(channelId);
1559
+ try {
1560
+ appendFileSync(this.file, JSON.stringify({ channelId, ...reply }) + "\n");
1561
+ } catch {
1562
+ try {
1563
+ mkdirSync(join(this.file, ".."), { recursive: true });
1564
+ appendFileSync(this.file, JSON.stringify({ channelId, ...reply }) + "\n");
1565
+ } catch {
1566
+ }
1567
+ }
1568
+ this.emitter.emit(channelId, reply);
1569
+ return reply;
1570
+ }
1571
+ /** Replies for `to` on `channelId` with seq > cursor (optionally one correlationId). */
1572
+ since(channelId, cursor, to, correlationId) {
1573
+ this._evict(channelId);
1574
+ return (this.byChannel.get(channelId) || []).filter((r) => r.seq > cursor && r.to === to && (!correlationId || r.correlationId === correlationId));
1575
+ }
1576
+ /** Highest seq on a channel (the cursor a caller gets back). */
1577
+ cursor(channelId) {
1578
+ return this.seqByChannel.get(channelId) || 0;
1579
+ }
1580
+ /**
1581
+ * Long-poll: resolve immediately if there are replies after `cursor`, else wait for
1582
+ * the next append (filtered to this channel + `to`) or `timeoutMs`, then return.
1583
+ */
1584
+ wait(channelId, cursor, to, timeoutMs, correlationId) {
1585
+ const ready = this.since(channelId, cursor, to, correlationId);
1586
+ if (ready.length) return Promise.resolve({ replies: ready, cursor: this.cursor(channelId) });
1587
+ return new Promise((resolve) => {
1588
+ const onReply = (r) => {
1589
+ if (r.to !== to || correlationId && r.correlationId !== correlationId) return;
1590
+ cleanup();
1591
+ resolve({ replies: this.since(channelId, cursor, to, correlationId), cursor: this.cursor(channelId) });
1592
+ };
1593
+ const timer = setTimeout(() => {
1594
+ cleanup();
1595
+ resolve({ replies: [], cursor: this.cursor(channelId) });
1596
+ }, Math.max(0, timeoutMs));
1597
+ const cleanup = () => {
1598
+ clearTimeout(timer);
1599
+ this.emitter.off(channelId, onReply);
1600
+ };
1601
+ this.emitter.on(channelId, onReply);
1602
+ });
1603
+ }
1604
+ /** Push subscription for SSE: calls onReply for each new reply addressed to `to`. */
1605
+ subscribe(channelId, to, onReply) {
1606
+ const handler = (r) => {
1607
+ if (r.to === to) onReply(r);
1608
+ };
1609
+ this.emitter.on(channelId, handler);
1610
+ return () => this.emitter.off(channelId, handler);
1611
+ }
1612
+ /** Drop a channel's outbox (on channel delete) — best-effort rewrite of the log. */
1613
+ purge(channelId) {
1614
+ this.byChannel.delete(channelId);
1615
+ this.seqByChannel.delete(channelId);
1616
+ if (!existsSync(this.file)) return;
1617
+ try {
1618
+ const kept = readFileSync(this.file, "utf8").split("\n").filter((l) => {
1619
+ if (!l.trim()) return false;
1620
+ try {
1621
+ return JSON.parse(l).channelId !== channelId;
1622
+ } catch {
1623
+ return false;
1624
+ }
1625
+ });
1626
+ const tmp = this.file + ".tmp";
1627
+ writeFileSync(tmp, kept.join("\n") + (kept.length ? "\n" : ""));
1628
+ renameSync(tmp, this.file);
1629
+ } catch {
1630
+ }
1631
+ }
1632
+ }
1633
+
1485
1634
  function getParamNames(fn) {
1486
1635
  const src = fn.toString();
1487
1636
  const match = src.match(/^(?:async\s+)?(?:function\s*\w*)?\s*\(([^)]*)\)/);
@@ -2527,7 +2676,7 @@ async function registerMachineService(server, machineId, metadata, daemonState,
2527
2676
  const tunnels = handlers.tunnels;
2528
2677
  if (!tunnels) throw new Error("Tunnel management not available");
2529
2678
  if (tunnels.has(params.name)) throw new Error(`Tunnel '${params.name}' already running`);
2530
- const { FrpcTunnel } = await import('./frpc-fsq8Ua7l.mjs');
2679
+ const { FrpcTunnel } = await import('./frpc-1aSnPVrE.mjs');
2531
2680
  const tunnel = new FrpcTunnel({
2532
2681
  name: params.name,
2533
2682
  ports: params.ports,
@@ -2788,7 +2937,7 @@ QUESTION: ${params.question || "Summarize this concisely."}` }
2788
2937
  }
2789
2938
  const deps = buildSessionDeps(rpc, { cwd, ownerEmail: owner });
2790
2939
  const sender = { name: context?.user?.email || context?.user?.id || "user", kind: "user", verified: true };
2791
- const { toolsForRole } = await import('./sideband-uW543Qst.mjs');
2940
+ const { toolsForRole } = await import('./sideband-vimBRRDF.mjs');
2792
2941
  const r2 = await runWiseAgent({ message: params.message, sender, config: { tools: toolsForRole(role2) }, deps, transport, model: resolved.model });
2793
2942
  return fmt(r2);
2794
2943
  }
@@ -2887,7 +3036,7 @@ QUESTION: ${params.question || "Summarize this concisely."}` }
2887
3036
  if (r.error || !r.sender) return { error: r.error || "unauthorized" };
2888
3037
  const callId = "call_" + Math.random().toString(16).slice(2, 12);
2889
3038
  const rendered = renderMessage(c, { sender: r.sender, body: { message: kwargs.message }, callId });
2890
- const { queryCore } = await import('./commands-OvJA3HMg.mjs');
3039
+ const { queryCore } = await import('./commands-B3NhziMR.mjs');
2891
3040
  const timeout = c.reply?.timeout_sec || 120;
2892
3041
  let result;
2893
3042
  try {
@@ -2904,7 +3053,7 @@ QUESTION: ${params.question || "Summarize this concisely."}` }
2904
3053
  }
2905
3054
  if (result.status === "error") return { ok: false, call_id: callId, status: "error", error: result.error };
2906
3055
  if (result.status === "permission-pending") return { ok: false, call_id: callId, status: "permission-pending", error: result.error };
2907
- return { ok: true, call_id: callId, status: "completed", reply: result.response };
3056
+ return { ok: true, call_id: callId, correlationId: callId, status: "completed", reply: result.response };
2908
3057
  };
2909
3058
  const channelsServiceInfo = await server.registerService(
2910
3059
  {
@@ -2925,10 +3074,16 @@ QUESTION: ${params.question || "Summarize this concisely."}` }
2925
3074
  }
2926
3075
  return out;
2927
3076
  },
2928
- describe: async (kwargs = {}) => {
2929
- const rpc = await findChannelOwner(kwargs.channel);
3077
+ // Accept a plain `?channel=<id>` GET (positional, like `skill`) AS WELL AS
3078
+ // the kwargs-wrapped form. The Hypha HTTP gateway only maps a `?channel=`
3079
+ // query onto a param literally named `channel`; a kwargs-only signature left
3080
+ // `channel` undefined on GET, so describe() 404'd on every channel that
3081
+ // list() returned. Normalize both the string and `{channel}` object shapes.
3082
+ describe: async (channel) => {
3083
+ const id = typeof channel === "string" ? channel : channel?.channel;
3084
+ const rpc = id ? await findChannelOwner(id) : void 0;
2930
3085
  if (!rpc?.channelDescribe) return { error: "channel not found" };
2931
- return rpc.channelDescribe(kwargs.channel);
3086
+ return rpc.channelDescribe(id);
2932
3087
  },
2933
3088
  // Clean GET endpoint for the self-contained skill markdown. Param is
2934
3089
  // named `channel` (not `kwargs`) so the Hypha HTTP gateway maps a plain
@@ -2958,9 +3113,26 @@ ${d?.error || "not found"}`;
2958
3113
  trackInbound();
2959
3114
  const res = resolveChannel(kwargs.channel, kwargs.session);
2960
3115
  if ("error" in res) return { error: res.error };
2961
- if (res.tier === "stateless") return { error: "stateless channels have no async replies (no persistent session)" };
2962
- if ("stopped" in res) return { error: `session ${res.sessionId.slice(0, 8)} is stopped` };
2963
- return res.rpc.channelReceive({ channel: kwargs.channel, key: kwargs.key, from: kwargs.from, cursor: kwargs.cursor, correlationId: kwargs.correlationId, wait: kwargs.wait }, context);
3116
+ if (res.tier === "session" && !("stopped" in res)) {
3117
+ return res.rpc.channelReceive({ channel: kwargs.channel, key: kwargs.key, from: kwargs.from, cursor: kwargs.cursor, correlationId: kwargs.correlationId, wait: kwargs.wait }, context);
3118
+ }
3119
+ const { c, dir } = res;
3120
+ if (c.reply?.mode !== "queue") {
3121
+ return { error: "this channel has no async replies (not a queue-mode channel)" };
3122
+ }
3123
+ const u = context?.user;
3124
+ const r = resolveSender(c, {
3125
+ key: kwargs.key,
3126
+ from: kwargs.from,
3127
+ hyphaUser: u && u.is_anonymous !== true ? u.email || u.id : void 0,
3128
+ hyphaAnonymous: u?.is_anonymous === true,
3129
+ hyphaWorkspace: u?.scope?.current_workspace
3130
+ });
3131
+ if (r.error || !r.sender) return { error: r.error || "unauthorized" };
3132
+ const cursor = Math.max(0, Number(kwargs.cursor) || 0);
3133
+ const outbox = new ChannelOutbox(dir);
3134
+ const replies = outbox.since(c.id, cursor, r.sender.name, kwargs.correlationId);
3135
+ return { ok: true, replies, cursor: outbox.cursor(c.id) };
2964
3136
  }
2965
3137
  },
2966
3138
  { overwrite: true }
@@ -3202,138 +3374,6 @@ class RoutineRunner {
3202
3374
  }
3203
3375
  }
3204
3376
 
3205
- const MAX_PER_CHANNEL = 200;
3206
- const TTL_MS = 60 * 60 * 1e3;
3207
- class ChannelOutbox {
3208
- file;
3209
- byChannel = /* @__PURE__ */ new Map();
3210
- seqByChannel = /* @__PURE__ */ new Map();
3211
- emitter = new EventEmitter();
3212
- constructor(projectDir) {
3213
- const dir = join(projectDir, ".svamp", "channels");
3214
- this.file = join(dir, "_outbox.jsonl");
3215
- this.emitter.setMaxListeners(0);
3216
- try {
3217
- mkdirSync(dir, { recursive: true });
3218
- } catch {
3219
- }
3220
- this._load();
3221
- }
3222
- _load() {
3223
- if (!existsSync(this.file)) return;
3224
- const cutoff = Date.now() - TTL_MS;
3225
- try {
3226
- for (const line of readFileSync(this.file, "utf8").split("\n")) {
3227
- if (!line.trim()) continue;
3228
- let rec = null;
3229
- try {
3230
- rec = JSON.parse(line);
3231
- } catch {
3232
- continue;
3233
- }
3234
- if (!rec || rec.ts < cutoff) continue;
3235
- const { channelId, ...reply } = rec;
3236
- const arr = this.byChannel.get(channelId) || [];
3237
- arr.push(reply);
3238
- this.byChannel.set(channelId, arr);
3239
- this.seqByChannel.set(channelId, Math.max(this.seqByChannel.get(channelId) || 0, reply.seq));
3240
- }
3241
- for (const [ch, arr] of this.byChannel) if (arr.length > MAX_PER_CHANNEL) this.byChannel.set(ch, arr.slice(-MAX_PER_CHANNEL));
3242
- } catch {
3243
- }
3244
- }
3245
- _evict(channelId) {
3246
- const cutoff = Date.now() - TTL_MS;
3247
- let arr = this.byChannel.get(channelId);
3248
- if (!arr) return;
3249
- if (arr.some((r) => r.ts < cutoff)) arr = arr.filter((r) => r.ts >= cutoff);
3250
- if (arr.length > MAX_PER_CHANNEL) arr = arr.slice(-MAX_PER_CHANNEL);
3251
- this.byChannel.set(channelId, arr);
3252
- }
3253
- /** Append a reply addressed to `to`. Assigns seq + ts, persists, and wakes waiters. */
3254
- append(channelId, r) {
3255
- const seq = (this.seqByChannel.get(channelId) || 0) + 1;
3256
- this.seqByChannel.set(channelId, seq);
3257
- const reply = { seq, ts: Date.now(), to: r.to, body: r.body, ...r.correlationId ? { correlationId: r.correlationId } : {} };
3258
- const arr = this.byChannel.get(channelId) || [];
3259
- arr.push(reply);
3260
- this.byChannel.set(channelId, arr);
3261
- this._evict(channelId);
3262
- try {
3263
- appendFileSync(this.file, JSON.stringify({ channelId, ...reply }) + "\n");
3264
- } catch {
3265
- try {
3266
- mkdirSync(join(this.file, ".."), { recursive: true });
3267
- appendFileSync(this.file, JSON.stringify({ channelId, ...reply }) + "\n");
3268
- } catch {
3269
- }
3270
- }
3271
- this.emitter.emit(channelId, reply);
3272
- return reply;
3273
- }
3274
- /** Replies for `to` on `channelId` with seq > cursor (optionally one correlationId). */
3275
- since(channelId, cursor, to, correlationId) {
3276
- this._evict(channelId);
3277
- return (this.byChannel.get(channelId) || []).filter((r) => r.seq > cursor && r.to === to && (!correlationId || r.correlationId === correlationId));
3278
- }
3279
- /** Highest seq on a channel (the cursor a caller gets back). */
3280
- cursor(channelId) {
3281
- return this.seqByChannel.get(channelId) || 0;
3282
- }
3283
- /**
3284
- * Long-poll: resolve immediately if there are replies after `cursor`, else wait for
3285
- * the next append (filtered to this channel + `to`) or `timeoutMs`, then return.
3286
- */
3287
- wait(channelId, cursor, to, timeoutMs, correlationId) {
3288
- const ready = this.since(channelId, cursor, to, correlationId);
3289
- if (ready.length) return Promise.resolve({ replies: ready, cursor: this.cursor(channelId) });
3290
- return new Promise((resolve) => {
3291
- const onReply = (r) => {
3292
- if (r.to !== to || correlationId && r.correlationId !== correlationId) return;
3293
- cleanup();
3294
- resolve({ replies: this.since(channelId, cursor, to, correlationId), cursor: this.cursor(channelId) });
3295
- };
3296
- const timer = setTimeout(() => {
3297
- cleanup();
3298
- resolve({ replies: [], cursor: this.cursor(channelId) });
3299
- }, Math.max(0, timeoutMs));
3300
- const cleanup = () => {
3301
- clearTimeout(timer);
3302
- this.emitter.off(channelId, onReply);
3303
- };
3304
- this.emitter.on(channelId, onReply);
3305
- });
3306
- }
3307
- /** Push subscription for SSE: calls onReply for each new reply addressed to `to`. */
3308
- subscribe(channelId, to, onReply) {
3309
- const handler = (r) => {
3310
- if (r.to === to) onReply(r);
3311
- };
3312
- this.emitter.on(channelId, handler);
3313
- return () => this.emitter.off(channelId, handler);
3314
- }
3315
- /** Drop a channel's outbox (on channel delete) — best-effort rewrite of the log. */
3316
- purge(channelId) {
3317
- this.byChannel.delete(channelId);
3318
- this.seqByChannel.delete(channelId);
3319
- if (!existsSync(this.file)) return;
3320
- try {
3321
- const kept = readFileSync(this.file, "utf8").split("\n").filter((l) => {
3322
- if (!l.trim()) return false;
3323
- try {
3324
- return JSON.parse(l).channelId !== channelId;
3325
- } catch {
3326
- return false;
3327
- }
3328
- });
3329
- const tmp = this.file + ".tmp";
3330
- writeFileSync(tmp, kept.join("\n") + (kept.length ? "\n" : ""));
3331
- renameSync(tmp, this.file);
3332
- } catch {
3333
- }
3334
- }
3335
- }
3336
-
3337
3377
  function channelPublicView(c) {
3338
3378
  return { id: c.id, name: c.name, description: c.description, identity: { mode: c.identity?.mode }, action: c.action?.kind, bind: c.bind };
3339
3379
  }
@@ -3439,6 +3479,48 @@ function appendMessage(messagesDir, sessionId, msg) {
3439
3479
  console.error(`[HYPHA SESSION ${sessionId}] Failed to persist message: ${err?.message ?? err}`);
3440
3480
  }
3441
3481
  }
3482
+ function editableInfo(msg) {
3483
+ const c = msg?.content;
3484
+ if (!c) return null;
3485
+ if (c.role === "user" && c.content?.type === "text") {
3486
+ return { role: "user", text: String(c.content.text ?? "") };
3487
+ }
3488
+ if (c.role === "agent" && c.content?.type === "output") {
3489
+ const data = c.content.data;
3490
+ if (data?.type === "assistant" && Array.isArray(data?.message?.content)) {
3491
+ const textBlocks = data.message.content.filter((b) => b && b.type === "text" && typeof b.text === "string");
3492
+ if (textBlocks.length >= 1) {
3493
+ return { role: "assistant", text: textBlocks.map((b) => b.text).join("\n") };
3494
+ }
3495
+ }
3496
+ if (data?.type === "user" && typeof data?.message?.content === "string") {
3497
+ return { role: "user", text: data.message.content };
3498
+ }
3499
+ }
3500
+ return null;
3501
+ }
3502
+ function patchStoredText(msg, newText) {
3503
+ const c = msg?.content;
3504
+ if (c?.role === "user" && c.content?.type === "text") {
3505
+ c.content.text = newText;
3506
+ return true;
3507
+ }
3508
+ if (c?.role === "agent" && c.content?.type === "output") {
3509
+ const data = c.content.data;
3510
+ if (data?.type === "user" && typeof data?.message?.content === "string") {
3511
+ data.message.content = newText;
3512
+ return true;
3513
+ }
3514
+ if (data?.type === "assistant" && Array.isArray(data?.message?.content)) {
3515
+ const textBlocks = data.message.content.filter((b) => b && b.type === "text" && typeof b.text === "string");
3516
+ if (textBlocks.length === 1) {
3517
+ textBlocks[0].text = newText;
3518
+ return true;
3519
+ }
3520
+ }
3521
+ }
3522
+ return false;
3523
+ }
3442
3524
  function createSessionStore(server, sessionId, initialMetadata, initialAgentState, callbacks, options) {
3443
3525
  const messages = options?.messagesDir ? loadMessages(options.messagesDir) : [];
3444
3526
  let nextSeq = messages.length > 0 ? messages[messages.length - 1].seq + 1 : 1;
@@ -3584,6 +3666,100 @@ function createSessionStore(server, sessionId, initialMetadata, initialAgentStat
3584
3666
  notifyListeners({ type: "update-session", sessionId, metadata: { value: metadata, version: metadataVersion } });
3585
3667
  callbacks.onMetadataUpdate?.(metadata);
3586
3668
  };
3669
+ const forkClaudePrint = async (prompt, claudeSessionId, cwd, maxTurns = 6) => {
3670
+ const { spawn } = await import('child_process');
3671
+ return new Promise((resolve) => {
3672
+ const child = spawn("claude", [
3673
+ "--print",
3674
+ prompt,
3675
+ "--resume",
3676
+ claudeSessionId,
3677
+ "--fork-session",
3678
+ "--no-session-persistence",
3679
+ "--permission-mode",
3680
+ "bypassPermissions",
3681
+ "--output-format",
3682
+ "json",
3683
+ "--max-turns",
3684
+ String(maxTurns)
3685
+ ], {
3686
+ cwd,
3687
+ timeout: 6e4,
3688
+ stdio: ["ignore", "pipe", "pipe"],
3689
+ env: { ...process.env, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1" }
3690
+ });
3691
+ let stdout = "";
3692
+ let stderr = "";
3693
+ child.stdout?.on("data", (d) => {
3694
+ stdout += d.toString();
3695
+ });
3696
+ child.stderr?.on("data", (d) => {
3697
+ stderr += d.toString();
3698
+ });
3699
+ child.on("close", (code) => {
3700
+ if (code !== 0 && !stdout) {
3701
+ resolve({ success: false, error: stderr || `claude exited with code ${code}` });
3702
+ return;
3703
+ }
3704
+ try {
3705
+ const result = JSON.parse(stdout);
3706
+ resolve({ success: true, text: result.result || result.text || stdout });
3707
+ } catch {
3708
+ resolve({ success: true, text: stdout.trim() });
3709
+ }
3710
+ });
3711
+ child.on("error", (err) => resolve({ success: false, error: err.message }));
3712
+ });
3713
+ };
3714
+ const performEdit = async (target, newText) => {
3715
+ if (!callbacks.onEditTranscript) return { success: false, message: "Editing history is not supported for this session." };
3716
+ const info = editableInfo(target);
3717
+ if (!info) return { success: false, message: "This message cannot be edited." };
3718
+ const text = typeof newText === "string" ? newText : "";
3719
+ if (!text.trim()) return { success: false, message: "New text is empty." };
3720
+ let fromEnd = 0;
3721
+ for (const m of messages) {
3722
+ if (m.seq <= target.seq) continue;
3723
+ const i2 = editableInfo(m);
3724
+ if (i2 && i2.role === info.role) fromEnd++;
3725
+ }
3726
+ const anchorSeq = target.seq;
3727
+ const applyStoreA = () => {
3728
+ for (let i = messages.length - 1; i >= 0; i--) {
3729
+ if (messages[i].seq > anchorSeq) messages.splice(i, 1);
3730
+ }
3731
+ const tgt = messages.find((m) => m.seq === anchorSeq);
3732
+ if (tgt) {
3733
+ patchStoredText(tgt, text);
3734
+ tgt.updatedAt = Date.now();
3735
+ }
3736
+ nextSeq = anchorSeq + 1;
3737
+ if (options?.messagesDir) {
3738
+ const filePath = join(options.messagesDir, "messages.jsonl");
3739
+ try {
3740
+ const lines = existsSync(filePath) ? readFileSync(filePath, "utf-8").split("\n").filter((l) => l.trim()) : [];
3741
+ const kept = [];
3742
+ for (const line of lines) {
3743
+ let m;
3744
+ try {
3745
+ m = JSON.parse(line);
3746
+ } catch {
3747
+ continue;
3748
+ }
3749
+ if (typeof m.seq === "number" && m.seq > anchorSeq) continue;
3750
+ kept.push(m.seq === anchorSeq && tgt ? JSON.stringify(tgt) : line);
3751
+ }
3752
+ const tmp = `${filePath}.tmp-${process.pid}`;
3753
+ writeFileSync(tmp, kept.length ? kept.join("\n") + "\n" : "");
3754
+ renameSync(tmp, filePath);
3755
+ } catch (err) {
3756
+ console.error(`[HYPHA SESSION ${sessionId}] Store A rewrite failed: ${err?.message ?? err}`);
3757
+ }
3758
+ }
3759
+ notifyListeners({ type: "messages-edited", sessionId, latestSeq: nextSeq - 1 });
3760
+ };
3761
+ return callbacks.onEditTranscript({ role: info.role, fromEnd, oldText: info.text, newText: text, applyStoreA });
3762
+ };
3587
3763
  const rpcHandlers = {
3588
3764
  // ── Messages ──
3589
3765
  getMessages: async (afterSeq, limit, context) => {
@@ -3870,7 +4046,7 @@ function createSessionStore(server, sessionId, initialMetadata, initialAgentStat
3870
4046
  const result = await dispatchAgentChannel({ channel: c, sender: r.sender, message: rendered, deps, env: process.env });
3871
4047
  channelStore.recordCall(c.id, { sender: r.sender.name, verified: r.sender.verified, callId, outcome: result.status });
3872
4048
  syncChannelsToMetadata();
3873
- return { ok: result.status === "completed", call_id: callId, status: result.status, reply: result.reply, tool_calls: result.toolCalls, error: result.error };
4049
+ return { ok: result.status === "completed", call_id: callId, correlationId: callId, status: result.status, reply: result.reply, tool_calls: result.toolCalls, error: result.error };
3874
4050
  }
3875
4051
  if (c.action?.kind === "loop") return { error: "loop channels are served by the channel server, not channelSend" };
3876
4052
  const queue = c.reply?.mode === "queue";
@@ -4263,59 +4439,72 @@ function createSessionStore(server, sessionId, initialMetadata, initialAgentStat
4263
4439
  if (!claudeSessionId) {
4264
4440
  return { success: false, error: "No active Claude session to query" };
4265
4441
  }
4266
- const { spawn } = await import('child_process');
4267
- const cwd = metadata.path || process.cwd();
4268
- return new Promise((resolve) => {
4269
- const args = [
4270
- "--print",
4271
- question,
4272
- "--resume",
4273
- claudeSessionId,
4274
- "--fork-session",
4275
- "--no-session-persistence",
4276
- "--permission-mode",
4277
- "bypassPermissions",
4278
- "--output-format",
4279
- "json",
4280
- // Allow a few turns so questions that need a look-up (read a file,
4281
- // check state) can make the tool call AND then answer. With 1 turn,
4282
- // any tool-using question died instantly with error_max_turns
4283
- // ("couldn't answer quickly"). The 60s subprocess timeout still bounds it.
4284
- "--max-turns",
4285
- "6"
4286
- ];
4287
- const child = spawn("claude", args, {
4288
- cwd,
4289
- timeout: 6e4,
4290
- // Ignore stdin: --print otherwise waits ~3s for piped input ("no stdin
4291
- // data received in 3s, proceeding without it") — pure latency per /btw.
4292
- stdio: ["ignore", "pipe", "pipe"],
4293
- env: { ...process.env, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1" }
4294
- });
4295
- let stdout = "";
4296
- let stderr = "";
4297
- child.stdout?.on("data", (d) => {
4298
- stdout += d.toString();
4299
- });
4300
- child.stderr?.on("data", (d) => {
4301
- stderr += d.toString();
4302
- });
4303
- child.on("close", (code) => {
4304
- if (code !== 0 && !stdout) {
4305
- resolve({ success: false, error: stderr || `claude exited with code ${code}` });
4306
- return;
4307
- }
4308
- try {
4309
- const result = JSON.parse(stdout);
4310
- resolve({ success: true, answer: result.result || result.text || stdout });
4311
- } catch {
4312
- resolve({ success: true, answer: stdout.trim() });
4313
- }
4314
- });
4315
- child.on("error", (err) => {
4316
- resolve({ success: false, error: err.message });
4317
- });
4318
- });
4442
+ const r = await forkClaudePrint(question, claudeSessionId, metadata.path || process.cwd());
4443
+ return r.success ? { success: true, answer: r.text } : { success: false, error: r.error };
4444
+ },
4445
+ editMessage: async (messageId, newText, context) => {
4446
+ authorizeRequest(context, metadata.sharing, "admin");
4447
+ if (!messageId) return { success: false, message: "messageId is required." };
4448
+ const target = messages.find((m) => m.id === messageId);
4449
+ if (!target) {
4450
+ return { success: false, message: "Message not found in the active window \u2014 only recent messages can be edited." };
4451
+ }
4452
+ return performEdit(target, newText);
4453
+ },
4454
+ refineLastReply: async (instruction, context) => {
4455
+ authorizeRequest(context, metadata.sharing, "admin");
4456
+ if (!instruction || typeof instruction !== "string") {
4457
+ return { success: false, message: "An instruction is required." };
4458
+ }
4459
+ const claudeSessionId = metadata.claudeSessionId;
4460
+ if (!claudeSessionId) return { success: false, message: "No active Claude session to refine." };
4461
+ let last;
4462
+ for (let i = messages.length - 1; i >= 0; i--) {
4463
+ const info = editableInfo(messages[i]);
4464
+ if (info && info.role === "assistant") {
4465
+ last = messages[i];
4466
+ break;
4467
+ }
4468
+ }
4469
+ if (!last) return { success: false, message: "No assistant reply to refine." };
4470
+ const oldText = editableInfo(last).text;
4471
+ const prompt = `You previously wrote this reply:
4472
+
4473
+ <previous_reply>
4474
+ ${oldText}
4475
+ </previous_reply>
4476
+
4477
+ Revise it according to this instruction:
4478
+ <instruction>
4479
+ ${instruction}
4480
+ </instruction>
4481
+
4482
+ Output ONLY the full revised reply text \u2014 no preamble, no commentary, no surrounding quotes or code fences.`;
4483
+ const revised = await forkClaudePrint(prompt, claudeSessionId, metadata.path || process.cwd());
4484
+ if (!revised.success || !revised.text?.trim()) {
4485
+ return { success: false, message: revised.error || "Failed to generate a revised reply." };
4486
+ }
4487
+ return performEdit(last, revised.text.trim());
4488
+ },
4489
+ undoLastEdit: async (context) => {
4490
+ authorizeRequest(context, metadata.sharing, "admin");
4491
+ if (!callbacks.onUndoEdit) return { success: false, message: "Undo is not supported for this session." };
4492
+ const restoreStoreA = () => {
4493
+ if (!options?.messagesDir) return;
4494
+ try {
4495
+ const undoBackup = join(options.messagesDir, "undo", "messages.jsonl");
4496
+ const target = join(options.messagesDir, "messages.jsonl");
4497
+ if (existsSync(undoBackup)) writeFileSync(target, readFileSync(undoBackup));
4498
+ const reloaded = loadMessages(options.messagesDir, sessionId);
4499
+ messages.length = 0;
4500
+ messages.push(...reloaded);
4501
+ nextSeq = messages.length ? messages[messages.length - 1].seq + 1 : 1;
4502
+ notifyListeners({ type: "messages-edited", sessionId, latestSeq: nextSeq - 1 });
4503
+ } catch (err) {
4504
+ console.error(`[HYPHA SESSION ${sessionId}] Undo Store A restore failed: ${err?.message ?? err}`);
4505
+ }
4506
+ };
4507
+ return callbacks.onUndoEdit({ restoreStoreA });
4319
4508
  }
4320
4509
  };
4321
4510
  const store = {
@@ -7149,6 +7338,171 @@ var GeminiTransport$1 = /*#__PURE__*/Object.freeze({
7149
7338
  GeminiTransport: GeminiTransport
7150
7339
  });
7151
7340
 
7341
+ function resolveTranscriptPath(cwd, claudeSessionId) {
7342
+ let real;
7343
+ try {
7344
+ real = realpathSync(cwd);
7345
+ } catch {
7346
+ real = resolve(cwd);
7347
+ }
7348
+ const projectId = real.replace(/[^a-zA-Z0-9-]/g, "-");
7349
+ const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR || join$1(os$1.homedir(), ".claude");
7350
+ return join$1(claudeConfigDir, "projects", projectId, `${claudeSessionId}.jsonl`);
7351
+ }
7352
+ function normalizeText(t) {
7353
+ return (t || "").replace(/\s+/g, " ").trim();
7354
+ }
7355
+ function parseTranscript(file) {
7356
+ const content = readFileSync$1(file, "utf-8");
7357
+ return content.split("\n").filter((l) => l.trim().length > 0).map((raw) => {
7358
+ let obj = null;
7359
+ try {
7360
+ obj = JSON.parse(raw);
7361
+ } catch {
7362
+ }
7363
+ return { raw, obj };
7364
+ });
7365
+ }
7366
+ function assistantText(obj) {
7367
+ const content = obj?.message?.content;
7368
+ if (typeof content === "string") return content;
7369
+ if (!Array.isArray(content)) return "";
7370
+ return content.filter((b) => b && b.type === "text" && typeof b.text === "string").map((b) => b.text).join("\n");
7371
+ }
7372
+ function isRealUserRecord(obj) {
7373
+ return !!obj && obj.type === "user" && !obj.isSidechain && typeof obj?.message?.content === "string";
7374
+ }
7375
+ function isAgentTextRecord(obj) {
7376
+ if (!obj || obj.type !== "assistant" || obj.isSidechain) return false;
7377
+ const content = obj?.message?.content;
7378
+ if (!Array.isArray(content)) return false;
7379
+ return content.some((b) => b && b.type === "text" && typeof b.text === "string");
7380
+ }
7381
+ function isRoleTextRecord(obj, role) {
7382
+ return role === "user" ? isRealUserRecord(obj) : isAgentTextRecord(obj);
7383
+ }
7384
+ function recordText(obj, role) {
7385
+ return role === "user" ? String(obj?.message?.content ?? "") : assistantText(obj);
7386
+ }
7387
+ function findAnchorIndex(lines, anchor) {
7388
+ const roleIdx = [];
7389
+ for (let i = 0; i < lines.length; i++) {
7390
+ if (isRoleTextRecord(lines[i].obj, anchor.role)) roleIdx.push(i);
7391
+ }
7392
+ if (roleIdx.length === 0) {
7393
+ return { ok: false, reason: `no ${anchor.role} text records in transcript` };
7394
+ }
7395
+ const pos = roleIdx.length - 1 - anchor.fromEnd;
7396
+ if (pos < 0 || pos >= roleIdx.length) {
7397
+ return { ok: false, reason: `anchor position out of range (fromEnd=${anchor.fromEnd}, have ${roleIdx.length})` };
7398
+ }
7399
+ const index = roleIdx[pos];
7400
+ const got = normalizeText(recordText(lines[index].obj, anchor.role));
7401
+ const want = normalizeText(anchor.oldText);
7402
+ if (got !== want) {
7403
+ if (!(want.length > 0 && got.startsWith(want))) {
7404
+ return { ok: false, reason: "anchor text mismatch \u2014 stores out of sync, refusing to edit" };
7405
+ }
7406
+ }
7407
+ return { ok: true, index };
7408
+ }
7409
+ function orphanCheckObjs(objs) {
7410
+ const toolUseIds = /* @__PURE__ */ new Set();
7411
+ const toolResultIds = /* @__PURE__ */ new Set();
7412
+ for (const obj of objs) {
7413
+ const content = obj?.message?.content;
7414
+ if (!Array.isArray(content)) continue;
7415
+ for (const b of content) {
7416
+ if (b?.type === "tool_use" && b.id) toolUseIds.add(b.id);
7417
+ if (b?.type === "tool_result" && b.tool_use_id) toolResultIds.add(b.tool_use_id);
7418
+ }
7419
+ }
7420
+ for (const id of toolUseIds) {
7421
+ if (!toolResultIds.has(id)) return true;
7422
+ }
7423
+ return false;
7424
+ }
7425
+ function applyTranscriptEdit(file, index, newText) {
7426
+ if (!existsSync$1(file)) return { ok: false, reason: `transcript not found: ${file}` };
7427
+ const lines = parseTranscript(file);
7428
+ if (index < 0 || index >= lines.length) return { ok: false, reason: "index out of range" };
7429
+ const target = lines[index].obj;
7430
+ if (!target) return { ok: false, reason: "target line not parseable" };
7431
+ if (target.type === "assistant") {
7432
+ const content = target?.message?.content;
7433
+ if (!Array.isArray(content)) return { ok: false, reason: "assistant content is not a block array" };
7434
+ const textBlocks = content.filter((b) => b && b.type === "text" && typeof b.text === "string");
7435
+ if (textBlocks.length !== 1) {
7436
+ return { ok: false, reason: `expected exactly 1 text block, found ${textBlocks.length}` };
7437
+ }
7438
+ textBlocks[0].text = newText;
7439
+ } else if (target.type === "user") {
7440
+ if (typeof target?.message?.content !== "string") {
7441
+ return { ok: false, reason: "user content is not a string" };
7442
+ }
7443
+ target.message.content = newText;
7444
+ } else {
7445
+ return { ok: false, reason: `unsupported record type: ${target.type}` };
7446
+ }
7447
+ const keptObjs = [];
7448
+ const kept = [];
7449
+ for (let i = 0; i <= index; i++) {
7450
+ kept.push(i === index ? JSON.stringify(target) : lines[i].raw);
7451
+ keptObjs.push(lines[i].obj);
7452
+ }
7453
+ if (orphanCheckObjs(keptObjs)) {
7454
+ return { ok: false, reason: "edit would orphan a tool_use (truncation drops its tool_result)" };
7455
+ }
7456
+ const out = kept.join("\n") + "\n";
7457
+ const tmp = `${file}.tmp-${process.pid}`;
7458
+ try {
7459
+ writeFileSync$1(tmp, out);
7460
+ renameSync$1(tmp, file);
7461
+ } catch (err) {
7462
+ return { ok: false, reason: `write failed: ${err?.message ?? err}` };
7463
+ }
7464
+ return { ok: true, truncatedAfter: index, totalBefore: lines.length };
7465
+ }
7466
+ function saveUndoSnapshot(undoDir, transcriptFile, messagesFile, meta) {
7467
+ mkdirSync$1(undoDir, { recursive: true });
7468
+ if (existsSync$1(transcriptFile)) copyFileSync(transcriptFile, join$1(undoDir, "transcript.jsonl"));
7469
+ if (existsSync$1(messagesFile)) copyFileSync(messagesFile, join$1(undoDir, "messages.jsonl"));
7470
+ writeFileSync$1(join$1(undoDir, "meta.json"), JSON.stringify(meta));
7471
+ }
7472
+ function readUndoMeta(undoDir) {
7473
+ const p = join$1(undoDir, "meta.json");
7474
+ if (!existsSync$1(p)) return null;
7475
+ try {
7476
+ return JSON.parse(readFileSync$1(p, "utf-8"));
7477
+ } catch {
7478
+ return null;
7479
+ }
7480
+ }
7481
+ function restoreTranscriptFromUndo(undoDir, targetFile) {
7482
+ const backup = join$1(undoDir, "transcript.jsonl");
7483
+ if (!existsSync$1(backup)) return false;
7484
+ const tmp = `${targetFile}.tmp-${process.pid}`;
7485
+ writeFileSync$1(tmp, readFileSync$1(backup));
7486
+ renameSync$1(tmp, targetFile);
7487
+ return true;
7488
+ }
7489
+ function clearUndoSnapshot(undoDir) {
7490
+ try {
7491
+ rmSync$1(undoDir, { recursive: true, force: true });
7492
+ } catch {
7493
+ }
7494
+ }
7495
+ function transcriptSessionIdMatches(file, expectedSessionId) {
7496
+ if (!existsSync$1(file)) return false;
7497
+ const lines = parseTranscript(file);
7498
+ for (const { obj } of lines) {
7499
+ if (obj && typeof obj.sessionId === "string") {
7500
+ return obj.sessionId === expectedSessionId;
7501
+ }
7502
+ }
7503
+ return false;
7504
+ }
7505
+
7152
7506
  const execFileAsync = promisify$1(execFile$1);
7153
7507
  const SVAMP_TOOLS_DIR = join(homedir(), ".svamp", "tools");
7154
7508
  const SVAMP_BIN_DIR = join(SVAMP_TOOLS_DIR, "bin");
@@ -10451,7 +10805,7 @@ async function startDaemon(options) {
10451
10805
  const list = loadExposedTunnels().filter((t) => t.name !== name);
10452
10806
  saveExposedTunnels(list);
10453
10807
  }
10454
- const { ServeManager } = await import('./serveManager-DX5SdhTq.mjs');
10808
+ const { ServeManager } = await import('./serveManager-BnwAe-VX.mjs');
10455
10809
  const serveManager = new ServeManager(SVAMP_HOME, (msg) => logger.log(`[SERVE] ${msg}`), hyphaServerUrl);
10456
10810
  ensureAutoInstalledSkills(logger).catch(() => {
10457
10811
  });
@@ -11503,12 +11857,13 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
11503
11857
  }
11504
11858
  return child;
11505
11859
  };
11506
- const restartClaudeHandler = async () => {
11860
+ const restartClaudeHandler = async (opts) => {
11507
11861
  logger.log(`[Session ${sessionId}] Restart Claude requested`);
11508
11862
  if (isRestartingClaude || isSwitchingMode) {
11509
11863
  return { success: false, message: "Restart already in progress." };
11510
11864
  }
11511
11865
  isRestartingClaude = true;
11866
+ let beforeRespawnError;
11512
11867
  try {
11513
11868
  if (claudeProcess && claudeProcess.exitCode === null) {
11514
11869
  isKillingClaude = true;
@@ -11520,6 +11875,14 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
11520
11875
  if (trackedSession?.stopped) {
11521
11876
  return { success: false, message: "Session was stopped during restart." };
11522
11877
  }
11878
+ if (opts?.beforeRespawn) {
11879
+ try {
11880
+ await opts.beforeRespawn();
11881
+ } catch (hookErr) {
11882
+ beforeRespawnError = hookErr?.message ?? String(hookErr);
11883
+ logger.log(`[Session ${sessionId}] beforeRespawn hook failed: ${beforeRespawnError}`);
11884
+ }
11885
+ }
11523
11886
  if (claudeResumeId) {
11524
11887
  if (!stagedCredentials && shouldIsolateSession()) {
11525
11888
  try {
@@ -11532,6 +11895,9 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
11532
11895
  spawnClaude(void 0, { permissionMode: currentPermissionMode });
11533
11896
  sessionService.updateMetadata(sessionMetadata);
11534
11897
  logger.log(`[Session ${sessionId}] Claude respawned with --resume ${claudeResumeId}`);
11898
+ if (beforeRespawnError) {
11899
+ return { success: false, message: `Edit failed (session restored): ${beforeRespawnError}` };
11900
+ }
11535
11901
  return { success: true, message: "Claude process restarted successfully." };
11536
11902
  } else {
11537
11903
  logger.log(`[Session ${sessionId}] No resume ID \u2014 cannot restart`);
@@ -11545,6 +11911,26 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
11545
11911
  isRestartingClaude = false;
11546
11912
  }
11547
11913
  };
11914
+ const editHistoryIdleGuard = () => {
11915
+ const lifecycle = sessionMetadata.lifecycleState;
11916
+ if (lifecycle === "running" || lifecycle === "restarting") {
11917
+ return { success: false, message: "Cannot edit while the agent is working \u2014 wait until it is idle." };
11918
+ }
11919
+ if (isRestartingClaude || isSwitchingMode || isKillingClaude) {
11920
+ return { success: false, message: "Cannot edit during a restart/mode switch \u2014 try again in a moment." };
11921
+ }
11922
+ const queueLen = sessionMetadata.messageQueue?.length ?? 0;
11923
+ if (queueLen > 0) {
11924
+ return { success: false, message: "Cannot edit while messages are queued." };
11925
+ }
11926
+ if (isLoopActiveForSession(directory, sessionId)) {
11927
+ return { success: false, message: "Cannot edit history while a loop is active." };
11928
+ }
11929
+ if (!claudeResumeId) {
11930
+ return { success: false, message: "No Claude transcript to edit yet." };
11931
+ }
11932
+ return null;
11933
+ };
11548
11934
  if (shouldIsolateSession()) {
11549
11935
  try {
11550
11936
  stagedCredentials = await stageCredentialsForSharing(sessionId);
@@ -11565,6 +11951,7 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
11565
11951
  logger.log(`[Session ${sessionId}] User message received`);
11566
11952
  userMessagePending = true;
11567
11953
  turnInitiatedByUser = true;
11954
+ clearUndoSnapshot(join$1(getSessionDir(directory, sessionId), "undo"));
11568
11955
  let text;
11569
11956
  let msgMeta = meta;
11570
11957
  try {
@@ -11784,6 +12171,86 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
11784
12171
  }
11785
12172
  },
11786
12173
  onRestartClaude: restartClaudeHandler,
12174
+ onEditTranscript: async ({ role, fromEnd, oldText, newText, applyStoreA }) => {
12175
+ const gate = editHistoryIdleGuard();
12176
+ if (gate) return gate;
12177
+ logger.log(`[Session ${sessionId}] Edit history: role=${role} fromEnd=${fromEnd} \u2192 kill + rewrite + resume`);
12178
+ const result = await restartClaudeHandler({
12179
+ beforeRespawn: async () => {
12180
+ const file = resolveTranscriptPath(directory, claudeResumeId);
12181
+ if (!transcriptSessionIdMatches(file, claudeResumeId)) {
12182
+ throw new Error("transcript file id mismatch (session rotated) \u2014 aborting edit");
12183
+ }
12184
+ const lines = parseTranscript(file);
12185
+ const anchor = findAnchorIndex(lines, { role, fromEnd, oldText });
12186
+ if (!anchor.ok) throw new Error(anchor.reason);
12187
+ const sessDir = getSessionDir(directory, sessionId);
12188
+ saveUndoSnapshot(join$1(sessDir, "undo"), file, join$1(sessDir, "messages.jsonl"), {
12189
+ preEditClaudeSessionId: claudeResumeId,
12190
+ createdAt: Date.now(),
12191
+ label: "edit"
12192
+ });
12193
+ const edit = applyTranscriptEdit(file, anchor.index, newText);
12194
+ if (!edit.ok) throw new Error(edit.reason);
12195
+ applyStoreA();
12196
+ logger.log(`[Session ${sessionId}] Edit applied \u2014 transcript truncated after line ${anchor.index}`);
12197
+ }
12198
+ });
12199
+ if (result.success && claudeResumeId && !trackedSession.stopped) {
12200
+ saveSession({
12201
+ sessionId,
12202
+ directory,
12203
+ claudeResumeId,
12204
+ permissionMode: currentPermissionMode,
12205
+ spawnMeta: lastSpawnMeta,
12206
+ metadata: sessionMetadata,
12207
+ createdAt: sessionCreatedAt,
12208
+ machineId,
12209
+ wasProcessing: false
12210
+ });
12211
+ artifactSync.syncSession(sessionId, getSessionDir(directory, sessionId), sessionMetadata, machineId).catch(() => {
12212
+ });
12213
+ }
12214
+ return result;
12215
+ },
12216
+ onUndoEdit: async ({ restoreStoreA }) => {
12217
+ const gate = editHistoryIdleGuard();
12218
+ if (gate) return gate;
12219
+ const undoDir = join$1(getSessionDir(directory, sessionId), "undo");
12220
+ const meta = readUndoMeta(undoDir);
12221
+ if (!meta) return { success: false, message: "Nothing to undo." };
12222
+ logger.log(`[Session ${sessionId}] Undo edit \u2192 restore pre-edit transcript ${meta.preEditClaudeSessionId} + messages, resume`);
12223
+ const result = await restartClaudeHandler({
12224
+ beforeRespawn: async () => {
12225
+ const target = resolveTranscriptPath(directory, meta.preEditClaudeSessionId);
12226
+ if (!restoreTranscriptFromUndo(undoDir, target)) {
12227
+ throw new Error("undo snapshot missing transcript backup");
12228
+ }
12229
+ restoreStoreA();
12230
+ claudeResumeId = meta.preEditClaudeSessionId;
12231
+ sessionMetadata = { ...sessionMetadata, claudeSessionId: claudeResumeId };
12232
+ }
12233
+ });
12234
+ if (result.success) {
12235
+ clearUndoSnapshot(undoDir);
12236
+ if (claudeResumeId && !trackedSession.stopped) {
12237
+ saveSession({
12238
+ sessionId,
12239
+ directory,
12240
+ claudeResumeId,
12241
+ permissionMode: currentPermissionMode,
12242
+ spawnMeta: lastSpawnMeta,
12243
+ metadata: sessionMetadata,
12244
+ createdAt: sessionCreatedAt,
12245
+ machineId,
12246
+ wasProcessing: false
12247
+ });
12248
+ artifactSync.syncSession(sessionId, getSessionDir(directory, sessionId), sessionMetadata, machineId).catch(() => {
12249
+ });
12250
+ }
12251
+ }
12252
+ return result;
12253
+ },
11787
12254
  onUpdateSecurityContext: async (newSecurityContext) => {
11788
12255
  logger.log(`[Session ${sessionId}] Security context update requested \u2014 restarting agent`);
11789
12256
  sessionMetadata = { ...sessionMetadata, securityContext: newSecurityContext };
@@ -12936,7 +13403,7 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
12936
13403
  const specs = loadExposedTunnels();
12937
13404
  if (specs.length === 0) return;
12938
13405
  logger.log(`[exposed-tunnels] Restoring ${specs.length} tunnel(s) from ${EXPOSED_TUNNELS_FILE}`);
12939
- const { FrpcTunnel } = await import('./frpc-fsq8Ua7l.mjs');
13406
+ const { FrpcTunnel } = await import('./frpc-1aSnPVrE.mjs');
12940
13407
  for (const spec of specs) {
12941
13408
  if (tunnels.has(spec.name)) continue;
12942
13409
  try {