@gmickel/gno 2.4.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/README.md +69 -6
  2. package/assets/skill/README.md +5 -1
  3. package/assets/skill/SKILL.md +98 -1
  4. package/assets/skill/cli-reference.md +140 -0
  5. package/assets/skill/examples.md +45 -0
  6. package/assets/skill/mcp-reference.md +62 -0
  7. package/assets/skill/recipes/capture-and-file.md +6 -0
  8. package/assets/skill/recipes/memory-file-decision.md +6 -0
  9. package/assets/skill/recipes/memory-supersede-fact.md +5 -0
  10. package/assets/skill/recipes/session-evidence-lookup.md +98 -0
  11. package/assets/spa-production.json.gz +0 -0
  12. package/browser-extension/artifacts/{gno-browser-clipper-v2.4.0.zip → gno-browser-clipper-v2.6.0.zip} +0 -0
  13. package/browser-extension/artifacts/gno-browser-clipper-v2.6.0.zip.sha256 +1 -0
  14. package/browser-extension/dist/manifest.json +1 -1
  15. package/package.json +2 -1
  16. package/spec/cli.md +358 -24
  17. package/spec/compiled-context.md +68 -0
  18. package/spec/mcp.md +223 -2
  19. package/spec/output-schemas/capture-receipt.schema.json +3 -0
  20. package/spec/output-schemas/compiled-context-check.schema.json +44 -0
  21. package/spec/output-schemas/compiled-context-file.schema.json +165 -0
  22. package/spec/output-schemas/compiled-context-preview.schema.json +142 -0
  23. package/spec/output-schemas/mcp-capture-result.schema.json +3 -0
  24. package/spec/output-schemas/memory-remember.schema.json +8 -2
  25. package/spec/output-schemas/request-status.schema.json +113 -0
  26. package/spec/output-schemas/sessions-automation-run.schema.json +46 -0
  27. package/spec/output-schemas/sessions-discovery.schema.json +38 -0
  28. package/spec/output-schemas/sessions-import-receipt.schema.json +156 -0
  29. package/spec/output-schemas/sessions-status.schema.json +432 -0
  30. package/src/app/compiled-context-files.ts +361 -0
  31. package/src/app/compiled-context.ts +240 -0
  32. package/src/app/context-surface.ts +7 -2
  33. package/src/cli/commands/ask.ts +14 -2
  34. package/src/cli/commands/audit.ts +4 -0
  35. package/src/cli/commands/capture.ts +55 -96
  36. package/src/cli/commands/context-compiled.ts +136 -0
  37. package/src/cli/commands/daemon.ts +41 -0
  38. package/src/cli/commands/ls.ts +3 -0
  39. package/src/cli/commands/memory.ts +12 -3
  40. package/src/cli/commands/request-status.ts +59 -0
  41. package/src/cli/commands/reset.ts +39 -5
  42. package/src/cli/commands/sessions.ts +713 -0
  43. package/src/cli/commands/shared.ts +14 -1
  44. package/src/cli/errors.ts +10 -3
  45. package/src/cli/program.ts +458 -1
  46. package/src/cli/session-binding.ts +49 -0
  47. package/src/config/types.ts +8 -0
  48. package/src/core/capture-publish.ts +239 -0
  49. package/src/core/capture-sync.ts +3 -0
  50. package/src/core/compiled-context.ts +254 -0
  51. package/src/core/context-budget.ts +2 -10
  52. package/src/core/file-lock.ts +22 -5
  53. package/src/core/folder-setup-planning.ts +2 -1
  54. package/src/core/memory-remember.ts +233 -122
  55. package/src/core/memory-types.ts +11 -0
  56. package/src/core/network-boundary-inventory.ts +16 -0
  57. package/src/core/request-receipts.ts +671 -0
  58. package/src/core/setup-receipt.ts +27 -20
  59. package/src/core/typed-metadata.ts +4 -0
  60. package/src/core/validation.ts +9 -2
  61. package/src/core/windows-private-path.ts +96 -0
  62. package/src/index.ts +11 -2
  63. package/src/ingestion/compiled-context.ts +15 -0
  64. package/src/ingestion/sync.ts +40 -8
  65. package/src/ingestion/walker.ts +5 -4
  66. package/src/llm/nodeLlamaCpp/simulator-install.ts +6 -2
  67. package/src/mcp/context.ts +8 -0
  68. package/src/mcp/http-egress.ts +15 -3
  69. package/src/mcp/http-transport.ts +2 -0
  70. package/src/mcp/retrieval-warnings.ts +24 -0
  71. package/src/mcp/tools/ask.ts +14 -1
  72. package/src/mcp/tools/capture.ts +87 -83
  73. package/src/mcp/tools/context.ts +46 -2
  74. package/src/mcp/tools/index.ts +119 -7
  75. package/src/mcp/tools/memory-remember.ts +7 -0
  76. package/src/mcp/tools/memory-shared.ts +7 -1
  77. package/src/mcp/tools/query.ts +3 -1
  78. package/src/mcp/tools/request-status.ts +73 -0
  79. package/src/mcp/tools/search.ts +3 -1
  80. package/src/mcp/tools/sessions.ts +208 -0
  81. package/src/mcp/tools/vsearch.ts +3 -1
  82. package/src/sdk/client.ts +252 -89
  83. package/src/sdk/index.ts +13 -0
  84. package/src/sdk/types.ts +82 -3
  85. package/src/serve/capture-service.ts +98 -32
  86. package/src/serve/compiled-context.ts +84 -0
  87. package/src/serve/config-sync.ts +3 -2
  88. package/src/serve/public/app.tsx +15 -1
  89. package/src/serve/public/components/CaptureModal.tsx +26 -8
  90. package/src/serve/public/components/sessions/AutomationPanel.tsx +800 -0
  91. package/src/serve/public/components/sessions/ImportReceipt.tsx +238 -0
  92. package/src/serve/public/components/sessions/SessionSearch.tsx +286 -0
  93. package/src/serve/public/components/sessions/SourcesPanel.tsx +541 -0
  94. package/src/serve/public/components/sessions/api.ts +40 -0
  95. package/src/serve/public/components/sessions/snippet.tsx +53 -0
  96. package/src/serve/public/globals.built.css +1 -1
  97. package/src/serve/public/hooks/use-api.ts +10 -2
  98. package/src/serve/public/lib/request-intent.ts +69 -0
  99. package/src/serve/public/lib/workspace-actions.ts +12 -1
  100. package/src/serve/public/lib/workspace-tabs.ts +4 -0
  101. package/src/serve/public/pages/CompiledContext.tsx +364 -0
  102. package/src/serve/public/pages/Dashboard.tsx +17 -0
  103. package/src/serve/public/pages/DocView.tsx +15 -1
  104. package/src/serve/public/pages/DocumentEditor.tsx +139 -96
  105. package/src/serve/public/pages/Sessions.tsx +350 -0
  106. package/src/serve/resident-runtime.ts +43 -3
  107. package/src/serve/routes/api.ts +476 -147
  108. package/src/serve/routes/sessions.ts +766 -0
  109. package/src/serve/security.ts +9 -0
  110. package/src/serve/server.ts +248 -3
  111. package/src/serve/session-automation.ts +146 -0
  112. package/src/serve/spa-production-build.ts +6 -5
  113. package/src/sessions/archive.ts +348 -0
  114. package/src/sessions/automation-state.ts +444 -0
  115. package/src/sessions/automation-status.ts +239 -0
  116. package/src/sessions/automation.ts +1169 -0
  117. package/src/sessions/binding.ts +105 -0
  118. package/src/sessions/claude-hook.ts +240 -0
  119. package/src/sessions/config.ts +176 -0
  120. package/src/sessions/format.ts +191 -0
  121. package/src/sessions/import-child-env.ts +8 -0
  122. package/src/sessions/import-child.ts +152 -0
  123. package/src/sessions/parsers/claude-code.ts +259 -0
  124. package/src/sessions/parsers/codex.ts +303 -0
  125. package/src/sessions/parsers/hermes.ts +248 -0
  126. package/src/sessions/parsers/openclaw.ts +496 -0
  127. package/src/sessions/parsers/shared.ts +184 -0
  128. package/src/sessions/sanitize.ts +222 -0
  129. package/src/sessions/service.ts +1533 -0
  130. package/src/sessions/setup.ts +477 -0
  131. package/src/sessions/sources.ts +518 -0
  132. package/src/sessions/state.ts +118 -0
  133. package/src/sessions/types.ts +457 -0
  134. package/src/store/sqlite/adapter.ts +68 -16
  135. package/src/store/sqlite/scoped-index.ts +9 -0
  136. package/browser-extension/artifacts/gno-browser-clipper-v2.4.0.zip.sha256 +0 -1
@@ -0,0 +1,800 @@
1
+ import {
2
+ Loader2Icon,
3
+ PauseIcon,
4
+ PlayIcon,
5
+ Trash2Icon,
6
+ WorkflowIcon,
7
+ } from "lucide-react";
8
+ import { type FormEvent, useEffect, useId, useRef, useState } from "react";
9
+
10
+ import type {
11
+ SessionAutomationRunResult,
12
+ SessionAutomationStatus,
13
+ SessionProfileStatus,
14
+ SessionSourceStatus,
15
+ } from "./api";
16
+
17
+ import { retryWording } from "../../../../sessions/format";
18
+ import { Badge } from "../ui/badge";
19
+ import { Button } from "../ui/button";
20
+ import { Input } from "../ui/input";
21
+ import { sessionsApi } from "./api";
22
+
23
+ const PROFILE_ID_PATTERN = "[a-z0-9][a-z0-9_-]{0,63}";
24
+ const CADENCE_PATTERN = "[1-9][0-9]{0,5}[smhd]";
25
+
26
+ /** Local-only preview returned before a trigger is switched on. */
27
+ interface AutomationPreview {
28
+ archiveRoot: string;
29
+ sources: Array<{
30
+ id: string;
31
+ harness: string | null;
32
+ path: string | null;
33
+ collection: string | null;
34
+ }>;
35
+ collections: string[];
36
+ hook: { settings: string; command: string };
37
+ daemon: { state: string; command: string };
38
+ notes: string[];
39
+ }
40
+
41
+ type Trigger = "hook" | "schedule";
42
+
43
+ const STATE_LABELS: Record<SessionProfileStatus["state"], string> = {
44
+ off: "off",
45
+ idle: "idle",
46
+ pending: "pending",
47
+ running: "running",
48
+ retrying: "retrying",
49
+ partial: "partial",
50
+ failed: "needs attention",
51
+ };
52
+
53
+ function stateVariant(
54
+ state: SessionProfileStatus["state"]
55
+ ): "secondary" | "outline" | "destructive" {
56
+ if (state === "failed") return "destructive";
57
+ if (state === "off" || state === "idle") return "outline";
58
+ return "secondary";
59
+ }
60
+
61
+ /** Poll status this often while a run is in progress. */
62
+ const RUN_POLL_MS = 2000;
63
+
64
+ type Notify = (text: string) => void;
65
+
66
+ function scheduleLine(
67
+ daemonRunning: boolean,
68
+ nextDueAt: string | null,
69
+ at: (iso: string | null) => string
70
+ ): string {
71
+ if (!daemonRunning) return "not running: no daemon";
72
+ return nextDueAt ? `next ${at(nextDueAt)}` : "due time set on the next tick";
73
+ }
74
+
75
+ /** After Run now: the retry time, the recovery action, or remaining work. */
76
+ function runOutcomeTail(
77
+ run: SessionAutomationRunResult,
78
+ profile: SessionProfileStatus,
79
+ daemonRunning: boolean,
80
+ at: (iso: string | null) => string
81
+ ): string {
82
+ if (run.outcome === "failed") {
83
+ const retry =
84
+ profile.state === "retrying"
85
+ ? retryWording(profile.retryAt, daemonRunning, new Date(), at)
86
+ : null;
87
+ if (retry) return `; ${retry}`;
88
+ return profile.recovery ? "; see the recovery action above" : "";
89
+ }
90
+ return run.pending ? "; work is still pending" : "";
91
+ }
92
+
93
+ function clockFor(timezone: string): (iso: string | null) => string {
94
+ return (iso) => {
95
+ if (!iso) return "never";
96
+ const date = new Date(iso);
97
+ if (Number.isNaN(date.getTime())) return iso;
98
+ try {
99
+ return date.toLocaleString(undefined, { timeZone: timezone });
100
+ } catch {
101
+ return date.toLocaleString();
102
+ }
103
+ };
104
+ }
105
+
106
+ function CreateProfileForm({
107
+ sources,
108
+ onDone,
109
+ notify,
110
+ }: {
111
+ sources: SessionSourceStatus[];
112
+ onDone: () => Promise<void>;
113
+ notify: Notify;
114
+ }) {
115
+ const idField = useId();
116
+ const [id, setId] = useState("");
117
+ const [selected, setSelected] = useState<string[]>([]);
118
+ const [error, setError] = useState<{ text: string; nonce: number } | null>(
119
+ null
120
+ );
121
+ const [saving, setSaving] = useState(false);
122
+ const errorRef = useRef<HTMLParagraphElement>(null);
123
+ useEffect(() => {
124
+ if (error) errorRef.current?.focus();
125
+ }, [error]);
126
+ const fail = (text: string) =>
127
+ setError((current) => ({ text, nonce: (current?.nonce ?? 0) + 1 }));
128
+
129
+ const submit = async (event: FormEvent<HTMLFormElement>) => {
130
+ event.preventDefault();
131
+ if (selected.length === 0) {
132
+ fail("Select at least one registered source.");
133
+ return;
134
+ }
135
+ setSaving(true);
136
+ const result = await sessionsApi(
137
+ `/api/sessions/automation/${encodeURIComponent(id.trim())}`,
138
+ { method: "PUT", body: JSON.stringify({ sources: selected }) }
139
+ );
140
+ setSaving(false);
141
+ if (result.error) {
142
+ fail(result.error);
143
+ return;
144
+ }
145
+ const created = id.trim();
146
+ setError(null);
147
+ setId("");
148
+ setSelected([]);
149
+ await onDone();
150
+ notify(`Profile ${created} created; every trigger is off.`);
151
+ };
152
+
153
+ return (
154
+ <form
155
+ aria-label="Create automation profile"
156
+ className="space-y-3 rounded-lg border border-dashed border-border/70 p-4"
157
+ onSubmit={(event) => void submit(event)}
158
+ >
159
+ <label className="grid max-w-xs gap-1 text-sm" htmlFor={idField}>
160
+ Profile ID
161
+ <Input
162
+ id={idField}
163
+ onChange={(event) => setId(event.currentTarget.value)}
164
+ pattern={PROFILE_ID_PATTERN}
165
+ placeholder="claude"
166
+ required
167
+ value={id}
168
+ />
169
+ </label>
170
+ <fieldset className="space-y-1 text-sm">
171
+ <legend className="mb-1">Sources this profile imports</legend>
172
+ {sources.map((source) => (
173
+ <label className="flex items-center gap-2" key={source.id}>
174
+ <input
175
+ checked={selected.includes(source.id)}
176
+ onChange={(event) => {
177
+ const { checked } = event.currentTarget;
178
+ setSelected((current) =>
179
+ checked
180
+ ? [...current, source.id]
181
+ : current.filter((item) => item !== source.id)
182
+ );
183
+ }}
184
+ type="checkbox"
185
+ />
186
+ <span className="break-all font-mono">{`${source.id} → ${source.collection}`}</span>
187
+ </label>
188
+ ))}
189
+ </fieldset>
190
+ {error && (
191
+ <p
192
+ className="break-words text-destructive text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
193
+ ref={errorRef}
194
+ role="alert"
195
+ tabIndex={-1}
196
+ >
197
+ {error.text}
198
+ </p>
199
+ )}
200
+ <Button disabled={saving} size="sm" type="submit">
201
+ {saving && <Loader2Icon className="animate-spin" />}
202
+ Create profile (everything stays off)
203
+ </Button>
204
+ </form>
205
+ );
206
+ }
207
+
208
+ function PreviewConfirm({
209
+ trigger,
210
+ preview,
211
+ cadence,
212
+ onCadence,
213
+ saving,
214
+ onConfirm,
215
+ onCancel,
216
+ }: {
217
+ trigger: Trigger;
218
+ preview: AutomationPreview;
219
+ cadence: string;
220
+ onCadence: (value: string) => void;
221
+ saving: boolean;
222
+ onConfirm: () => void;
223
+ onCancel: () => void;
224
+ }) {
225
+ const cadenceField = useId();
226
+ const groupRef = useRef<HTMLDivElement>(null);
227
+ useEffect(() => {
228
+ groupRef.current?.focus();
229
+ }, []);
230
+ return (
231
+ <div
232
+ aria-label={`Confirm ${trigger === "hook" ? "Claude Code hook" : "schedule"}`}
233
+ className="min-w-0 space-y-2 rounded-md border border-primary/40 bg-primary/5 p-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
234
+ ref={groupRef}
235
+ role="group"
236
+ tabIndex={-1}
237
+ >
238
+ <p className="font-medium">
239
+ {trigger === "hook"
240
+ ? "Install the Claude Code SessionEnd hook?"
241
+ : "Run this profile on the daemon's schedule?"}
242
+ </p>
243
+ <ul className="list-disc space-y-1 pl-5">
244
+ {preview.sources.map((source) => (
245
+ <li className="break-all" key={source.id}>
246
+ <span className="font-mono">{source.id}</span>{" "}
247
+ {source.path ?? "not registered"} → {source.collection ?? "none"}
248
+ </li>
249
+ ))}
250
+ <li className="break-all">Archive: {preview.archiveRoot}</li>
251
+ {trigger === "hook" && (
252
+ <li className="break-all">
253
+ Adds one owned entry to {preview.hook.settings}; other hooks are
254
+ kept.
255
+ </li>
256
+ )}
257
+ </ul>
258
+ {trigger === "schedule" && (
259
+ <label className="grid max-w-[10rem] gap-1" htmlFor={cadenceField}>
260
+ Every (elapsed, min 1m)
261
+ <Input
262
+ id={cadenceField}
263
+ onChange={(event) => onCadence(event.currentTarget.value)}
264
+ pattern={CADENCE_PATTERN}
265
+ placeholder="1h"
266
+ required
267
+ value={cadence}
268
+ />
269
+ </label>
270
+ )}
271
+ <p className="text-muted-foreground">
272
+ Daemon:{" "}
273
+ {preview.daemon.state === "running"
274
+ ? "running"
275
+ : "not running: no daemon"}
276
+ . Runs happen only while{" "}
277
+ <code className="break-all font-mono text-xs">
278
+ {preview.daemon.command}
279
+ </code>{" "}
280
+ runs; GNO never installs or starts it.
281
+ </p>
282
+ <div className="flex flex-wrap gap-2">
283
+ <Button
284
+ disabled={saving || (trigger === "schedule" && !cadence.trim())}
285
+ onClick={onConfirm}
286
+ size="sm"
287
+ >
288
+ {saving && <Loader2Icon className="animate-spin" />}
289
+ Enable
290
+ </Button>
291
+ <Button onClick={onCancel} size="sm" variant="ghost">
292
+ Cancel
293
+ </Button>
294
+ </div>
295
+ </div>
296
+ );
297
+ }
298
+
299
+ function ProfileCard({
300
+ profile,
301
+ timezone,
302
+ daemonRunning,
303
+ localClient,
304
+ onChanged,
305
+ notify,
306
+ onRunActive,
307
+ }: {
308
+ profile: SessionProfileStatus;
309
+ timezone: string;
310
+ daemonRunning: boolean;
311
+ localClient: boolean;
312
+ onChanged: () => Promise<void>;
313
+ notify: Notify;
314
+ onRunActive: (active: boolean) => void;
315
+ }) {
316
+ const at = clockFor(timezone);
317
+ const hookSwitch = useId();
318
+ const scheduleSwitch = useId();
319
+ const [busy, setBusy] = useState<string | null>(null);
320
+ const [error, setError] = useState<{ text: string; nonce: number } | null>(
321
+ null
322
+ );
323
+ const [confirming, setConfirming] = useState<{
324
+ trigger: Trigger;
325
+ preview: AutomationPreview;
326
+ } | null>(null);
327
+ const [cadence, setCadence] = useState(profile.schedule?.cadence ?? "");
328
+ const [confirmRemove, setConfirmRemove] = useState(false);
329
+ const confirmRemoveRef = useRef<HTMLButtonElement>(null);
330
+ const removeRef = useRef<HTMLButtonElement>(null);
331
+ const removeReturnFocus = useRef(false);
332
+ // The Remove button unmounts when its confirm step opens: move focus to
333
+ // Confirm, and back to Remove on Cancel.
334
+ useEffect(() => {
335
+ if (confirmRemove) confirmRemoveRef.current?.focus();
336
+ else if (removeReturnFocus.current) {
337
+ removeReturnFocus.current = false;
338
+ removeRef.current?.focus();
339
+ }
340
+ }, [confirmRemove]);
341
+ const [lastRun, setLastRun] = useState<SessionAutomationRunResult | null>(
342
+ null
343
+ );
344
+ const base = `/api/sessions/automation/${encodeURIComponent(profile.id)}`;
345
+ const switches = {
346
+ hook: useRef<HTMLInputElement>(null),
347
+ schedule: useRef<HTMLInputElement>(null),
348
+ };
349
+ const outcomeRef = useRef<HTMLParagraphElement>(null);
350
+ const errorRef = useRef<HTMLParagraphElement>(null);
351
+ // Disabled buttons drop keyboard focus; hand it to the outcome instead.
352
+ useEffect(() => {
353
+ if (lastRun) outcomeRef.current?.focus();
354
+ }, [lastRun]);
355
+ useEffect(() => {
356
+ if (error) errorRef.current?.focus();
357
+ }, [error]);
358
+
359
+ const closeConfirm = (trigger: Trigger) => {
360
+ setConfirming(null);
361
+ switches[trigger].current?.focus();
362
+ };
363
+
364
+ const call = async <T,>(
365
+ label: string,
366
+ endpoint: string,
367
+ init: RequestInit
368
+ ): Promise<T | null> => {
369
+ setBusy(label);
370
+ const result = await sessionsApi<T>(endpoint, init);
371
+ setBusy(null);
372
+ if (result.error) {
373
+ const text = result.error;
374
+ setError((current) => ({ text, nonce: (current?.nonce ?? 0) + 1 }));
375
+ return null;
376
+ }
377
+ setError(null);
378
+ return result.data;
379
+ };
380
+
381
+ const startEnable = async (trigger: Trigger) => {
382
+ const preview = await call<AutomationPreview>(
383
+ "preview",
384
+ `${base}/preview`,
385
+ {
386
+ method: "GET",
387
+ }
388
+ );
389
+ if (preview) setConfirming({ trigger, preview });
390
+ };
391
+
392
+ const confirmEnable = async () => {
393
+ if (!confirming) return;
394
+ const body =
395
+ confirming.trigger === "hook"
396
+ ? { hook: { harness: "claude-code" } }
397
+ : { schedule: { cadence: cadence.trim() } };
398
+ const done = await call("enable", `${base}/enable`, {
399
+ method: "POST",
400
+ body: JSON.stringify(body),
401
+ });
402
+ if (done) {
403
+ closeConfirm(confirming.trigger);
404
+ await onChanged();
405
+ }
406
+ };
407
+
408
+ const disable = async (which: Partial<Record<Trigger, boolean>>) => {
409
+ const done = await call("disable", `${base}/disable`, {
410
+ method: "POST",
411
+ body: JSON.stringify(which),
412
+ });
413
+ if (!done) return;
414
+ await onChanged();
415
+ // The Pause button is gone once nothing is enabled: land on a switch.
416
+ (which.schedule && !which.hook
417
+ ? switches.schedule
418
+ : switches.hook
419
+ ).current?.focus();
420
+ };
421
+
422
+ const toggle = (trigger: Trigger, on: boolean) => {
423
+ // Switches stay focusable while a request or confirmation is open.
424
+ if (busy !== null || confirming !== null) return;
425
+ if (on) void startEnable(trigger);
426
+ else void disable({ [trigger]: true });
427
+ };
428
+
429
+ const runNow = async () => {
430
+ onRunActive(true);
431
+ const result = await call<SessionAutomationRunResult>(
432
+ "run",
433
+ "/api/sessions/automation/run",
434
+ { method: "POST", body: JSON.stringify({ profileId: profile.id }) }
435
+ ).finally(() => onRunActive(false));
436
+ if (result) {
437
+ setLastRun(result);
438
+ await onChanged();
439
+ }
440
+ };
441
+
442
+ const remove = async () => {
443
+ const done = await call("remove", base, { method: "DELETE" });
444
+ if (!done) return;
445
+ // Announce (and focus) before the refresh unmounts this card.
446
+ notify(`Profile ${profile.id} removed; archived sessions were kept.`);
447
+ await onChanged();
448
+ };
449
+
450
+ const retryLine = retryWording(
451
+ profile.retryAt,
452
+ daemonRunning,
453
+ new Date(),
454
+ at
455
+ );
456
+ const hookOn = profile.hook?.enabled === true;
457
+ const scheduleOn = profile.schedule?.enabled === true;
458
+ const run = profile.lastRun;
459
+ return (
460
+ <li className="min-w-0 space-y-3 rounded-lg border border-border/60 p-4">
461
+ <div className="flex flex-wrap items-start justify-between gap-3">
462
+ <div className="min-w-0 space-y-1">
463
+ <div className="flex flex-wrap items-center gap-2">
464
+ <h3 className="break-all font-mono font-semibold">{profile.id}</h3>
465
+ <Badge variant={stateVariant(profile.state)}>
466
+ {STATE_LABELS[profile.state]}
467
+ </Badge>
468
+ </div>
469
+ <p className="break-words text-muted-foreground text-sm">
470
+ {`Sources ${profile.sources.join(", ")} → ${profile.collections.join(", ") || "no collection"} · ${profile.limit} units per source per run`}
471
+ </p>
472
+ </div>
473
+ <div className="flex flex-wrap gap-2">
474
+ <Button
475
+ aria-label={`Run ${profile.id} now`}
476
+ disabled={busy !== null}
477
+ onClick={() => void runNow()}
478
+ size="sm"
479
+ >
480
+ {busy === "run" ? (
481
+ <Loader2Icon className="animate-spin" />
482
+ ) : (
483
+ <PlayIcon />
484
+ )}
485
+ Run now
486
+ </Button>
487
+ {localClient && (hookOn || scheduleOn) && (
488
+ <Button
489
+ aria-label={`Pause ${profile.id}`}
490
+ disabled={busy !== null}
491
+ onClick={() => void disable({})}
492
+ size="sm"
493
+ variant="outline"
494
+ >
495
+ <PauseIcon />
496
+ Pause
497
+ </Button>
498
+ )}
499
+ {localClient &&
500
+ (confirmRemove ? (
501
+ <>
502
+ <Button
503
+ aria-disabled={busy !== null}
504
+ onClick={() => {
505
+ if (busy === null) void remove();
506
+ }}
507
+ ref={confirmRemoveRef}
508
+ size="sm"
509
+ variant="destructive"
510
+ >
511
+ Confirm remove
512
+ </Button>
513
+ <Button
514
+ onClick={() => {
515
+ setConfirmRemove(false);
516
+ removeReturnFocus.current = true;
517
+ }}
518
+ size="sm"
519
+ variant="ghost"
520
+ >
521
+ Cancel
522
+ </Button>
523
+ </>
524
+ ) : (
525
+ <Button
526
+ aria-label={`Remove profile ${profile.id}`}
527
+ disabled={busy !== null}
528
+ onClick={() => setConfirmRemove(true)}
529
+ ref={removeRef}
530
+ size="sm"
531
+ variant="ghost"
532
+ >
533
+ <Trash2Icon />
534
+ Remove
535
+ </Button>
536
+ ))}
537
+ </div>
538
+ </div>
539
+
540
+ <div className="grid gap-2 text-sm sm:grid-cols-2">
541
+ <label className="flex items-start gap-2" htmlFor={hookSwitch}>
542
+ <input
543
+ aria-disabled={busy !== null || confirming !== null}
544
+ checked={hookOn}
545
+ className="mt-1"
546
+ disabled={!localClient}
547
+ id={hookSwitch}
548
+ onChange={(event) => toggle("hook", event.currentTarget.checked)}
549
+ ref={switches.hook}
550
+ role="switch"
551
+ type="checkbox"
552
+ />
553
+ <span className="min-w-0">
554
+ Claude Code SessionEnd hook
555
+ <span className="block text-muted-foreground text-xs">
556
+ {hookOn
557
+ ? profile.hook?.installed === false
558
+ ? "on, but the settings entry is missing"
559
+ : "on: session ends mark this profile pending"
560
+ : "off"}
561
+ </span>
562
+ </span>
563
+ </label>
564
+ <label className="flex items-start gap-2" htmlFor={scheduleSwitch}>
565
+ <input
566
+ aria-disabled={busy !== null || confirming !== null}
567
+ checked={scheduleOn}
568
+ className="mt-1"
569
+ disabled={!localClient}
570
+ id={scheduleSwitch}
571
+ onChange={(event) =>
572
+ toggle("schedule", event.currentTarget.checked)
573
+ }
574
+ ref={switches.schedule}
575
+ role="switch"
576
+ type="checkbox"
577
+ />
578
+ <span className="min-w-0">
579
+ Daemon schedule
580
+ <span className="block text-muted-foreground text-xs">
581
+ {scheduleOn
582
+ ? `every ${profile.schedule?.cadence}; ${scheduleLine(
583
+ daemonRunning,
584
+ profile.schedule?.nextDueAt ?? null,
585
+ at
586
+ )}`
587
+ : "off"}
588
+ </span>
589
+ </span>
590
+ </label>
591
+ </div>
592
+
593
+ {confirming && (
594
+ <PreviewConfirm
595
+ cadence={cadence}
596
+ onCadence={setCadence}
597
+ onCancel={() => closeConfirm(confirming.trigger)}
598
+ onConfirm={() => void confirmEnable()}
599
+ preview={confirming.preview}
600
+ saving={busy === "enable"}
601
+ trigger={confirming.trigger}
602
+ />
603
+ )}
604
+
605
+ <dl className="grid gap-x-4 gap-y-1 text-sm sm:grid-cols-[auto_1fr]">
606
+ {profile.pending && profile.state !== "failed" && (
607
+ <>
608
+ <dt className="text-muted-foreground">Pending</dt>
609
+ <dd>
610
+ since {at(profile.pending.since)} (
611
+ {profile.pending.triggers.join(", ") || "continuation"})
612
+ {retryLine ? `; ${retryLine}` : ""}
613
+ </dd>
614
+ </>
615
+ )}
616
+ {profile.running && (
617
+ <>
618
+ <dt className="text-muted-foreground">Running</dt>
619
+ <dd>since {at(profile.running.startedAt)}</dd>
620
+ </>
621
+ )}
622
+ <dt className="text-muted-foreground">Last run</dt>
623
+ <dd className="break-words">
624
+ {run
625
+ ? `${at(run.finishedAt)}: ${run.outcome}${run.reason ? ` (${run.reason})` : ""}; ${run.threads.imported} imported, ${run.threads.updated} updated, ${run.units.failed} failed units`
626
+ : "never"}
627
+ </dd>
628
+ <dt className="text-muted-foreground">Last success</dt>
629
+ <dd>{at(profile.lastSuccessAt)}</dd>
630
+ </dl>
631
+
632
+ {profile.recovery && (
633
+ <p
634
+ className="break-words rounded-md bg-amber-500/10 px-3 py-2 text-amber-900 text-sm dark:text-amber-100"
635
+ role="status"
636
+ >
637
+ {profile.recovery}
638
+ </p>
639
+ )}
640
+ {lastRun && (
641
+ <p
642
+ className="break-words text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
643
+ ref={outcomeRef}
644
+ role="status"
645
+ tabIndex={-1}
646
+ >
647
+ Run now:{" "}
648
+ {lastRun.ran
649
+ ? `${lastRun.outcome}${lastRun.reason ? ` (${lastRun.reason})` : ""}`
650
+ : `not started (${lastRun.reason ?? "unknown"})`}
651
+ {runOutcomeTail(lastRun, profile, daemonRunning, at)}
652
+ </p>
653
+ )}
654
+ {error && (
655
+ <p
656
+ className="break-words text-destructive text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
657
+ ref={errorRef}
658
+ role="alert"
659
+ tabIndex={-1}
660
+ >
661
+ {error.text}
662
+ </p>
663
+ )}
664
+ </li>
665
+ );
666
+ }
667
+
668
+ export interface AutomationPanelProps {
669
+ automation: SessionAutomationStatus;
670
+ sources: SessionSourceStatus[];
671
+ localClient: boolean;
672
+ onChanged: () => Promise<void>;
673
+ }
674
+
675
+ /**
676
+ * Opt-in automation: every trigger is off until a same-host owner switches
677
+ * it on after reviewing the preview. Remote browsers can read status and run
678
+ * a configured profile, never enable machine integrations.
679
+ */
680
+ export function AutomationPanel({
681
+ automation,
682
+ sources,
683
+ localClient,
684
+ onChanged,
685
+ }: AutomationPanelProps) {
686
+ const at = clockFor(automation.timezone);
687
+ const daemonRunning = automation.daemon.state === "running";
688
+ const [notice, setNotice] = useState<{ text: string; nonce: number } | null>(
689
+ null
690
+ );
691
+ const noticeRef = useRef<HTMLParagraphElement>(null);
692
+ useEffect(() => {
693
+ if (notice) noticeRef.current?.focus();
694
+ }, [notice]);
695
+ const notify: Notify = (text) =>
696
+ setNotice((current) => ({ text, nonce: (current?.nonce ?? 0) + 1 }));
697
+
698
+ // Poll while a run is in progress (here, in the daemon, or requested from
699
+ // this page) so running/pending states appear without a reload.
700
+ const [runsInFlight, setRunsInFlight] = useState(0);
701
+ const onRunActive = (active: boolean) =>
702
+ setRunsInFlight((count) => Math.max(0, count + (active ? 1 : -1)));
703
+ const active =
704
+ runsInFlight > 0 ||
705
+ automation.profiles.some(
706
+ (profile) =>
707
+ profile.state === "running" ||
708
+ (daemonRunning && profile.state === "pending")
709
+ );
710
+ const refresh = useRef(onChanged);
711
+ refresh.current = onChanged;
712
+ useEffect(() => {
713
+ if (!active) return;
714
+ let polling = false;
715
+ const timer = setInterval(() => {
716
+ if (polling) return;
717
+ polling = true;
718
+ void refresh.current().finally(() => {
719
+ polling = false;
720
+ });
721
+ }, RUN_POLL_MS);
722
+ return () => clearInterval(timer);
723
+ }, [active]);
724
+ let daemonLine = "not running: no daemon";
725
+ if (daemonRunning) {
726
+ daemonLine = `running (heartbeat ${at(automation.daemon.heartbeatAt)})`;
727
+ } else if (automation.daemon.state === "stale") {
728
+ daemonLine = `stale (last heartbeat ${at(automation.daemon.heartbeatAt)})`;
729
+ }
730
+ return (
731
+ <section
732
+ aria-labelledby="sessions-automation-heading"
733
+ className="space-y-4"
734
+ >
735
+ <div className="space-y-1">
736
+ <div className="flex flex-wrap items-center gap-2">
737
+ <WorkflowIcon aria-hidden="true" className="size-5 text-primary" />
738
+ <h2
739
+ className="font-semibold text-xl"
740
+ id="sessions-automation-heading"
741
+ >
742
+ Automation
743
+ </h2>
744
+ <Badge variant="outline">
745
+ {automation.profiles.some(
746
+ (profile) => profile.hook?.enabled || profile.schedule?.enabled
747
+ )
748
+ ? "opt-in enabled"
749
+ : "manual"}
750
+ </Badge>
751
+ </div>
752
+ <p className="text-muted-foreground text-sm">
753
+ Off by default. A hook or schedule only marks a profile pending; the
754
+ import runs in <span className="font-mono">gno daemon</span> on this
755
+ archive (or with Run now).{" "}
756
+ <span className="font-mono">gno serve</span> never runs it. Daemon:{" "}
757
+ {daemonLine}. Times in {automation.timezone}.
758
+ </p>
759
+ {!localClient && (
760
+ <p className="text-muted-foreground text-xs">
761
+ Only a browser on this machine can change hooks or schedules.
762
+ </p>
763
+ )}
764
+ </div>
765
+ {automation.profiles.length > 0 && (
766
+ <ul className="space-y-3">
767
+ {automation.profiles.map((profile) => (
768
+ <ProfileCard
769
+ daemonRunning={daemonRunning}
770
+ key={profile.id}
771
+ localClient={localClient}
772
+ notify={notify}
773
+ onChanged={onChanged}
774
+ onRunActive={onRunActive}
775
+ profile={profile}
776
+ timezone={automation.timezone}
777
+ />
778
+ ))}
779
+ </ul>
780
+ )}
781
+ {localClient && sources.length > 0 && (
782
+ <CreateProfileForm
783
+ notify={notify}
784
+ onDone={onChanged}
785
+ sources={sources}
786
+ />
787
+ )}
788
+ {notice && (
789
+ <p
790
+ className="break-words text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
791
+ ref={noticeRef}
792
+ role="status"
793
+ tabIndex={-1}
794
+ >
795
+ {notice.text}
796
+ </p>
797
+ )}
798
+ </section>
799
+ );
800
+ }