geneseed 4.9.2 → 4.9.3

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 (31) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/SHIPPED.md +1 -1
  3. package/adapters/opencode/plugins/geneseed-guard.js +105 -2
  4. package/docs/concepts/enforcement-table.md +1 -1
  5. package/docs/concepts/exclusions.md +1 -2
  6. package/docs/concepts/hooks.md +2 -1
  7. package/docs/guides/upgrade.md +1 -1
  8. package/harness.config.json +1 -1
  9. package/js/hosts/hooks.mjs +13 -3
  10. package/js/hosts/loss.mjs +98 -0
  11. package/js/hosts/settings.mjs +1 -1
  12. package/js/inspect/checks-probe.mjs +1 -1
  13. package/js/inspect/diff.mjs +28 -9
  14. package/js/web/api.mjs +5 -1
  15. package/package.json +1 -1
  16. package/web/dist/assets/{Activity-D7Igwuoo.js → Activity-C8sZVIUU.js} +1 -1
  17. package/web/dist/assets/{ActivityDetail-53rp05hx.js → ActivityDetail-Cc6vWUS4.js} +1 -1
  18. package/web/dist/assets/{Dashboard-BoEH5qyY.js → Dashboard-C5ATa3ow.js} +2 -2
  19. package/web/dist/assets/{Docs-BM56RCT8.js → Docs-tDW7i7mo.js} +1 -1
  20. package/web/dist/assets/{GreenhouseView-BTMvGVco.js → GreenhouseView-zk2Uzc3A.js} +1 -1
  21. package/web/dist/assets/{Installs-Dh1tB1NK.js → Installs-CB_rvbBP.js} +3 -2
  22. package/web/dist/assets/{JournalView-F4hlWTVB.js → JournalView-Bfj27XHC.js} +1 -1
  23. package/web/dist/assets/{Laws-iEheocNI.js → Laws-BA3CzUf5.js} +1 -1
  24. package/web/dist/assets/{Library-d4_uOGmC.js → Library-BWwiy9Vb.js} +1 -1
  25. package/web/dist/assets/{Loops-BbYeWl70.js → Loops-DaPk-ckL.js} +1 -1
  26. package/web/dist/assets/{OperatorHudView-Dki9Q6W3.js → OperatorHudView-CyiGhiHk.js} +1 -1
  27. package/web/dist/assets/{Personal-B_YQJjA_.js → Personal-Bwd27CmT.js} +1 -1
  28. package/web/dist/assets/{Tabs-CAl6oFwG.js → Tabs-BOD7Kaz1.js} +1 -1
  29. package/web/dist/assets/{drawable-BdTDG5oI.js → drawable-CyLe1gtW.js} +1 -1
  30. package/web/dist/assets/{index-BZgEGRbL.js → index-DQXG_mqo.js} +2 -2
  31. package/web/dist/index.html +1 -1
package/CHANGELOG.md CHANGED
@@ -8,6 +8,17 @@ label. For the capability ↔ spec map, see [SHIPPED.md](SHIPPED.md).
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [4.9.3] — 2026-10-18
12
+
13
+ Patch: rm -rf / and other catastrophic commands are caught on every host even with the deletion gate off; a skill you replaced with a link reads as yours, not as an edit.
14
+
15
+ ### Upgrade notes
16
+ - Run `geneseed rebuild-all` once after updating so installed hooks and OpenCode plugins pick up the changes.
17
+
18
+ ### Fixed
19
+ - **`rm -rf /` asks on Claude Code, OpenClaude and Bob even with the deletion gate off.** These hosts had no catastrophic-command check of their own: a recursive removal of `/`, a drive root or the home directory was caught only by Law IV's real-loss check, so `--no-deletion-gate` let it through unasked. `git-gate` (and Bob's `tool-gate`) now run the catastrophic class before that switch and whatever it says: a recursive removal of `/`, `C:\`, `/c` or `~`/`$HOME` (`rm`, `rd`/`del /s`, `Remove-Item -Recurse`), `mkfs` on a device, `dd` over a raw disk, `format C:` and a fork bomb ask on Claude Code and OpenClaude and exit 2 on Bob, as `law-4`. It judges the program after the shell structure is stripped, so `sudo rm -rf /`, `true & rm -rf /`, `(rm -rf /)`, `rm -rf /;`, `bash -c 'rm -rf /'` and a heredoc or here-string fed to a shell (`bash <<'EOF'`) are caught, and a commit message or a heredoc fed to `cat` or `git commit -F -` that only names `rm -rf /` is not. The check is one definition shared with the OpenCode guard (the loss twin block, pinned byte for byte), which now also blocks `rm -rf C:\`, `rm -rf /;`, `rd /s/q C:\` and `bash -c 'rm -rf /'`; its own stricter text match stays on top (it still blocks a command that only quotes one, and any `Remove-Item -Recurse -Force`). Ordinary commands are unaffected.
20
+ - **A skill you replaced with a link to your own copy no longer reads as a hand edit forever.** The emit rightly refuses to write through a link, but `geneseed diff` then compared the rendered file with your linked one and reported it as "edited — review to back-port", which no rebuild could clear. A deployed path that is, or sits under, a link the emit refuses (a junction on Windows, a symlink on POSIX — the same test the emit uses) is now `linked`: `geneseed diff` lists it in its own group ("linked (yours, left alone)"), it is not drift, and it never goes into an improvements file. The console keeps it out of every drift figure: the Overview's "Hand edits" cell shows it as a muted "N linked" when it is all there is, and the Local edits page lists it apart, not selectable for Restore. A regular file you edited is still `edited`.
21
+
11
22
  ## [4.9.2] — 2026-10-18
12
23
 
13
24
  Patch: the review pass — faster emits, console and Stop; Build and setup keep your gate switches; protected checks follow links both ways.
package/SHIPPED.md CHANGED
@@ -20,7 +20,7 @@ historical references, not files distributed with the repo.
20
20
  > **On version numbers.** [`harness.config.json`](harness.config.json)'s `version` is a
21
21
  > human-readable label only. The canonical identity of an installed harness is the
22
22
  > **source fingerprint** stamped in `.geneseed-version` (see `geneseed version`), not the
23
- > string. Current label: **4.9.2**.
23
+ > string. Current label: **4.9.3**.
24
24
 
25
25
  ## Capabilities
26
26
 
@@ -218,6 +218,11 @@ const SECRET_CONTENT_RE =
218
218
 
219
219
  // Catastrophic, effectively irreversible shell → BLOCK. Both Unix and Windows shells,
220
220
  // since OpenCode runs natively on Windows and the agent may emit cmd / PowerShell.
221
+ // The catastrophic class itself is `catastrophic()` in the loss twin block below — one definition,
222
+ // shared with the hook (js/hosts/loss.mjs), judged in program position and run whatever the
223
+ // deletion gate's switch says. This raw text match is OpenCode's own stricter layer on top of it:
224
+ // it also blocks a command that only quotes one (a heredoc fed to `cat` included) and any
225
+ // `Remove-Item -Recurse -Force`, whatever its target.
221
226
  const SHELL_BLOCK_RE = [
222
227
  // rm -rf /, ~, /*, ~/* — flags may be split (-r -f), long (--recursive), or end with
223
228
  // `--`; the lookahead insists one of them recurses. The target must be the root or
@@ -865,6 +870,104 @@ function lossReason(segments, cwd, { always = true, split } = {}) {
865
870
  return `the gate could not verify what this command would lose (${e.message})`;
866
871
  }
867
872
  }
873
+
874
+ /**
875
+ * THE CATASTROPHIC CLASS — what no state can make safe: a recursive removal of a filesystem root,
876
+ * a drive root or the home directory, a device format or raw-disk overwrite, a fork bomb. Unlike
877
+ * `lossReason` it reads no state, and it stands whatever the deletion gate's switch says (user
878
+ * decision 2026-10-18): `--no-deletion-gate` silences the real-loss check, never this one. Judged
879
+ * in PROGRAM position, segment by segment after `stripStructure` and the wrappers, so `true & rm
880
+ * -rf /`, `sudo rm -rf /`, `(rm -rf /)` and `rm -rf /;` are caught and a commit message that only
881
+ * names `rm -rf /` is not. A shell or `eval` (`bash -c '…'`, `pwsh -Command …`) and a `$(…)` body
882
+ * are judged as commands of their own, and so is what a shell reads on stdin: a heredoc or
883
+ * here-string fed to `bash`/`sh`/`pwsh`/`cmd`… (`bash <<'EOF'` over `rm -rf /`). One fed to any
884
+ * other program (`cat <<EOF`, `git commit -F - <<EOF`) is data. The fork bomb spans `|`/`&`/`;`, so
885
+ * it is tested on the whole command. `split` cuts a command into raw-token segments, as for
886
+ * `lossReason`. Returns the offending text, or null.
887
+ */
888
+ const CATASTROPHIC_CANDIDATE_RE = /(?:^|[^\w-])(?:rm|rmdir|rd|del|erase|remove-item|ri|mkfs(?:\.\w+)?|dd|format)(?:\.exe)?(?![\w-])|:\(\)/i;
889
+ const FORK_BOMB_RE = /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/;
890
+ // `/`, a Git Bash or WSL drive (`/c`, `/mnt/c`), a Windows drive (`C:`, `C:\`), the home dir — globbed or not.
891
+ const ROOT_TARGET_RE = /^(?:\/|~[\\/]?|\$\{?HOME\}?[\\/]?|\/[a-z]\/?|\/mnt\/[a-z]\/?|[a-z]:[\\/]?)\*?$/i;
892
+ const RM_RECURSE_RE = /^(?:-[a-zA-Z]*[rR][a-zA-Z]*|--recursive)$/;
893
+ // PowerShell's `-Recurse` (or any prefix of it) and cmd.exe's `/s`, alone or glued (`/s/q`).
894
+ const PS_CMD_RECURSE_RE = /^(?:-r(?:e(?:c(?:u(?:r(?:s(?:e)?)?)?)?)?)?|(?:\/[a-z]+)*\/s(?:\/[a-z]+)*)$/i;
895
+ const leadingFlags = (w) => { let i = 0; while (i < w.length && /^[-/][\w-]+$/.test(w[i])) i += 1; return w.slice(i); };
896
+ // The programs that run what they read on stdin as commands.
897
+ const STDIN_SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'fish', 'pwsh', 'powershell', 'cmd']);
898
+ // The same lexer as the splitter's (a comment, a quoted run, a heredoc opener, a newline), so the
899
+ // openers it finds are the ones left in the segments' tokens, in the same order.
900
+ const HEREDOC_SCAN_RE = /(?<![^\s;|&(])#[^\n]*|"[^"]*"?|'[^']*'?|(?<!<)<<(?!<)(-?)[ \t]*(["']?)([A-Za-z_]\w*)\2|\n/g;
901
+ /** Every heredoc body in `command`, in opener order; an unterminated one runs to the end, as in bash. */
902
+ function heredocBodies(command) {
903
+ const out = [];
904
+ const tags = [];
905
+ const re = new RegExp(HEREDOC_SCAN_RE);
906
+ let m;
907
+ while ((m = re.exec(command))) {
908
+ if (m[3]) { tags.push([m[1], m[3]]); continue; }
909
+ if (m[0] !== '\n' || !tags.length) continue;
910
+ let start = re.lastIndex;
911
+ while (tags.length) {
912
+ const [dash, tag] = tags.shift();
913
+ const body = [];
914
+ while (start < command.length) {
915
+ const end = command.indexOf('\n', start);
916
+ const line = command.slice(start, end < 0 ? command.length : end).replace(/\r$/, '');
917
+ start = end < 0 ? command.length : end + 1;
918
+ if ((dash ? line.replace(/^\t+/, '') : line) === tag) break;
919
+ body.push(line);
920
+ }
921
+ out.push(body.join('\n'));
922
+ }
923
+ re.lastIndex = start;
924
+ }
925
+ return out;
926
+ }
927
+ function catastrophic(command, split, depth = 0) {
928
+ if (!CATASTROPHIC_CANDIDATE_RE.test(command)) return null;
929
+ if (FORK_BOMB_RE.test(command)) return command.trim();
930
+ const bodies = command.includes('<<') ? heredocBodies(command) : [];
931
+ let next = 0;
932
+ for (const raw of split(command).map(stripStructure)) {
933
+ const text = raw.join(' ');
934
+ // What this segment feeds on stdin: each heredoc opener takes the next body, a here-string its word.
935
+ const fed = [];
936
+ raw.forEach((t, i) => {
937
+ if (t.startsWith('<<<')) fed.push(word(t.length > 3 ? t.slice(3) : raw[i + 1] || '').replace(OPAQUE, ''));
938
+ else if (t.startsWith('<<')) fed.push(bodies[next++] ?? '');
939
+ });
940
+ for (const body of depth < 4 ? substitutions(text) : []) {
941
+ const v = catastrophic(body, split, depth + 1);
942
+ if (v) return v;
943
+ }
944
+ let w = words(raw).map((t) => t.replace(OPAQUE, ''));
945
+ while (w.length && (/^\w+=/.test(w[0]) || WRAPPERS.has(progOf(w[0])))) {
946
+ w = /^\w+=/.test(w[0]) ? w.slice(1) : pastWrapper(progOf(w[0]), w.slice(1)) || leadingFlags(w.slice(1));
947
+ }
948
+ if (!w.length) continue;
949
+ const prog = progOf(w[0]);
950
+ for (const body of STDIN_SHELLS.has(prog) && depth < 4 ? fed : []) {
951
+ const v = catastrophic(body, split, depth + 1);
952
+ if (v) return v;
953
+ }
954
+ const args = w.slice(1).map((a) => a.replace(/^-(?:literal)?path[=:]/i, ''));
955
+ // cmd.exe's `/s` `/q` are switches, not the Git Bash drives `/s` `/q`.
956
+ const root = () => args.some((a) => ROOT_TARGET_RE.test(a) && !(CMD_SWITCH_PROGS.has(prog) && /^\/[a-z]$/i.test(a)));
957
+ let hit = false;
958
+ if (prog === 'rm') hit = args.some((a) => RM_RECURSE_RE.test(a)) && root();
959
+ else if (RM_PROGS.has(prog)) hit = args.some((a) => PS_CMD_RECURSE_RE.test(a)) && root();
960
+ else if (/^mkfs(?:\.\w+)?$/.test(prog)) hit = args.some((a) => a.startsWith('/dev/'));
961
+ else if (prog === 'dd') hit = args.some((a) => /^of=\/dev\/(?:sd|nvme|hd|disk)/.test(a));
962
+ else if (prog === 'format') hit = args.some((a) => /^[a-z]:/i.test(a));
963
+ else if (INDIRECT.has(prog) && depth < 4) {
964
+ const v = catastrophic(leadingFlags(w.slice(1)).join(' '), split, depth + 1);
965
+ if (v) return v;
966
+ }
967
+ if (hit) return text.trim();
968
+ }
969
+ return null;
970
+ }
868
971
  // ---- loss twin: END
869
972
 
870
973
  // A recursive scan rooted at a whole filesystem (Commands Must Return) → BLOCK. Twin of
@@ -1319,7 +1422,8 @@ export const GeneseedGuard = async (ctx) => {
1319
1422
  // classes, and the shell check must still run after the write check.
1320
1423
  if (hasAny(tool, SHELL_TOOLS)) {
1321
1424
  const c = pickCommand(args)
1322
- if (c && SHELL_BLOCK_RE.some((re) => re.test(c))) { await deny(`catastrophic command (Deletion Is Deliberate): ${c.slice(0, 80)}`, "law-4"); return }
1425
+ const cut = (t) => scanSegments(t).map((seg) => seg.match(SCAN_TOKEN_RE) || [])
1426
+ if (c && (SHELL_BLOCK_RE.some((re) => re.test(c)) || catastrophic(c, cut))) { await deny(`catastrophic command (Deletion Is Deliberate): ${c.slice(0, 80)}`, "law-4"); return }
1323
1427
  const scan = c && rootScan(c)
1324
1428
  if (scan) {
1325
1429
  await deny(`\`${scan.slice(0, 80)}\` recursively scans a whole filesystem root (Commands Must Return) — ` +
@@ -1329,7 +1433,6 @@ export const GeneseedGuard = async (ctx) => {
1329
1433
  if (c && !DELETION_GATE_OFF && LOSS_CANDIDATE_RE.test(c)) {
1330
1434
  // OpenCode's bash tool may run in `workdir`, resolved against the session directory.
1331
1435
  const wd = typeof args.workdir === "string" && args.workdir ? path.resolve(cwd, args.workdir) : cwd
1332
- const cut = (t) => scanSegments(t).map((seg) => seg.match(SCAN_TOKEN_RE) || [])
1333
1436
  const loss = lossReason(cut(c), wd, { always: false, split: cut })
1334
1437
  if (loss) { await deny(`${loss} (Deletion Is Deliberate) — confirm with the user first`, "law-4"); return }
1335
1438
  }
@@ -22,7 +22,7 @@ silently guarding the wrong rule after a renumber. See [ADR 0005](../adr/0005-st
22
22
  | id | Pack | Claude Code | OpenClaude | OpenCode | Bob |
23
23
  |---|---|---|---|---|---|
24
24
  | `sealed-secrets` | law | ask: rule-gate (credential-shaped content outside `.env*`). Switchable per install with `--no-secrets-gate` (see [Exclusions](exclusions.md)): this scan stands down on every host, the guard plugin's path block included | same as Claude Code | block: guard plugin (key/credential file path, or vendor-prefixed secret content); warns only on `.env*` | block (exit 2) |
25
- | `deletion-is-deliberate` | law | ask: git-gate, only on real data loss judged against the repository's state (the list in [Hooks](hooks.md): a protected branch deleted or force-pushed, an unpushed unmerged branch deleted, uncommitted work discarded, a stash dropped, the reflog pruned, files git cannot restore removed); asks when it cannot verify. Switchable per install with `--no-deletion-gate` (see [Exclusions](exclusions.md)): the real-loss check and the Law IV `permission.bash` keys stand down, everything else in this table stays | same as Claude Code | ask: permission prompt (`git reflog expire*`, `git gc --prune*`, a force push or delete of `main`/`master` by name) **+** block: guard plugin (catastrophic shell: `rm -rf /`, fork bomb, `format`, `dd` over a raw disk) **+** block: guard plugin (every other real-loss case, the same state-aware decisions as the hook) | block (exit 2) |
25
+ | `deletion-is-deliberate` | law | ask: git-gate, only on real data loss judged against the repository's state (the list in [Hooks](hooks.md): a protected branch deleted or force-pushed, an unpushed unmerged branch deleted, uncommitted work discarded, a stash dropped, the reflog pruned, files git cannot restore removed); asks when it cannot verify. Switchable per install with `--no-deletion-gate` (see [Exclusions](exclusions.md)): the real-loss check and the Law IV `permission.bash` keys stand down, everything else in this table stays **+** ask: git-gate, catastrophic shell (`rm -rf` of `/`, a drive root or `~`, `mkfs`, `dd` over a raw disk, `format`, fork bomb), whatever the state and even with the switch off | same as Claude Code | ask: permission prompt (`git reflog expire*`, `git gc --prune*`, a force push or delete of `main`/`master` by name) **+** block: guard plugin (catastrophic shell, the hook's check plus its own stricter text match: `rm -rf` of `/`, a drive root or `~`, fork bomb, `format`, `dd` over a raw disk) **+** block: guard plugin (every other real-loss case, the same state-aware decisions as the hook) | block (exit 2) |
26
26
  | `persist-insight` | process | ask: rule-gate (a write to `user-rules.md` or the memory store) | same as Claude Code | ask once: guard plugin (refuses the *first* write to that path; a re-issued write goes through) | log only (warning, call goes through) |
27
27
  | `consent-before-push` | process | ask: git-gate (every `git commit`/`git push`). Togglable: build without the process pack, or drop this rule alone with `--exclude-rules` (see [Exclusions](exclusions.md)). Only this question drops; the `deletion-is-deliberate` gate stays. | same as Claude Code | ask: permission prompt (`git commit*`/`git push*`), wired only when the process pack is on; a `loop/*` push is statically allowed | log only |
28
28
  | `external-gate` | rigor | ask: rule-gate (a write under a project's `.geneseed/protected-checks.txt` list) | same as Claude Code | block: guard plugin (same list) | block (exit 2) |
@@ -80,10 +80,9 @@ geneseed build --emit claude-global --no-deletion-gate
80
80
 
81
81
  The web console has the same switch on Rule IV's row of the Constitution page; the row's *Enforced by* reads **Gate off** while it is off. The install records it (`Deletion gate: off` under its pack list, written only when off) and every rebuild and upgrade keeps it; rebuild without the flag, or flip the switch back, to re-arm it. `geneseed status` shows `deletion gate: off` on the install's row.
82
82
 
83
- What stops asking, per host: the real-loss check of `git-gate` (Claude Code, OpenClaude) and `tool-gate` (Bob, which then no longer exits 2 for it), the OpenCode guard plugin's real-loss block, and the Law IV keys of OpenCode's `permission.bash` (`git reflog expire*`, `git gc --prune*`, the `main`/`master` force-push and delete forms). What stays: the Rule's text in the carrier, *Sealed Secrets*, the root-wide scan check (*Commands Must Return*), *Consent Before Push* (a forced push still asks as a push while that rule is on), *External Gate*, and the OpenCode guard's catastrophic-command block (`rm -rf /`, `format`, `dd` over a disk).
83
+ What stops asking, per host: the real-loss check of `git-gate` (Claude Code, OpenClaude) and `tool-gate` (Bob, which then no longer exits 2 for it), the OpenCode guard plugin's real-loss block, and the Law IV keys of OpenCode's `permission.bash` (`git reflog expire*`, `git gc --prune*`, the `main`/`master` force-push and delete forms). What stays: the Rule's text in the carrier, *Sealed Secrets*, the root-wide scan check (*Commands Must Return*), *Consent Before Push* (a forced push still asks as a push while that rule is on), *External Gate*, and the catastrophic-command check on every host (`rm -rf` of `/`, a drive root or the home directory, `format`, `dd` over a disk): a prompt on Claude Code and OpenClaude, a block on Bob and OpenCode.
84
84
 
85
85
  Limits:
86
- - **Claude Code, OpenClaude and Bob have no separate catastrophic-command check.** There, a removal outside a repository — `rm -rf /` included — is caught only by the real-loss check, so with the gate off it no longer asks.
87
86
  - **OpenCode reads the switch at start.** The guard plugin reads `Deletion gate:` from the install's `AGENT.md` once, when OpenCode loads it: restart OpenCode after flipping it.
88
87
  - **A global install beside a project install.** On Claude Code and OpenClaude the global `git-gate` stands down for a project install, so the project's switch decides. Bob's `tool-gate` and the OpenCode guard do not stand down: an armed global install still refuses real loss inside a project whose own install is off.
89
88
  - **An existing `opencode.json` keeps its Law IV keys.** Switching off does not remove `permission.bash` entries already written — Geneseed cannot tell its own from yours — so the build warns that it keeps them and they keep asking. Delete them by hand.
@@ -28,6 +28,7 @@ Geneseed writes its hooks into the host's settings file. Every hook calls one pr
28
28
  **context.** Before your first message, the agent receives your own harness files — `user-rules.md`, `PROFILE.md`, the memory index, the notebook index, your wiki declaration — skipping any still untouched since install, then the repo's docs, discovered by convention or listed in a `context.json`. Each file is capped at 16 KB and the whole injection at 48 KB; what does not fit is listed for the agent to read on demand. You see nothing, except that the agent already knows your project. See [Project context](../guides/project-context.md).
29
29
 
30
30
  **git-gate.** Runs before every shell command and looks for `git` in it, including chained one-liners and `git -C <path>` forms, and for a recursive scan of a whole filesystem.
31
+ - **A catastrophic command** gets a confirmation prompt citing *Deletion Is Deliberate* (on Bob, it is refused with exit 2) whatever the repository holds, even with the deletion gate switched off: a recursive removal of `/`, a drive root (`C:\`, `/c`) or the home directory (`~`, `$HOME`), `mkfs` on a device, `dd` over a raw disk, `format C:`, a fork bomb. It judges the program, not the text: a `sudo`, a background `&`, a subshell, `bash -c '…'` or a heredoc fed to a shell (`bash <<'EOF'`) hides nothing, and a heredoc fed to anything else (`cat`, `git commit -F -`) or a commit message that only names `rm -rf /` passes. The OpenCode guard runs the same code, plus its own stricter text match.
31
32
  - **Real data loss** gets a confirmation prompt citing *Deletion Is Deliberate* — and only real loss, judged against the repository's actual state:
32
33
  - deleting or force-pushing a protected branch (`main`, `master`, or a remote's default branch), locally or on the remote;
33
34
  - `branch -D` of a branch whose commits are on no remote branch and not merged into the current one;
@@ -36,7 +37,7 @@ Geneseed writes its hooks into the host's settings file. Every hook calls one pr
36
37
  - `stash drop`/`clear` while a stash exists; `reflog expire` and `gc --prune`, always;
37
38
  - removing files (`rm`, `rmdir`, `del`, `erase`, `Remove-Item`) that git cannot restore: untracked, ignored or modified files, a repository's `.git`, or anything outside a repository.
38
39
 
39
- A force push to a feature branch, the protected-branch-only forms above on a feature branch, a new branch (`checkout -b`, `switch -c`), `reset --hard` on a clean tree, `branch -D` of a pushed branch, or `rm` of a tracked, unmodified file passes. When the gate cannot tell — a glob or `$variable` it would have to expand, a `cd` it cannot follow, `sh -c`/`xargs`, git failing or missing — it asks, and says it could not verify. To read the state it runs `git` itself, only once a command already looked destructive; every other command pays nothing. That is a Rule, so it holds in every build — unless you switch its gate off for one install with `--no-deletion-gate` (or the switch on Rule IV in the console's Constitution page). The hook is then wired with `--no-deletion-gate` and this check stands down; the Rule's text still binds the agent, and the root-scan and consent checks below still run ([Exclusions](exclusions.md)).
40
+ A force push to a feature branch, the protected-branch-only forms above on a feature branch, a new branch (`checkout -b`, `switch -c`), `reset --hard` on a clean tree, `branch -D` of a pushed branch, or `rm` of a tracked, unmodified file passes. When the gate cannot tell — a glob or `$variable` it would have to expand, a `cd` it cannot follow, `sh -c`/`xargs`, git failing or missing — it asks, and says it could not verify. To read the state it runs `git` itself, only once a command already looked destructive; every other command pays nothing. That is a Rule, so it holds in every build — unless you switch its gate off for one install with `--no-deletion-gate` (or the switch on Rule IV in the console's Constitution page). The hook is then wired with `--no-deletion-gate` and this check stands down; the Rule's text still binds the agent, and the catastrophic-command check above and the root-scan and consent checks below still run ([Exclusions](exclusions.md)).
40
41
  - **Every `git commit` and `git push`** gets a prompt citing *Consent Before Push*. This part belongs to the **process** doctrine pack: build without it, or exclude that rule, and the hook stays wired with `--no-consent` — commits and pushes go through without asking, real data loss is still stopped ([Rules](rules.md)).
41
42
  - **A recursive scan of a whole filesystem** — `find /`, `du -sh /`, `grep -r foo /`, `rg x C:\`, `Get-ChildItem C:\ -Recurse` — gets a prompt citing *Commands Must Return* (on Bob, it is blocked). In Git Bash `/` mounts every drive and mounted share, so such a scan runs for hours, and `| head` does not stop it. The prompt tells the agent to search a specific directory instead. A deeper path (`find /c/Users/me/project`), `ls /` without `-R` (`ls -r` is reverse order, not recursion), a quoted argument (a search pattern such as `grep -rn "/" src`), a `#` comment, and the remote half of an `ssh` command pass. This check is wired in every build, whatever packs or rules you excluded: it rides inside `git-gate` with no flag of its own, because a new flag or verb would reach machines still running an older hook shim, which would refuse it — and Claude Code blocks a tool call whose gate fails.
42
43
  - Any other command passes untouched.
@@ -58,7 +58,7 @@ geneseed diff --full # with line-level diffs
58
58
  geneseed diff --out improvements.md # write the improvements file
59
59
  ```
60
60
 
61
- `diff` compares the global OpenCode install by default. Pass `--target <config dir>` to compare another install. The improvements file is self-contained. To make the changes permanent, hand it to an agent in a Geneseed clone and ask it to fold them back into `src/`. Then they survive every future build. The web console's **Changes** page exports the same file.
61
+ `diff` compares the global OpenCode install by default. Pass `--target <config dir>` to compare another install. A file you replaced with a link to your own copy (a skill folder junctioned or symlinked elsewhere, say) is listed as `linked`: Geneseed never writes through a link, so it is yours and not drift, and it stays out of the improvements file. The improvements file is self-contained. To make the changes permanent, hand it to an agent in a Geneseed clone and ask it to fold them back into `src/`. Then they survive every future build. The web console's **Changes** page exports the same file.
62
62
 
63
63
  ## Moving from a clone to npm
64
64
 
@@ -2,5 +2,5 @@
2
2
  "_comment": "version is a human-readable release label only; the canonical identity of an installed harness is the source fingerprint stamped in .geneseed-version (see `harness version`), not this string.",
3
3
  "theme": "neutral",
4
4
  "product": "Geneseed",
5
- "version": "4.9.2"
5
+ "version": "4.9.3"
6
6
  }
@@ -58,7 +58,7 @@ import {
58
58
  GATE_LEDGER, globalHookStandingDown, hookProjectDir, resolvePath, sovereignBypass,
59
59
  } from './hosts.mjs';
60
60
  import { currentBranch, gitRootOf, loopLaunched } from './gitref.mjs';
61
- import { LOSS_CANDIDATE_RE, lossReason, stripStructure } from './loss.mjs';
61
+ import { LOSS_CANDIDATE_RE, catastrophic, lossReason, stripStructure } from './loss.mjs';
62
62
  import { selfAndParents, readStdin } from './hooks-prims.mjs';
63
63
  import { recordUse } from './usage.mjs';
64
64
 
@@ -438,8 +438,18 @@ const rootArg = (t) => !/^["']/.test(t) && FS_ROOT_RE.test(t);
438
438
  function gitDecide(args, payload) {
439
439
  const command = ((payload && payload.tool_input) || {}).command;
440
440
  if (typeof command !== 'string') return 0;
441
- // `--no-deletion-gate`: the install switched Law IV's boundary off (user decision,
442
- // 2026-10-17). Only this check stands down — ops 2 and process 5 below still run.
441
+ // Law IV's catastrophic class (a root, a drive, the home dir, a disk): no state makes it safe,
442
+ // so it runs BEFORE the switch below and whatever it says (user decision, 2026-10-18). On
443
+ // Claude an ask is the strongest verdict the hook has; on Bob, `law-4` exits 2.
444
+ const doomed = catastrophic(command, lossSegments);
445
+ if (doomed) {
446
+ return ask(args, 'git-gate', 'law-4', `Geneseed (Deletion Is Deliberate) \u2014 \`${doomed.slice(0, 80)}\` `
447
+ + 'would destroy a filesystem root, a drive, the home directory or a whole disk, which nothing '
448
+ + 'could restore. This check stands even with the deletion gate off.');
449
+ }
450
+ // `--no-deletion-gate`: the install switched Law IV's real-loss boundary off (user decision,
451
+ // 2026-10-17). Only this check stands down — the catastrophic class above, ops 2 and process 5
452
+ // below still run.
443
453
  if (!args.noDeletionGate && LOSS_CANDIDATE_RE.test(command)) {
444
454
  // A pass here FALLS THROUGH: a force push to a feature branch loses nothing, but it is still
445
455
  // a push, and process 5 below still asks for it.
package/js/hosts/loss.mjs CHANGED
@@ -625,4 +625,102 @@ export function lossReason(segments, cwd, { always = true, split } = {}) {
625
625
  return `the gate could not verify what this command would lose (${e.message})`;
626
626
  }
627
627
  }
628
+
629
+ /**
630
+ * THE CATASTROPHIC CLASS — what no state can make safe: a recursive removal of a filesystem root,
631
+ * a drive root or the home directory, a device format or raw-disk overwrite, a fork bomb. Unlike
632
+ * `lossReason` it reads no state, and it stands whatever the deletion gate's switch says (user
633
+ * decision 2026-10-18): `--no-deletion-gate` silences the real-loss check, never this one. Judged
634
+ * in PROGRAM position, segment by segment after `stripStructure` and the wrappers, so `true & rm
635
+ * -rf /`, `sudo rm -rf /`, `(rm -rf /)` and `rm -rf /;` are caught and a commit message that only
636
+ * names `rm -rf /` is not. A shell or `eval` (`bash -c '…'`, `pwsh -Command …`) and a `$(…)` body
637
+ * are judged as commands of their own, and so is what a shell reads on stdin: a heredoc or
638
+ * here-string fed to `bash`/`sh`/`pwsh`/`cmd`… (`bash <<'EOF'` over `rm -rf /`). One fed to any
639
+ * other program (`cat <<EOF`, `git commit -F - <<EOF`) is data. The fork bomb spans `|`/`&`/`;`, so
640
+ * it is tested on the whole command. `split` cuts a command into raw-token segments, as for
641
+ * `lossReason`. Returns the offending text, or null.
642
+ */
643
+ const CATASTROPHIC_CANDIDATE_RE = /(?:^|[^\w-])(?:rm|rmdir|rd|del|erase|remove-item|ri|mkfs(?:\.\w+)?|dd|format)(?:\.exe)?(?![\w-])|:\(\)/i;
644
+ const FORK_BOMB_RE = /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/;
645
+ // `/`, a Git Bash or WSL drive (`/c`, `/mnt/c`), a Windows drive (`C:`, `C:\`), the home dir — globbed or not.
646
+ const ROOT_TARGET_RE = /^(?:\/|~[\\/]?|\$\{?HOME\}?[\\/]?|\/[a-z]\/?|\/mnt\/[a-z]\/?|[a-z]:[\\/]?)\*?$/i;
647
+ const RM_RECURSE_RE = /^(?:-[a-zA-Z]*[rR][a-zA-Z]*|--recursive)$/;
648
+ // PowerShell's `-Recurse` (or any prefix of it) and cmd.exe's `/s`, alone or glued (`/s/q`).
649
+ const PS_CMD_RECURSE_RE = /^(?:-r(?:e(?:c(?:u(?:r(?:s(?:e)?)?)?)?)?)?|(?:\/[a-z]+)*\/s(?:\/[a-z]+)*)$/i;
650
+ const leadingFlags = (w) => { let i = 0; while (i < w.length && /^[-/][\w-]+$/.test(w[i])) i += 1; return w.slice(i); };
651
+ // The programs that run what they read on stdin as commands.
652
+ const STDIN_SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'fish', 'pwsh', 'powershell', 'cmd']);
653
+ // The same lexer as the splitter's (a comment, a quoted run, a heredoc opener, a newline), so the
654
+ // openers it finds are the ones left in the segments' tokens, in the same order.
655
+ const HEREDOC_SCAN_RE = /(?<![^\s;|&(])#[^\n]*|"[^"]*"?|'[^']*'?|(?<!<)<<(?!<)(-?)[ \t]*(["']?)([A-Za-z_]\w*)\2|\n/g;
656
+ /** Every heredoc body in `command`, in opener order; an unterminated one runs to the end, as in bash. */
657
+ function heredocBodies(command) {
658
+ const out = [];
659
+ const tags = [];
660
+ const re = new RegExp(HEREDOC_SCAN_RE);
661
+ let m;
662
+ while ((m = re.exec(command))) {
663
+ if (m[3]) { tags.push([m[1], m[3]]); continue; }
664
+ if (m[0] !== '\n' || !tags.length) continue;
665
+ let start = re.lastIndex;
666
+ while (tags.length) {
667
+ const [dash, tag] = tags.shift();
668
+ const body = [];
669
+ while (start < command.length) {
670
+ const end = command.indexOf('\n', start);
671
+ const line = command.slice(start, end < 0 ? command.length : end).replace(/\r$/, '');
672
+ start = end < 0 ? command.length : end + 1;
673
+ if ((dash ? line.replace(/^\t+/, '') : line) === tag) break;
674
+ body.push(line);
675
+ }
676
+ out.push(body.join('\n'));
677
+ }
678
+ re.lastIndex = start;
679
+ }
680
+ return out;
681
+ }
682
+ export function catastrophic(command, split, depth = 0) {
683
+ if (!CATASTROPHIC_CANDIDATE_RE.test(command)) return null;
684
+ if (FORK_BOMB_RE.test(command)) return command.trim();
685
+ const bodies = command.includes('<<') ? heredocBodies(command) : [];
686
+ let next = 0;
687
+ for (const raw of split(command).map(stripStructure)) {
688
+ const text = raw.join(' ');
689
+ // What this segment feeds on stdin: each heredoc opener takes the next body, a here-string its word.
690
+ const fed = [];
691
+ raw.forEach((t, i) => {
692
+ if (t.startsWith('<<<')) fed.push(word(t.length > 3 ? t.slice(3) : raw[i + 1] || '').replace(OPAQUE, ''));
693
+ else if (t.startsWith('<<')) fed.push(bodies[next++] ?? '');
694
+ });
695
+ for (const body of depth < 4 ? substitutions(text) : []) {
696
+ const v = catastrophic(body, split, depth + 1);
697
+ if (v) return v;
698
+ }
699
+ let w = words(raw).map((t) => t.replace(OPAQUE, ''));
700
+ while (w.length && (/^\w+=/.test(w[0]) || WRAPPERS.has(progOf(w[0])))) {
701
+ w = /^\w+=/.test(w[0]) ? w.slice(1) : pastWrapper(progOf(w[0]), w.slice(1)) || leadingFlags(w.slice(1));
702
+ }
703
+ if (!w.length) continue;
704
+ const prog = progOf(w[0]);
705
+ for (const body of STDIN_SHELLS.has(prog) && depth < 4 ? fed : []) {
706
+ const v = catastrophic(body, split, depth + 1);
707
+ if (v) return v;
708
+ }
709
+ const args = w.slice(1).map((a) => a.replace(/^-(?:literal)?path[=:]/i, ''));
710
+ // cmd.exe's `/s` `/q` are switches, not the Git Bash drives `/s` `/q`.
711
+ const root = () => args.some((a) => ROOT_TARGET_RE.test(a) && !(CMD_SWITCH_PROGS.has(prog) && /^\/[a-z]$/i.test(a)));
712
+ let hit = false;
713
+ if (prog === 'rm') hit = args.some((a) => RM_RECURSE_RE.test(a)) && root();
714
+ else if (RM_PROGS.has(prog)) hit = args.some((a) => PS_CMD_RECURSE_RE.test(a)) && root();
715
+ else if (/^mkfs(?:\.\w+)?$/.test(prog)) hit = args.some((a) => a.startsWith('/dev/'));
716
+ else if (prog === 'dd') hit = args.some((a) => /^of=\/dev\/(?:sd|nvme|hd|disk)/.test(a));
717
+ else if (prog === 'format') hit = args.some((a) => /^[a-z]:/i.test(a));
718
+ else if (INDIRECT.has(prog) && depth < 4) {
719
+ const v = catastrophic(leadingFlags(w.slice(1)).join(' '), split, depth + 1);
720
+ if (v) return v;
721
+ }
722
+ if (hit) return text.trim();
723
+ }
724
+ return null;
725
+ }
628
726
  // ---- loss twin: END
@@ -104,7 +104,7 @@ const consentRuleOn = (d, excluded = []) =>
104
104
  *
105
105
  * `rm -rf *` WENT TOO (user ruling, 2026-10-17): a blanket ask on every recursive removal is the
106
106
  * glob-cannot-read-state problem again — the guard's `lossReason` lets an `rm` of tracked,
107
- * unmodified files through and blocks one git cannot restore, and its `SHELL_BLOCK_RE` still
107
+ * unmodified files through and blocks one git cannot restore, and its `catastrophic()` still
108
108
  * blocks `rm -rf /`, `~` and the drive-root forms outright. Same stranded-key handling as above:
109
109
  * an existing `opencode.json` keeps its `rm -rf *` key, asking, until the user deletes it.
110
110
  *
@@ -185,7 +185,7 @@ export function gateProbe(targets = installTargets(), { platform = process.platf
185
185
  // ---- OpenCode: the guard plugin, probed in-process ------------------------------------------
186
186
 
187
187
  const GUARD_PLUGIN = 'geneseed-guard.js';
188
- /** The guard blocks catastrophic shell (`SHELL_BLOCK_RE`); destructive git is the permission
188
+ /** The guard blocks catastrophic shell (`catastrophic()`); destructive git is the permission
189
189
  * prompt's job there, and only WARNS in the guard — so its must-refuse payload is this one. */
190
190
  const GUARD_BAD_COMMAND = 'rm -rf /';
191
191
 
@@ -54,6 +54,7 @@ import {
54
54
  trustOfDir,
55
55
  readJsonMaybe, readMaybe, themeOfDir,
56
56
  } from '../hosts/installs.mjs';
57
+ import { safeLeaf } from '../hosts/owned.mjs';
57
58
  import { unifiedDiff, splitLines } from '../lib/udiff.mjs';
58
59
  import { printOut, printErr, readText, writeText, isFile } from '../lib/fs.mjs';
59
60
  import { toPlatformPath } from '../lib/paths.mjs';
@@ -277,6 +278,7 @@ export function diffCollect({
277
278
  for (const rel of rels) {
278
279
  const a = path.join(dir, rel);
279
280
  const tb = exp.texts.get(posixRel(rel));
281
+ let rec = null;
280
282
  if (isFile(a) && tb !== undefined) {
281
283
  // `read_text(errors="replace")` — Node's utf8 decoder substitutes U+FFFD for the
282
284
  // same bytes, and `readText` folds the line endings the way `Path.read_text` does.
@@ -285,20 +287,27 @@ export function diffCollect({
285
287
  const diff = unifiedDiff(splitLines(tb), splitLines(ta), {
286
288
  fromfile: `source/${rel}`, tofile: `deployed/${rel}`, lineterm: '',
287
289
  });
288
- files.push({ rel, status: 'edited', diff });
290
+ rec = { rel, status: 'edited', diff };
289
291
  }
290
292
  } else if (isFile(a)) {
291
293
  const body = splitLines(readText(a));
292
- files.push({
294
+ rec = {
293
295
  rel,
294
296
  status: 'added',
295
297
  diff: ['(only in deployed — your addition)', '', ...body.map((ln) => `+${ln}`)],
296
- });
298
+ };
297
299
  } else {
298
- files.push({
299
- rel, status: 'missing', diff: ['(in source, not deployed — re-emit to add)'],
300
- });
300
+ rec = { rel, status: 'missing', diff: ['(in source, not deployed — re-emit to add)'] };
301
301
  }
302
+ if (rec === null) continue;
303
+ // A path that is, or sits under, a link the emit refused to write through (a skill dir the
304
+ // user junctioned to their own copy) is theirs: no rebuild can clear it, so it is not drift.
305
+ // `safeLeaf` is the claimer's own test, so "linked" here is exactly "refused" there — and
306
+ // it runs only on a path that differs, because it lstats and resolves.
307
+ if (!safeLeaf(dir, a, deployedDir)) {
308
+ rec = { rel, status: 'linked', diff: ['(a link — yours; Geneseed leaves it alone)'] };
309
+ }
310
+ files.push(rec);
302
311
  }
303
312
  // Already sorted by `rel` above; the Python sorts again after the loop and so does this,
304
313
  // because the two sorts are over different things — that one is over the RECORDS, and a
@@ -307,8 +316,13 @@ export function diffCollect({
307
316
  return { target: dir, theme: themeName, files };
308
317
  }
309
318
 
319
+ /** The records that are drift: everything but a `linked` path, which is the user's own. */
320
+ export const drifted = (files) => files.filter((f) => f.status !== 'linked');
321
+
310
322
  /** `_harness_diff._improvements_md` — pure; `when` is the caller's timestamp. */
311
- export function improvementsMd(target, theme, files, when) {
323
+ export function improvementsMd(target, theme, allFiles, when) {
324
+ // A linked path is never a back-port item: the file is the user's, not a local edit of ours.
325
+ const files = drifted(allFiles);
312
326
  const edited = files.filter((f) => f.status === 'edited');
313
327
  const added = files.filter((f) => f.status === 'added');
314
328
  const missing = files.filter((f) => f.status === 'missing');
@@ -398,7 +412,8 @@ export function exportImprovements(target = null, theme = null, outPath = null)
398
412
  const collected = diffCollect({ target, theme });
399
413
  // Python's `if not files` — null AND the empty list both return, which is why the caller
400
414
  // gets `files` back rather than a bare path and can tell the two apart itself.
401
- if (!collected.files || collected.files.length === 0) return [null, collected.files];
415
+ // A linked path alone is not drift, or every update would export an empty report.
416
+ if (!collected.files || drifted(collected.files).length === 0) return [null, collected.files];
402
417
  return [writeImprovements(collected.target, collected.theme, collected.files, outPath),
403
418
  collected.files];
404
419
  }
@@ -454,14 +469,18 @@ export function cmdDiff(args) {
454
469
  const edited = files.filter((f) => f.status === 'edited');
455
470
  const added = files.filter((f) => f.status === 'added');
456
471
  const missing = files.filter((f) => f.status === 'missing');
472
+ const linked = files.filter((f) => f.status === 'linked');
457
473
  printOut(`[diff] deployed ${target} vs source (theme: ${theme})\n`);
458
474
  printOut(`[diff] ${edited.length} edited, ${added.length} added-in-deployed, `
459
475
  + `${missing.length} missing-from-deployed\n`);
476
+ // Its own line, printed only when there is one, so the summary above is unchanged otherwise.
477
+ if (linked.length) printOut(`[diff] ${linked.length} linked (yours, left alone)\n`);
460
478
  for (const f of edited) printOut(` ~ ${f.rel} (edited in deployed — review to back-port)\n`);
461
479
  for (const f of added) printOut(` + ${f.rel} (only in deployed — your addition)\n`);
462
480
  for (const f of missing) printOut(` - ${f.rel} (in source, not deployed — re-emit to add)\n`);
481
+ for (const f of linked) printOut(` = ${f.rel} (a link — yours, left alone)\n`);
463
482
  if (args.out) {
464
- if (files.length) {
483
+ if (drifted(files).length) {
465
484
  // Bare `--out` is argparse's `nargs="?"` const: True, meaning "the default timestamped
466
485
  // path". `parse()` gives a flag `true` and an option a string, so the two arrive
467
486
  // distinguishable here without a third kind of entry in the VERBS table.
package/js/web/api.mjs CHANGED
@@ -136,11 +136,15 @@ export function* activeInstalls(targets) {
136
136
  */
137
137
  const memoOf = (state) => (state.renderMemo ??= renderMemo());
138
138
 
139
- /** The edited/added/missing tally of a `diffCollect` file list. */
139
+ /**
140
+ * The edited/added/missing tally of a `diffCollect` file list, and apart from it the `linked`
141
+ * paths — the user's own links, which the console never adds into a drift total.
142
+ */
140
143
  const driftCounts = (files) => ({
141
144
  edited: files.filter((f) => f.status === 'edited').length,
142
145
  added: files.filter((f) => f.status === 'added').length,
143
146
  missing: files.filter((f) => f.status === 'missing').length,
147
+ linked: files.filter((f) => f.status === 'linked').length,
144
148
  });
145
149
 
146
150
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "Every field here is argued and gated in tests/unit/package_manifest.test.mjs. `files`, `bin` and `engines` each have a test that fails when reality drifts from this file; `version` mirrors harness.config.json and is gated too. Read that docblock before editing this one.",
3
3
  "name": "geneseed",
4
- "version": "4.9.2",
4
+ "version": "4.9.3",
5
5
  "description": "A portable, theme-able harness you implant once and use everywhere to grow a disciplined AI coding agent.",
6
6
  "keywords": [
7
7
  "ai",
@@ -1 +1 @@
1
- import{s as e}from"./sections-BET3c1WT.js";import{i as t,t as n}from"./react-BOF6yL40.js";import{t as r}from"./api-CiXlkbe0.js";import{C as i,N as a,Q as o,R as s,x as c}from"./index-BZgEGRbL.js";import{a as l,i as u,n as d,o as f,r as p,t as m}from"./activity-DvPxoS4Y.js";import{t as h}from"./usePoll-NSTzLOF4.js";var g=e(t(),1),_=n();function v({s:e}){let t=p[e.status]||p.idle,n=e.files,r=e.todos,i=r&&r.total>0||e.error||e.blocked_on;return(0,_.jsxs)(`a`,{className:`card pad-md act-card`,href:`#/activity/${encodeURIComponent(e.session_id)}`,children:[(0,_.jsxs)(`div`,{className:`row between wrap gap-12`,children:[(0,_.jsxs)(`div`,{className:`row gap-10`,style:{minWidth:0},children:[(0,_.jsx)(`span`,{className:`feed-dot ${t.cls||`acc`}`,style:{width:9,height:9}}),(0,_.jsxs)(`div`,{style:{minWidth:0},children:[(0,_.jsx)(`h3`,{style:{margin:0},children:e.title||l(e.cwd)||e.session_id}),e.cwd&&(0,_.jsx)(`div`,{className:`dim mono`,style:{fontSize:12,...m},title:e.cwd,children:e.cwd})]})]}),(0,_.jsxs)(`div`,{className:`row gap-8`,style:{flexShrink:0},children:[e.model&&(0,_.jsx)(`span`,{className:`badge mono`,title:`model`,children:e.model}),e.agent&&(0,_.jsx)(`span`,{className:`badge`,children:e.agent}),(0,_.jsxs)(`span`,{className:`badge ${t.cls}`,children:[(0,_.jsx)(`span`,{className:`dot`}),t.label]}),(0,_.jsx)(a,{name:`chevron`,className:`glyph act-chev`})]})]}),e.phase&&(0,_.jsxs)(`div`,{className:`row gap-8`,style:{marginTop:10,fontSize:14},children:[(0,_.jsx)(a,{name:`activity`}),(0,_.jsx)(`span`,{style:m,children:e.phase})]}),(e.tokens>0||e.cost>0||e.turn_started_at||n&&n.count>0)&&(0,_.jsxs)(`div`,{className:`row wrap`,style:{gap:16,marginTop:12,fontSize:13,color:`var(--text-2)`},children:[e.tokens>0&&(0,_.jsxs)(`span`,{children:[f(e.tokens),` tok`]}),e.cost>0&&(0,_.jsxs)(`span`,{children:[`$`,e.cost.toFixed(2)]}),e.turn_started_at?(0,_.jsx)(`span`,{children:(0,_.jsx)(d,{startedAt:e.turn_started_at})}):null,n&&n.count>0&&(0,_.jsxs)(`span`,{children:[n.count,` file`,n.count===1?``:`s`,` `,(0,_.jsxs)(`span`,{style:{color:`var(--good)`},children:[`+`,n.additions]}),` `,(0,_.jsxs)(`span`,{style:{color:`var(--bad)`},children:[`−`,n.deletions]})]})]}),i&&(0,_.jsxs)(`div`,{className:`row between wrap gap-12`,style:{marginTop:12,paddingTop:10,borderTop:`1px solid var(--line)`},children:[r&&r.total>0?(0,_.jsxs)(`span`,{className:`row gap-8 dim`,style:{fontSize:13},children:[(0,_.jsx)(u,{done:r.done,total:r.total}),`plan `,r.done,`/`,r.total]}):(0,_.jsx)(`span`,{}),e.error?(0,_.jsx)(`span`,{className:`badge bad`,title:e.error,style:{maxWidth:300,...m},children:e.error}):e.blocked_on?(0,_.jsx)(`span`,{className:`badge bad`,title:e.blocked_on,style:{maxWidth:300,...m},children:e.blocked_on}):null]}),(0,_.jsxs)(`div`,{className:`dim mono`,style:{fontSize:11,marginTop:8},children:[`updated `,s(e.updated_at),` ago`]})]})}function y(){let[e,t]=(0,g.useState)(null),[n,a]=(0,g.useState)(``);h(e=>r.activity().then(n=>{e()&&(t(n),a(``))},t=>{e()&&a(t.message)}),2e3,[]);let s=!e||e.enabled!==!1,l=e?.activity||[],u=o(),d=async()=>{let e=!s;try{let n=await r.activityToggle(e);t(e=>({...e||{},enabled:n.enabled,activity:n.enabled&&e?.activity||[]})),a(``)}catch(e){a(e.message)}};return(0,_.jsxs)(`div`,{className:`narrow`,children:[(0,_.jsxs)(`div`,{className:`head-row mb-16`,children:[(0,_.jsxs)(`div`,{children:[(0,_.jsx)(`h1`,{className:`h`,children:`Activity`}),(0,_.jsx)(`p`,{className:`sub`,children:`Live OpenCode sessions this harness is running: what each one is doing right now. Refreshes every couple of seconds; sessions drop off when their process exits.`})]}),(0,_.jsxs)(`div`,{className:`row gap-10`,style:{alignItems:`center`,flexShrink:0},children:[(0,_.jsx)(`span`,{className:`dim mono`,style:{fontSize:12},children:s?`tracking on`:`tracking off`}),(0,_.jsx)(`div`,{className:`sw-toggle${s?` on`:``}`,role:`switch`,"aria-checked":s,"aria-label":`Toggle activity tracking`,tabIndex:0,onClick:d,onKeyDown:e=>{(e.key===`Enter`||e.key===` `)&&(e.key===` `&&e.preventDefault(),d())}})]})]}),(0,_.jsx)(c,{error:n,style:{marginBottom:12}}),e?s?l.length===0?(0,_.jsxs)(`div`,{className:`empty`,children:[(0,_.jsx)(`div`,{className:`big`,children:`No active sessions`}),`Run `,(0,_.jsx)(`code`,{children:`opencode`}),` and the sessions it spins up appear here.`]}):(0,_.jsx)(`div`,{className:`stack gap-12`,ref:u,children:l.map(e=>(0,_.jsx)(`div`,{"data-flip":e.session_id,children:(0,_.jsx)(v,{s:e})},e.session_id))}):(0,_.jsxs)(`div`,{className:`empty`,children:[(0,_.jsx)(`div`,{className:`big`,children:`Activity tracking is off`}),`The plugin isn't recording sessions. Flip the switch to turn it back on.`]}):(0,_.jsx)(i,{label:`Reading live sessions…`})]})}export{y as default};
1
+ import{s as e}from"./sections-BET3c1WT.js";import{i as t,t as n}from"./react-BOF6yL40.js";import{t as r}from"./api-CiXlkbe0.js";import{C as i,N as a,Q as o,R as s,x as c}from"./index-DQXG_mqo.js";import{a as l,i as u,n as d,o as f,r as p,t as m}from"./activity-DvPxoS4Y.js";import{t as h}from"./usePoll-NSTzLOF4.js";var g=e(t(),1),_=n();function v({s:e}){let t=p[e.status]||p.idle,n=e.files,r=e.todos,i=r&&r.total>0||e.error||e.blocked_on;return(0,_.jsxs)(`a`,{className:`card pad-md act-card`,href:`#/activity/${encodeURIComponent(e.session_id)}`,children:[(0,_.jsxs)(`div`,{className:`row between wrap gap-12`,children:[(0,_.jsxs)(`div`,{className:`row gap-10`,style:{minWidth:0},children:[(0,_.jsx)(`span`,{className:`feed-dot ${t.cls||`acc`}`,style:{width:9,height:9}}),(0,_.jsxs)(`div`,{style:{minWidth:0},children:[(0,_.jsx)(`h3`,{style:{margin:0},children:e.title||l(e.cwd)||e.session_id}),e.cwd&&(0,_.jsx)(`div`,{className:`dim mono`,style:{fontSize:12,...m},title:e.cwd,children:e.cwd})]})]}),(0,_.jsxs)(`div`,{className:`row gap-8`,style:{flexShrink:0},children:[e.model&&(0,_.jsx)(`span`,{className:`badge mono`,title:`model`,children:e.model}),e.agent&&(0,_.jsx)(`span`,{className:`badge`,children:e.agent}),(0,_.jsxs)(`span`,{className:`badge ${t.cls}`,children:[(0,_.jsx)(`span`,{className:`dot`}),t.label]}),(0,_.jsx)(a,{name:`chevron`,className:`glyph act-chev`})]})]}),e.phase&&(0,_.jsxs)(`div`,{className:`row gap-8`,style:{marginTop:10,fontSize:14},children:[(0,_.jsx)(a,{name:`activity`}),(0,_.jsx)(`span`,{style:m,children:e.phase})]}),(e.tokens>0||e.cost>0||e.turn_started_at||n&&n.count>0)&&(0,_.jsxs)(`div`,{className:`row wrap`,style:{gap:16,marginTop:12,fontSize:13,color:`var(--text-2)`},children:[e.tokens>0&&(0,_.jsxs)(`span`,{children:[f(e.tokens),` tok`]}),e.cost>0&&(0,_.jsxs)(`span`,{children:[`$`,e.cost.toFixed(2)]}),e.turn_started_at?(0,_.jsx)(`span`,{children:(0,_.jsx)(d,{startedAt:e.turn_started_at})}):null,n&&n.count>0&&(0,_.jsxs)(`span`,{children:[n.count,` file`,n.count===1?``:`s`,` `,(0,_.jsxs)(`span`,{style:{color:`var(--good)`},children:[`+`,n.additions]}),` `,(0,_.jsxs)(`span`,{style:{color:`var(--bad)`},children:[`−`,n.deletions]})]})]}),i&&(0,_.jsxs)(`div`,{className:`row between wrap gap-12`,style:{marginTop:12,paddingTop:10,borderTop:`1px solid var(--line)`},children:[r&&r.total>0?(0,_.jsxs)(`span`,{className:`row gap-8 dim`,style:{fontSize:13},children:[(0,_.jsx)(u,{done:r.done,total:r.total}),`plan `,r.done,`/`,r.total]}):(0,_.jsx)(`span`,{}),e.error?(0,_.jsx)(`span`,{className:`badge bad`,title:e.error,style:{maxWidth:300,...m},children:e.error}):e.blocked_on?(0,_.jsx)(`span`,{className:`badge bad`,title:e.blocked_on,style:{maxWidth:300,...m},children:e.blocked_on}):null]}),(0,_.jsxs)(`div`,{className:`dim mono`,style:{fontSize:11,marginTop:8},children:[`updated `,s(e.updated_at),` ago`]})]})}function y(){let[e,t]=(0,g.useState)(null),[n,a]=(0,g.useState)(``);h(e=>r.activity().then(n=>{e()&&(t(n),a(``))},t=>{e()&&a(t.message)}),2e3,[]);let s=!e||e.enabled!==!1,l=e?.activity||[],u=o(),d=async()=>{let e=!s;try{let n=await r.activityToggle(e);t(e=>({...e||{},enabled:n.enabled,activity:n.enabled&&e?.activity||[]})),a(``)}catch(e){a(e.message)}};return(0,_.jsxs)(`div`,{className:`narrow`,children:[(0,_.jsxs)(`div`,{className:`head-row mb-16`,children:[(0,_.jsxs)(`div`,{children:[(0,_.jsx)(`h1`,{className:`h`,children:`Activity`}),(0,_.jsx)(`p`,{className:`sub`,children:`Live OpenCode sessions this harness is running: what each one is doing right now. Refreshes every couple of seconds; sessions drop off when their process exits.`})]}),(0,_.jsxs)(`div`,{className:`row gap-10`,style:{alignItems:`center`,flexShrink:0},children:[(0,_.jsx)(`span`,{className:`dim mono`,style:{fontSize:12},children:s?`tracking on`:`tracking off`}),(0,_.jsx)(`div`,{className:`sw-toggle${s?` on`:``}`,role:`switch`,"aria-checked":s,"aria-label":`Toggle activity tracking`,tabIndex:0,onClick:d,onKeyDown:e=>{(e.key===`Enter`||e.key===` `)&&(e.key===` `&&e.preventDefault(),d())}})]})]}),(0,_.jsx)(c,{error:n,style:{marginBottom:12}}),e?s?l.length===0?(0,_.jsxs)(`div`,{className:`empty`,children:[(0,_.jsx)(`div`,{className:`big`,children:`No active sessions`}),`Run `,(0,_.jsx)(`code`,{children:`opencode`}),` and the sessions it spins up appear here.`]}):(0,_.jsx)(`div`,{className:`stack gap-12`,ref:u,children:l.map(e=>(0,_.jsx)(`div`,{"data-flip":e.session_id,children:(0,_.jsx)(v,{s:e})},e.session_id))}):(0,_.jsxs)(`div`,{className:`empty`,children:[(0,_.jsx)(`div`,{className:`big`,children:`Activity tracking is off`}),`The plugin isn't recording sessions. Flip the switch to turn it back on.`]}):(0,_.jsx)(i,{label:`Reading live sessions…`})]})}export{y as default};
@@ -1 +1 @@
1
- import{s as e}from"./sections-BET3c1WT.js";import{i as t,t as n}from"./react-BOF6yL40.js";import{t as r}from"./api-CiXlkbe0.js";import{C as i,N as a,R as o}from"./index-BZgEGRbL.js";import{a as s,n as c,o as l,r as u,t as d}from"./activity-DvPxoS4Y.js";import{t as f}from"./usePoll-NSTzLOF4.js";var p=e(t(),1),m=n();function h(e){return e.kind===`tool`?e.status===`error`?`bad`:e.status===`completed`?`ok`:`acc`:{retry:`warn`,error:`bad`,subtask:`acc`,text:`acc`}[e.kind]||``}function g({rec:e}){let t=e.who===`you`,n=[];t||(e.kind===`tool`&&e.ms!=null&&n.push(`${(e.ms/1e3).toFixed(1)}s`),e.kind===`step`&&(e.tokens&&n.push(`${l(e.tokens)} tok`),e.cost&&n.push(`$${e.cost.toFixed(2)}`)),e.kind===`subtask`&&e.agent&&n.unshift(e.agent));let r=!t&&(e.kind===`text`||e.kind===`thinking`||e.kind===`step`),i=t?e.text:e.label||e.snippet||e.kind;return(0,m.jsxs)(`div`,{className:`row gap-10`,style:{alignItems:`baseline`},children:[(0,m.jsx)(`span`,{className:`badge ${t?``:`acc`}`,style:{flexShrink:0,minWidth:52,justifyContent:`center`},children:t?`you`:`agent`}),(0,m.jsxs)(`div`,{style:{minWidth:0,flex:1,fontSize:13},children:[!t&&(0,m.jsx)(`span`,{className:`feed-dot ${h(e)||`acc`}`,style:{display:`inline-block`,marginRight:8,verticalAlign:`middle`}}),e.kind===`thinking`&&(0,m.jsx)(`span`,{className:`dim`,children:`thinking: `}),r?(0,m.jsx)(`span`,{className:`dim`,children:i}):t?i:(0,m.jsx)(`b`,{children:i}),e.error&&(0,m.jsxs)(`span`,{className:`dim`,children:[`: `,e.error]})]}),n.length>0&&(0,m.jsx)(`span`,{className:`dim mono`,style:{flexShrink:0,fontSize:12},children:n.join(` · `)})]})}function _({turns:e}){return!e||e.length===0?(0,m.jsx)(`div`,{className:`dim`,style:{fontSize:13},children:`No conversation captured yet.`}):(0,m.jsx)(`div`,{className:`stack gap-12`,children:e.map((e,t)=>{let n=e.role===`user`;return(0,m.jsxs)(`div`,{className:`row gap-10`,style:{alignItems:`baseline`},children:[(0,m.jsx)(`span`,{className:`badge ${n?``:`acc`}`,style:{flexShrink:0,minWidth:52,justifyContent:`center`},children:n?`you`:`agent`}),(0,m.jsx)(`div`,{style:{minWidth:0,fontSize:13,color:n?`inherit`:`var(--text-2)`},children:e.text})]},t)})})}function v({sid:e}){let[t,n]=(0,p.useState)(null),[h,v]=(0,p.useState)(``),[y,b]=(0,p.useState)(!1),[x,S]=(0,p.useState)(!1);f(t=>r.activityDetail(e).then(e=>{t()&&(n(e),v(``),b(!1))},e=>{t()&&(e.status===404?b(!0):v(e.message))}),2e3,[e]);let C=(0,m.jsxs)(`a`,{className:`btn ghost sm`,href:`#/activity`,style:{marginBottom:12,display:`inline-flex`,gap:6},children:[(0,m.jsx)(a,{name:`arrow`,style:{transform:`rotate(180deg)`}}),` Activity`]});if(y)return(0,m.jsxs)(`div`,{className:`narrow`,children:[C,(0,m.jsxs)(`div`,{className:`empty`,children:[(0,m.jsx)(`div`,{className:`big`,children:`Session ended`}),`It's no longer live. `,(0,m.jsx)(`a`,{href:`#/activity`,children:`Back to Activity`}),`.`]})]});if(!t)return(0,m.jsxs)(`div`,{className:`narrow`,children:[C,(0,m.jsx)(i,{label:`Loading session…`})]});let w=t.session,T=u[w.status]||u.idle,E=w.files,D=w.todos,O=!!(E&&E.items&&E.items.length>0),k=!!(D&&D.items&&D.items.length>0),A=t.conversation||[],j=t.timeline||[],M=[...A.filter(e=>e.role===`user`).map(e=>({who:`you`,text:e.text,t:e.t||0})),...j.map(e=>({...e,who:`agent`}))].sort((e,t)=>(e.t||0)-(t.t||0));return(0,m.jsxs)(`div`,{className:`narrow`,children:[C,h&&(0,m.jsx)(`p`,{className:`badge bad`,style:{marginBottom:12},children:h}),(0,m.jsxs)(`div`,{className:`card pad-md`,children:[(0,m.jsxs)(`div`,{className:`row between wrap gap-12`,children:[(0,m.jsxs)(`div`,{style:{minWidth:0},children:[(0,m.jsx)(`h1`,{className:`h`,style:{margin:0},children:w.title||s(w.cwd)||w.session_id}),w.cwd&&(0,m.jsx)(`div`,{className:`dim mono`,style:{fontSize:12,...d},title:w.cwd,children:w.cwd})]}),(0,m.jsxs)(`div`,{className:`row gap-8`,style:{flexShrink:0},children:[w.model&&(0,m.jsx)(`span`,{className:`badge mono`,children:w.model}),w.agent&&(0,m.jsx)(`span`,{className:`badge`,children:w.agent}),(0,m.jsxs)(`span`,{className:`badge ${T.cls}`,children:[(0,m.jsx)(`span`,{className:`dot`}),T.label]})]})]}),w.phase&&(0,m.jsxs)(`div`,{className:`row gap-8`,style:{marginTop:10,fontSize:14},children:[(0,m.jsx)(a,{name:`activity`}),(0,m.jsx)(`span`,{style:d,children:w.phase})]}),(0,m.jsxs)(`div`,{className:`row wrap`,style:{gap:16,marginTop:12,fontSize:13,color:`var(--text-2)`},children:[w.tokens>0&&(0,m.jsxs)(`span`,{children:[l(w.tokens),` tok`]}),w.cost>0&&(0,m.jsxs)(`span`,{children:[`$`,w.cost.toFixed(2)]}),w.turn_started_at?(0,m.jsx)(`span`,{children:(0,m.jsx)(c,{startedAt:w.turn_started_at})}):null,(0,m.jsxs)(`span`,{className:`dim`,children:[`updated `,o(w.updated_at),` ago`]})]}),(w.error||w.blocked_on)&&(0,m.jsx)(`div`,{style:{marginTop:10},children:(0,m.jsx)(`span`,{className:`badge bad`,style:{maxWidth:`100%`,...d},children:w.error||w.blocked_on})})]}),(O||k)&&(0,m.jsxs)(`div`,{className:`grid gap-12`,style:{marginTop:12,gridTemplateColumns:O&&k?`minmax(0, 1fr) minmax(0, 1fr)`:`1fr`},children:[O&&(0,m.jsxs)(`div`,{className:`card pad-md`,children:[(0,m.jsx)(`div`,{className:`card-head`,children:(0,m.jsxs)(`h3`,{children:[`Files touched `,(0,m.jsxs)(`span`,{className:`dim`,children:[`(`,E.count,`)`]})]})}),(0,m.jsx)(`div`,{className:`stack`,style:{gap:6,marginTop:8},children:E.items.map(e=>(0,m.jsxs)(`div`,{className:`row between gap-12`,style:{fontSize:13},children:[(0,m.jsx)(`span`,{className:`mono`,style:{...d,minWidth:0,flex:1},title:e.file,children:e.file}),(0,m.jsxs)(`span`,{style:{flexShrink:0},children:[(0,m.jsxs)(`span`,{style:{color:`var(--good)`},children:[`+`,e.additions]}),` `,(0,m.jsxs)(`span`,{style:{color:`var(--bad)`},children:[`−`,e.deletions]})]})]},e.file))})]}),k&&(0,m.jsxs)(`div`,{className:`card pad-md`,children:[(0,m.jsx)(`div`,{className:`card-head`,children:(0,m.jsxs)(`h3`,{children:[`Plan`,` `,(0,m.jsxs)(`span`,{className:`dim`,children:[`(`,D.done,`/`,D.total,`)`]})]})}),(0,m.jsx)(`div`,{className:`stack`,style:{gap:6,marginTop:8},children:D.items.map((e,t)=>(0,m.jsxs)(`div`,{className:`row gap-8`,style:{fontSize:13},children:[(0,m.jsx)(`span`,{className:`feed-dot ${e.status===`completed`?`ok`:e.status===`in_progress`?`acc`:``}`}),(0,m.jsx)(`span`,{style:{textDecoration:e.status===`completed`?`line-through`:`none`,color:e.status===`completed`?`var(--text-3)`:`inherit`},children:e.content})]},t))})]})]}),(0,m.jsxs)(`div`,{className:`card pad-md`,style:{marginTop:12},children:[(0,m.jsxs)(`div`,{className:`card-head`,style:{justifyContent:`space-between`,marginBottom:12},children:[(0,m.jsx)(`h3`,{children:`Timeline`}),j.length>0&&(0,m.jsx)(`button`,{className:`btn ghost sm`,onClick:()=>S(e=>!e),children:x?`Show conversation`:`Show all ${j.length} steps`})]}),x?(0,m.jsx)(`div`,{className:`stack gap-12`,children:M.map((e,t)=>(0,m.jsx)(g,{rec:e},t))}):(0,m.jsx)(_,{turns:A})]})]})}export{v as default};
1
+ import{s as e}from"./sections-BET3c1WT.js";import{i as t,t as n}from"./react-BOF6yL40.js";import{t as r}from"./api-CiXlkbe0.js";import{C as i,N as a,R as o}from"./index-DQXG_mqo.js";import{a as s,n as c,o as l,r as u,t as d}from"./activity-DvPxoS4Y.js";import{t as f}from"./usePoll-NSTzLOF4.js";var p=e(t(),1),m=n();function h(e){return e.kind===`tool`?e.status===`error`?`bad`:e.status===`completed`?`ok`:`acc`:{retry:`warn`,error:`bad`,subtask:`acc`,text:`acc`}[e.kind]||``}function g({rec:e}){let t=e.who===`you`,n=[];t||(e.kind===`tool`&&e.ms!=null&&n.push(`${(e.ms/1e3).toFixed(1)}s`),e.kind===`step`&&(e.tokens&&n.push(`${l(e.tokens)} tok`),e.cost&&n.push(`$${e.cost.toFixed(2)}`)),e.kind===`subtask`&&e.agent&&n.unshift(e.agent));let r=!t&&(e.kind===`text`||e.kind===`thinking`||e.kind===`step`),i=t?e.text:e.label||e.snippet||e.kind;return(0,m.jsxs)(`div`,{className:`row gap-10`,style:{alignItems:`baseline`},children:[(0,m.jsx)(`span`,{className:`badge ${t?``:`acc`}`,style:{flexShrink:0,minWidth:52,justifyContent:`center`},children:t?`you`:`agent`}),(0,m.jsxs)(`div`,{style:{minWidth:0,flex:1,fontSize:13},children:[!t&&(0,m.jsx)(`span`,{className:`feed-dot ${h(e)||`acc`}`,style:{display:`inline-block`,marginRight:8,verticalAlign:`middle`}}),e.kind===`thinking`&&(0,m.jsx)(`span`,{className:`dim`,children:`thinking: `}),r?(0,m.jsx)(`span`,{className:`dim`,children:i}):t?i:(0,m.jsx)(`b`,{children:i}),e.error&&(0,m.jsxs)(`span`,{className:`dim`,children:[`: `,e.error]})]}),n.length>0&&(0,m.jsx)(`span`,{className:`dim mono`,style:{flexShrink:0,fontSize:12},children:n.join(` · `)})]})}function _({turns:e}){return!e||e.length===0?(0,m.jsx)(`div`,{className:`dim`,style:{fontSize:13},children:`No conversation captured yet.`}):(0,m.jsx)(`div`,{className:`stack gap-12`,children:e.map((e,t)=>{let n=e.role===`user`;return(0,m.jsxs)(`div`,{className:`row gap-10`,style:{alignItems:`baseline`},children:[(0,m.jsx)(`span`,{className:`badge ${n?``:`acc`}`,style:{flexShrink:0,minWidth:52,justifyContent:`center`},children:n?`you`:`agent`}),(0,m.jsx)(`div`,{style:{minWidth:0,fontSize:13,color:n?`inherit`:`var(--text-2)`},children:e.text})]},t)})})}function v({sid:e}){let[t,n]=(0,p.useState)(null),[h,v]=(0,p.useState)(``),[y,b]=(0,p.useState)(!1),[x,S]=(0,p.useState)(!1);f(t=>r.activityDetail(e).then(e=>{t()&&(n(e),v(``),b(!1))},e=>{t()&&(e.status===404?b(!0):v(e.message))}),2e3,[e]);let C=(0,m.jsxs)(`a`,{className:`btn ghost sm`,href:`#/activity`,style:{marginBottom:12,display:`inline-flex`,gap:6},children:[(0,m.jsx)(a,{name:`arrow`,style:{transform:`rotate(180deg)`}}),` Activity`]});if(y)return(0,m.jsxs)(`div`,{className:`narrow`,children:[C,(0,m.jsxs)(`div`,{className:`empty`,children:[(0,m.jsx)(`div`,{className:`big`,children:`Session ended`}),`It's no longer live. `,(0,m.jsx)(`a`,{href:`#/activity`,children:`Back to Activity`}),`.`]})]});if(!t)return(0,m.jsxs)(`div`,{className:`narrow`,children:[C,(0,m.jsx)(i,{label:`Loading session…`})]});let w=t.session,T=u[w.status]||u.idle,E=w.files,D=w.todos,O=!!(E&&E.items&&E.items.length>0),k=!!(D&&D.items&&D.items.length>0),A=t.conversation||[],j=t.timeline||[],M=[...A.filter(e=>e.role===`user`).map(e=>({who:`you`,text:e.text,t:e.t||0})),...j.map(e=>({...e,who:`agent`}))].sort((e,t)=>(e.t||0)-(t.t||0));return(0,m.jsxs)(`div`,{className:`narrow`,children:[C,h&&(0,m.jsx)(`p`,{className:`badge bad`,style:{marginBottom:12},children:h}),(0,m.jsxs)(`div`,{className:`card pad-md`,children:[(0,m.jsxs)(`div`,{className:`row between wrap gap-12`,children:[(0,m.jsxs)(`div`,{style:{minWidth:0},children:[(0,m.jsx)(`h1`,{className:`h`,style:{margin:0},children:w.title||s(w.cwd)||w.session_id}),w.cwd&&(0,m.jsx)(`div`,{className:`dim mono`,style:{fontSize:12,...d},title:w.cwd,children:w.cwd})]}),(0,m.jsxs)(`div`,{className:`row gap-8`,style:{flexShrink:0},children:[w.model&&(0,m.jsx)(`span`,{className:`badge mono`,children:w.model}),w.agent&&(0,m.jsx)(`span`,{className:`badge`,children:w.agent}),(0,m.jsxs)(`span`,{className:`badge ${T.cls}`,children:[(0,m.jsx)(`span`,{className:`dot`}),T.label]})]})]}),w.phase&&(0,m.jsxs)(`div`,{className:`row gap-8`,style:{marginTop:10,fontSize:14},children:[(0,m.jsx)(a,{name:`activity`}),(0,m.jsx)(`span`,{style:d,children:w.phase})]}),(0,m.jsxs)(`div`,{className:`row wrap`,style:{gap:16,marginTop:12,fontSize:13,color:`var(--text-2)`},children:[w.tokens>0&&(0,m.jsxs)(`span`,{children:[l(w.tokens),` tok`]}),w.cost>0&&(0,m.jsxs)(`span`,{children:[`$`,w.cost.toFixed(2)]}),w.turn_started_at?(0,m.jsx)(`span`,{children:(0,m.jsx)(c,{startedAt:w.turn_started_at})}):null,(0,m.jsxs)(`span`,{className:`dim`,children:[`updated `,o(w.updated_at),` ago`]})]}),(w.error||w.blocked_on)&&(0,m.jsx)(`div`,{style:{marginTop:10},children:(0,m.jsx)(`span`,{className:`badge bad`,style:{maxWidth:`100%`,...d},children:w.error||w.blocked_on})})]}),(O||k)&&(0,m.jsxs)(`div`,{className:`grid gap-12`,style:{marginTop:12,gridTemplateColumns:O&&k?`minmax(0, 1fr) minmax(0, 1fr)`:`1fr`},children:[O&&(0,m.jsxs)(`div`,{className:`card pad-md`,children:[(0,m.jsx)(`div`,{className:`card-head`,children:(0,m.jsxs)(`h3`,{children:[`Files touched `,(0,m.jsxs)(`span`,{className:`dim`,children:[`(`,E.count,`)`]})]})}),(0,m.jsx)(`div`,{className:`stack`,style:{gap:6,marginTop:8},children:E.items.map(e=>(0,m.jsxs)(`div`,{className:`row between gap-12`,style:{fontSize:13},children:[(0,m.jsx)(`span`,{className:`mono`,style:{...d,minWidth:0,flex:1},title:e.file,children:e.file}),(0,m.jsxs)(`span`,{style:{flexShrink:0},children:[(0,m.jsxs)(`span`,{style:{color:`var(--good)`},children:[`+`,e.additions]}),` `,(0,m.jsxs)(`span`,{style:{color:`var(--bad)`},children:[`−`,e.deletions]})]})]},e.file))})]}),k&&(0,m.jsxs)(`div`,{className:`card pad-md`,children:[(0,m.jsx)(`div`,{className:`card-head`,children:(0,m.jsxs)(`h3`,{children:[`Plan`,` `,(0,m.jsxs)(`span`,{className:`dim`,children:[`(`,D.done,`/`,D.total,`)`]})]})}),(0,m.jsx)(`div`,{className:`stack`,style:{gap:6,marginTop:8},children:D.items.map((e,t)=>(0,m.jsxs)(`div`,{className:`row gap-8`,style:{fontSize:13},children:[(0,m.jsx)(`span`,{className:`feed-dot ${e.status===`completed`?`ok`:e.status===`in_progress`?`acc`:``}`}),(0,m.jsx)(`span`,{style:{textDecoration:e.status===`completed`?`line-through`:`none`,color:e.status===`completed`?`var(--text-3)`:`inherit`},children:e.content})]},t))})]})]}),(0,m.jsxs)(`div`,{className:`card pad-md`,style:{marginTop:12},children:[(0,m.jsxs)(`div`,{className:`card-head`,style:{justifyContent:`space-between`,marginBottom:12},children:[(0,m.jsx)(`h3`,{children:`Timeline`}),j.length>0&&(0,m.jsx)(`button`,{className:`btn ghost sm`,onClick:()=>S(e=>!e),children:x?`Show conversation`:`Show all ${j.length} steps`})]}),x?(0,m.jsx)(`div`,{className:`stack gap-12`,children:M.map((e,t)=>(0,m.jsx)(g,{rec:e},t))}):(0,m.jsx)(_,{turns:A})]})]})}export{v as default};