@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.
- package/README.md +69 -6
- package/assets/skill/README.md +5 -1
- package/assets/skill/SKILL.md +98 -1
- package/assets/skill/cli-reference.md +140 -0
- package/assets/skill/examples.md +45 -0
- package/assets/skill/mcp-reference.md +62 -0
- package/assets/skill/recipes/capture-and-file.md +6 -0
- package/assets/skill/recipes/memory-file-decision.md +6 -0
- package/assets/skill/recipes/memory-supersede-fact.md +5 -0
- package/assets/skill/recipes/session-evidence-lookup.md +98 -0
- package/assets/spa-production.json.gz +0 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v2.4.0.zip → gno-browser-clipper-v2.6.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.6.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +2 -1
- package/spec/cli.md +358 -24
- package/spec/compiled-context.md +68 -0
- package/spec/mcp.md +223 -2
- package/spec/output-schemas/capture-receipt.schema.json +3 -0
- package/spec/output-schemas/compiled-context-check.schema.json +44 -0
- package/spec/output-schemas/compiled-context-file.schema.json +165 -0
- package/spec/output-schemas/compiled-context-preview.schema.json +142 -0
- package/spec/output-schemas/mcp-capture-result.schema.json +3 -0
- package/spec/output-schemas/memory-remember.schema.json +8 -2
- package/spec/output-schemas/request-status.schema.json +113 -0
- package/spec/output-schemas/sessions-automation-run.schema.json +46 -0
- package/spec/output-schemas/sessions-discovery.schema.json +38 -0
- package/spec/output-schemas/sessions-import-receipt.schema.json +156 -0
- package/spec/output-schemas/sessions-status.schema.json +432 -0
- package/src/app/compiled-context-files.ts +361 -0
- package/src/app/compiled-context.ts +240 -0
- package/src/app/context-surface.ts +7 -2
- package/src/cli/commands/ask.ts +14 -2
- package/src/cli/commands/audit.ts +4 -0
- package/src/cli/commands/capture.ts +55 -96
- package/src/cli/commands/context-compiled.ts +136 -0
- package/src/cli/commands/daemon.ts +41 -0
- package/src/cli/commands/ls.ts +3 -0
- package/src/cli/commands/memory.ts +12 -3
- package/src/cli/commands/request-status.ts +59 -0
- package/src/cli/commands/reset.ts +39 -5
- package/src/cli/commands/sessions.ts +713 -0
- package/src/cli/commands/shared.ts +14 -1
- package/src/cli/errors.ts +10 -3
- package/src/cli/program.ts +458 -1
- package/src/cli/session-binding.ts +49 -0
- package/src/config/types.ts +8 -0
- package/src/core/capture-publish.ts +239 -0
- package/src/core/capture-sync.ts +3 -0
- package/src/core/compiled-context.ts +254 -0
- package/src/core/context-budget.ts +2 -10
- package/src/core/file-lock.ts +22 -5
- package/src/core/folder-setup-planning.ts +2 -1
- package/src/core/memory-remember.ts +233 -122
- package/src/core/memory-types.ts +11 -0
- package/src/core/network-boundary-inventory.ts +16 -0
- package/src/core/request-receipts.ts +671 -0
- package/src/core/setup-receipt.ts +27 -20
- package/src/core/typed-metadata.ts +4 -0
- package/src/core/validation.ts +9 -2
- package/src/core/windows-private-path.ts +96 -0
- package/src/index.ts +11 -2
- package/src/ingestion/compiled-context.ts +15 -0
- package/src/ingestion/sync.ts +40 -8
- package/src/ingestion/walker.ts +5 -4
- package/src/llm/nodeLlamaCpp/simulator-install.ts +6 -2
- package/src/mcp/context.ts +8 -0
- package/src/mcp/http-egress.ts +15 -3
- package/src/mcp/http-transport.ts +2 -0
- package/src/mcp/retrieval-warnings.ts +24 -0
- package/src/mcp/tools/ask.ts +14 -1
- package/src/mcp/tools/capture.ts +87 -83
- package/src/mcp/tools/context.ts +46 -2
- package/src/mcp/tools/index.ts +119 -7
- package/src/mcp/tools/memory-remember.ts +7 -0
- package/src/mcp/tools/memory-shared.ts +7 -1
- package/src/mcp/tools/query.ts +3 -1
- package/src/mcp/tools/request-status.ts +73 -0
- package/src/mcp/tools/search.ts +3 -1
- package/src/mcp/tools/sessions.ts +208 -0
- package/src/mcp/tools/vsearch.ts +3 -1
- package/src/sdk/client.ts +252 -89
- package/src/sdk/index.ts +13 -0
- package/src/sdk/types.ts +82 -3
- package/src/serve/capture-service.ts +98 -32
- package/src/serve/compiled-context.ts +84 -0
- package/src/serve/config-sync.ts +3 -2
- package/src/serve/public/app.tsx +15 -1
- package/src/serve/public/components/CaptureModal.tsx +26 -8
- package/src/serve/public/components/sessions/AutomationPanel.tsx +800 -0
- package/src/serve/public/components/sessions/ImportReceipt.tsx +238 -0
- package/src/serve/public/components/sessions/SessionSearch.tsx +286 -0
- package/src/serve/public/components/sessions/SourcesPanel.tsx +541 -0
- package/src/serve/public/components/sessions/api.ts +40 -0
- package/src/serve/public/components/sessions/snippet.tsx +53 -0
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/hooks/use-api.ts +10 -2
- package/src/serve/public/lib/request-intent.ts +69 -0
- package/src/serve/public/lib/workspace-actions.ts +12 -1
- package/src/serve/public/lib/workspace-tabs.ts +4 -0
- package/src/serve/public/pages/CompiledContext.tsx +364 -0
- package/src/serve/public/pages/Dashboard.tsx +17 -0
- package/src/serve/public/pages/DocView.tsx +15 -1
- package/src/serve/public/pages/DocumentEditor.tsx +139 -96
- package/src/serve/public/pages/Sessions.tsx +350 -0
- package/src/serve/resident-runtime.ts +43 -3
- package/src/serve/routes/api.ts +476 -147
- package/src/serve/routes/sessions.ts +766 -0
- package/src/serve/security.ts +9 -0
- package/src/serve/server.ts +248 -3
- package/src/serve/session-automation.ts +146 -0
- package/src/serve/spa-production-build.ts +6 -5
- package/src/sessions/archive.ts +348 -0
- package/src/sessions/automation-state.ts +444 -0
- package/src/sessions/automation-status.ts +239 -0
- package/src/sessions/automation.ts +1169 -0
- package/src/sessions/binding.ts +105 -0
- package/src/sessions/claude-hook.ts +240 -0
- package/src/sessions/config.ts +176 -0
- package/src/sessions/format.ts +191 -0
- package/src/sessions/import-child-env.ts +8 -0
- package/src/sessions/import-child.ts +152 -0
- package/src/sessions/parsers/claude-code.ts +259 -0
- package/src/sessions/parsers/codex.ts +303 -0
- package/src/sessions/parsers/hermes.ts +248 -0
- package/src/sessions/parsers/openclaw.ts +496 -0
- package/src/sessions/parsers/shared.ts +184 -0
- package/src/sessions/sanitize.ts +222 -0
- package/src/sessions/service.ts +1533 -0
- package/src/sessions/setup.ts +477 -0
- package/src/sessions/sources.ts +518 -0
- package/src/sessions/state.ts +118 -0
- package/src/sessions/types.ts +457 -0
- package/src/store/sqlite/adapter.ts +68 -16
- package/src/store/sqlite/scoped-index.ts +9 -0
- 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
|
+
}
|