hop2 0.11.80 → 0.11.82

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.
@@ -276,6 +276,8 @@ type SessionInfo = {
276
276
  turnSeen?: boolean;
277
277
  // Scheduled check-backs waiting on this session (hop checkback).
278
278
  checkbacks?: number;
279
+ checkbackNext?: number;
280
+ codexAutomations?: number;
279
281
  attentionReason?: string;
280
282
  attentionNote?: string;
281
283
  cols?: number;
@@ -3301,6 +3303,8 @@ const App = () => {
3301
3303
  agentPhase: s.agentPhase === "working" || s.agentPhase === "done" ? s.agentPhase : null,
3302
3304
  turnSeen: s.turnSeen !== false,
3303
3305
  checkbacks: Number(s.checkbacks) > 0 ? Number(s.checkbacks) : undefined,
3306
+ checkbackNext: Number(s.checkbackNext) > 0 ? Number(s.checkbackNext) : undefined,
3307
+ codexAutomations: Number(s.codexAutomations) > 0 ? Number(s.codexAutomations) : undefined,
3304
3308
  cols: Number.isInteger(s.cols) ? s.cols : undefined,
3305
3309
  rows: Number.isInteger(s.rows) ? s.rows : undefined,
3306
3310
  // These three were silently dropped here, so the switcher never saw
@@ -1,10 +1,15 @@
1
1
  import { useEffect, useState } from "react";
2
- import { describeState, describeTrigger, orderForSession, type Checkback } from "../utils/checkbackText";
2
+ import {
3
+ chipWhen, conditionLabel, dayHeading, describeRrule, describeState, describeTrigger, isTimed,
4
+ nextOfTrigger, orderForSession, relative, timeOfDay, type Checkback, type CodexAutomation
5
+ } from "../utils/checkbackText";
3
6
 
4
- // What is waiting on a session: the check-backs hop will type into it, when,
5
- // and the recent ones it already typed. Opened from the ⏰ chip on a card.
6
- // Pending ones can be cancelled here; scheduling stays with `hop checkback`
7
- // and the agent's own MCP tool.
7
+ // What is scheduled on a session, read like a reminders list: the timed ones
8
+ // under their day, the ones waiting on something (a job, a file, idle) in a
9
+ // list of their own, the Codex automations aimed at the same conversation
10
+ // beside hop's, and the recent past folded away. Opened from a card's chip.
11
+ // hop's own can be cancelled here; scheduling stays with `hop checkback` and
12
+ // the agent's MCP tool, and Codex's automations with Codex.
8
13
 
9
14
  type Props = {
10
15
  session: string;
@@ -15,16 +20,26 @@ type Props = {
15
20
  onChanged?: () => void;
16
21
  };
17
22
 
23
+ type Timed =
24
+ | { source: "hop"; at: number; cb: Checkback }
25
+ | { source: "codex"; at: number; auto: CodexAutomation; rule: string };
26
+
18
27
  export const CheckbacksPanel = ({ session, sessionName, onClose, onChanged }: Props) => {
19
28
  const [items, setItems] = useState<Checkback[] | null>(null);
29
+ const [automations, setAutomations] = useState<CodexAutomation[]>([]);
20
30
  const [failed, setFailed] = useState(false);
21
31
  const [busy, setBusy] = useState<string | null>(null);
32
+ const [open, setOpen] = useState<string | null>(null);
22
33
  const now = Date.now();
23
34
 
24
35
  const load = () => {
25
- fetch("/api/checkbacks", { cache: "no-store" })
36
+ fetch(`/api/checkbacks?session=${encodeURIComponent(session)}`, { cache: "no-store" })
26
37
  .then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
27
- .then((d) => { setItems(Array.isArray(d?.items) ? d.items : []); setFailed(false); })
38
+ .then((d) => {
39
+ setItems(Array.isArray(d?.items) ? d.items : []);
40
+ setAutomations(Array.isArray(d?.automations) ? d.automations : []);
41
+ setFailed(false);
42
+ })
28
43
  .catch(() => { setFailed(true); setItems([]); });
29
44
  };
30
45
  useEffect(() => { load(); }, [session]);
@@ -47,14 +62,43 @@ export const CheckbacksPanel = ({ session, sessionName, onClose, onChanged }: Pr
47
62
 
48
63
  const rows = items ? orderForSession(items, session) : [];
49
64
  const pending = rows.filter((c) => c.status === "pending");
50
- const past = rows.filter((c) => c.status !== "pending").slice(0, 8);
65
+ const past = rows.filter((c) => c.status !== "pending").slice(0, 12);
66
+ // A one-shot Codex automation whose time has passed is not upcoming:
67
+ // Codex keeps it ACTIVE whether or not it ran (some never arrive), so it
68
+ // folds away under "Past due" instead of heading the list as "Overdue".
69
+ const autoTimes = automations.map((auto) => ({ auto, r: describeRrule(auto.rrule, now) }));
70
+ const isPastDue = (r: { next: number | null }, rule: string) => /COUNT=1/.test(rule) && r.next !== null && r.next < now;
71
+ const activeAutos = autoTimes.filter(({ auto, r }) => auto.status === "ACTIVE" && !isPastDue(r, auto.rrule)).map(({ auto }) => auto);
72
+ const pastDueAutos = autoTimes.filter(({ auto, r }) => auto.status === "ACTIVE" && isPastDue(r, auto.rrule));
73
+ const pausedAutos = automations.filter((a) => a.status !== "ACTIVE");
74
+
75
+ // Everything with a clock time, hop's and Codex's together, soonest first.
76
+ const timed: Timed[] = [
77
+ ...pending.filter((c) => isTimed(c.trigger)).map((cb) => ({ source: "hop" as const, at: nextOfTrigger(cb.trigger) ?? now, cb })),
78
+ ...activeAutos.map((auto) => { const r = describeRrule(auto.rrule, now); return { source: "codex" as const, at: r.next ?? Number.MAX_SAFE_INTEGER, auto, rule: r.label }; })
79
+ ].sort((a, b) => a.at - b.at);
80
+ const days: Array<[string, Timed[]]> = [];
81
+ for (const t of timed) {
82
+ const head = t.at === Number.MAX_SAFE_INTEGER ? "Repeating" : dayHeading(t.at, now);
83
+ const last = days[days.length - 1];
84
+ if (last && last[0] === head) last[1].push(t); else days.push([head, [t]]);
85
+ }
86
+ const waiting = pending.filter((c) => !isTimed(c.trigger));
87
+ const nothing = timed.length === 0 && waiting.length === 0;
88
+
89
+ const message = (key: string, text: string) => (
90
+ <div className={"sched-message" + (open === key ? " open" : "")}
91
+ onClick={() => setOpen((o) => (o === key ? null : key))}
92
+ title={open === key ? "" : "Click to read it all"}>{text}</div>
93
+ );
94
+ const who = (c: Checkback) => (c.createdBy === "agent" ? "the agent" : "you");
51
95
 
52
96
  return (
53
97
  <>
54
98
  <div className="views-backdrop" onClick={onClose} />
55
- <div className="views-panel checkbacks-panel" role="dialog" aria-label={`Check-backs for ${sessionName}`}>
99
+ <div className="views-panel checkbacks-panel" role="dialog" aria-label={`Scheduled for ${sessionName}`}>
56
100
  <div className="views-head">
57
- <span className="views-badge">⏰ Check-backs</span>
101
+ <span className="views-badge">Scheduled</span>
58
102
  <span className="checkbacks-session">{sessionName}</span>
59
103
  <span className="views-head-spacer" />
60
104
  <button type="button" className="views-close" aria-label="Close" onClick={onClose}>×</button>
@@ -62,32 +106,116 @@ export const CheckbacksPanel = ({ session, sessionName, onClose, onChanged }: Pr
62
106
  {items === null ? (
63
107
  <p className="checkbacks-empty">Loading…</p>
64
108
  ) : failed ? (
65
- <p className="checkbacks-empty">Could not load check-backs.</p>
109
+ <p className="checkbacks-empty">Could not load what is scheduled.</p>
66
110
  ) : (
67
111
  <div className="checkbacks-list">
68
- {pending.length === 0 && <p className="checkbacks-empty">Nothing waiting on this session.</p>}
69
- {pending.map((c) => (
70
- <div key={c.id} className={"checkback-row pending" + (c.firedAt ? " fired" : "")}>
71
- <div className="checkback-when">{describeTrigger(c.trigger, now)}{c.force ? " · typed even mid-turn" : ""}</div>
72
- <div className="checkback-message">{c.message}</div>
73
- <div className="checkback-foot">
74
- <span className="checkback-state">{describeState(c, now)} · by {c.createdBy === "agent" ? "the agent" : "you"}</span>
75
- <button type="button" className="checkback-cancel" disabled={busy === c.id} onClick={() => cancel(c.id)}>Cancel</button>
76
- </div>
77
- </div>
112
+ {nothing && <p className="checkbacks-empty">Nothing scheduled on this session.</p>}
113
+
114
+ {days.map(([head, list]) => (
115
+ <section key={head} className="sched-day">
116
+ <div className="sched-day-head">{head}</div>
117
+ {list.map((t) => t.source === "hop" ? (
118
+ <div key={t.cb.id} className={"sched-row" + (t.cb.firedAt ? " fired" : "")}>
119
+ <div className="sched-time">{t.at === Number.MAX_SAFE_INTEGER ? "—" : timeOfDay(t.at)}</div>
120
+ <div className="sched-body">
121
+ {message(t.cb.id, t.cb.message)}
122
+ <div className="sched-meta">
123
+ {t.cb.trigger.kind === "every" ? describeTrigger(t.cb.trigger, now) : chipWhen(t.at, now)}
124
+ {t.cb.firedAt ? " · due, waiting for the agent to be idle" : ""}
125
+ {t.cb.force ? " · typed even mid-turn" : ""} · by {who(t.cb)}
126
+ </div>
127
+ </div>
128
+ <button type="button" className="checkback-cancel" disabled={busy === t.cb.id} onClick={() => cancel(t.cb.id)}>Cancel</button>
129
+ </div>
130
+ ) : (
131
+ <div key={"codex-" + t.auto.id} className="sched-row codex">
132
+ <div className="sched-time">{t.at === Number.MAX_SAFE_INTEGER ? "—" : timeOfDay(t.at)}</div>
133
+ <div className="sched-body">
134
+ <div className="sched-title">{t.auto.name}</div>
135
+ {message("codex-" + t.auto.id, t.auto.prompt)}
136
+ <div className="sched-meta">Codex automation · {t.rule}{t.at !== Number.MAX_SAFE_INTEGER && t.at < now ? " · past due" : ""}</div>
137
+ </div>
138
+ <span className="sched-owner" title="Codex runs this one; manage it in Codex">Codex</span>
139
+ </div>
140
+ ))}
141
+ </section>
78
142
  ))}
143
+
144
+ {waiting.length > 0 && (
145
+ <section className="sched-day">
146
+ <div className="sched-day-head">When something happens</div>
147
+ {waiting.map((c) => {
148
+ const cond = conditionLabel(c.trigger);
149
+ return (
150
+ <div key={c.id} className={"sched-row" + (c.firedAt ? " fired" : "")}>
151
+ <div className="sched-time tag">{cond.tag}</div>
152
+ <div className="sched-body">
153
+ {message(c.id, c.message)}
154
+ <div className="sched-meta">
155
+ <code className="sched-cond" title={cond.detail}>{cond.detail}</code>
156
+ </div>
157
+ <div className="sched-meta">
158
+ {c.firedAt ? "happened · waiting for the agent to be idle" : c.trigger.kind === "cmd" ? describeTrigger(c.trigger, now).replace(/^when `.*` succeeds · /, "") : "watching"}
159
+ {c.lastError && !c.firedAt ? ` · ${c.lastError}` : ""} · by {who(c)}
160
+ </div>
161
+ </div>
162
+ <button type="button" className="checkback-cancel" disabled={busy === c.id} onClick={() => cancel(c.id)}>Cancel</button>
163
+ </div>
164
+ );
165
+ })}
166
+ </section>
167
+ )}
168
+
169
+ {pastDueAutos.length > 0 && (
170
+ <details className="sched-fold">
171
+ <summary>Past due Codex automations ({pastDueAutos.length})</summary>
172
+ <p className="checkbacks-hint">Codex still lists these as active, but a one-shot follow-up is not always delivered — check the session before counting on it.</p>
173
+ {pastDueAutos.map(({ auto, r }) => (
174
+ <div key={auto.id} className="sched-row codex past">
175
+ <div className="sched-time tag">was due</div>
176
+ <div className="sched-body">
177
+ <div className="sched-title">{auto.name}</div>
178
+ <div className="sched-meta">{r.next ? `${dayHeading(r.next, now) === "Due now" ? new Date(r.next).toLocaleString([], { weekday: "short", month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }) : timeOfDay(r.next)} · ${relative(r.next, now)}` : r.label}</div>
179
+ </div>
180
+ </div>
181
+ ))}
182
+ </details>
183
+ )}
184
+
185
+ {pausedAutos.length > 0 && (
186
+ <details className="sched-fold">
187
+ <summary>Paused Codex automations ({pausedAutos.length})</summary>
188
+ {pausedAutos.map((a) => (
189
+ <div key={a.id} className="sched-row codex past">
190
+ <div className="sched-time tag">paused</div>
191
+ <div className="sched-body">
192
+ <div className="sched-title">{a.name}</div>
193
+ <div className="sched-meta">{describeRrule(a.rrule, now).label}</div>
194
+ </div>
195
+ </div>
196
+ ))}
197
+ </details>
198
+ )}
199
+
79
200
  {past.length > 0 && (
80
- <>
81
- <div className="checkbacks-past-head">Recent</div>
201
+ <details className="sched-fold">
202
+ <summary>Recent ({past.length})</summary>
82
203
  {past.map((c) => (
83
- <div key={c.id} className={"checkback-row " + c.status}>
84
- <div className="checkback-when">{describeState(c, now)} · {describeTrigger(c.trigger, now)}</div>
85
- <div className="checkback-message">{c.message}</div>
204
+ <div key={c.id} className={"sched-row past " + c.status}>
205
+ <div className="sched-time tag">{c.status === "delivered" ? "sent" : "cancelled"}</div>
206
+ <div className="sched-body">
207
+ {message(c.id, c.message)}
208
+ <div className="sched-meta">{describeState(c, now)}</div>
209
+ </div>
86
210
  </div>
87
211
  ))}
88
- </>
212
+ </details>
89
213
  )}
90
- <p className="checkbacks-hint">Schedule one with <code>hop checkback</code> inside the session, or the agent does it itself over MCP.</p>
214
+
215
+ <p className="checkbacks-hint">
216
+ hop types these into the session when they come due and the agent is idle. Schedule with <code>hop checkback</code> or
217
+ the agent's <code>hop_checkback</code> tool; Codex automations are run by Codex.
218
+ </p>
91
219
  </div>
92
220
  )}
93
221
  </div>
@@ -220,9 +220,16 @@ export const DigestCard = ({ sessions, onOpen }: {
220
220
  <span className="digest-dateline">{sessionLabel(item.session)}</span>
221
221
  <span className="digest-headline">{item.headline}</span>
222
222
  {item.why ? <span className="digest-why">{item.why}</span> : null}
223
- {item.recommendation ? <span className="digest-reco">→ {item.recommendation}</span> : null}
223
+ {item.recommendation ? (
224
+ <span className="digest-reco"><span className="digest-reco-label">Suggested</span>{item.recommendation}</span>
225
+ ) : null}
224
226
  {item.replies?.length ? (
225
- <span className="digest-replies">{item.replies.map((r) => <span key={r} className="digest-reply">{r}</span>)}</span>
227
+ // The ways the agent offered to go — words, not buttons: the card
228
+ // opens the session, where the answer is given.
229
+ <span className="digest-replies">
230
+ <span className="digest-replies-label">Options</span>
231
+ {item.replies.map((r, i) => <span key={r} className="digest-reply">{i > 0 ? " · " : ""}{r}</span>)}
232
+ </span>
226
233
  ) : null}
227
234
  </button>
228
235
  );
@@ -237,7 +244,7 @@ export const DigestCard = ({ sessions, onOpen }: {
237
244
  if (!rows.length) return null;
238
245
  return (
239
246
  <div key={slot} className={"digest-slot slot-" + slot}>
240
- <div className="digest-slot-head">{SLOT_LABEL[slot]}</div>
247
+ <div className="digest-slot-head">{SLOT_LABEL[slot]}<span className="digest-slot-count">{rows.length}</span></div>
241
248
  {rows.map((it) => renderItem(it, s))}
242
249
  </div>
243
250
  );
@@ -351,7 +358,9 @@ export const DigestCard = ({ sessions, onOpen }: {
351
358
  <span className="digest-when">{ago(item.at)}</span>
352
359
  <span className="digest-headline">{item.headline}</span>
353
360
  {item.why ? <span className="digest-why">{item.why}</span> : null}
354
- {item.recommendation ? <span className="digest-reco">→ {item.recommendation}</span> : null}
361
+ {item.recommendation ? (
362
+ <span className="digest-reco"><span className="digest-reco-label">Suggested</span>{item.recommendation}</span>
363
+ ) : null}
355
364
  </div>
356
365
  ))}
357
366
  </div>
@@ -38,6 +38,7 @@ import {
38
38
  insertAfter, describeAccount, type AgentHome, type AgentHomes } from "../utils/switcherModel";
39
39
  import { scanKeyboardProtocol } from "../utils/keyboardProtocol";
40
40
  import { isAppCtrlEnter, isFullscreenChord } from "../utils/fullscreenChord";
41
+ import { chipWhen } from "../utils/checkbackText";
41
42
  import { DigestCard } from "./DigestCard";
42
43
  import { hasUnseenViews, loadViewsSeen } from "./ViewsPanel";
43
44
 
@@ -3087,18 +3088,29 @@ export const SessionSwitcher = ({
3087
3088
  </span>
3088
3089
  )}
3089
3090
  {!current && s.starting && !s.active && <span className="switcher-chip starting">STARTING</span>}
3090
- {!!s.checkbacks && (
3091
- <button
3092
- type="button"
3093
- className="switcher-chip checkback"
3094
- title={`${s.checkbacks} scheduled check-back${s.checkbacks === 1 ? "" : "s"} will be typed into this session — click to see what is waiting`}
3095
- aria-label={`${s.checkbacks} check-back${s.checkbacks === 1 ? "" : "s"} waiting on ${s.displayName}`}
3096
- onClick={(e) => { e.stopPropagation(); onOpenCheckbacks?.(key); }}
3097
- onPointerDown={(e) => e.stopPropagation()}
3098
- >
3099
- ⏰ {s.checkbacks}
3100
- </button>
3101
- )}
3091
+ {(!!s.checkbacks || !!s.codexAutomations) && (() => {
3092
+ // What is scheduled here, hop's and Codex's own: the chip says WHEN
3093
+ // the next timed one is due ("in 12m", "4:30 PM"), or that it is
3094
+ // waiting on something (a job, a file) when nothing has a time.
3095
+ const total = (s.checkbacks || 0) + (s.codexAutomations || 0);
3096
+ const when = s.checkbackNext ? chipWhen(s.checkbackNext, Date.now()) : "waiting";
3097
+ return (
3098
+ <button
3099
+ type="button"
3100
+ className="switcher-chip checkback"
3101
+ title={`${total} scheduled${s.codexAutomations ? ` (${s.codexAutomations} Codex automation${s.codexAutomations === 1 ? "" : "s"})` : ""}${s.checkbackNext ? ` — next ${when}` : " — waiting on a condition"}. Click to see the list.`}
3102
+ aria-label={`${total} scheduled on ${s.displayName}`}
3103
+ onClick={(e) => { e.stopPropagation(); onOpenCheckbacks?.(key); }}
3104
+ onPointerDown={(e) => e.stopPropagation()}
3105
+ >
3106
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
3107
+ <rect x="3" y="5" width="18" height="16" rx="3" /><path d="M3 10h18M8 3v4M16 3v4" />
3108
+ </svg>
3109
+ <span>{when}</span>
3110
+ {total > 1 && <span className="checkback-count">{total}</span>}
3111
+ </button>
3112
+ );
3113
+ })()}
3102
3114
  {s.agentHome && homesFor(s.agent).length > 1 && (() => {
3103
3115
  const known = homesFor(s.agent).find((h) => h.dir === s.agentHome?.dir);
3104
3116
  const out = known?.login && !known.login.loggedIn;
@@ -46,7 +46,10 @@ type ViewItem = {
46
46
  mtime?: number;
47
47
  // "server" is a LIVE proxied localhost server (`hop port`), not a stored
48
48
  // file: it stops working when the server does, which a file never does.
49
- kind?: "file" | "server";
49
+ // "link" points at another app entirely — a Nebula notebook on its own
50
+ // server — opened at `target`; `app` names what opens it.
51
+ kind?: "file" | "server" | "link";
52
+ app?: string;
50
53
  // For a server, the proxy itself. The manifest scan can only see the
51
54
  // redirect page `hop port` leaves behind; going straight to the proxy skips
52
55
  // a visible flash and a round trip.
@@ -104,6 +107,7 @@ export const hasUnseenViews = (
104
107
  // that the column stops lining up. The tag also survives a monospace column.
105
108
  const kindOf = (name: string, kind?: string) => {
106
109
  if (kind === "server") return "LIVE";
110
+ if (kind === "link") return "NB";
107
111
  const ext = (name.split(".").pop() || "").toLowerCase();
108
112
  if (["csv", "tsv"].includes(ext)) return "TBL";
109
113
  if (["mp3", "m4a", "wav", "flac", "aac", "ogg", "opus"].includes(ext)) return "AUD";
@@ -128,13 +132,13 @@ const formatBytes = (n?: number) => {
128
132
  };
129
133
 
130
134
  const hrefFor = (item: ViewItem) =>
131
- item.kind === "server" && item.target ? item.target : item.path;
135
+ (item.kind === "server" || item.kind === "link") && item.target ? item.target : item.path;
132
136
 
133
137
  // The stored file itself, as an attachment: the daemon's /download mode
134
138
  // serves the markdown an agent wrote rather than the page it renders into.
135
139
  // Only files — a server is live, there is nothing to save.
136
140
  const downloadHrefFor = (item: ViewItem) =>
137
- item.kind === "server" ? null : item.path.replace(/\/inline$/, "") + "/download";
141
+ item.kind === "server" || item.kind === "link" ? null : item.path.replace(/\/inline$/, "") + "/download";
138
142
 
139
143
  // A hidden same-origin anchor with `download`: the row is already an <a>
140
144
  // (anchors cannot nest), and window.open would show the file, not save it.
@@ -371,7 +375,8 @@ export const ViewsPanel = ({ session, item, sessions = [], dock = false, onClose
371
375
  };
372
376
 
373
377
  const copyLink = (item: ViewItem) => {
374
- const url = location.origin + hrefFor(item);
378
+ const href = hrefFor(item);
379
+ const url = /^https?:\/\//.test(href) ? href : location.origin + href;
375
380
  navigator.clipboard?.writeText(url).then(() => {
376
381
  setCopied(item.path);
377
382
  window.setTimeout(() => setCopied((c) => (c === item.path ? null : c)), 1400);
@@ -447,12 +452,16 @@ export const ViewsPanel = ({ session, item, sessions = [], dock = false, onClose
447
452
  const rowsFor = (key: string, rows: ViewItem[]) => rows.map((item) => {
448
453
  const fresh = (item.mtime || 0) > (seenAtOpen[key] || 0);
449
454
  const isServer = item.kind === "server";
455
+ const isLink = item.kind === "link";
450
456
  const selected = preview?.path === item.path;
451
457
  // A server has no meaningful size and its filename is an implementation
452
458
  // detail (the redirect page) — it is a door, not a document, so say what
453
459
  // it is instead.
460
+ const linkHost = isLink ? (() => { try { return new URL(item.target || "").host; } catch { return ""; } })() : "";
454
461
  const meta = isServer
455
462
  ? "live server"
463
+ : isLink
464
+ ? [item.app === "nebula" ? "Nebula notebook" : "link", linkHost, relativeTime((item.mtime || 0) * 1000, now)].filter(Boolean).join(" · ")
456
465
  : [
457
466
  // The filename is only worth a line of its own when the title isn't
458
467
  // already showing it.
@@ -464,7 +473,7 @@ export const ViewsPanel = ({ session, item, sessions = [], dock = false, onClose
464
473
  <a
465
474
  key={item.path}
466
475
  className={"views-row" + (fresh ? " fresh" : "")
467
- + (isServer ? " server" : "") + (selected ? " selected" : "")}
476
+ + (isServer || isLink ? " server" : "") + (selected ? " selected" : "")}
468
477
  href={hrefFor(item)}
469
478
  target="_blank"
470
479
  rel="noopener"
@@ -487,7 +496,7 @@ export const ViewsPanel = ({ session, item, sessions = [], dock = false, onClose
487
496
  <button type="button" className="views-act"
488
497
  title="Open in a new tab" aria-label="Open in a new tab"
489
498
  onClick={() => window.open(hrefFor(item), "_blank", "noopener")}>↗</button>
490
- {!isServer && (
499
+ {!isServer && !isLink && (
491
500
  <button type="button" className="views-act"
492
501
  title="Download" aria-label={`Download ${item.name}`}
493
502
  onClick={() => triggerDownload(item)}>⤓</button>
@@ -496,7 +505,7 @@ export const ViewsPanel = ({ session, item, sessions = [], dock = false, onClose
496
505
  title={copied === item.path ? "Copied" : "Copy link"}
497
506
  aria-label="Copy link"
498
507
  onClick={() => copyLink(item)}>{copied === item.path ? "✓" : "⧉"}</button>
499
- {!isServer && (
508
+ {!isServer && !isLink && (
500
509
  /* The FILE, not the page: ?download=1 serves the published bytes as
501
510
  an attachment under their real name (md/csv/json raw, not the
502
511
  rendered view). A window.open, not an anchor: anchors can't nest
@@ -556,8 +565,24 @@ export const ViewsPanel = ({ session, item, sessions = [], dock = false, onClose
556
565
  // stay iframes — sandboxed only for LIVE servers (an arbitrary dev app
557
566
  // must not frame-bust the wall); our own files skip the sandbox, because a
558
567
  // sandboxed iframe disables the browser's PDF viewer.
568
+ // A notebook lives in its own app: the pane says where it opens rather
569
+ // than framing Nebula (another origin, often reached over a forwarded port).
570
+ const linkFile = (() => {
571
+ if (!preview || preview.kind !== "link") return "";
572
+ try { return new URL(preview.target || "").searchParams.get("file") || ""; } catch { return ""; }
573
+ })();
559
574
  const page = showPreview && (
560
- preview.kind !== "server" && isImage(preview.name) ? (
575
+ preview.kind === "link" ? (
576
+ <div className="views-link-card">
577
+ <div className="views-link-app">{preview.app === "nebula" ? "Nebula notebook" : "Link"}</div>
578
+ <div className="views-link-title">{preview.title || preview.name}</div>
579
+ {linkFile && <code className="views-link-path">{linkFile}</code>}
580
+ <a className="views-link-open" href={preview.target} target="_blank" rel="noopener">
581
+ Open in {preview.app === "nebula" ? "Nebula" : "a new tab"} ↗
582
+ </a>
583
+ <div className="views-link-host">{(() => { try { return new URL(preview.target || "").host; } catch { return ""; } })()}</div>
584
+ </div>
585
+ ) : preview.kind !== "server" && isImage(preview.name) ? (
561
586
  <div className="views-media"><img src={preview.path} alt={preview.title || preview.name} /></div>
562
587
  ) : preview.kind !== "server" && isVideo(preview.name) ? (
563
588
  <div className="views-media"><video src={preview.path} controls autoPlay={false} /></div>
@@ -600,7 +625,7 @@ export const ViewsPanel = ({ session, item, sessions = [], dock = false, onClose
600
625
  <span className="views-head-spacer" />
601
626
  {preview && (
602
627
  <span className="views-nav-acts">
603
- {preview.kind !== "server" && (
628
+ {preview.kind !== "server" && preview.kind !== "link" && (
604
629
  <button type="button" className="views-act"
605
630
  title="Download" aria-label={`Download ${preview.name}`}
606
631
  onClick={() => triggerDownload(preview)}>⤓</button>
@@ -5035,3 +5035,106 @@ body.pane-dragging { user-select: none; }
5035
5035
  }
5036
5036
  .math-tip-hint { margin-top: 4px; font-size: 10px; color: var(--ink-muted); }
5037
5037
  .math-tip-error { font-family: var(--font-mono); color: var(--err); }
5038
+
5039
+ /* A Views row that opens in another app (a Nebula notebook): the pane is a
5040
+ door, not a frame. */
5041
+ .views-link-card {
5042
+ margin: auto; max-width: 460px; padding: 28px 24px; display: flex; flex-direction: column;
5043
+ align-items: center; gap: 10px; text-align: center;
5044
+ }
5045
+ .views-link-app { font-size: 12px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: var(--ink-muted); }
5046
+ .views-link-title { font-size: 17px; font-weight: 600; color: var(--ink); overflow-wrap: anywhere; }
5047
+ .views-link-path { font-size: 12px; color: var(--ink-muted); overflow-wrap: anywhere; }
5048
+ .views-link-open {
5049
+ margin-top: 6px; padding: 8px 16px; border-radius: 8px; background: var(--accent, #7c5cff);
5050
+ color: #fff; font-weight: 600; text-decoration: none;
5051
+ }
5052
+ .views-link-host { font-size: 12px; color: var(--ink-muted); }
5053
+
5054
+ /* Scheduled: the card chip names WHEN, with a small calendar glyph; the list
5055
+ reads like reminders — a time column, the message, its provenance. */
5056
+ .switcher-chip.checkback { display: inline-flex; align-items: center; gap: 4px; }
5057
+ .switcher-chip.checkback svg { flex-shrink: 0; opacity: 0.8; }
5058
+ .checkback-count {
5059
+ min-width: 15px; padding: 0 4px; border-radius: 999px; font-size: 10px; line-height: 15px; text-align: center;
5060
+ background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--ink);
5061
+ }
5062
+ .sched-day { display: flex; flex-direction: column; }
5063
+ .sched-day-head {
5064
+ font-size: 11px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--ink-muted);
5065
+ padding: 8px 2px 4px; border-bottom: 1px solid var(--line);
5066
+ }
5067
+ .sched-row {
5068
+ display: grid; grid-template-columns: 72px 1fr auto; gap: 10px; align-items: start;
5069
+ padding: 9px 2px; border-bottom: 1px solid color-mix(in srgb, var(--line) 60%, transparent);
5070
+ }
5071
+ .sched-row.fired .sched-time { color: var(--warn); }
5072
+ .sched-row.past { opacity: 0.7; }
5073
+ .sched-time { font-size: 13px; font-weight: 600; color: var(--ink); font-variant-numeric: tabular-nums; padding-top: 1px; }
5074
+ .sched-time.tag { font-size: 10.5px; font-weight: 700; letter-spacing: 0.03em; text-transform: uppercase; color: var(--ink-muted); padding-top: 3px; }
5075
+ .sched-body { min-width: 0; }
5076
+ .sched-title { font-size: 13px; font-weight: 600; color: var(--ink); }
5077
+ .sched-message {
5078
+ font-size: 13px; color: var(--ink); white-space: pre-wrap; word-break: break-word; cursor: pointer;
5079
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
5080
+ }
5081
+ .sched-message.open { display: block; cursor: default; }
5082
+ .sched-meta { margin-top: 3px; font-size: 11.5px; color: var(--ink-muted); }
5083
+ .sched-cond {
5084
+ display: block; font-family: var(--font-mono); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
5085
+ }
5086
+ .sched-owner {
5087
+ font-size: 10.5px; font-weight: 600; padding: 2px 7px; border-radius: 6px; border: 1px solid var(--line); color: var(--ink-muted);
5088
+ }
5089
+ .sched-fold { margin-top: 6px; }
5090
+ .sched-fold > summary { cursor: pointer; font-size: 12px; font-weight: 600; color: var(--ink-muted); padding: 6px 2px; }
5091
+
5092
+ /* Briefing, read as a page: the SECTION carries the urgency colour (a dot in
5093
+ its heading, a rule beside each item); session names are quiet labels, not
5094
+ a second set of red capitals; lines stop at a readable measure; the
5095
+ agent's recommendation is a tinted "Suggested" note; the reply options are
5096
+ words, since the card itself is what opens the session. */
5097
+ .digest-slot { margin-top: 12px; display: flex; flex-direction: column; }
5098
+ .digest-slot-head {
5099
+ font-family: inherit; font-size: 11px; font-weight: 700; letter-spacing: 0.06em;
5100
+ display: flex; align-items: center; gap: 7px;
5101
+ padding: 0 0 6px; border-bottom: 1px solid var(--line);
5102
+ }
5103
+ .digest-slot-head::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
5104
+ .digest-slot-count { font-weight: 600; color: var(--ink-muted); letter-spacing: 0; }
5105
+ .digest-slot .digest-item {
5106
+ position: relative; margin: 0; border-radius: 0; gap: 3px;
5107
+ padding: 10px 10px 11px 16px;
5108
+ border-bottom: 1px solid color-mix(in srgb, var(--line) 60%, transparent);
5109
+ }
5110
+ .digest-slot .digest-item::before {
5111
+ content: ""; position: absolute; left: 4px; top: 12px; bottom: 12px; width: 3px; border-radius: 2px;
5112
+ background: var(--ink-muted); opacity: 0.35;
5113
+ }
5114
+ .digest-slot.slot-decide .digest-item::before { background: #e5484d; opacity: 0.85; }
5115
+ .digest-slot.slot-watch .digest-item::before { background: #e79d13; opacity: 0.85; }
5116
+ .digest-slot.slot-done .digest-item::before { background: #30a46c; opacity: 0.85; }
5117
+ .digest-slot .digest-item .digest-dateline,
5118
+ .digest-slot .digest-item.needs .digest-dateline,
5119
+ .digest-slot .digest-item.blocked .digest-dateline,
5120
+ .digest-slot .digest-item.done .digest-dateline {
5121
+ color: var(--ink-muted); text-transform: none; letter-spacing: 0; font-size: 11px; font-weight: 600;
5122
+ }
5123
+ .digest-headline { font-size: 13.5px; line-height: 1.35; }
5124
+ .digest-headline, .digest-why, .digest-reco, .digest-replies { max-width: 76ch; }
5125
+ .digest-why { font-size: 12.5px; line-height: 1.45; }
5126
+ .digest-reco {
5127
+ font-style: normal; font-size: 12.5px; line-height: 1.4; margin-top: 4px;
5128
+ padding: 5px 9px; border-radius: 7px; width: fit-content;
5129
+ background: color-mix(in srgb, var(--accent, #7c5cff) 9%, transparent);
5130
+ }
5131
+ .digest-reco-label {
5132
+ font-size: 10px; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase;
5133
+ color: var(--accent, #7c5cff); margin-right: 7px;
5134
+ }
5135
+ .digest-replies { display: block; margin-top: 3px; font-size: 12px; color: var(--ink-muted); }
5136
+ .digest-replies-label { font-size: 10px; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; margin-right: 7px; }
5137
+ .digest-reply { font-family: inherit; font-size: inherit; padding: 0; border: none; border-radius: 0; background: none; color: var(--ink-muted); }
5138
+ /* The item is a <button>, whose global weight made every line bold. */
5139
+ .digest-item .digest-why, .digest-item .digest-replies, .digest-item .digest-reply { font-weight: 400; }
5140
+ .digest-item .digest-reco { font-weight: 500; }
@@ -71,3 +71,104 @@ export const orderForSession = (items: Checkback[], session: string): Checkback[
71
71
  const done = mine.filter((c) => c.status !== "pending").sort((a, b) => ((b.deliveredAt || b.cancelledAt || b.createdAt || 0) - (a.deliveredAt || a.cancelledAt || a.createdAt || 0)));
72
72
  return [...pending, ...done];
73
73
  };
74
+
75
+ // Codex's own scheduled jobs ("automations"), as the daemon reads them from
76
+ // ~/.codex/automations for the thread a session runs. Codex runs them; hop
77
+ // only shows them beside its own.
78
+ export type CodexAutomation = {
79
+ id: string;
80
+ name: string;
81
+ kind: string;
82
+ status: string;
83
+ prompt: string;
84
+ rrule: string;
85
+ thread: string;
86
+ updatedAt?: number | null;
87
+ };
88
+
89
+ const PERIOD_MS: Record<string, number> = { MINUTELY: MIN, HOURLY: HOUR, DAILY: DAY, WEEKLY: 7 * DAY };
90
+
91
+ const parseDtstart = (v: string): number | null => {
92
+ const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)$/.exec(v.trim());
93
+ if (!m) return null;
94
+ const [, y, mo, d, h, mi, s, z] = m;
95
+ return z
96
+ ? Date.UTC(+y, +mo - 1, +d, +h, +mi, +s)
97
+ : new Date(+y, +mo - 1, +d, +h, +mi, +s).getTime();
98
+ };
99
+
100
+ /** An RRULE (with optional DTSTART) as words, and its next run when that can be known. */
101
+ export const describeRrule = (rrule: string, now: number): { label: string; next: number | null } => {
102
+ let start: number | null = null;
103
+ const parts: Record<string, string> = {};
104
+ for (const line of rrule.split(/\r?\n/)) {
105
+ const [key, ...rest] = line.split(":");
106
+ const value = rest.join(":");
107
+ if (key === "DTSTART") start = parseDtstart(value);
108
+ else for (const kv of (key === "RRULE" ? value : line).split(";")) {
109
+ const [k, v] = kv.split("=");
110
+ if (k && v !== undefined) parts[k.trim()] = v.trim();
111
+ }
112
+ }
113
+ const freq = parts.FREQ || "";
114
+ const interval = Math.max(1, Number(parts.INTERVAL) || 1);
115
+ const count = Number(parts.COUNT) || 0;
116
+ if (count === 1 && start !== null) return { label: `at ${clock(start)}`, next: start };
117
+ const step = PERIOD_MS[freq] ? PERIOD_MS[freq] * interval : 0;
118
+ if (parts.BYHOUR !== undefined && (freq === "DAILY" || freq === "WEEKLY")) {
119
+ // Floating local times, one or more hours a day.
120
+ const hours = parts.BYHOUR.split(",").map(Number).filter(Number.isFinite).sort((a, b) => a - b);
121
+ const minute = Number(parts.BYMINUTE) || 0;
122
+ let next: number | null = null;
123
+ for (let dayOffset = 0; dayOffset < 2 && next === null; dayOffset++) {
124
+ for (const h of hours) {
125
+ const d = new Date(now);
126
+ d.setDate(d.getDate() + dayOffset);
127
+ d.setHours(h, minute, 0, 0);
128
+ if (d.getTime() > now) { next = d.getTime(); break; }
129
+ }
130
+ }
131
+ const mm = String(minute).padStart(2, "0");
132
+ const label = hours.length > 3 ? `${hours.length}× a day` : `daily at ${hours.map((h) => `${h}:${mm}`).join(", ")}`;
133
+ return { label, next };
134
+ }
135
+ if (step) {
136
+ const label = `every ${period(step)}`;
137
+ if (start === null) return { label, next: null };
138
+ const k = now <= start ? 0 : Math.ceil((now - start) / step);
139
+ return { label, next: start + k * step };
140
+ }
141
+ return { label: freq ? freq.toLowerCase() : "scheduled", next: start };
142
+ };
143
+
144
+ /** "Today" / "Tomorrow" / "Mon, Oct 19" — the day heading a time sits under. */
145
+ export const dayHeading = (ms: number, now: number): string => {
146
+ const startOf = (t: number) => { const d = new Date(t); d.setHours(0, 0, 0, 0); return d.getTime(); };
147
+ const days = Math.round((startOf(ms) - startOf(now)) / DAY);
148
+ if (days < 0) return "Due now"; // fired; hop types it once the agent is idle
149
+ if (days === 0) return "Today";
150
+ if (days === 1) return "Tomorrow";
151
+ return new Date(ms).toLocaleDateString([], { weekday: "short", month: "short", day: "numeric" });
152
+ };
153
+
154
+ /** The clock time alone ("4:30 PM"), for the left column of a day's list. */
155
+ export const timeOfDay = (ms: number): string =>
156
+ new Date(ms).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
157
+
158
+ /** Short words for the chip: "in 12m" under an hour, else the clock time (with the day when not today). */
159
+ export const chipWhen = (ms: number, now: number): string =>
160
+ ms - now < HOUR && ms > now ? relative(ms, now) : clock(ms);
161
+
162
+ /** A condition trigger in a few words, for the "when something happens" list. */
163
+ export const conditionLabel = (t: CheckbackTrigger): { tag: string; detail: string } => {
164
+ switch (t.kind) {
165
+ case "cmd": return { tag: "on check", detail: t.command };
166
+ case "file": return { tag: t.baselineMtime ? "on change" : "on file", detail: t.path };
167
+ case "idle": return { tag: "on idle", detail: t.afterMs ? `after ${period(t.afterMs)} idle` : "the next time the agent is idle" };
168
+ default: return { tag: "", detail: "" };
169
+ }
170
+ };
171
+
172
+ export const isTimed = (t: CheckbackTrigger) => t.kind === "at" || t.kind === "every";
173
+ export const nextOfTrigger = (t: CheckbackTrigger): number | null =>
174
+ t.kind === "at" ? t.at : t.kind === "every" ? t.next : null;
@@ -59,6 +59,10 @@ export type SwitcherSession = {
59
59
  turnSeen?: boolean;
60
60
  /** Scheduled check-backs waiting on this session (hop checkback). */
61
61
  checkbacks?: number;
62
+ /** When the soonest TIMED one is due (ms); absent when all wait on a condition. */
63
+ checkbackNext?: number;
64
+ /** Active Codex automations aimed at this session's conversation. */
65
+ codexAutomations?: number;
62
66
  agentPermitted?: boolean;
63
67
  cols?: number;
64
68
  rows?: number;
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { conditionLabel, dayHeading, describeRrule } from "../src/utils/checkbackText";
3
+
4
+ const at = (y: number, mo: number, d: number, h = 0, mi = 0) => new Date(y, mo - 1, d, h, mi).getTime();
5
+
6
+ describe("Codex automation schedules", () => {
7
+ const now = at(2026, 10, 16, 12, 0);
8
+ it("a one-shot (DTSTART + COUNT=1) runs at its start", () => {
9
+ const r = describeRrule("DTSTART:20261017T040000Z\nRRULE:FREQ=DAILY;COUNT=1", now);
10
+ expect(r.next).toBe(Date.UTC(2026, 9, 17, 4, 0, 0));
11
+ expect(r.label).toMatch(/^at /);
12
+ });
13
+ it("an interval without a start says how often, with no next time", () => {
14
+ expect(describeRrule("FREQ=HOURLY;INTERVAL=4", now)).toEqual({ label: "every 4h", next: null });
15
+ expect(describeRrule("FREQ=MINUTELY;INTERVAL=15", now).label).toBe("every 15m");
16
+ });
17
+ it("a daily clock time finds today's or tomorrow's slot", () => {
18
+ const r = describeRrule("FREQ=DAILY;BYHOUR=9;BYMINUTE=0;BYSECOND=0", now);
19
+ expect(r.label).toBe("daily at 9:00");
20
+ expect(r.next).toBe(at(2026, 10, 17, 9, 0));
21
+ expect(describeRrule("FREQ=DAILY;BYHOUR=8,18;BYMINUTE=30", now).next).toBe(at(2026, 10, 16, 18, 30));
22
+ });
23
+ });
24
+
25
+ describe("reminder-list headings", () => {
26
+ const now = at(2026, 10, 16, 12, 0);
27
+ it("names today, tomorrow, and later days", () => {
28
+ expect(dayHeading(at(2026, 10, 16, 18), now)).toBe("Today");
29
+ expect(dayHeading(at(2026, 10, 17, 1), now)).toBe("Tomorrow");
30
+ expect(dayHeading(at(2026, 10, 15, 9), now)).toBe("Due now");
31
+ expect(dayHeading(at(2026, 10, 20, 9), now)).not.toMatch(/Today|Tomorrow/);
32
+ });
33
+ it("labels a condition by what it waits on", () => {
34
+ expect(conditionLabel({ kind: "cmd", command: "squeue -j 1 | grep -q DONE" })).toEqual({ tag: "on check", detail: "squeue -j 1 | grep -q DONE" });
35
+ expect(conditionLabel({ kind: "file", path: "out.csv" }).tag).toBe("on file");
36
+ });
37
+ });
package/hop CHANGED
@@ -644,8 +644,11 @@ function rebuildViewManifest() {
644
644
  // than a stored file: it can stop working when the server
645
645
  // does, and `target` is the proxy itself, so a client can
646
646
  // skip the redirect page the scan actually found.
647
- kind: m.kind === 'server' ? 'server' : 'file',
648
- target: m.kind === 'server' ? (m.target || '') : undefined,
647
+ // 'link' points somewhere else entirely — a Nebula
648
+ // notebook on its own server — and `app` says what opens it.
649
+ kind: m.kind === 'server' ? 'server' : m.kind === 'link' ? 'link' : 'file',
650
+ target: m.kind === 'server' || m.kind === 'link' ? (m.target || '') : undefined,
651
+ app: m.kind === 'link' && m.app ? String(m.app) : undefined,
649
652
  port: m.kind === 'server' ? m.port : undefined,
650
653
  // Whether the proxied session behind a server row still
651
654
  // EXISTS. A row whose session evaporated used to open the
@@ -1303,7 +1306,11 @@ function getEffectiveSessionConfig(name) {
1303
1306
  displayName: resolveDisplayNameClaim(name, durable.displayName),
1304
1307
  folderId: durable.folderId,
1305
1308
  createdBy: durable.createdBy,
1306
- cwd: durable.cwd
1309
+ cwd: durable.cwd,
1310
+ // A stopped session has nothing but this record; dropping its
1311
+ // flags listed it as a live, unparked card that would not open.
1312
+ ...(durable.parked !== undefined ? { parked: durable.parked } : {}),
1313
+ ...(durable.archived !== undefined ? { archived: durable.archived } : {})
1307
1314
  };
1308
1315
  }
1309
1316
  const merged = { ...base };
@@ -4668,6 +4675,26 @@ function hopSessionExists(name) {
4668
4675
  return hayRooms.hasRoom(name) || activeSessions[name];
4669
4676
  }
4670
4677
 
4678
+ // Internal names whose durable record says Stop & park (archived). Re-read
4679
+ // only when the records directory changes — the list is polled every few
4680
+ // seconds and a directory listing plus ~100 small reads each time is waste.
4681
+ let stoppedDurableCache = { mtimeMs: -1, names: [] };
4682
+ function listStoppedDurableSessions() {
4683
+ let st;
4684
+ try { st = fs.statSync(CLAUDE_SESSIONS_DIR); } catch (e) { return []; }
4685
+ if (st.mtimeMs === stoppedDurableCache.mtimeMs) return stoppedDurableCache.names;
4686
+ const names = [];
4687
+ let files = [];
4688
+ try { files = fs.readdirSync(CLAUDE_SESSIONS_DIR).filter((f) => f.endsWith('.meta')); } catch (e) { /* none */ }
4689
+ for (const f of files) {
4690
+ const name = f.slice(0, -'.meta'.length);
4691
+ if (!SESSION_NAME_RE.test(name)) continue;
4692
+ if (readClaudeSessionMeta(name)?.archived === true) names.push(name);
4693
+ }
4694
+ stoppedDurableCache = { mtimeMs: st.mtimeMs, names };
4695
+ return names;
4696
+ }
4697
+
4671
4698
  function buildSessionEntries(haySessions, opts = {}) {
4672
4699
  const includeStoredTerminalSessions = opts.includeStoredTerminalSessions !== false;
4673
4700
  const sessionMap = new Map();
@@ -4821,6 +4848,37 @@ function buildSessionEntries(haySessions, opts = {}) {
4821
4848
  });
4822
4849
  }
4823
4850
 
4851
+ // Stopped & parked sessions have no room, and their saved definition is
4852
+ // in-memory only (no workspace file), so a daemon restart dropped them
4853
+ // from every list — the wall's parked section, search, `hop ls` — while
4854
+ // their durable record still says parked + archived and opening one
4855
+ // still resumes the conversation. List them from that record.
4856
+ if (includeStoredTerminalSessions) {
4857
+ for (const name of listStoppedDurableSessions()) {
4858
+ if (sessionMap.has(name)) continue;
4859
+ const effective = getEffectiveSessionConfig(name) || {};
4860
+ const clientSummary = getSessionClientSummary(name);
4861
+ sessionMap.set(name, {
4862
+ name,
4863
+ windows: null,
4864
+ attached: false,
4865
+ type: 'terminal',
4866
+ live: false,
4867
+ displayName: effective.displayName || toDisplaySessionName(name),
4868
+ folderId: effective.folderId || null,
4869
+ internalName: name,
4870
+ cwd: effective.cwd,
4871
+ workspaceName: resolveSessionWorkspaceName(effective),
4872
+ createdBy: effective.createdBy || 'user',
4873
+ agentPermitted: effective.agentPermitted === true,
4874
+ ...sessionAgentFields(name, effective),
4875
+ localCliCount: clientSummary.localCliCount,
4876
+ hasLocalCli: clientSummary.hasLocalCli,
4877
+ ...getSessionSeenInfo(name)
4878
+ });
4879
+ }
4880
+ }
4881
+
4824
4882
  const folderIndex = new Map();
4825
4883
  folderIndex.set('root', -1);
4826
4884
  sessionStore.order.folders.forEach((id, index) => {
@@ -6022,6 +6080,73 @@ function saveCheckbacks() {
6022
6080
  function pendingCheckbacksFor(internalName) {
6023
6081
  return loadCheckbacks().items.filter((c) => c.status === 'pending' && c.session === internalName).length;
6024
6082
  }
6083
+ // When the soonest TIMED check-back on a session is due (ms), or null when
6084
+ // everything waiting is a condition (a job, a file, idle) with no clock time.
6085
+ function nextCheckbackAt(internalName) {
6086
+ let next = null;
6087
+ for (const c of loadCheckbacks().items) {
6088
+ if (c.status !== 'pending' || c.session !== internalName) continue;
6089
+ const t = c.trigger || {};
6090
+ const at = t.kind === 'at' ? t.at : t.kind === 'every' ? t.next : null;
6091
+ if (Number.isFinite(at) && (next === null || at < next)) next = at;
6092
+ }
6093
+ return next;
6094
+ }
6095
+
6096
+ // Codex keeps its own scheduled jobs ("automations", ~/.codex/automations/
6097
+ // <id>/automation.toml) aimed at a THREAD. Agents in hop's Codex sessions
6098
+ // create them, so a session's schedule is incomplete without them. Read-only
6099
+ // here: Codex owns and runs them. Cached briefly; the files change rarely.
6100
+ let codexAutomationCache = { at: 0, items: [] };
6101
+ function readCodexAutomations() {
6102
+ if (Date.now() - codexAutomationCache.at < 30_000) return codexAutomationCache.items;
6103
+ const root = path.join(process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), 'automations');
6104
+ const items = [];
6105
+ let names = [];
6106
+ try { names = fs.readdirSync(root); } catch (e) { /* none */ }
6107
+ for (const name of names) {
6108
+ let text = '';
6109
+ try { text = fs.readFileSync(path.join(root, name, 'automation.toml'), 'utf8'); } catch (e) { continue; }
6110
+ const field = (k) => {
6111
+ const m = text.match(new RegExp(`^${k}\\s*=\\s*(?:"((?:[^"\\\\]|\\\\.)*)"|(\\S+))`, 'm'));
6112
+ if (!m) return null;
6113
+ return m[1] !== undefined ? m[1].replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\') : m[2];
6114
+ };
6115
+ const thread = field('target_thread_id');
6116
+ if (!thread) continue;
6117
+ items.push({
6118
+ id: field('id') || name,
6119
+ name: field('name') || name,
6120
+ kind: field('kind') || 'automation',
6121
+ status: field('status') || 'ACTIVE',
6122
+ prompt: field('prompt') || '',
6123
+ rrule: field('rrule') || '',
6124
+ thread,
6125
+ updatedAt: Number(field('updated_at')) || null
6126
+ });
6127
+ }
6128
+ codexAutomationCache = { at: Date.now(), items };
6129
+ return items;
6130
+ }
6131
+ // A one-shot (COUNT=1) whose start has passed. Codex leaves these ACTIVE
6132
+ // whether or not they ran — Accessibility's 10-15 follow-up never arrived in
6133
+ // its thread — so they are not "upcoming", and the card's count skips them.
6134
+ function codexAutomationPastDue(a, now = Date.now()) {
6135
+ const rule = String(a?.rrule || '');
6136
+ if (!/(^|[;:\n])COUNT=1(;|$|\n)/.test(rule)) return false;
6137
+ const m = rule.match(/DTSTART:(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)/);
6138
+ if (!m) return false;
6139
+ const [, y, mo, d, h, mi, sec, z] = m;
6140
+ const at = z ? Date.UTC(+y, +mo - 1, +d, +h, +mi, +sec) : new Date(+y, +mo - 1, +d, +h, +mi, +sec).getTime();
6141
+ return at < now;
6142
+ }
6143
+ function codexAutomationsForSession(internalName) {
6144
+ const turn = readTurnInfo(internalName);
6145
+ if (!turn || turn.agent !== 'codex' || !turn.sessionId) return [];
6146
+ // The notify hook already records the ROOT thread — the one automations
6147
+ // target — so no rollout walk here (the wall polls this list).
6148
+ return readCodexAutomations().filter((a) => a.thread === turn.sessionId);
6149
+ }
6025
6150
  // Prune delivered/cancelled records older than a day so the file stays small.
6026
6151
  function pruneCheckbacks(now = Date.now()) {
6027
6152
  const store = loadCheckbacks();
@@ -6078,7 +6203,10 @@ async function composerStillHolds(internalName, message) {
6078
6203
  return holds;
6079
6204
  } catch (e) { if (process.env.HOP_CHECKBACK_DEBUG) log(`[checkback] composer check failed: ${e?.message || e}`, DIM); return false; }
6080
6205
  }
6081
- async function typeAndSubmit(entry, internalName, message) {
6206
+ // `submitKey`: Enter by default. Codex treats Enter DURING a turn as a steer —
6207
+ // it interrupts the model to inject the message — and Tab as "queue it for
6208
+ // when this turn ends"; a forced check-back typed into a busy Codex queues.
6209
+ async function typeAndSubmit(entry, internalName, message, submitKey = '\r') {
6082
6210
  // The attach returns before its socket is open; type only once it is,
6083
6211
  // or the text and the Enter leave in one burst (see adapterBundle.ready).
6084
6212
  const t0 = Date.now();
@@ -6091,13 +6219,13 @@ async function typeAndSubmit(entry, internalName, message) {
6091
6219
  // Codex folds keys that arrive within ~half a second of the text into a
6092
6220
  // paste, Enter included (measured: 400ms failed, 1s submitted).
6093
6221
  await sleepMs(1000);
6094
- dbg('sending Enter');
6095
- sendTerminalInput(entry, '\r');
6222
+ dbg(submitKey === '\t' ? 'sending Tab (queue)' : 'sending Enter');
6223
+ sendTerminalInput(entry, submitKey);
6096
6224
  for (let attempt = 0; attempt < 2; attempt++) {
6097
6225
  await sleepMs(1500);
6098
6226
  if (!(await composerStillHolds(internalName, message))) return;
6099
- if (process.env.HOP_CHECKBACK_DEBUG) log(`[checkback] Enter did not take (attempt ${attempt + 1}); sending it again`, DIM);
6100
- sendTerminalInput(entry, '\r');
6227
+ if (process.env.HOP_CHECKBACK_DEBUG) log(`[checkback] submit did not take (attempt ${attempt + 1}); sending it again`, DIM);
6228
+ sendTerminalInput(entry, submitKey);
6101
6229
  }
6102
6230
  }
6103
6231
 
@@ -6151,7 +6279,9 @@ async function runCheckbackSweep() {
6151
6279
  continue;
6152
6280
  }
6153
6281
  const e2 = await attachTerminalApiClient(cb.session, entry.displayName || cb.session, { actor: 'system', label: 'checkback', typeOnly: true });
6154
- try { await typeAndSubmit(e2, cb.session, cb.message); }
6282
+ // Forced into a Codex mid-turn: queue (Tab), never steer (Enter).
6283
+ const queueIntoCodex = phase === 'working' && readTurnInfo(cb.session)?.agent === 'codex';
6284
+ try { await typeAndSubmit(e2, cb.session, cb.message, queueIntoCodex ? '\t' : '\r'); }
6155
6285
  finally { try { closeTerminalApiEntry(e2.id); } catch (e) { /* closed */ } }
6156
6286
  Object.assign(cb, checkbacks.afterDelivery(cb, now));
6157
6287
  dirty = true;
@@ -12355,6 +12485,8 @@ happens until someone clicks it.
12355
12485
  ...(phaseBySession.get(s.internalName) || { agentPhase: null, turnSeen: true, turnAt: null }),
12356
12486
  // Scheduled check-backs waiting on this session (hop checkback).
12357
12487
  checkbacks: pendingCheckbacksFor(s.internalName) || undefined,
12488
+ checkbackNext: nextCheckbackAt(s.internalName) || undefined,
12489
+ codexAutomations: codexAutomationsForSession(s.internalName).filter((a) => a.status === 'ACTIVE' && !codexAutomationPastDue(a)).length || undefined,
12358
12490
  lastActivityAt: s.lastActivityAt,
12359
12491
  // For sessions armed with notify-on-finish, each completed turn
12360
12492
  // adds to bellSeq so the phone treats a finished response like a
@@ -13616,8 +13748,12 @@ happens until someone clicks it.
13616
13748
  if (parsedUrl.pathname === '/api/checkbacks' && req.method === 'GET') {
13617
13749
  const now = Date.now();
13618
13750
  const items = loadCheckbacks().items.map((c) => ({ ...c, sessionName: getSessionDisplayName(c.session) || c.session, line: checkbacks.describe(c, now) }));
13751
+ // ?session=: also the Codex automations aimed at that session's thread.
13752
+ const forSession = typeof parsedUrl.query.session === 'string' && parsedUrl.query.session
13753
+ ? resolveSessionInternalName(parsedUrl.query.session, parsedUrl.query.session) : null;
13754
+ const automations = forSession ? codexAutomationsForSession(forSession) : undefined;
13619
13755
  res.writeHead(200, { 'Content-Type': 'application/json' });
13620
- res.end(JSON.stringify({ items, now }));
13756
+ res.end(JSON.stringify({ items, now, ...(automations ? { automations } : {}) }));
13621
13757
  return;
13622
13758
  }
13623
13759
  if (parsedUrl.pathname === '/api/checkbacks' && req.method === 'POST') {
@@ -16836,6 +16972,7 @@ const HELP_SECTIONS = [
16836
16972
  `hop view <file...> Publish HTML/PDF/image/video/markdown behind hop's auth and print the link`,
16837
16973
  `hop view --title "..." Say what the result IS — clients lead with this, not the filename`,
16838
16974
  `hop view --name x.md - Publish what's on stdin (no temp file needed)`,
16975
+ `hop view --nebula <nb> Link a notebook on a Nebula server; opens in your Nebula UI (hop config nebula <url>)`,
16839
16976
  `hop view --list [--all] What this session has published (--all: the whole fleet)`,
16840
16977
  `hop view --rm <name> Unpublish one`,
16841
16978
  `hop view --clear [--all] Clear published copies — frees hop's space, never touches source files`,
@@ -18158,8 +18295,9 @@ async function main() {
18158
18295
  } catch (e) { /* publish under the name given */ }
18159
18296
  }
18160
18297
  }
18298
+ const nebulaUrlArg = flagValue('--nebula-url');
18161
18299
  const consumed = new Set(['--title', title, '--name', stdinName, '--session', wantSessionArg,
18162
- '--list', '--rm', ...removals]);
18300
+ '--nebula-url', nebulaUrlArg, '--list', '--rm', ...removals]);
18163
18301
  const files = argv.filter((a) => !consumed.has(a) && (a === '-' || !a.startsWith('-')));
18164
18302
  // WHICH session a result belongs to is the whole filing system: the
18165
18303
  // chip that lights, the panel it appears in, the conversation it
@@ -18219,6 +18357,80 @@ async function main() {
18219
18357
  log(`Removed ${gone} view${gone === 1 ? '' : 's'}.`, gone ? GREEN : RESET);
18220
18358
  process.exit(gone ? 0 : 1);
18221
18359
  }
18360
+ // A Nebula notebook is not a file to copy: it lives — and runs — on
18361
+ // its Nebula server, often a cluster this machine reaches through a
18362
+ // forwarded port (ssh -L 3000:…). The view is a LINK that opens the
18363
+ // notebook in that Nebula UI, live kernel and all; the path is the
18364
+ // one the Nebula server sees.
18365
+ if (argv.includes('--nebula')) {
18366
+ const base = String(nebulaUrlArg || process.env.HOP_NEBULA_URL || getHopConfig().nebula_url
18367
+ || 'http://localhost:3000').replace(/\/+$/, '');
18368
+ if (!/^https?:\/\/[^/\s]+$/.test(base)) {
18369
+ log(`hop view --nebula: not a Nebula address: ${base}`, RED);
18370
+ process.exit(2);
18371
+ }
18372
+ if (!files.length) {
18373
+ log('Usage: hop view --nebula [--title "..."] [--nebula-url http://localhost:3000] <notebook path on the Nebula server>...', YELLOW);
18374
+ log(` Default Nebula: ${base} (hop config nebula <url> to change it)`, DIM);
18375
+ process.exit(1);
18376
+ }
18377
+ if (!session) { callerSessionOrExplain('hop view'); process.exit(2); }
18378
+ const getJson = async (u) => {
18379
+ try {
18380
+ const r = await fetch(u, { signal: AbortSignal.timeout(5000) });
18381
+ return { status: r.status, body: await r.json().catch(() => null) };
18382
+ } catch (e) { return null; }
18383
+ };
18384
+ const health = await getJson(`${base}/api/health`);
18385
+ const reachable = !!(health && health.status === 200 && health.body);
18386
+ if (!reachable) {
18387
+ log(`hop view: Nebula at ${base} is not answering — filing the link anyway; it opens once the server (or its port forward) is up.`, YELLOW);
18388
+ }
18389
+ const serverCwd = reachable && typeof health.body.cwd === 'string' ? health.body.cwd : null;
18390
+ fs.mkdirSync(destDir, { recursive: true });
18391
+ const meta = readViewMeta(session);
18392
+ let failed = 0;
18393
+ for (const raw of files) {
18394
+ // Relative paths are the SERVER's relative paths: pin them to
18395
+ // its working directory so the link means one notebook.
18396
+ const nb = raw.startsWith('/') || !serverCwd ? raw : path.posix.join(serverCwd, raw);
18397
+ if (reachable) {
18398
+ const st = await getJson(`${base}/api/fs/file-mtime?path=${encodeURIComponent(nb)}`);
18399
+ if (st && st.status === 404) {
18400
+ log(`hop view: Nebula has no notebook at ${nb}`, RED);
18401
+ failed += 1;
18402
+ continue;
18403
+ }
18404
+ }
18405
+ const target = `${base}/?file=${encodeURIComponent(nb)}`;
18406
+ const label = path.posix.basename(nb);
18407
+ const artifactName = `${label.replace(/[^\w.-]/g, '_')}.nebula.html`;
18408
+ const attr = (v) => String(v).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
18409
+ // The redirect page is what older clients open; current ones
18410
+ // read `target` from the metadata and go straight there.
18411
+ fs.writeFileSync(path.join(destDir, artifactName), [
18412
+ '<!DOCTYPE html><html><head><meta charset="utf-8">',
18413
+ `<title>${attr(label)}</title>`,
18414
+ `<meta http-equiv="refresh" content="0;url=${attr(target)}">`,
18415
+ `</head><body>Opening <a href="${attr(target)}">${attr(label)}</a> in Nebula…</body></html>`
18416
+ ].join(''));
18417
+ meta[artifactName] = {
18418
+ ...(meta[artifactName] || {}),
18419
+ title: title || `${label} — Nebula notebook`,
18420
+ kind: 'link',
18421
+ app: 'nebula',
18422
+ target
18423
+ };
18424
+ console.log(`View: ${target}`);
18425
+ }
18426
+ writeViewMeta(session, meta);
18427
+ rebuildViewManifest();
18428
+ if (files.length > failed) {
18429
+ console.log(`Filed under ${getSessionDisplayName(session) || session}.`);
18430
+ process.stdout.write('\x07');
18431
+ }
18432
+ process.exit(failed ? 1 : 0);
18433
+ }
18222
18434
  if (files.length === 0) {
18223
18435
  log('Usage: hop view [--title "what this is"] <file...>', YELLOW);
18224
18436
  log(' hop view --name report.md --title "..." - (read the file from stdin)', DIM);
@@ -18416,6 +18628,29 @@ async function main() {
18416
18628
  // Handle `hop config` command
18417
18629
  if (process.argv[2] === 'config') {
18418
18630
  const subCmd = process.argv[3];
18631
+ // Where `hop view --nebula` links open: the Nebula UI as THIS
18632
+ // machine reaches it (often a port forward to a cluster server).
18633
+ if (subCmd === 'nebula') {
18634
+ const value = String(process.argv[4] || '').trim().replace(/\/+$/, '');
18635
+ const cfg = getHopConfig();
18636
+ if (value === 'reset') {
18637
+ delete cfg.nebula_url;
18638
+ saveHopConfig(cfg);
18639
+ log('Nebula links open at http://localhost:3000 (the default).', GREEN);
18640
+ } else if (value) {
18641
+ if (!/^https?:\/\/[^/\s]+$/.test(value)) {
18642
+ log(`Not a Nebula address: ${value} — e.g. http://localhost:3000`, RED);
18643
+ process.exit(1);
18644
+ }
18645
+ cfg.nebula_url = value;
18646
+ saveHopConfig(cfg);
18647
+ log(`Nebula links open at ${value}.`, GREEN);
18648
+ } else {
18649
+ log(`Nebula: ${BOLD}${cfg.nebula_url || 'http://localhost:3000'}${RESET}${cfg.nebula_url ? '' : ' (default)'}`, RESET);
18650
+ log(`Change with: ${CYAN}hop config nebula <url>${RESET} (reset: hop config nebula reset)`, DIM);
18651
+ }
18652
+ process.exit(0);
18653
+ }
18419
18654
  // How restored sessions answer claude's resume question.
18420
18655
  if (subCmd === 'resume' || subCmd === 'resume-mode') {
18421
18656
  const modeArg = String(process.argv[4] || '').toLowerCase();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hop2",
3
- "version": "0.11.80",
3
+ "version": "0.11.82",
4
4
  "description": "🐰 Hop into your terminal from anywhere. Secure remote shell access with 2FA via Cloudflare Tunnel. Next-gen rewrite with native terminal sharing.",
5
5
  "main": "hop",
6
6
  "bin": {
@@ -73,6 +73,18 @@ quotes), not dumped as raw text:
73
73
  generate_summary | hop view --name findings.md --title "What the profiler found" -
74
74
  ```
75
75
 
76
+ A **Nebula notebook** is handed over as a link, not a copy — it lives (and
77
+ runs) on its Nebula server, and the human opens it in their own Nebula UI
78
+ with the kernel live. Give the path **as the Nebula server sees it** (a
79
+ relative path is resolved against the server's working directory):
80
+
81
+ ```bash
82
+ hop view --nebula --title "Fit diagnostics, run 12" /gpfs/data/lab/runs/fit.ipynb
83
+ ```
84
+
85
+ The link opens at the human's Nebula address (`hop config nebula`, default
86
+ `http://localhost:3000`); a notebook the server doesn't have is refused.
87
+
76
88
  Reach for this whenever the answer is long enough that reading it in a
77
89
  terminal is worse than reading it as a page: comparisons, tables of numbers,
78
90
  anything with structure. A table in the scrollback of a phone terminal is
@@ -133,6 +145,32 @@ Write the message as the prompt your future self needs — it arrives with
133
145
  no other context. Never poll in a loop or sleep to wait for a job; schedule
134
146
  a check-back and finish.
135
147
 
148
+ **How it arrives.** hop types the message only when you are idle (between
149
+ turns), so it lands as a normal new prompt for both Claude and Codex — you
150
+ need not think about Enter vs Tab. A forced one (`--force`, typed even
151
+ mid-turn) is queued into a busy Codex with Tab, never sent with Enter, which
152
+ in Codex would interrupt the running turn to steer it; Claude queues a
153
+ mid-turn message on its own.
154
+
155
+ **Your own scheduler, or hop's?** If your agent has a built-in scheduler
156
+ (Claude Code's `/loop` and wakeups, Codex automations), it is fine for a
157
+ plain "remind me in 30 minutes" inside a session that will stay up. Use
158
+ hop's when any of these matter — they are what only hop does here:
159
+
160
+ - **Wait on a condition, not a clock:** a job finishing (`--when-cmd`), a
161
+ file appearing or changing (`--when-file`), the agent going idle.
162
+ - **Survive restarts:** hop's schedule lives in the daemon, so it still
163
+ fires after the agent, the terminal or the Mac restarts — it waits until
164
+ the agent is back on screen, and never types into a bare shell.
165
+ - **Reach another terminal:** schedule into a different session
166
+ (`--session <name>`) — hand work to another agent, Claude or Codex alike.
167
+ - **Be visible:** the human sees every pending one on the session card and
168
+ can cancel it there.
169
+
170
+ When one wait has several exits (done / failed / deadline), cancel the
171
+ others once one fires (`hop checkback --cancel <id>`) — they don't cancel
172
+ each other.
173
+
136
174
  ## Asking for attention — two tiers
137
175
 
138
176
  **Finished something, or pausing normally?** Ring the bell:
@@ -870,6 +870,75 @@ test('fork falls past a poisoned record cwd to the durable one', async () => {
870
870
  });
871
871
 
872
872
 
873
+ // A Stop & parked session has no room and its saved definition lives only in
874
+ // memory, so after a daemon restart only its durable record remains. It must
875
+ // still be LISTED — parked and stopped — or the wall's parked section and
876
+ // search lose it while opening it would still resume the conversation.
877
+ test('a stopped & parked session known only from its record is listed, parked', async () => {
878
+ const name = 'parkedonlyrecord';
879
+ await fs.mkdir(path.join(hopHome, 'claude-sessions'), { recursive: true });
880
+ await fs.writeFile(path.join(hopHome, 'claude-sessions', `${name}.meta`), JSON.stringify({
881
+ internalName: name, displayName: 'lightscope-probe', cwd: tempDir, createdBy: 'user', parked: true, archived: true
882
+ }), { mode: 0o600 });
883
+ try {
884
+ const list = await requestJson(state.port, state.sessionSecret, 'GET', '/api/sessions');
885
+ const hit = list.data.sessions.find((s) => s.internalName === name);
886
+ assert.ok(hit, 'the stopped session is in the list');
887
+ assert.equal(hit.displayName, 'lightscope-probe');
888
+ assert.equal(hit.parked, true);
889
+ assert.equal(hit.archived, true);
890
+ assert.equal(hit.live, false);
891
+ } finally {
892
+ await fs.rm(path.join(hopHome, 'claude-sessions', `${name}.meta`), { force: true });
893
+ }
894
+ });
895
+
896
+
897
+ // `hop view --nebula`: a notebook on a Nebula server becomes a LINK that
898
+ // opens in the Nebula UI this machine reaches. A relative path is pinned to
899
+ // the server's working directory, and a notebook the server doesn't have is
900
+ // refused rather than filed as a dead link.
901
+ test('hop view --nebula files a link to the notebook on its Nebula server', async () => {
902
+ const { execFile } = require('node:child_process');
903
+ const nebula = http.createServer((req, res) => {
904
+ const u = new URL(req.url, 'http://x');
905
+ res.setHeader('Content-Type', 'application/json');
906
+ if (u.pathname === '/api/health') return res.end(JSON.stringify({ status: 'ok', cwd: '/srv/lab' }));
907
+ if (u.pathname === '/api/fs/file-mtime') {
908
+ if (u.searchParams.get('path') === '/srv/lab/runs/fit.ipynb') return res.end(JSON.stringify({ mtime: 1 }));
909
+ res.statusCode = 404; return res.end(JSON.stringify({ detail: 'File not found' }));
910
+ }
911
+ res.statusCode = 404; res.end('{}');
912
+ });
913
+ await new Promise((r) => nebula.listen(0, '127.0.0.1', r));
914
+ const base = `http://127.0.0.1:${nebula.address().port}`;
915
+ const run = (args) => new Promise((resolve) => execFile(process.execPath,
916
+ [path.join(__dirname, '..', 'hop'), 'view', '--session', 'nebulaprobe', ...args],
917
+ { env: daemonEnv, timeout: 30000 }, (err, stdout, stderr) => resolve({ code: err ? err.code : 0, out: stdout + stderr })));
918
+ try {
919
+ const ok = await run(['--nebula', '--nebula-url', base, '--title', 'Fit results', 'runs/fit.ipynb']);
920
+ assert.equal(ok.code, 0, ok.out);
921
+ const want = `${base}/?file=${encodeURIComponent('/srv/lab/runs/fit.ipynb')}`;
922
+ assert.ok(ok.out.includes(`View: ${want}`), ok.out);
923
+
924
+ const missing = await run(['--nebula', '--nebula-url', base, 'runs/gone.ipynb']);
925
+ assert.notEqual(missing.code, 0);
926
+ assert.match(missing.out, /no notebook at \/srv\/lab\/runs\/gone\.ipynb/);
927
+
928
+ const views = await requestJson(state.port, state.sessionSecret, 'GET', '/api/views');
929
+ const mine = views.data.items.filter((i) => i.session === 'nebulaprobe');
930
+ assert.equal(mine.length, 1, JSON.stringify(mine));
931
+ assert.equal(mine[0].kind, 'link');
932
+ assert.equal(mine[0].app, 'nebula');
933
+ assert.equal(mine[0].title, 'Fit results');
934
+ assert.equal(mine[0].target, want);
935
+ } finally {
936
+ nebula.close();
937
+ await fs.rm(path.join(hopHome, 'views', 'nebulaprobe'), { recursive: true, force: true });
938
+ }
939
+ });
940
+
941
+
873
942
  // A view the browser cannot display (pptx, zip) is saved instead of shown.
874
943
  // Opening it must still name the file — without a Content-Disposition name
875
944
  // the browser took the URL's last segment and every download read "inline".