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.
- package/hay/apps/web/src/App.tsx +4 -0
- package/hay/apps/web/src/components/CheckbacksPanel.tsx +156 -28
- package/hay/apps/web/src/components/DigestCard.tsx +13 -4
- package/hay/apps/web/src/components/SessionSwitcher.tsx +24 -12
- package/hay/apps/web/src/components/ViewsPanel.tsx +34 -9
- package/hay/apps/web/src/styles.css +103 -0
- package/hay/apps/web/src/utils/checkbackText.ts +101 -0
- package/hay/apps/web/src/utils/switcherModel.ts +4 -0
- package/hay/apps/web/test/scheduleText.test.ts +37 -0
- package/hop +246 -11
- package/package.json +1 -1
- package/skills/hop-session/SKILL.md +38 -0
- package/tests/terminal-api.integration.test.cjs +69 -0
package/hay/apps/web/src/App.tsx
CHANGED
|
@@ -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 {
|
|
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
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// and the
|
|
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(
|
|
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) => {
|
|
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,
|
|
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={`
|
|
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"
|
|
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
|
|
109
|
+
<p className="checkbacks-empty">Could not load what is scheduled.</p>
|
|
66
110
|
) : (
|
|
67
111
|
<div className="checkbacks-list">
|
|
68
|
-
{
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
<div className="
|
|
73
|
-
|
|
74
|
-
<
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
<
|
|
201
|
+
<details className="sched-fold">
|
|
202
|
+
<summary>Recent ({past.length})</summary>
|
|
82
203
|
{past.map((c) => (
|
|
83
|
-
<div key={c.id} className={"
|
|
84
|
-
<div className="
|
|
85
|
-
<div className="
|
|
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
|
-
|
|
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 ?
|
|
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
|
-
|
|
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 ?
|
|
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
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
648
|
-
|
|
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
|
-
|
|
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,
|
|
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]
|
|
6100
|
-
sendTerminalInput(entry,
|
|
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
|
-
|
|
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, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
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
|
@@ -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".
|