@hncode/hncode 0.4.1 → 0.5.0

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 (65) hide show
  1. package/README.md +3 -16
  2. package/README_CN.md +1 -15
  3. package/bin/hncode +22 -9
  4. package/package.json +3 -2
  5. package/src/agent.js +145 -33
  6. package/src/bootstrap.js +56 -0
  7. package/src/btw-panel.js +224 -0
  8. package/src/cache.js +3 -2
  9. package/src/cell-buffer.js +173 -0
  10. package/src/ci.js +4 -1
  11. package/src/clipboard.js +202 -43
  12. package/src/colors.js +175 -30
  13. package/src/config.js +376 -34
  14. package/src/context-bar.js +237 -0
  15. package/src/doctor.js +320 -0
  16. package/src/experiments.js +122 -0
  17. package/src/file-history.js +112 -0
  18. package/src/file-tree.js +255 -0
  19. package/src/flush-tick.js +56 -0
  20. package/src/fork.js +44 -0
  21. package/src/fx.js +148 -0
  22. package/src/index.js +123 -11
  23. package/src/keybindings.js +229 -0
  24. package/src/llm.js +355 -34
  25. package/src/lsp.js +401 -0
  26. package/src/notebook.js +232 -0
  27. package/src/output-styles.js +183 -0
  28. package/src/patch.js +248 -0
  29. package/src/permissions.js +130 -1
  30. package/src/plan.js +3 -2
  31. package/src/plugin.js +691 -43
  32. package/src/prompt-presets.js +1 -1
  33. package/src/registry-browser.js +317 -0
  34. package/src/remote.js +289 -0
  35. package/src/rollout.js +151 -0
  36. package/src/row-cache.js +143 -0
  37. package/src/schedule.js +294 -0
  38. package/src/session-approvals.js +86 -0
  39. package/src/session.js +178 -8
  40. package/src/shell-safety.js +473 -0
  41. package/src/side-thread.js +240 -0
  42. package/src/skills.js +27 -0
  43. package/src/stream-buffer.js +350 -0
  44. package/src/subagent-control.js +160 -0
  45. package/src/tasks-browser.js +54 -11
  46. package/src/teleport.js +260 -0
  47. package/src/term-caps.js +411 -0
  48. package/src/term.js +283 -8
  49. package/src/tools/agent-control.js +98 -0
  50. package/src/tools/agent-swarm.js +11 -2
  51. package/src/tools/agent.js +35 -1
  52. package/src/tools/bash.js +17 -3
  53. package/src/tools/diagnostics.js +175 -0
  54. package/src/tools/edit.js +25 -10
  55. package/src/tools/grep.js +62 -20
  56. package/src/tools/ignore.js +38 -14
  57. package/src/tools/index.js +10 -0
  58. package/src/tools/notebook.js +113 -0
  59. package/src/tools/read-media-file.js +10 -3
  60. package/src/tools/shell.js +134 -0
  61. package/src/tui.js +16682 -11665
  62. package/src/web-public/app.css +22 -9
  63. package/src/web-public/app.js +107 -54
  64. package/src/web-public/home.css +3 -3
  65. package/src/web-public/login.css +1 -1
@@ -0,0 +1,160 @@
1
+ // Live control of running subagents: message one, interrupt one, close one.
2
+ //
3
+ // WHY A REGISTRY AND NOT A MAP ON THE AGENT
4
+ // ----------------------------------------
5
+ // `tools/agent.js` already keeps `subagents` (agent id -> { id, type, messages }) so a
6
+ // `resume` can find the conversation. That map is the WRONG thing to control from: it
7
+ // holds the RECORD of an agent, which outlives the run, while "interrupt" and "send a
8
+ // message" need the in-flight one. A finished agent has no live handle and must not be
9
+ // shown as controllable.
10
+ //
11
+ // So this module holds a SECOND map, of runs only: id -> { steer, interrupt, taskId,
12
+ // startedAt, type, description, status }. `tools/agent.js` registers a run when it starts
13
+ // and clears it when the run settles, which makes the registry's contents EXACTLY the set
14
+ // of agents that can still be acted on. Nothing here guesses that from timestamps.
15
+ //
16
+ // The handles are the real Agent methods (`steer`, `interrupt`), so a message sent here
17
+ // takes the same path as one the user steers into the main turn, and an interrupt stops
18
+ // the subagent the same way Esc stops the parent — there is no second implementation to
19
+ // drift out of step.
20
+
21
+ /** runId -> live handle. Only RUNNING agents are ever in here. */
22
+ const runs = new Map();
23
+
24
+ let seq = 0;
25
+ function nextId() {
26
+ seq = (seq + 1) % 1e6;
27
+ return `run-${Date.now().toString(36)}${String(seq).padStart(3, '0')}`;
28
+ }
29
+
30
+ /**
31
+ * Register a running subagent.
32
+ *
33
+ * @param {object} handle
34
+ * @param {string} handle.agentId the agent id from tools/agent.js (what `resume` uses)
35
+ * @param {string} [handle.taskId] the background task id, when the run was detached
36
+ * @param {string} handle.type subagent type
37
+ * @param {string} [handle.description]
38
+ * @param {(text:string)=>void} handle.steer inject a message into the running run
39
+ * @param {()=>void} handle.interrupt stop the run
40
+ * @returns {string} the run id, for the control tools to name it
41
+ */
42
+ export function registerRun(handle) {
43
+ const runId = nextId();
44
+ runs.set(runId, {
45
+ runId,
46
+ agentId: String(handle.agentId || ''),
47
+ taskId: handle.taskId ? String(handle.taskId) : '',
48
+ type: String(handle.type || ''),
49
+ description: String(handle.description || ''),
50
+ startedAt: Date.now(),
51
+ steer: handle.steer,
52
+ interrupt: handle.interrupt,
53
+ messages: [],
54
+ status: 'running',
55
+ });
56
+ return runId;
57
+ }
58
+
59
+ /** Drop a run. Called when it settles, so the registry only ever holds live work. */
60
+ export function endRun(runId) {
61
+ const r = runs.get(runId);
62
+ if (r) r.status = 'done';
63
+ runs.delete(runId);
64
+ }
65
+
66
+ export function listRuns() {
67
+ return [...runs.values()];
68
+ }
69
+
70
+ /** Find a run by EITHER its run id or the agent id it belongs to — a caller usually has
71
+ * one or the other and should not have to know which kind it holds. */
72
+ export function findRun(ref) {
73
+ const key = String(ref == null ? '' : ref).trim();
74
+ if (!key) return null;
75
+ if (runs.has(key)) return runs.get(key);
76
+ for (const r of runs.values()) {
77
+ if (r.agentId === key || r.taskId === key) return r;
78
+ }
79
+ return null;
80
+ }
81
+
82
+ /**
83
+ * Send a message into a running subagent.
84
+ *
85
+ * `steer` rather than a new turn: the message is injected once the subagent's in-flight
86
+ * tool calls finish, which is the only safe point to change its instructions. A new
87
+ * request while tools are running would either queue behind them anyway or fork the
88
+ * conversation, and the second is not what "say something to this agent" means.
89
+ *
90
+ * @returns {{ok: boolean, message: string}}
91
+ */
92
+ export function sendToRun(ref, text) {
93
+ const run = findRun(ref);
94
+ if (!run) return { ok: false, message: `No running subagent matches "${ref}". Use AgentMessage with no id, or TaskList, to see what is running.` };
95
+ const body = String(text == null ? '' : text).trim();
96
+ if (!body) return { ok: false, message: 'The message is empty.' };
97
+ try {
98
+ run.steer(body);
99
+ run.messages.push({ at: Date.now(), text: body });
100
+ return { ok: true, message: `Sent to ${run.agentId} (${run.type}): ${body}` };
101
+ } catch (e) {
102
+ return { ok: false, message: `Could not reach ${run.agentId}: ${e.message}` };
103
+ }
104
+ }
105
+
106
+ /** Stop a running subagent. Returns the agent id, so the caller can report it. */
107
+ export function interruptRun(ref) {
108
+ const run = findRun(ref);
109
+ if (!run) return { ok: false, message: `No running subagent matches "${ref}".` };
110
+ try {
111
+ run.interrupt();
112
+ return { ok: true, message: `Interrupted ${run.agentId} (${run.type}).`, agentId: run.agentId, runId: run.runId };
113
+ } catch (e) {
114
+ return { ok: false, message: `Could not interrupt ${run.agentId}: ${e.message}` };
115
+ }
116
+ }
117
+
118
+ /** Stop every running subagent. The escape hatch when several were fanned out. */
119
+ export function interruptAll() {
120
+ const all = listRuns();
121
+ const ids = [];
122
+ for (const r of all) {
123
+ try { r.interrupt(); ids.push(r.agentId); } catch { /* an already-dead run */ }
124
+ }
125
+ return { ok: ids.length > 0, count: ids.length, agents: ids };
126
+ }
127
+
128
+ /**
129
+ * Close a run: interrupt it AND forget it, so it stops appearing in listings.
130
+ *
131
+ * Distinct from interrupt on purpose. An interrupted run is removed when its `await`
132
+ * unwinds, which is asynchronous — so a user who interrupts and immediately lists would
133
+ * still see it, and would reasonably conclude the interrupt did nothing. `close` removes
134
+ * it at once. The record of the conversation stays in `tools/agent.js`, so `resume` still
135
+ * works; only the live handle goes.
136
+ */
137
+ export function closeRun(ref) {
138
+ const run = findRun(ref);
139
+ if (!run) return { ok: false, message: `No running subagent matches "${ref}".` };
140
+ try { run.interrupt(); } catch { /* already stopping */ }
141
+ endRun(run.runId);
142
+ return { ok: true, message: `Closed ${run.agentId} (${run.type}). Its conversation stays available to \`resume\`.`, agentId: run.agentId };
143
+ }
144
+
145
+ /** Lines for a control tool / panel: one row per running subagent. */
146
+ export function describeRuns() {
147
+ const all = listRuns();
148
+ if (!all.length) return ['No subagent is running.'];
149
+ const out = [`Running subagents (${all.length})`, ''];
150
+ for (const r of all) {
151
+ const secs = Math.round((Date.now() - r.startedAt) / 1000);
152
+ out.push(` ${r.runId} ${r.type.padEnd(10)} ${secs}s ${r.agentId}`);
153
+ if (r.description) out.push(` ${r.description}`);
154
+ if (r.messages.length) {
155
+ const last = r.messages[r.messages.length - 1];
156
+ out.push(` last message: ${String(last.text).slice(0, 80)}`);
157
+ }
158
+ }
159
+ return out;
160
+ }
@@ -136,20 +136,47 @@ export function renderTasksBrowser(state, cols, rows) {
136
136
 
137
137
  const key = (s) => C.cyan + C.bold + s + C.reset;
138
138
  const dim = (s) => C.gray + s + C.reset;
139
+ // The footer doubles as a TOOLBAR: each `KEY label` pair is clickable, so the
140
+ // whole pair is recorded as one span. `cursor` tracks the column so the spans stay
141
+ // correct as the parts are concatenated by `join(' ')`.
142
+ const footerHits = [];
139
143
  let footer;
140
144
  if (state.pendingStop) {
141
- footer = fitExactly(' ' + C.orange + C.bold + 'Stop' + C.reset + ' ' + C.white + state.pendingStop + C.reset + '? '
142
- + key('Y') + ' ' + dim('confirm') + ' ' + key('N') + dim('/') + key('esc') + ' ' + dim('cancel') + ' ', cols);
145
+ // The confirm prompt is a toolbar too: Y / N / Esc each do something, so each
146
+ // gets a clickable span. `c` tracks the column so the spans land exactly on the
147
+ // rendered words — the original drew `N/esc cancel` as one run, so the two keys
148
+ // share the single "cancel" label and are measured as one span.
149
+ const prefix = ' ' + C.orange + C.bold + 'Stop' + C.reset + ' ' + C.white + state.pendingStop + C.reset + '? ';
150
+ let c = visWidth(prefix);
151
+ const yPart = key('Y') + ' ' + dim('confirm');
152
+ footerHits.push({ keyWord: 'Y', label: 'confirm', action: 'confirmStop', col0: c, col1: c + visWidth(yPart) - 1 });
153
+ c += visWidth(yPart) + 2;
154
+ const nPart = key('N') + dim('/') + key('esc') + ' ' + dim('cancel');
155
+ footerHits.push({ keyWord: 'N/esc', label: 'cancel', action: 'cancelStop', col0: c, col1: c + visWidth(nPart) - 1 });
156
+ footer = fitExactly(prefix + yPart + ' ' + nPart + ' ', cols);
143
157
  } else {
144
- const parts = [
145
- ' ' + key('\u2191\u2193') + ' ' + dim('select'),
146
- key('Enter/O') + ' ' + dim('output'),
147
- key('S') + ' ' + dim('stop'),
148
- key('R') + ' ' + dim('refresh'),
149
- key('Tab') + ' ' + dim('filter'),
150
- key('Q/Esc') + ' ' + dim('close') + ' ',
158
+ const specs = [
159
+ ['\u2191\u2193', 'select', 'select'],
160
+ ['Enter/O', 'output', 'openOutput'],
161
+ ['S', 'stop', 'requestStop'],
162
+ ['R', 'refresh', 'refresh'],
163
+ ['Tab', 'filter', 'toggleFilter'],
164
+ ['Q/Esc', 'close', 'close'],
151
165
  ];
152
- const left = parts.join(' ');
166
+ const parts = [];
167
+ let cursor = 1;
168
+ for (const [kw, label, action] of specs) {
169
+ const piece = key(kw) + ' ' + dim(label);
170
+ const w = visWidth(piece);
171
+ // `col1` is the LAST included column (hitboxes use an inclusive range, see
172
+ // hitAt), so a piece starting at `cursor` spanning `w` cells ends at
173
+ // `cursor + w - 1`. Using `cursor + w` made the span one column too wide, so a
174
+ // hover tinted the following space as well.
175
+ footerHits.push({ keyWord: kw, label, action, col0: cursor, col1: cursor + w - 1 });
176
+ cursor += w + 2; // the `parts.join(' ')` separator
177
+ parts.push(piece);
178
+ }
179
+ const left = ' ' + parts.join(' ');
153
180
  const flash = state.flash ? C.orange + ' ' + state.flash + ' ' + C.reset : '';
154
181
  const total = visWidth(left) + visWidth(flash);
155
182
  footer = total <= cols ? left + ' '.repeat(cols - total) + flash : fitExactly(left, cols);
@@ -168,7 +195,11 @@ export function renderTasksBrowser(state, cols, rows) {
168
195
  } else {
169
196
  listLines = visible.map((t, i) => {
170
197
  const selected = i === state.selectedIndex;
171
- const ptr = selected ? C.cyan + '\u276f ' + C.reset : ' ';
198
+ // Hover highlight, mirroring the rest of the TUI: the row under the pointer is
199
+ // tinted by the generic hover pass in tui.js (composeFrame), which needs a
200
+ // hitbox to aim at — those are recorded in state._taskHits below.
201
+ const hot = state.hoverIndex === i;
202
+ const ptr = selected ? C.cyan + '\u276f ' + C.reset : (hot ? C.hover + '\u276f ' + C.reset : ' ');
172
203
  const idCol = t.kind === 'agent' ? C.green : t.kind === 'question' ? C.orange : C.blue;
173
204
  const idText = selected ? idCol + C.bold + t.taskId + C.reset : idCol + t.taskId + C.reset;
174
205
  const pad = ' '.repeat(Math.max(0, 20 - visWidth(t.taskId)));
@@ -180,6 +211,18 @@ export function renderTasksBrowser(state, cols, rows) {
180
211
  return fitExactly(prefix + ' ' + C.white + truncateToWidth(desc, budget) + C.reset, listInnerW);
181
212
  });
182
213
  }
214
+ // Click targets. Coordinates are FRAME cells: line 0 is the header, line 1 is the
215
+ // list box's TOP RULE, so the first content row is line 2 — using `1 + i` put the
216
+ // hitboxes one row high, which highlighted the border instead of the task and made
217
+ // a click select the wrong row. Columns match the box interior (`│` + 1 cell of
218
+ // padding), and `col1` is the LAST included column.
219
+ state._taskHits = [];
220
+ for (let i = 0; i < Math.min(visible.length, listInnerH); i++) {
221
+ state._taskHits.push({ row: 2 + i, col0: 1, col1: listInnerW, kind: 'taskRow', index: i });
222
+ }
223
+ for (const h of footerHits) {
224
+ state._taskHits.push({ row: rows - 1, col0: h.col0, col1: h.col1, kind: 'taskFooter', action: h.action });
225
+ }
183
226
  while (listLines.length < listInnerH) listLines.push('');
184
227
  const listFrame = renderFrame('Tasks [' + state.filter + ']', listLines.slice(0, listInnerH), listW, bodyH);
185
228
 
@@ -0,0 +1,260 @@
1
+ // Session teleport — carry a conversation to another machine or another checkout.
2
+ //
3
+ // Why not just copy the session file: a session's JSON contains absolute paths (the
4
+ // workspace, every file the tools touched) and the tool arguments that name them. A
5
+ // file teleported verbatim to a different checkout resumes with a workspace that
6
+ // does not exist and a transcript full of paths that resolve to nothing. So a
7
+ // teleport is a TRANSFORM, not a copy:
8
+ //
9
+ // * paths under the old workspace are rewritten to the new one;
10
+ // * the workspace itself is recorded, so the receiving side can check it exists;
11
+ // * everything machine-specific that would be WRONG on the other machine is
12
+ // dropped rather than carried (see `stripVolatile`).
13
+ //
14
+ // The container is plain JSON with a small header, so a teleport can be inspected,
15
+ // edited by hand, or piped through `ssh`. Compression is deliberately not used: a
16
+ // session is text, base64 would make it unreadable, and the transport (scp, ssh,
17
+ // git) can compress better than we can.
18
+
19
+ /** The header written into every teleport file, so a stale format is detectable. */
20
+ export const TELEPORT_FORMAT = 'hncode-teleport';
21
+ export const TELEPORT_VERSION = 1;
22
+
23
+ /**
24
+ * Fields that describe THIS machine's runtime rather than the conversation. Carried
25
+ * verbatim they would be wrong on the other side in a way that looks like data
26
+ * corruption, so they are dropped: a running tool's live output refers to a process
27
+ * that no longer exists, and the token/cost totals belong to the machine that spent
28
+ * them.
29
+ */
30
+ const VOLATILE_KEYS = [
31
+ 'tasks', // background jobs: the processes are gone
32
+ 'usage', // token totals spent on the exporting machine
33
+ 'sessionApprovals', // grants the user made here must not transfer silently
34
+ '_tokTimes',
35
+ '_metrics',
36
+ ];
37
+
38
+ /** Drop the machine-specific fields, returning a copy. */
39
+ export function stripVolatile(session) {
40
+ const out = { ...(session || {}) };
41
+ for (const k of VOLATILE_KEYS) delete out[k];
42
+ // A live or pending tool row belongs to a turn that is not running here.
43
+ if (Array.isArray(out.messages)) {
44
+ out.messages = out.messages.map((m) => {
45
+ if (!m || typeof m !== 'object') return m;
46
+ const c = { ...m };
47
+ delete c.pending;
48
+ delete c.liveOutput;
49
+ return c;
50
+ });
51
+ }
52
+ return out;
53
+ }
54
+
55
+ /** Normalise a path to forward slashes with no trailing separator, for comparison. */
56
+ function norm(p) {
57
+ return String(p == null ? '' : p).replace(/\\/g, '/').replace(/\/+$/, '');
58
+ }
59
+
60
+ /**
61
+ * Rewrite the paths under `from` to `to`, anywhere in the session.
62
+ *
63
+ * A STRUCTURAL walk over the parsed object, not a text replace over the serialized
64
+ * JSON. The text approach looked simpler but could never work on Windows: JSON
65
+ * escapes a backslash as `\\`, so the string `D:\old\proj` appears in the file as
66
+ * `D:\\old\\proj` and searching for the raw path finds nothing. Escaping the search
67
+ * term to match produced a second class of misses (a path that legitimately contains
68
+ * a backslash next to text that is not part of it).
69
+ *
70
+ * Walking every string compares the path in its NORMALISED form (forward slashes, no
71
+ * trailing separator), so `D:\old\proj`, `D:/old/proj` and a trailing-slash variant
72
+ * all match, and the REPLACEMENT is written back in the separator style the original
73
+ * string used — a Windows session keeps backslashes, a POSIX one keeps slashes.
74
+ */
75
+ export function rewriteWorkspace(session, from, to) {
76
+ const oldPath = norm(from);
77
+ const newPath = norm(to);
78
+ if (!oldPath || !newPath || oldPath === newPath) return session;
79
+ const walk = (value) => {
80
+ if (typeof value === 'string') return replaceInString(value, oldPath, newPath);
81
+ if (Array.isArray(value)) return value.map(walk);
82
+ if (value && typeof value === 'object') {
83
+ const out = {};
84
+ for (const [k, v] of Object.entries(value)) out[k] = walk(v);
85
+ return out;
86
+ }
87
+ return value;
88
+ };
89
+ return walk(session);
90
+ }
91
+
92
+ /**
93
+ * Replace `oldPath` inside one string, preserving the separator style.
94
+ *
95
+ * The match is done on a normalised COPY of the string with index mapping, so the
96
+ * replacement lands in the right place while the surrounding text is untouched. A
97
+ * simple `split/join` on the normalised form would rewrite every backslash in the
98
+ * string, including ones that are not path separators.
99
+ */
100
+ function replaceInString(text, oldPath, newPath) {
101
+ const normText = norm(text);
102
+ // The whole-string case is by far the most common (a path ARGUMENT). The tail may
103
+ // carry a trailing separator the normalisation removed, which is dropped with it:
104
+ // `D:\old\proj\` becomes the new path, not the new path plus a stray slash.
105
+ if (normText === oldPath) return toStyle(newPath);
106
+ return replaceByIndex(text, oldPath, newPath);
107
+ }
108
+
109
+ // Replace every path-shaped occurrence, matching on the normalised form and writing
110
+ // the replacement at the ORIGINAL indices. norm() rewrites separators one character
111
+ // for one, so the indices line up exactly and the surrounding text is never touched.
112
+ //
113
+ // The replacement extends past the matched prefix to the END OF THE PATH TOKEN, and
114
+ // the tail is re-emitted with POSIX separators. Without that, moving a Windows
115
+ // checkout to Linux produced `read /home/me/proj\src\a.js` — the prefix rewritten and
116
+ // the rest still using backslashes, which resolves on neither system.
117
+ function replaceByIndex(text, oldPath, newPath) {
118
+ const normText = norm(text);
119
+ let at = normText.indexOf(oldPath);
120
+ if (at < 0) return text;
121
+ const styled = toStyle(newPath);
122
+ let out = '';
123
+ let cursor = 0;
124
+ while (at >= 0) {
125
+ const before = at === 0 ? '' : text[at - 1];
126
+ const end = at + oldPath.length;
127
+ const after = end >= text.length ? '' : text[end];
128
+ // Only a path-SHAPED hit counts, so a coincidental substring inside a word is
129
+ // left alone. `okAfter` has to accept a separator too: the match may stop at the
130
+ // workspace root while the string continues into a file (`…/proj/src/a.js`).
131
+ const okBefore = at === 0 || /[\\/\s"'(=,:;]/.test(before);
132
+ const okAfter = end >= text.length || /[\\/\s"'),:;]/.test(after);
133
+ if (okBefore && okAfter) {
134
+ // Consume the rest of the path token so its separators can be normalised too.
135
+ let stop = end;
136
+ const sep = /[\\/]/;
137
+ if (end < text.length && sep.test(text[end])) {
138
+ while (stop < text.length && !/[\s"'),:;]/.test(text[stop])) stop++;
139
+ }
140
+ out += text.slice(cursor, at) + styled + toStyle(text.slice(end, stop));
141
+ cursor = stop;
142
+ at = normText.indexOf(oldPath, stop);
143
+ continue;
144
+ }
145
+ at = normText.indexOf(oldPath, at + 1);
146
+ }
147
+ return out + text.slice(cursor);
148
+ }
149
+
150
+ /**
151
+ * Write `p` with POSIX separators.
152
+ *
153
+ * Always forward slashes, whatever the input string used: the target workspace is
154
+ * what decides the style, and the target is recorded POSIX-normalised (see pack). A
155
+ * Windows SOURCE path that lands on a Linux checkout must come out as `/home/x/proj`,
156
+ * not `\home\x\proj`, or the receiving side resolves nothing.
157
+ */
158
+ function toStyle(p) {
159
+ return String(p).replace(/\\/g, '/');
160
+ }
161
+
162
+ /**
163
+ * Build the teleport container for a session.
164
+ *
165
+ * `opts.from` / `opts.to` rewrite the workspace; `to` is also recorded as the
166
+ * workspace the receiving side should have.
167
+ */
168
+ export function packSession(session, opts = {}) {
169
+ const from = opts.from || session.workspace || '';
170
+ const to = opts.to || from;
171
+ const body = rewriteWorkspace(stripVolatile(session), from, to);
172
+ return {
173
+ format: TELEPORT_FORMAT,
174
+ version: TELEPORT_VERSION,
175
+ exportedAt: new Date().toISOString(),
176
+ // The workspace the session EXPECTS. The importing side checks this and reports
177
+ // a mismatch rather than silently resuming somewhere else.
178
+ workspace: norm(to),
179
+ // Recorded so the importer can show what happened, and a human reviewing the
180
+ // file can see it moved.
181
+ from: norm(from),
182
+ title: body.title || '',
183
+ rounds: body.rounds || 0,
184
+ session: { ...body, workspace: norm(to) },
185
+ };
186
+ }
187
+
188
+ /** Serialize a teleport for writing. */
189
+ export function serializeTeleport(pack) {
190
+ return JSON.stringify(pack, null, 2) + '\n';
191
+ }
192
+
193
+ /**
194
+ * Parse and validate a teleport. Throws with a specific reason, because the common
195
+ * failure is pasting the wrong file and "invalid JSON" alone does not say that.
196
+ */
197
+ export function parseTeleport(text) {
198
+ let pack;
199
+ try { pack = JSON.parse(String(text || '')); }
200
+ catch (e) { throw new Error(`not valid JSON: ${e.message}`); }
201
+ if (!pack || typeof pack !== 'object') throw new Error('empty teleport');
202
+ if (pack.format !== TELEPORT_FORMAT) {
203
+ throw new Error(`not an hncode teleport (format is ${JSON.stringify(pack.format)})`);
204
+ }
205
+ if (!(pack.version <= TELEPORT_VERSION)) {
206
+ throw new Error(`teleport version ${pack.version} is newer than this build (${TELEPORT_VERSION})`);
207
+ }
208
+ if (!pack.session || typeof pack.session !== 'object') throw new Error('teleport has no session');
209
+ return pack;
210
+ }
211
+
212
+ /**
213
+ * Prepare an imported teleport for this machine.
214
+ *
215
+ * Returns `{ session, warnings }`. Warnings rather than errors: a renamed checkout is
216
+ * a common, recoverable situation, and refusing to import would strand the
217
+ * conversation. The caller decides whether to continue.
218
+ */
219
+ export function unpackInto(pack, opts = {}) {
220
+ const warnings = [];
221
+ const here = norm(opts.workspace || pack.workspace || '');
222
+ const there = norm(pack.workspace || '');
223
+ if (there && here && there !== here) {
224
+ warnings.push(`the session was recorded in ${there}, but this workspace is ${here} — paths have been rewritten`);
225
+ }
226
+ let session = pack.session;
227
+ // Re-point at THIS workspace when it differs, so the transcript's paths resolve.
228
+ session = rewriteWorkspace(session, there, here);
229
+ // A fresh id unless the caller wants the original: importing twice under one id
230
+ // would make the two copies overwrite each other on save.
231
+ session = { ...session, workspace: here, importedAt: new Date().toISOString() };
232
+ if (opts.newId && typeof opts.newId === 'function') session.id = opts.newId();
233
+ return { session, warnings };
234
+ }
235
+
236
+ /** `hncode-teleport-<id>-<stamp>.json`, a name that sorts by time and names the origin. */
237
+ export function teleportFileName(session, at = new Date()) {
238
+ const id = String((session && session.id) || 'session').replace(/[^A-Za-z0-9_-]/g, '').slice(0, 24) || 'session';
239
+ const stamp = at.toISOString().replace(/[:.]/g, '-').slice(0, 19);
240
+ return `hncode-teleport-${id}-${stamp}.json`;
241
+ }
242
+
243
+ /** A human summary for the confirmation message. */
244
+ export function describeTeleport(pack) {
245
+ const msgs = Array.isArray(pack.session && pack.session.messages) ? pack.session.messages.length : 0;
246
+ return [
247
+ `title ${pack.title || '(untitled)'}`,
248
+ `from ${pack.from || '(unknown)'}`,
249
+ `workspace ${pack.workspace || '(unknown)'}`,
250
+ `exported ${pack.exportedAt || '(unknown)'}`,
251
+ `messages ${msgs}`,
252
+ `rounds ${pack.rounds || 0}`,
253
+ ].join('\n');
254
+ }
255
+
256
+ export default {
257
+ packSession, serializeTeleport, parseTeleport, unpackInto,
258
+ rewriteWorkspace, stripVolatile, teleportFileName, describeTeleport,
259
+ TELEPORT_FORMAT, TELEPORT_VERSION,
260
+ };