instar 1.3.1584 → 1.3.1586

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 (68) hide show
  1. package/dashboard/console/console.css +17 -0
  2. package/dashboard/console/sessions-model.js +54 -6
  3. package/dashboard/console/sessions.js +137 -13
  4. package/dist/commands/server.d.ts.map +1 -1
  5. package/dist/commands/server.js +20 -2
  6. package/dist/commands/server.js.map +1 -1
  7. package/dist/core/AutoUpdater.d.ts.map +1 -1
  8. package/dist/core/AutoUpdater.js +7 -3
  9. package/dist/core/AutoUpdater.js.map +1 -1
  10. package/dist/core/ForegroundRestartWatcher.d.ts +2 -0
  11. package/dist/core/ForegroundRestartWatcher.d.ts.map +1 -1
  12. package/dist/core/ForegroundRestartWatcher.js +13 -0
  13. package/dist/core/ForegroundRestartWatcher.js.map +1 -1
  14. package/dist/core/PlannedRestartVerifyGuard.d.ts +26 -0
  15. package/dist/core/PlannedRestartVerifyGuard.d.ts.map +1 -0
  16. package/dist/core/PlannedRestartVerifyGuard.js +125 -0
  17. package/dist/core/PlannedRestartVerifyGuard.js.map +1 -0
  18. package/dist/core/QueueDrainLoop.d.ts +10 -2
  19. package/dist/core/QueueDrainLoop.d.ts.map +1 -1
  20. package/dist/core/QueueDrainLoop.js +28 -6
  21. package/dist/core/QueueDrainLoop.js.map +1 -1
  22. package/dist/core/SessionManager.d.ts +3 -0
  23. package/dist/core/SessionManager.d.ts.map +1 -1
  24. package/dist/core/SessionManager.js +1 -0
  25. package/dist/core/SessionManager.js.map +1 -1
  26. package/dist/core/SessionRouter.d.ts +21 -3
  27. package/dist/core/SessionRouter.d.ts.map +1 -1
  28. package/dist/core/SessionRouter.js +58 -6
  29. package/dist/core/SessionRouter.js.map +1 -1
  30. package/dist/core/SessionTranscriptReader.d.ts +25 -0
  31. package/dist/core/SessionTranscriptReader.d.ts.map +1 -0
  32. package/dist/core/SessionTranscriptReader.js +143 -0
  33. package/dist/core/SessionTranscriptReader.js.map +1 -0
  34. package/dist/core/WriteDomainRegistry.d.ts.map +1 -1
  35. package/dist/core/WriteDomainRegistry.js +8 -0
  36. package/dist/core/WriteDomainRegistry.js.map +1 -1
  37. package/dist/data/standards-guard-index.json +1 -1
  38. package/dist/data/standards-guard-index.meta.json +2 -2
  39. package/dist/data/standards-registry.meta.json +1 -1
  40. package/dist/lifeline/ServerSupervisor.d.ts +1 -0
  41. package/dist/lifeline/ServerSupervisor.d.ts.map +1 -1
  42. package/dist/lifeline/ServerSupervisor.js +19 -2
  43. package/dist/lifeline/ServerSupervisor.js.map +1 -1
  44. package/dist/localVerify/LocalVerifyService.d.ts +1 -1
  45. package/dist/localVerify/LocalVerifyService.d.ts.map +1 -1
  46. package/dist/localVerify/LocalVerifyService.js +8 -3
  47. package/dist/localVerify/LocalVerifyService.js.map +1 -1
  48. package/dist/monitoring/guardManifest.d.ts.map +1 -1
  49. package/dist/monitoring/guardManifest.js +1 -0
  50. package/dist/monitoring/guardManifest.js.map +1 -1
  51. package/dist/native/instar-desktop-host +0 -0
  52. package/dist/server/routes.d.ts.map +1 -1
  53. package/dist/server/routes.js +146 -1
  54. package/dist/server/routes.js.map +1 -1
  55. package/package.json +1 -1
  56. package/src/data/builtin-manifest.json +48 -48
  57. package/src/data/standards-guard-index.json +1 -1
  58. package/src/data/standards-guard-index.meta.json +2 -2
  59. package/src/data/standards-registry.meta.json +1 -1
  60. package/upgrades/1.3.1585.md +29 -0
  61. package/upgrades/1.3.1586.md +28 -0
  62. package/upgrades/side-effects/console-session-followups.eli16.md +18 -0
  63. package/upgrades/side-effects/console-session-followups.md +44 -0
  64. package/upgrades/side-effects/console-session-history.eli16.md +14 -0
  65. package/upgrades/side-effects/console-session-history.md +32 -0
  66. package/upgrades/side-effects/planned-restart-local-verify-hold.md +81 -0
  67. package/upgrades/side-effects/spawn-handoff-ack.md +117 -0
  68. package/upgrades/spawn-handoff-ack.eli16.md +43 -0
@@ -985,11 +985,28 @@ button.run-mark:focus-visible { outline: 2px solid var(--accent); outline-offset
985
985
  .sess-term-wrap { position: relative; flex: 1; min-height: 320px; display: flex; }
986
986
  .sess-term { flex: 1; min-width: 0; min-height: 320px; height: 52dvh; background: #0a0e17; border: 1px solid var(--hairline); border-radius: var(--r-md); padding: 6px; overflow: hidden; }
987
987
  .sess-term.plain { overflow: auto; }
988
+ .sess-tabs { display: flex; gap: var(--s-2); }
989
+ .sess-tab[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); background: var(--accent-wash); }
990
+ .sess-history { flex: 1; min-width: 0; min-height: 320px; height: 52dvh; overflow-y: auto; padding: var(--s-3); background: var(--surface); border: 1px solid var(--hairline); border-radius: var(--r-md); display: flex; flex-direction: column; gap: var(--s-3); }
991
+ .sess-history[hidden], .sess-term[hidden] { display: none; }
992
+ .sess-history-note { margin: 0; text-align: center; font-size: 13px; color: var(--text-muted); }
993
+ .sess-h { max-width: 92%; }
994
+ .sess-h-user { align-self: flex-end; background: var(--accent-wash); border: 1px solid var(--hairline); border-radius: var(--r-md); padding: var(--s-2) var(--s-3); }
995
+ .sess-h-assistant { align-self: flex-start; }
996
+ .sess-h-who { font-size: 12px; color: var(--text-faint); margin-bottom: 2px; }
997
+ .sess-h-text { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 14.5px; line-height: 1.5; color: var(--text); }
998
+ .sess-h-tool { margin: 0; font-family: var(--font-mono); font-size: 12px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
988
999
  .sess-pre { margin: 0; font-family: var(--font-mono); font-size: 12px; line-height: 1.4; color: #d6dbe4; white-space: pre-wrap; overflow-wrap: anywhere; }
989
1000
  .sess-resume { position: absolute; bottom: 12px; left: 50%; transform: translateX(-50%); z-index: 2; }
990
1001
  .sess-composer { display: flex; gap: var(--s-2); align-items: flex-end; position: sticky; bottom: 0; padding: var(--s-2) 0; background: var(--bg); }
991
1002
  .sess-input { flex: 1; min-height: 44px; font-size: 16px; resize: none; }
992
1003
  .sess-composer-btns { display: flex; gap: var(--s-2); }
1004
+ .sess-attach { min-width: 44px; min-height: 44px; padding: 0 10px; font-size: 18px; }
1005
+ .sess-attach:disabled { opacity: .45; cursor: not-allowed; }
1006
+ .sess-attachments { display: flex; flex-wrap: wrap; gap: var(--s-2); }
1007
+ .sess-file { gap: 8px; max-width: 100%; overflow: hidden; }
1008
+ .sess-file span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 260px; }
1009
+ .sess-composer.dropping { outline: 2px dashed var(--accent); outline-offset: 4px; border-radius: var(--r-md); }
993
1010
  .keys-tray { display: flex; flex-direction: column; gap: var(--s-3); padding: var(--s-3); border: 1px solid var(--hairline); border-radius: var(--r-md); }
994
1011
  .keys-row { display: flex; flex-wrap: wrap; gap: var(--s-2); }
995
1012
  .key-chip { min-width: 44px; min-height: 44px; justify-content: center; font-family: var(--font-mono); }
@@ -102,7 +102,52 @@ function nicknameOf(pool, machineId) {
102
102
  * The whole list. sessionsRes is the GET /sessions?scope=pool read; ws is the live
103
103
  * socket's list for this machine (null when the socket has not sent one).
104
104
  */
105
- export function buildSessions(sessionsRes, { ws = null, now = Date.now(), topicNames = {} } = {}) {
105
+ /** The message a session receives for attached files: the operator's text, then one
106
+ * line per file naming where it was saved, so the session can open it. */
107
+ export function attachmentMessage(text, paths) {
108
+ const lines = paths.map((p) => `[Attached file from the dashboard: ${p} (read it to see what I sent)]`);
109
+ return [String(text || '').trim(), ...lines].filter(Boolean).join('\n');
110
+ }
111
+
112
+ /** `triggeredBy` on a session the operator created from the dashboard's New session box. */
113
+ export const OPERATOR_CREATED = 'dashboard-operator';
114
+
115
+ /** Internal = machinery the operator did not start: scheduled jobs, and helper sessions
116
+ * the agent spawned for itself (no chat attached, not created from the dashboard). A
117
+ * conversation (Telegram, Slack, ...) is never internal. Hidden by default, one toggle away. */
118
+ export function isInternalSession(s) {
119
+ if (!s) return false;
120
+ if (s.jobSlug || s.type === 'job') return true;
121
+ return (s.platform || 'headless') === 'headless' && s.triggeredBy !== OPERATOR_CREATED;
122
+ }
123
+
124
+ /** The History read for a session: its conversation record, a page at a time. A session
125
+ * on another machine is read through this machine, which asks the owner. */
126
+ export const HISTORY_PAGE = 150;
127
+ export function historyUrl(s, before = null) {
128
+ const q = new URLSearchParams({ limit: String(HISTORY_PAGE) });
129
+ if (before != null) q.set('before', String(before));
130
+ if (s.remote && s.machineId) q.set('machineId', s.machineId);
131
+ return `/sessions/${encodeURIComponent(s.tmuxSession)}/transcript?${q}`;
132
+ }
133
+
134
+ /** Words for a failed History read: never "no history" when the read failed. */
135
+ export function historyErrorWords(res) {
136
+ const d = (res && res.data) || {};
137
+ if (d.reason === 'no-transcript') return 'This session has no conversation record yet.';
138
+ if (d.reason === 'peer-unreachable' || d.reason === 'peer-unknown') return `Can’t tell: ${d.error || 'the session’s machine did not answer'}.`;
139
+ const why = d.error || (res && res.reason) || (res && res.status ? `the server answered ${res.status}` : 'the server could not be reached');
140
+ return `Can’t tell: ${String(why).replace(/\.$/, '')}.`;
141
+ }
142
+
143
+ /** A user line from a chat bridge carries a routing prefix; show only what was said. */
144
+ export function historyText(e) {
145
+ let t = String((e && e.text) || '');
146
+ if (e && e.role === 'user') t = t.replace(/^\[(telegram|slack|whatsapp):[^\]]*\]\s*/, '');
147
+ return t;
148
+ }
149
+
150
+ export function buildSessions(sessionsRes, { ws = null, now = Date.now(), topicNames = {}, showInternal = false } = {}) {
106
151
  if (!sessionsRes || !sessionsRes.ok) return { ok: false, reason: `Couldn’t list sessions: ${(sessionsRes && sessionsRes.reason) || 'no answer'}.` };
107
152
  const data = sessionsRes.data || {};
108
153
  if (!Array.isArray(data.sessions)) return { ok: false, reason: 'The session list answered without sessions.' };
@@ -114,7 +159,7 @@ export function buildSessions(sessionsRes, { ws = null, now = Date.now(), topicN
114
159
  const duplicateConversations = new Set((Array.isArray(pool.duplicateTopics) ? pool.duplicateTopics : [])
115
160
  .filter((d) => typeof d.platform === 'string' && d.platformId != null)
116
161
  .map((d) => `${d.platform}:${String(d.platformId)}`));
117
- const rows = [...local.map((s) => ({ ...s, machineNickname: s.machineNickname || selfName })), ...remote].map((s) => {
162
+ const allRows = [...local.map((s) => ({ ...s, machineNickname: s.machineNickname || selfName })), ...remote].map((s) => {
118
163
  const a = activity(s, now);
119
164
  const duplicate = s.duplicateTopic === true || (typeof s.platform === 'string' && s.platformId != null
120
165
  && duplicateConversations.has(`${s.platform}:${String(s.platformId)}`));
@@ -123,15 +168,18 @@ export function buildSessions(sessionsRes, { ws = null, now = Date.now(), topicN
123
168
  const running = Number.isFinite(started) ? spanWords(now - started) : null;
124
169
  const sub = [machine, running && running !== 'now' ? running : running ? 'just started' : null, duplicate ? 'running on two machines at once' : a.state === 'no-reading' ? null : a.words].filter(Boolean).join(' · ');
125
170
  return {
126
- key: sessionKey(s), title: sessionTitle(s, topicNames), sub, end: a.end, activity: a.state, duplicate,
171
+ key: sessionKey(s), title: sessionTitle(s, topicNames), sub, end: a.end, activity: a.state, duplicate, internal: isInternalSession(s),
127
172
  tone: duplicate ? 'bad' : a.state === 'quiet' ? 'attn' : a.state === 'no-reading' ? 'unknown' : null,
128
173
  machine, remote: s.remote === true, model: modelWords(s.model),
129
- session: { tmuxSession: s.tmuxSession, id: s.id, name: s.name, machineId: s.machineId || null, machineNickname: machine, remote: s.remote === true, protected: s.protected, platform: s.platform || 'headless', platformId: s.platformId ?? null, model: s.model || null, type: s.type || (s.jobSlug ? 'job' : 'interactive'), telemetry: s.telemetry || null, startedAt: s.startedAt || null, status: s.status },
174
+ session: { tmuxSession: s.tmuxSession, id: s.id, name: s.name, machineId: s.machineId || null, machineNickname: machine, remote: s.remote === true, protected: s.protected, platform: s.platform || 'headless', platformId: s.platformId ?? null, model: s.model || null, type: s.type || (s.jobSlug ? 'job' : 'interactive'), internal: isInternalSession(s), telemetry: s.telemetry || null, startedAt: s.startedAt || null, status: s.status },
130
175
  };
131
176
  });
132
177
  // Most recently active first inside each group.
133
178
  const lastOf = (r) => Date.parse(r.session.telemetry && r.session.telemetry.lastActivity) || Date.parse(r.session.startedAt) || 0;
134
- rows.sort((x, y) => lastOf(y) - lastOf(x));
179
+ allRows.sort((x, y) => lastOf(y) - lastOf(x));
180
+ const hiddenInternal = showInternal ? 0 : allRows.filter((r) => r.internal).length;
181
+ const internalCount = allRows.filter((r) => r.internal).length;
182
+ const rows = showInternal ? allRows : allRows.filter((r) => !r.internal);
135
183
  const needsLook = rows.filter((r) => r.duplicate || r.activity === 'quiet');
136
184
  const working = rows.filter((r) => !needsLook.includes(r));
137
185
  const failedIds = new Set((Array.isArray(pool.failed) ? pool.failed : []).map((f) => f.machineId));
@@ -148,7 +196,7 @@ export function buildSessions(sessionsRes, { ws = null, now = Date.now(), topicN
148
196
  })) : [];
149
197
  const machineCount = multi ? machines.length : 1;
150
198
  return {
151
- ok: true, rows, needsLook, working, idleMachines, unanswered, multi, selfName,
199
+ ok: true, rows, allRows, hiddenInternal, internalCount, showInternal, needsLook, working, idleMachines, unanswered, multi, selfName,
152
200
  selfMachineId: pool.selfMachineId || null,
153
201
  counts: { running: rows.length, quiet: rows.filter((r) => r.activity === 'quiet').length, duplicate: rows.filter((r) => r.duplicate).length, machines: machineCount, answering: machineCount - unanswered.length },
154
202
  };
@@ -11,7 +11,7 @@ import {
11
11
  buildSessions, sessionsHeadline, sessionsFacts, partialWords, titleLine, closeRequest, closeConfirmWords,
12
12
  closeResultWords, doorChoices, modelChoices, doorModelWords, regimeWords, machineChoices, PLATFORMS,
13
13
  platformAllowsDoor, createBody, createResultWords, defaultWords, profileSourceWords, switchLanded,
14
- canSwitchDoor, KEYS, STOP_KEY, frame, nextHistoryBatch, streamErrorWords, composerRows, modelWords, HISTORY_FIRST,
14
+ canSwitchDoor, attachmentMessage, historyUrl, historyErrorWords, historyText, KEYS, STOP_KEY, frame, nextHistoryBatch, streamErrorWords, composerRows, modelWords, HISTORY_FIRST,
15
15
  } from './sessions-model.js';
16
16
  import { el, setKids, pageHead, sheet, closeSheet, story, details, note, cantTell } from './ui.js';
17
17
  import { legacyHref } from './nav-model.js';
@@ -22,6 +22,20 @@ const XTERM_CSS = 'https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min
22
22
  const FIT_JS = 'https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js';
23
23
  const POOL_TIMEOUT_MS = 8000;
24
24
 
25
+ const ATTACH_MAX_BYTES = 8 * 1024 * 1024;
26
+ function toBase64(file) {
27
+ return new Promise((resolve, reject) => {
28
+ const r = new FileReader();
29
+ r.onload = () => resolve(String(r.result).replace(/^data:[^,]*,/, ''));
30
+ r.onerror = () => reject(r.error);
31
+ r.readAsDataURL(file);
32
+ });
33
+ }
34
+
35
+ const SHOW_INTERNAL_KEY = 'instar_console_show_internal';
36
+ function showInternal() { try { return localStorage.getItem(SHOW_INTERNAL_KEY) === '1'; } catch { return false; } }
37
+ function setShowInternal(on) { try { localStorage.setItem(SHOW_INTERNAL_KEY, on ? '1' : '0'); } catch { /* per-viewer convenience only */ } }
38
+
25
39
  function bearer() { try { return localStorage.getItem('instar_token') || ''; } catch { return ''; } }
26
40
 
27
41
  /** A write that keeps the status and the server's body. */
@@ -69,7 +83,7 @@ const onPage = () => Boolean(st.main && st.main.isConnected && st.main.querySele
69
83
  function currentModel() {
70
84
  const src = st.poolData || (st.lastFailReason ? { ok: false, reason: st.lastFailReason } : null);
71
85
  if (!src) return null;
72
- return buildSessions(src, { ws: st.wsUp ? st.wsList : null, now: Date.now(), topicNames: st.topicNames || {} });
86
+ return buildSessions(src, { ws: st.wsUp ? st.wsList : null, now: Date.now(), topicNames: st.topicNames || {}, showInternal: showInternal() });
73
87
  }
74
88
 
75
89
  function paint() {
@@ -83,7 +97,7 @@ function paint() {
83
97
  paintList(page.querySelector('.sess-list'), m, key);
84
98
  const v = page.querySelector('.sess-view');
85
99
  if (!key) { if (st.view) leaveView(); setKids(v, el('p', { class: 'empty-line sess-pick', text: 'Pick a session to watch it and type to it.' })); return; }
86
- const row = m.ok ? m.rows.find((r) => r.key === key) : null;
100
+ const row = m.ok ? m.allRows.find((r) => r.key === key) : null;
87
101
  if (st.view && st.view.key === key) { if (row) updateViewTitle(row); else markEnded(); return; }
88
102
  if (st.view) leaveView();
89
103
  if (!row) {
@@ -118,7 +132,13 @@ function paintList(box, m, openKey) {
118
132
  m.idleMachines.map((i) => el('p', { class: `machine-line${i.ok ? '' : ' dashed'}`, text: `${i.name}: ${i.words}` }))));
119
133
  }
120
134
  }
121
- parts.push(el('p', { class: 'foot-note' }, el('a', { href: legacyHref('dropzone'), text: 'Paste long text to a session' }), document.createTextNode(' · '), el('a', { href: legacyHref('sessions'), text: 'Open sessions in the legacy dashboard' })));
135
+ if (m.ok && m.internalCount) {
136
+ const on = m.showInternal;
137
+ parts.push(el('button', { class: 'link-btn sess-internal-toggle', type: 'button', 'aria-pressed': on ? 'true' : 'false',
138
+ text: on ? `Hide ${m.internalCount} internal session${m.internalCount === 1 ? '' : 's'} (jobs and helpers)` : `Show ${m.internalCount} internal session${m.internalCount === 1 ? '' : 's'} (jobs and helpers)`,
139
+ onclick: () => { setShowInternal(!on); paint(); } }));
140
+ }
141
+ parts.push(el('p', { class: 'foot-note' }, el('a', { href: legacyHref('sessions'), text: 'Open sessions in the legacy dashboard' })));
122
142
  setKids(box, parts);
123
143
  if (focused) {
124
144
  const esc = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(focused) : focused.replace(/["\\]/g, '\\$&');
@@ -222,8 +242,37 @@ function openView(box, row) {
222
242
  const input = el('textarea', { class: 'reason-input sess-input', rows: '1', placeholder: 'Type a message…', 'aria-label': 'Message to the session' });
223
243
  const sendBtn = el('button', { class: 'btn-primary btn-inline', type: 'button', text: 'Send', 'data-send': '1' });
224
244
  const keysBtn = el('button', { class: 'btn-quiet', type: 'button', text: 'Keys', 'aria-expanded': 'false', 'data-keys': '1' });
245
+ // Attach: a screenshot or file, picked, pasted or dropped. Saved on this machine, so a
246
+ // session on another machine says so instead of offering it.
247
+ const filePick = el('input', { type: 'file', multiple: true, hidden: true, 'data-file': '1' });
248
+ const attachBtn = el('button', { class: 'btn-quiet sess-attach', type: 'button', 'aria-label': 'Attach a screenshot or file', text: '📎', 'data-attach': '1',
249
+ title: s.remote ? `Attaching works for sessions on this machine; open ${s.machineNickname}’s own console to attach there.` : 'Attach a screenshot or file' });
250
+ if (s.remote) attachBtn.disabled = true;
251
+ const chips = el('div', { class: 'sess-attachments', hidden: true });
252
+ const files = [];
253
+ const paintChips = () => {
254
+ chips.hidden = !files.length;
255
+ setKids(chips, files.map((f, i) => el('span', { class: 'chip sess-file' }, el('span', { text: f.name }),
256
+ el('button', { class: 'link-btn', type: 'button', 'aria-label': `Remove ${f.name}`, text: '✕', onclick: () => { files.splice(i, 1); paintChips(); } }))));
257
+ };
258
+ const addFiles = (list) => {
259
+ if (s.remote) { setStatus(`Attaching works for sessions on this machine; open ${s.machineNickname}’s own console to attach there.`); return; }
260
+ for (const f of list) {
261
+ if (f.size > ATTACH_MAX_BYTES) { setStatus(`${f.name} is too big to attach (up to 8 MB).`); continue; }
262
+ files.push({ name: f.name || `pasted-${Date.now()}.png`, file: f });
263
+ }
264
+ paintChips();
265
+ };
266
+ attachBtn.addEventListener('click', () => filePick.click());
267
+ filePick.addEventListener('change', () => { addFiles([...filePick.files]); filePick.value = ''; });
225
268
  const tray = el('div', { class: 'keys-tray', hidden: true });
226
- const view = { key: row.key, s, titleSub, statusLine, termBox, resume, following: true, pending: null, loaded: HISTORY_FIRST, exhausted: false, loading: false, term: null, fit: null, pre: null };
269
+ // Live (the terminal) or History (the conversation record, which goes back to the start:
270
+ // a full-screen session keeps no terminal scrollback).
271
+ const historyBox = el('div', { class: 'sess-history', hidden: true, 'aria-label': 'Conversation history' });
272
+ const liveTab = el('button', { class: 'chip sess-tab', type: 'button', text: 'Live', 'aria-pressed': 'true', 'data-tab': 'live' });
273
+ const historyTab = el('button', { class: 'chip sess-tab', type: 'button', text: 'History', 'aria-pressed': 'false', 'data-tab': 'history' });
274
+ const tabs = el('div', { class: 'sess-tabs', role: 'group', 'aria-label': 'View' }, liveTab, historyTab);
275
+ const view = { key: row.key, s, titleSub, statusLine, termBox, historyBox, history: null, resume, following: true, pending: null, loaded: HISTORY_FIRST, exhausted: false, loading: false, term: null, fit: null, pre: null };
227
276
  st.view = view;
228
277
 
229
278
  // Keys: the ordinary ones, then Stop separated, red, and asked once.
@@ -239,26 +288,60 @@ function openView(box, row) {
239
288
  tray.append(keyRow, stopBox);
240
289
  keysBtn.addEventListener('click', () => { tray.hidden = !tray.hidden; keysBtn.setAttribute('aria-expanded', tray.hidden ? 'false' : 'true'); });
241
290
 
242
- const sendText = () => {
243
- const text = input.value;
244
- if (!text.trim()) return;
291
+ const sendText = async () => {
292
+ let text = input.value;
293
+ if (!text.trim() && !files.length) return;
294
+ if (!st.wsUp) { setStatus('Not sent: live updates are paused. Your text is still here.'); return; }
295
+ if (files.length) {
296
+ sendBtn.disabled = true; setStatus(`Attaching ${files.length === 1 ? files[0].name : `${files.length} files`}…`);
297
+ const paths = [];
298
+ for (const f of files) {
299
+ const r = await send('POST', `/sessions/${encodeURIComponent(view.s.tmuxSession)}/attachments`, { filename: f.name, dataBase64: await toBase64(f.file) });
300
+ if (!r.ok || !r.data.path) { sendBtn.disabled = false; setStatus(`Not sent: ${f.name} could not be attached (${r.data.error || `status ${r.status}`}). Your message is still here.`); return; }
301
+ paths.push(r.data.path);
302
+ }
303
+ sendBtn.disabled = false; setStatus('');
304
+ text = attachmentMessage(text, paths);
305
+ }
245
306
  if (!wsSend(frame('input', view.s, { text }))) { setStatus('Not sent: live updates are paused. Your text is still here.'); return; }
307
+ files.length = 0; paintChips();
246
308
  input.value = ''; input.rows = 1; input.focus();
247
309
  };
310
+ input.addEventListener('paste', (e) => {
311
+ const pasted = [...((e.clipboardData && e.clipboardData.files) || [])];
312
+ if (pasted.length) { e.preventDefault(); addFiles(pasted); }
313
+ });
314
+ const composer = el('div', { class: 'sess-composer' }, attachBtn, filePick, input, el('div', { class: 'sess-composer-btns' }, keysBtn, sendBtn));
315
+ for (const t of [composer, termBox]) {
316
+ t.addEventListener('dragover', (e) => { if (e.dataTransfer && [...e.dataTransfer.types].includes('Files')) { e.preventDefault(); composer.classList.add('dropping'); } });
317
+ t.addEventListener('dragleave', () => composer.classList.remove('dropping'));
318
+ t.addEventListener('drop', (e) => { if (!e.dataTransfer || !e.dataTransfer.files.length) return; e.preventDefault(); composer.classList.remove('dropping'); addFiles([...e.dataTransfer.files]); });
319
+ }
248
320
  sendBtn.addEventListener('click', sendText);
249
321
  input.addEventListener('input', () => { input.rows = composerRows(input.value); });
250
322
  input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); sendText(); } });
251
323
  more.addEventListener('click', () => moreSheet(view));
252
324
  resume.addEventListener('click', () => backToLive());
325
+ const showTab = (which) => {
326
+ const hist = which === 'history';
327
+ liveTab.setAttribute('aria-pressed', hist ? 'false' : 'true'); historyTab.setAttribute('aria-pressed', hist ? 'true' : 'false');
328
+ termBox.hidden = hist; historyBox.hidden = !hist; if (hist) resume.hidden = true;
329
+ if (hist && !view.history) openHistory(view);
330
+ if (!hist && view.fit) requestAnimationFrame(() => { try { view.fit.fit(); } catch { /* not visible yet */ } });
331
+ };
332
+ liveTab.addEventListener('click', () => showTab('live'));
333
+ historyTab.addEventListener('click', () => showTab('history'));
253
334
 
254
335
  setKids(box, el('div', { class: 'sess-open', 'data-open': row.key },
255
336
  el('div', { class: 'sess-bar' },
256
337
  el('a', { class: 'chip icon-btn sess-back', href: listHref, 'aria-label': 'All sessions', text: '‹' }),
257
338
  el('div', { class: 'sess-titles' }, el('h1', { class: 'sess-title', text: row.title }), titleSub),
258
339
  more),
340
+ tabs,
259
341
  statusLine,
260
- el('div', { class: 'sess-term-wrap' }, termBox, resume),
261
- el('div', { class: 'sess-composer' }, input, el('div', { class: 'sess-composer-btns' }, keysBtn, sendBtn)),
342
+ el('div', { class: 'sess-term-wrap' }, termBox, historyBox, resume),
343
+ chips,
344
+ composer,
262
345
  tray));
263
346
 
264
347
  if (s.remote) setStatus(`Streaming from ${s.machineNickname}…`);
@@ -349,6 +432,50 @@ function sendKey(key) {
349
432
  if (!wsSend(frame('key', st.view.s, { key }))) setStatus('Not sent: live updates are paused.');
350
433
  }
351
434
 
435
+ // ── History: the conversation record, newest at the bottom, older on scroll-up ──
436
+ async function openHistory(view) {
437
+ view.history = { before: null, done: false, loading: false, count: 0 };
438
+ view.historyBox.addEventListener('scroll', () => { if (view.historyBox.scrollTop < 200) loadHistory(view); });
439
+ await loadHistory(view);
440
+ }
441
+
442
+ async function loadHistory(view) {
443
+ const h = view.history;
444
+ if (!h || h.loading || h.done || st.view !== view) return;
445
+ h.loading = true;
446
+ const box = view.historyBox;
447
+ const marker = el('p', { class: 'sess-history-note', text: h.count ? 'Loading earlier messages…' : 'Loading the conversation…' });
448
+ box.prepend(marker);
449
+ const res = await send('GET', historyUrl(view.s, h.before));
450
+ marker.remove();
451
+ h.loading = false;
452
+ if (st.view !== view) return;
453
+ if (!res.ok || !res.data || !Array.isArray(res.data.entries)) {
454
+ h.done = true;
455
+ box.prepend(el('p', { class: 'sess-history-note', text: historyErrorWords(res) }));
456
+ return;
457
+ }
458
+ const first = !h.count;
459
+ const prevHeight = box.scrollHeight, prevTop = box.scrollTop;
460
+ const frag = document.createDocumentFragment();
461
+ for (const e of res.data.entries) {
462
+ const text = historyText(e);
463
+ if (!text) continue;
464
+ const when = e.at ? new Date(e.at).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : null;
465
+ frag.append(e.role === 'tool'
466
+ ? el('p', { class: 'sess-h sess-h-tool', text: `→ ${text}` })
467
+ : el('div', { class: `sess-h sess-h-${e.role}` }, el('div', { class: 'sess-h-who', text: [e.role === 'user' ? 'Message in' : 'Session', when].filter(Boolean).join(' · ') }), el('div', { class: 'sess-h-text', text })));
468
+ }
469
+ h.count += res.data.entries.length;
470
+ h.before = res.data.nextBefore;
471
+ if (h.before == null) { h.done = true; frag.prepend(el('p', { class: 'sess-history-note', text: h.count ? 'The start of this conversation.' : 'Nothing has been said in this session yet.' })); }
472
+ box.prepend(frag);
473
+ if (first) box.scrollTop = box.scrollHeight;
474
+ else box.scrollTop = prevTop + (box.scrollHeight - prevHeight);
475
+ // A short first page may not fill the box: keep loading until it scrolls.
476
+ if (!h.done && box.scrollHeight <= box.clientHeight + 200) loadHistory(view);
477
+ }
478
+
352
479
  function leaveView() {
353
480
  const v = st.view;
354
481
  if (!v) return;
@@ -369,9 +496,6 @@ function moreSheet(view) {
369
496
  ])];
370
497
  const actions = el('div', { class: 'sheet-actions sess-actions' });
371
498
  if (canSwitchDoor(s)) actions.append(el('button', { class: 'btn-primary btn-inline', type: 'button', text: 'Switch door and model', 'data-door': '1', onclick: () => doorSheet(view) }));
372
- actions.append(s.remote
373
- ? note(`Long text can only be stored for sessions on this machine; type it into the composer instead, or open ${s.machineNickname}’s own console.`)
374
- : el('a', { class: 'btn-quiet', href: legacyHref('dropzone'), text: 'Paste long text' }));
375
499
  blocks.push(actions, closeBlock(view));
376
500
  const t = s.telemetry || {};
377
501
  blocks.push(details({
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/commands/server.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/commands/server.ts"],"names":[],"mappings":"AAuHA,OAAO,EAAE,cAAc,EAAwD,MAAM,2BAA2B,CAAC;AAgBjH,OAAO,EAAE,eAAe,EAAiC,MAAM,iCAAiC,CAAC;AA6BjG,OAAO,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AAGvD,OAAO,EAAE,YAAY,EAAE,MAAM,+BAA+B,CAAC;AA6J7D,OAAO,KAAK,EAAW,oBAAoB,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AACjG,OAAO,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AA4GtD,wBAAgB,yBAAyB,CACvC,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,MAAM,EACf,gBAAgB,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAC1C,OAAO,CAUT;AA0ID,UAAU,YAAY;IACpB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;2DACuD;IACvD,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AA0DD,+EAA+E;AAC/E,wBAAgB,qCAAqC,CACnD,SAAS,EAAE,IAAI,CAAC,OAAO,2BAA2B,EAAE,cAAc,EAAE,QAAQ,CAAC,GAAG,IAAI,EACpF,OAAO,EAAE,MAAM,GACd,IAAI,CAEN;AAukBD,wBAAsB,oBAAoB,CACxC,cAAc,EAAE,cAAc,EAC9B,QAAQ,EAAE,eAAe,EACzB,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,MAAM,EACf,aAAa,CAAC,EAAE,MAAM,EACtB,WAAW,CAAC,EAAE,WAAW,EACzB,WAAW,CAAC,EAAE,WAAW,EACzB,kBAAkB,CAAC,EAAE,MAAM;AAC3B;;qCAEqC;AACrC,WAAW,CAAC,EAAE;IAAE,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE;AACzD;;wEAEwE;AACxE,SAAS,CAAC,EAAE;IACV,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC;;;sEAGkE;IAClE,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB,GACA,OAAO,CAAC,MAAM,CAAC,CAiZjB;AA0xBD;;;;;;;;GAQG;AACH,wBAAgB,wBAAwB,CACtC,MAAM,EAAE,YAAY,EACpB,YAAY,EAAE,oBAAoB,GAAG,IAAI,GACxC;IACD,YAAY,EAAE,oBAAoB,GAAG,IAAI,CAAC;IAC1C,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,OAAO,CAAC;IAChB,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,GAAG,UAAU,GAAG,SAAS,CAAC;IAC3C,QAAQ,EAAE,MAAM,CAAC;CAClB,CAqBA;AAED,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,eAAe,EACzB,cAAc,EAAE,cAAc,EAC9B,YAAY,CAAC,EAAE,YAAY,EAC3B,WAAW,CAAC,EAAE,WAAW,EACzB,WAAW,CAAC,EAAE,WAAW,EACzB,iBAAiB,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,EAGvE,UAAU,CAAC,EAAE,MAAM,OAAO,8BAA8B,EAAE,WAAW,GAAG,IAAI,EAK5E,qBAAqB,CAAC,EAAE,MAAM,OAAO,gCAAgC,EAAE,kBAAkB,GAAG,IAAI,EAKhG,mBAAmB,CAAC,EAAE,MAAM,MAAM,GAAG,IAAI,GAAG,SAAS,EAQrD,oBAAoB,CAAC,EAAE,MAAM;IAC3B,YAAY,EAAE,OAAO,kBAAkB,EAAE,oBAAoB,GAAG,IAAI,CAAC;IACrE,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,OAAO,CAAC;IAChB,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,GAAG,UAAU,GAAG,SAAS,CAAC;IAC3C,QAAQ,EAAE,MAAM,CAAC;CAClB,GAAG,IAAI,GACP,IAAI,CAo4BN;AA+pBD,wBAAsB,WAAW,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CA8y1BtE;AAED,wBAAsB,UAAU,CAAC,OAAO,EAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAuDzE;AA+BD,wBAAsB,aAAa,CAAC,OAAO,EAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAkE5E"}
@@ -26536,11 +26536,29 @@ export async function startServer(options) {
26536
26536
  }
26537
26537
  throw new Error(`deliverMessage rejected: ${res.status} ${res.reason ?? ''}`);
26538
26538
  },
26539
- spawnOnMachine: async (machineId, msg) => {
26539
+ // The placement hop is a deliverMessage at the epoch the CAS just
26540
+ // landed, read exactly like a forward. It used to send epoch 0 and
26541
+ // discard the reply: a receiver holding an older record for the
26542
+ // topic NACKed it stale and the router still logged 'spawned'.
26543
+ spawnOnMachine: async (machineId, msg, ownershipEpoch) => {
26540
26544
  const url = peerUrl(machineId);
26541
26545
  if (!url)
26542
26546
  throw new Error(`no peer url for ${machineId}`);
26543
- await meshClient.send({ machineId, url }, { type: 'deliverMessage', session: msg.sessionKey, messageId: msg.messageId, payload: msg.payload, ownershipEpoch: 0 }, 0);
26547
+ const res = await meshClient.send({ machineId, url }, { type: 'deliverMessage', session: msg.sessionKey, messageId: msg.messageId, payload: msg.payload, ownershipEpoch, ...(msg.senderEnvelope ? { senderEnvelope: msg.senderEnvelope } : {}) }, ownershipEpoch);
26548
+ if (res.ok && res.result && typeof res.result === 'object' && 'accepted' in res.result) {
26549
+ return res.result;
26550
+ }
26551
+ throw new Error(`spawn deliverMessage rejected: ${res.status} ${res.reason ?? ''}`);
26552
+ },
26553
+ // Take back a placement whose spawn never landed (see SessionRouter):
26554
+ // only the still-placing record this router put on `placedOn`.
26555
+ reclaimPlacement: (sk, placedOn) => {
26556
+ const cur = ownReg.read(sk);
26557
+ if (!cur || cur.status !== 'placing' || cur.ownerMachineId !== placedOn)
26558
+ return false;
26559
+ const r = ownReg.cas({ type: 'force-claim', machineId: meshSelfId }, { sessionKey: sk, sender: meshSelfId, nonce: `${meshSelfId}:rc:${++routerNonce}` });
26560
+ emitPlacement(sk, r, 'failover', placedOn);
26561
+ return r.ok;
26544
26562
  },
26545
26563
  handleLocally: async () => { },
26546
26564
  // Durable Inbound Message Queue §2.2: tri-state custody taking.