samagotchi 0.72.0 → 0.73.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6421f740a2683f0dcf3d24b92fba7bba62bed9edb92ddfd532ce69c74e14e0e8
4
- data.tar.gz: 23841684a58d0a3efedddcd0fb0caef4d1403c61a88268ad79f6b28a9d8f00ab
3
+ metadata.gz: 71fb1fa81145a92b955f7beaa2a9a1eabc37d29b9c8336dd65e27d5d616f23fa
4
+ data.tar.gz: b68963f09b363c1b03f8a32f14ebc41cf36297df3c290c594acef6735d0bb863
5
5
  SHA512:
6
- metadata.gz: 91ccf28b2be3f0bfff968c5d0448b0c1748ef90c505f1195ec655ae93784e48975409e54a56e18d7f78605812770d4ec9eeae336208da1a95ddbab2aa057f3da
7
- data.tar.gz: c5f8e40dfea68d7d6d706970106cbbdc5caf8e9571b92d771dc2928d6a3358704aafcd6b3a047f96ccfbe285b28ee2bc98de81505f10a342cc8b6efc228b0b9c
6
+ metadata.gz: 1752a44dd7c444534218f23c00ecc1746047875e4dac427b8395cd6cfb06169b38c456ff2bcbf4ca4000d68ce467454f52c2f4564547824ddb5c83dd586f5f2f
7
+ data.tar.gz: 66d5fcfd2b0a62957011653911edc44774843ac13837203cf1211e0523ae8d834025d0145324cd955d2f5c827f8b4a1e52c8b9c7db6a3735c6c24e4442eea7c0
data/CHANGELOG.md CHANGED
@@ -8,6 +8,30 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.73.0] - 2026-10-16
12
+
13
+ ### Added
14
+
15
+ - Web: the stage's asking text (a question's or approval's reply) renders as markdown with clickable links when
16
+ `web.markdown` is on, and so does a resolved question card's copy of it once the card is opened; their
17
+ `path:line` refs are links with markdown off too.
18
+
19
+ ### Changed
20
+
21
+ - Web: `continue →` fills the new link's composer with the previous link's own first message when that link was a
22
+ continuation itself (else `Continue where we left off.`), so a chain's opener comes back on every link; a chain
23
+ link's top bar and tab show its card's title whatever opener it was sent.
24
+ - Web: http(s) URLs in a hook's notice line (source-links' `sources:` line with markdown off) are links; they open
25
+ in a new tab.
26
+
27
+ ### Fixed
28
+
29
+ - A `task_create` task given a `cwd`, or running `cd`, recorded `failed` / `process_ended_without_exit_code` even
30
+ when it succeeded (its exit code was written relative to the shell's directory); a task killed by a signal now
31
+ records 128 + the signal.
32
+
33
+ Update with `chi update` (the system bundle 0.73.0), then restart `chi web`.
34
+
11
35
  ## [0.72.0] - 2026-10-16
12
36
 
13
37
  ### Added
@@ -3109,7 +3133,8 @@ and long-lived sessions.
3109
3133
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
3110
3134
  hotkey panel that send selected text or the clipboard to your sessions.
3111
3135
 
3112
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.72.0...HEAD
3136
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.73.0...HEAD
3137
+ [0.73.0]: https://github.com/dm1try/samagotchi/compare/v0.72.0...v0.73.0
3113
3138
  [0.72.0]: https://github.com/dm1try/samagotchi/compare/v0.71.0...v0.72.0
3114
3139
  [0.71.0]: https://github.com/dm1try/samagotchi/compare/v0.70.0...v0.71.0
3115
3140
  [0.70.0]: https://github.com/dm1try/samagotchi/compare/v0.69.0...v0.70.0
data/docs/architecture.md CHANGED
@@ -148,7 +148,7 @@ agent logic and can be used without any terminal rendering; the UI is a thin lay
148
148
  | UI | `Samagotchi::TerminalUI` | Interactive REPL (Reline), rendering (ANSI, spinner, status line), REPL commands. Delegates all core work to an `Engine`. |
149
149
  | Model loops and adapters | `KernelLoop`, `LLM::ChatLoop`, `Samagotchi::Client`, `LLM::OpenAIChat`, `LLM::AnthropicMessages`, `LLM::HTTP` | The model↔tool loops and the HTTP adapters they talk through (see "Model loops and adapters"). |
150
150
  | Bridge (SSE/HTTP) | `Samagotchi::Bridge`, `SessionManager` | The **single live client transport**: an SSE read stream + HTTP POST turn/cancel/answer surface that attaches to a worker's existing `Engine` via `Engine#subscribe`. Every session worker starts it (bound `127.0.0.1`, no auth, localhost-only). |
151
- | Web (Rack) | `Samagotchi::Web::App`, `SessionManager` | Single-port `127.0.0.1:4567` control plane via `rack`+`webrick` (serve `index.html` + `/api/*`). With `web.host: lan` (or one of this machine's IPv4 addresses) the server also listens on that address, where every request but this machine's needs the access token (`App#token_gate`: the `chi_token` cookie or `Authorization: Bearer`; [CLI](cli.md#chi-web-on-your-phone)). It talks to each worker's Bridge (`App#relay`, `bridge_get`; `/stream` proxies it) and reads the session files; no `Engine` of its own. `bin/chi web` entrypoint. |
151
+ | Web (Rack) | `Samagotchi::Web::App`, `SessionManager` | Single-port `127.0.0.1:4567` control plane via `rack`+`webrick` (serve `index.html` + `/api/*`). With `web.host: lan` (or one of this machine's IPv4 addresses) the server also listens on that address, where every request but this machine's needs the access token (`App#token_gate`: the `chi_token` cookie or `Authorization: Bearer`; [CLI](cli.md#chi-web-on-your-phone)). It talks to each worker's Bridge (`App#relay`, `bridge_get`; `/stream` proxies it) and reads the session files; no `Engine` of its own. Markdown (`web.markdown`) is rendered here only (`Web::MarkdownRenderer`): the answers it serves, card bodies, and `POST /api/render` for a text the page already shows. `bin/chi web` entrypoint. |
152
152
  | Sessions | `Samagotchi::Session`, `SessionManager` | File-based `~/.local/state/samagotchi/sessions/<uuid>.json` + sidecar `input/`/`output/`; retention (14d/500) + ordering (`updated_at desc`). |
153
153
 
154
154
  - `bin/chi` in REPL mode (see `LaunchMode` above) builds `TerminalUI`. `TerminalUI#run` is the single
data/docs/cli.md CHANGED
@@ -13,7 +13,7 @@
13
13
  - A session id can be shortened to any unique prefix (like git): `chi --attach 2ea8`. `--resume`, `--attach`, `sessions stop`, `sessions archive` and `sessions delete` take one; an ambiguous prefix lists the sessions it matches.
14
14
  - `chi web [--port 4567] [--open] [--scope=all]` — start the Web UI (single localhost port session control plane) on this git project's sessions (`--scope=all`, or a folder in no repo: every session); if a chi web already runs on the port, print (with `--open`, open) its page for this folder and exit. Something else on the port (an older chi web too) exits 1 with "port N is in use"
15
15
  - `chi web --web-host lan` — the Web UI on your home network too, for your phone: a link with an access token and its QR code (see [chi web on your phone](#chi-web-on-your-phone)); `chi web --new-token` replaces the token
16
- - `chi web --web-markdown` — opt in to sanitized Markdown rendering for completed assistant messages
16
+ - `chi web --web-markdown` — opt in to sanitized Markdown rendering for completed assistant messages, the stage's asking text and a resolved question card's reply
17
17
  - `chi web --web-view turn` — draw turns with the turn view (each turn as one block of steps, the running one at the bottom of the history) instead of the default stage view (the running turn pinned above the composer); `?view=stage|turn` on the page URL overrides it (see [Web views](#web-views))
18
18
  - `chi sessions list|stop|restart|archive|unarchive|delete|prune|clean|stats` — manage persisted sessions; `list` shows this git project's, `list --scope=all` every one, a delegated session with `↳ <parent>`, `list --archived` the archived ones too (see [Sessions](sessions.md)). `stats ID` prints a session's cost, tokens and progress (a live worker's `GET stats`, else its saved `analytics.json`; `--format json` one object) without running a model turn. A usage error (an unknown subcommand or flag, a flag missing its value, `stop`, `restart` or `delete` with no ids, a bad `list --format` or `--scope`, a `--days`, `--keep` or `--limit` that isn't a whole number) exits 2; an unknown or refused session exits 1
19
19
  - `chi note [--source NAME] [-m TEXT] (ID|PREFIX)... | --all` — add a context note (TEXT or stdin) to sessions: background the model sees on its next turn; it starts no turn (see [Sessions: Context notes](sessions.md#context-notes))
@@ -698,8 +698,14 @@ web:
698
698
  ```
699
699
 
700
700
  Only finalized assistant messages are rendered; user messages and live streaming
701
- chunks remain escaped text. Generated HTML is sanitized, raw HTML in model output
702
- is not trusted, and unsafe links are removed. If Markdown is enabled without
701
+ chunks remain escaped text. The stage's asking text (the whole reply that asked
702
+ a waiting question or approval) shows raw at once, then as markdown, its links
703
+ opening in a new tab; a resolved question card's copy of that reply renders when
704
+ the card is first opened (its summary line stays plain). A text the page already shows and renders later goes
705
+ through `POST /api/render` (`{text}` → `{html}`, up to 64 KB; `html` is null
706
+ with markdown off), the same renderer and sanitizer. Generated HTML is
707
+ sanitized, raw HTML in model output is not trusted, and unsafe links are
708
+ removed. If Markdown is enabled without
703
709
  commonmarker installed, Chi Web keeps the normal escaped-text display and shows a
704
710
  warning explaining how to install the optional gem.
705
711
 
data/docs/hooks.md CHANGED
@@ -124,7 +124,8 @@ links), for a UI that doesn't render that display. The hook never names a UI;
124
124
  each decides by what it renders. The web with markdown on (`web.markdown` and
125
125
  the commonmarker gem) leaves such a line out, live and after a reload; with
126
126
  markdown off it shows, as it does in the REPL, the attached TUI and
127
- `chi -p`, which print the answer as text. The log keeps the line with
127
+ `chi -p`, which print the answer as text (in the web its http(s) URLs are
128
+ links, as in any hook's notice line). The log keeps the line with
128
129
  `fallback_for=display`. `:editor` marks a line that names a file the viewer
129
130
  could open from a ref in its editor: the web leaves it out for a viewer on
130
131
  this machine with `web.editor` set, and every other UI and viewer (a phone,
@@ -660,7 +661,8 @@ keeps the web links. With markdown on, the web leaves the line out when the
660
661
  answer links every URL it names, since it would only repeat them as plain
661
662
  text ([`fallback_for: :display`](#what-a-hook-can-do-the-runtime)); it shows
662
663
  when it names one the answer doesn't link (a ref only in code, two sources on
663
- one ref) and whenever markdown is off. Bundle 0.4.0 needs chi 0.35.0 for that.
664
+ one ref) and whenever markdown is off (its URLs are links in the web). Bundle
665
+ 0.4.0 needs chi 0.35.0 for that.
664
666
 
665
667
  Each ref the answer links is also a [ref on the
666
668
  answer](#refs-on-the-answer-display-only): kind `issue` (or the source's
@@ -23,9 +23,9 @@ Behavior:
23
23
  - A stop (`task_stop`, the user's Stop of an `execute`, the execute limit in kill mode) TERMs the task's process group, then KILLs it after a grace. The groups its processes started are stopped too: a GNU `timeout N cmd` (without `--foreground`) moves itself and its command to a group of its own. `ProcessGroup.stop` finds them with one `ps -A -o pid=,ppid=,pgid=` (~40 ms on macOS) before the TERM and again before the KILL, walking down from the group's members. A process whose parent already exited (reparented to init, e.g. a setsid'd `cmd &`, or a backgrounded `timeout N cmd &` after its shell exited) is not found and runs on.
24
24
  - Task listing is workspace-scoped (current project only).
25
25
  - `execute` runs its command with stdout and stderr in two logs under `$XDG_STATE_HOME/samagotchi/execute/<id>/` (outside the workspace: `git status` and `rm -rf tmp` never see them), stdin `/dev/null` and the same `exit_code` wrapper as a task. Its own copies of the logs hold a shared `flock` across the spawn: while a fresh exclusive `flock` fails, some process the command started still has them open. When the shell exits, a background child still holding them (`server &`) gets 1.5 s, then its group is stopped; one holding none (`nohup server >log 2>&1 &`) is left alone. A holder that left the group (setsid) is out of the stop's reach: it runs on, the result ends with the "left running" hint, and its dir stays for it to write to (the sweep removes it once no one holds the logs). Otherwise a finished command's dir is removed. Known gap on Linux: a command that writes through `/dev/stdout` or `/dev/stderr` (`echo b >/dev/stdout`) reopens the log file itself with `O_TRUNC`, so the output before it is lost (macOS dups the descriptor; the fix would be a relay pipe). The check needs the state dir on a local file system: over NFS, `flock` is emulated with `fcntl` locks, which children don't inherit.
26
- - The exit code: the command runs after an EXIT trap that writes the shell's status to the `exit_code` file (`TaskRuntime.wrapped_shell_command`, shared with `task_create`), so `exit N`, a `set -e` failure and a trailing `\` or `|` in the command all record what the shell exited with. A command that sets its own EXIT trap replaces it: `execute` still reports the shell's status, and a handed-over one gets its code from chi's waiter while that chi runs; a `task_create` task ends failed with no exit code.
26
+ - The exit code: the command runs after an EXIT trap that writes the shell's status to the `exit_code` file (`TaskRuntime.wrapped_shell_command`, shared with `task_create`), so `exit N`, a `set -e` failure and a trailing `\` or `|` in the command all record what the shell exited with. The trap runs wherever the shell is when it exits (a `task_create` `cwd`, or a `cd` in the command), so `task_create`'s `exit_code_path` is absolute; its `output_path` stays relative (`tmp/tasks/<id>/output.log`, opened by chi in the worker's cwd before the spawn). A command that sets its own EXIT trap replaces it, and a shell killed by a signal skips it (zsh's sh mode, on TERM, INT and KILL): `execute` still reports the shell's status, and a `task_create` task or a handed-over execute gets its code from chi's waiter while the chi that started it runs (`TaskRuntime.record_exit_status`): the exit status, or `128 + the signal` (TERM → 143), with no new `stop_reason`. A record a refresh already finished without a code in between (`failed`, `process_ended_without_exit_code` or the not-chi's-process reason) is corrected, its `finished_at` kept; a stopped task (its stop flag on disk) is never touched. `process_ended_without_exit_code` is left only when the shell wrote nothing and the chi that started it was gone too.
27
27
  - A stop of an `execute` (the user's Stop, the limit in kill mode) is done once the shell is reaped and nothing holds the logs: a child that ignores TERM after its shell died gets the KILL.
28
- - A command still running at `execute.timeout_sec` is handed over as a task (`execute.on_timeout: task`, the default; `kill` stops it instead, and the `!cmd` bang always kills): the result starts `Still running at the 120s limit: moved to a background task, not killed.`, then `task_id: …` and the tails of both logs so far. A timeout during the 1.5 s background grace is a kill (the shell is gone, nothing to hand over). If the record can't be written, the command is killed. `TaskRuntime.adopt` writes a record for the process execute already started, with `handed_over_by: "execute"`. Its logs stay where execute wrote them, so the record's paths are absolute: `output_path` (stdout), `stderr_path` (stderr, a log of its own) and `exit_code_path`. `started_at` (and `created_at`) is the spawn time, not the hand-over's; `pid_started` is read at the hand-over, while execute's detach waiter is still running. From there it is an ordinary task: `task_wait`, `task_get`, `task_list` and `task_stop` treat it as any other. Its dir gets a `task` back-pointer file holding the record's absolute path. Once per process, the first `execute` sweeps `…/execute/`: a dir whose logs no process holds is removed when it has no back-pointer and is a day old (chi died mid-command), when its record is gone, or when it is 14 days old. The records live in each project's `tmp/tasks`, which the state dir can't list: hence the back-pointer.
28
+ - A command still running at `execute.timeout_sec` is handed over as a task (`execute.on_timeout: task`, the default; `kill` stops it instead, and the `!cmd` bang always kills): the result starts `Still running at the 120s limit: moved to a background task, not killed.`, then `task_id: …` and the tails of both logs so far. A timeout during the 1.5 s background grace is a kill (the shell is gone, nothing to hand over). If the record can't be written, the command is killed. `TaskRuntime.adopt` writes a record for the process execute already started, with `handed_over_by: "execute"`. Its logs stay where execute wrote them, so the record's paths are absolute: `output_path` (stdout), `stderr_path` (stderr, a log of its own) and `exit_code_path` (a `task_create` record's `exit_code_path` is absolute too; its `output_path` is relative). `started_at` (and `created_at`) is the spawn time, not the hand-over's; `pid_started` is read at the hand-over, while execute's detach waiter is still running. From there it is an ordinary task: `task_wait`, `task_get`, `task_list` and `task_stop` treat it as any other. Its dir gets a `task` back-pointer file holding the record's absolute path. Once per process, the first `execute` sweeps `…/execute/`: a dir whose logs no process holds is removed when it has no back-pointer and is a day old (chi died mid-command), when its record is gone, or when it is 14 days old. The records live in each project's `tmp/tasks`, which the state dir can't list: hence the back-pointer.
29
29
  - `task_wait` and `task_get` show `stderr_path` when the record has one; `task_wait`'s output tail is followed by a `stderr_tail:` of at most 10 lines when that log isn't empty. A `task_create` task has one log (stdout and stderr together) and no `stderr_path`. A `task_wait` result longer than `max_tool_output_chars` keeps its header and the tail's end, with a gap line naming `output_path`, where the whole log already is (and `stderr_path`, when the record has one) (`ToolOutputCut`'s `:head_tail_ref`; nothing is copied; see [Tool output guardrails](tool-guardrails.md#the-runners-cut-max_tool_output_chars)).
30
30
  - Which tasks are a session's own (a cancelled turn's note, the turn-end note, a worker's idle exit, the web's stop-task button): the ids its `task_create` results and its handed-over executes name (`TaskRuntime.created_ids_in`, `created_id`). An execute's id is read only after its first line (`Execute::HANDED_OVER`, through `TaskRuntime.handed_over_id`; the web has a JS twin in `task_stop.js`), not from a `task_id:` line inside a run, which the command can print. (A command that prints the `---` separator chi joins a turn's results with, then a hand-over line, still fakes one: a known gap.) Either result may start with the `ran as: …` line of a gate-corrected call.
31
31
  - `task_get` returns metadata and `output_path`; use `read` for output contents.
data/docs/sessions.md CHANGED
@@ -215,8 +215,8 @@ A session can **continue** an earlier one: "the next day of the same routine" (a
215
215
  - **Its open delegates move to the new link** instead: each direct delegate that is running, waiting for an answer, has a live worker or a reply its parent wasn't given, or has such a delegate below it (checked again after the recap wait, so one delegated meanwhile moves too). It moves with its own delegates, stays unarchived, and from then on is the new link's: its card folds under the new link, its reports ring the new link (an idle one runs a wake turn for them, as for its own delegates), `delegate_result` and the `⑂` chip count it there, and what its parent was already told (`delegates.json`) and any report still waiting come along, so nothing is reported twice. A moved delegate gets a context note from `session chain`, `Your parent session is now 4b7e9a01 (…), the next link of its session chain: …`, as its system prompt keeps naming the parent it started with. The move writes `<child>/parent.json` (`{parent_id, from, at}`) beside the child's session file, never the file itself, so a live child worker saving its own copy changes nothing; every reader applies it (a later move overwrites it; it is never removed). A continue is **refused for an open delegate that can't move** (`SessionContinue::Refused`, naming it): one whose live worker runs an older chi (`older chi worker 0.46.1; restart it`: `chi sessions restart <id>`, or stop it) or that is open in a chi REPL (close it there). An older `chi web` or CLI still running shows a moved delegate under the archived link until it is restarted.
216
216
  - **If the continue fails part-way:** a new link that fails to start gives its moved delegates back before it is deleted (each gets a note saying so; its worker is stopped first, if one started, and a worker that won't stop is reported, the link left), with any report one of them rang it with meanwhile; the previous link, back in the lists, is woken for reports waiting. A continue that dies in the middle leaves a `move.json` in the new link's folder: the new link's next worker finishes that move (only the delegates it names, and without a second note to one already told) as it starts. Until the new link's own worker holds it, a ring from a moved delegate doesn't start another (a `starting` marker in its folder, which that worker removes; a stale one, over 60 s old or from a process that is gone, is ignored). An open delegate below an archived delegate refuses the continue (`under archived …`): unarchive that one, or archive this one.
217
217
  - **Refused too:** a link that is continued already (naming the link that continues it; continue that one, or `last:<id>`), a previous link whose folder is gone, and one whose model's host can't run it when no default model is configured to fall back to. A refusal starts nothing and archives nothing; a new link that fails to start puts back what was archived. Every refusal that can be known up front (the archive's ones included) comes before the recap wait. One continue of a session runs at a time (a lock in its folder; other chains don't wait), so a double click finds the first one's link and is refused, while a `last:<id>` continue that waited follows the chain to its new end.
218
- - **Web:** `POST /api/sessions` takes `continues: "<id, prefix or last:id>"`, with `idle: true` (a new link waiting for its first message, previewed by the previous link's, or `preview`) or a `prompt`, and `model` (or `model_name`) for the new link's model, but not `dir`, `llm_context` or `thinking` (400 `invalid_continues`: the previous link's are taken). 201 is the new session's summary, `continues` set, with `moved`: the ids of the delegates that moved to it (`[]` for none), and `model_warning`: the default-model fallback's warning, then any about the model id, joined by `; ` (`null` for none); 404 `not_found`; 400 `ambiguous_id` for a prefix of several sessions, `invalid_model` for a given model whose host config.yml doesn't have (or a fallback with no default model); 409 `continued` with `next_id` (the link that continues it already, which the page opens instead), `open_children` with `ids` (open delegates that can't move), `folder_gone`, `busy`, `scratch` or `owned_by_tui`. Every session summary carries `continues`.
219
- - **The page:** the info bar's `continue →` continues the open session (`continues: <id>, idle: true`; it reads `continuing…` through the recap wait) and opens the new link with `Continue where we left off.` in the composer, not sent (the same line for every chain; a draft already there stays instead); the previous link is archived, a toast says so (`Continued 3fa2c1d0 in 4b7e9a01; 3fa2c1d0 is archived; 1 open delegate moved`), and a moved delegate's card folds under the new link. When the previous link's model's host is gone the link starts on the default model and the toast carries the server's `model_warning` too, and then stays until closed. A fresh link (one with no turn yet, however it was made: `continue →`, `chi send --new --continues`, a reload) shows the model picker in its composer with the link's model, until its first turn starts (a failed or canceled first turn ends it too); a pick runs `/model <name>` in the link (a command bubble; the info bar follows) and changes neither the start page's remembered model nor its Recent list, and a Send right after it waits for the `/model` to be taken. A link a chi REPL owns, or one continued already, shows none. A link continued already has no `continue →`: its `→` link goes to the next link (a stale page's continue answered 409 `continued` opens `next_id`). A continue refused for open delegates that can't move shows a toast that names each one (its preview and what the server says to do: `older chi worker 0.46.1; restart it`, `open in a chi REPL; close it there`), each a link to its page; any other refusal shows the server's words. The info bar of a link carries `← 2026-10-08` (the previous link's day) and, on an earlier link, `2026-10-10 →`, each a link with its preview and id in the tooltip. A link whose title is the opener and nothing else (its first message once sent) shows its chain's first link's title instead, on its card, in the popover and in its info bar's title line, display only (its messages and data keep the opener; its own title when the first link is gone). The lists fold a chain into the card of its latest listed link, whatever the earlier links' archived state (typing in an archived link brings it back, folded there still), their delegates with them: the card's `↩ day N` chip (`day N of M` when later links aren't listed) opens a popover of the other links, newest first (`day N`, its date, its recap's first sentence or its preview, `archived`), a click opens one; the chip shows a live dot or `waiting` for a link that has a worker or waits on the user, and a waiting link puts its chain's card first. All sessions folds nothing while a search is typed or "include archived" is on: every link is a card of its own then, its chip `day N of M`, with its own delegates folded in, so a search finds an earlier link and its delegates.
218
+ - **Web:** `POST /api/sessions` takes `continues: "<id, prefix or last:id>"`, with `idle: true` (a new link waiting for its first message, previewed by the previous link's, or `preview`) or a `prompt`, and `model` (or `model_name`) for the new link's model, but not `dir`, `llm_context` or `thinking` (400 `invalid_continues`: the previous link's are taken). 201 is the new session's summary, `continues` set, with `moved`: the ids of the delegates that moved to it (`[]` for none), and `model_warning`: the default-model fallback's warning, then any about the model id, joined by `; ` (`null` for none), and `opener`: the previous link's first typed message, in full, when that link continues one itself (`null` otherwise: a chain's first link's first message is its task); 404 `not_found`; 400 `ambiguous_id` for a prefix of several sessions, `invalid_model` for a given model whose host config.yml doesn't have (or a fallback with no default model); 409 `continued` with `next_id` (the link that continues it already, which the page opens instead), `open_children` with `ids` (open delegates that can't move), `folder_gone`, `busy`, `scratch` or `owned_by_tui`. Every session summary carries `continues`.
219
+ - **The page:** the info bar's `continue →` continues the open session (`continues: <id>, idle: true`; it reads `continuing…` through the recap wait) and opens the new link with an opener in the composer, not sent (a draft already there stays instead): the previous link's own first typed message when that link continues one (the 201's `opener`; a `!shell` line isn't taken), else `Continue where we left off.`. So whatever you sent on the last link comes back on the next one, and editing it once changes it for every later link; the previous link is archived, a toast says so (`Continued 3fa2c1d0 in 4b7e9a01; 3fa2c1d0 is archived; 1 open delegate moved`), and a moved delegate's card folds under the new link. When the previous link's model's host is gone the link starts on the default model and the toast carries the server's `model_warning` too, and then stays until closed. A fresh link (one with no turn yet, however it was made: `continue →`, `chi send --new --continues`, a reload) shows the model picker in its composer with the link's model, until its first turn starts (a failed or canceled first turn ends it too); a pick runs `/model <name>` in the link (a command bubble; the info bar follows) and changes neither the start page's remembered model nor its Recent list, and a Send right after it waits for the `/model` to be taken. A link a chi REPL owns, or one continued already, shows none. A link continued already has no `continue →`: its `→` link goes to the next link (a stale page's continue answered 409 `continued` opens `next_id`). A continue refused for open delegates that can't move shows a toast that names each one (its preview and what the server says to do: `older chi worker 0.46.1; restart it`, `open in a chi REPL; close it there`), each a link to its page; any other refusal shows the server's words. The info bar of a link carries `← 2026-10-08` (the previous link's day) and, on an earlier link, `2026-10-10 →`, each a link with its preview and id in the tooltip. A link made by `continue →` inherits the previous link's title when it is created, so its card keeps its chain's title whatever opener it is sent, and its top bar and browser tab show its card's title. A link whose title is the opener and nothing else (a `chi send --new --continues` link whose first message is `Continue where we left off.`) shows its chain's first link's title instead, on its card, in the popover and in its info bar's title line, display only (its messages and data keep the opener; its own title when the first link is gone). The lists fold a chain into the card of its latest listed link, whatever the earlier links' archived state (typing in an archived link brings it back, folded there still), their delegates with them: the card's `↩ day N` chip (`day N of M` when later links aren't listed) opens a popover of the other links, newest first (`day N`, its date, its recap's first sentence or its preview, `archived`), a click opens one; the chip shows a live dot or `waiting` for a link that has a worker or waits on the user, and a waiting link puts its chain's card first. All sessions folds nothing while a search is typed or "include archived" is on: every link is a card of its own then, its chip `day N of M`, with its own delegates folded in, so a search finds an earlier link and its delegates.
220
220
  - **CLI:** `chi send --new --continues (ID|PREFIX|last:ID) [--model M] [-m TEXT]` (see *Starting a session*).
221
221
  - **Which link:** an id, a unique prefix, or `last:<id or prefix>`, the latest link of the chain that session is in. It walks the links that still exist: a deleted link in the middle (`chi sessions delete`, the retention sweep of an unarchived one) ends the walk before it, so `last:` of an earlier link then names that earlier link, and continuing it starts a second line beside the links after the gap; name a link after the gap instead. A trigger names the chain this way: `chi send` to an archived link would bring it back (a human's input), not continue the chain.
222
222
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.72.0
3
+ version: 0.73.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -220,6 +220,11 @@ module Samagotchi
220
220
  # SessionManager.continue_session sets it); not saved.
221
221
  attr_accessor :moved_children
222
222
 
223
+ # The text web `continue →` prefills in this new link
224
+ # (SessionChain.opener_for; SessionManager.continue_session sets it); not
225
+ # saved.
226
+ attr_accessor :opener
227
+
223
228
  def initialize(id:, mode:, model_name:, working_directory:, messages:, created_at:, updated_at:,
224
229
  metadata_version: METADATA_VERSION, status: STATUS_IDLE, last_prompt: "",
225
230
  first_preview: "", test_run: false, pending_question: nil,
@@ -78,6 +78,30 @@ module Samagotchi
78
78
  "#{head} Its recap#{", as of #{as_of}" if as_of}:\n#{text}"
79
79
  end
80
80
 
81
+ # What web `continue →` prefills in +previous+'s next link: +previous+'s
82
+ # own first typed message when +previous+ is itself a continuation (so a
83
+ # chain's opener comes back on every link, edited once for all later
84
+ # ones), else nil (a chain's first link's first message is its task; the
85
+ # page falls back to its constant). Typed means a user message with text
86
+ # and no kind: Engine#turn_messages' (turn_id), or a checkpointed prompt
87
+ # (none); a stored `!shell` line ("!(cmd)\n<output>", no turn_id) is not.
88
+ # @param previous [Session]
89
+ # @return [String, nil]
90
+ def opener_for(previous)
91
+ return nil unless previous.continues
92
+
93
+ previous.messages.each do |msg|
94
+ next unless msg[:role] == "user" && msg[:kind].nil?
95
+
96
+ text = msg[:content]
97
+ next unless text.is_a?(String) && !text.strip.empty?
98
+ next if msg[:turn_id].nil? && text.start_with?("!(")
99
+
100
+ return text.strip
101
+ end
102
+ nil
103
+ end
104
+
81
105
  def day(iso)
82
106
  Time.iso8601(iso.to_s).localtime.strftime("%Y-%m-%d")
83
107
  rescue ArgumentError
@@ -86,7 +86,8 @@ module Samagotchi
86
86
  # @param title [String, nil] what the lists show before the first turn;
87
87
  # by default the prompt, else the previous link's preview
88
88
  # @return [Session] the new link (#model_warning as spawn_session's,
89
- # #moved_children: the delegates it took over)
89
+ # #moved_children: the delegates it took over, #opener:
90
+ # SessionChain.opener_for the previous link)
90
91
  # @raise [ArgumentError] no such session (Session::AmbiguousId for a prefix of several)
91
92
  # @raise [Refused] continued already, delegates open that can't
92
93
  # move, or its folder is gone
@@ -208,6 +209,7 @@ module Samagotchi
208
209
  note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir, before_spawn: before_spawn
209
210
  )
210
211
  link.moved_children = move
212
+ link.opener = SessionChain.opener_for(previous)
211
213
  # The fallback's warning first: spawn_session set its own.
212
214
  warnings = [choice.warning, link.model_warning].compact
213
215
  link.model_warning = warnings.join("; ") unless warnings.empty?
@@ -350,7 +350,7 @@ module Samagotchi
350
350
  return nil if error
351
351
 
352
352
  File.write(File.join(dir, BACK_POINTER), File.expand_path(TaskRuntime.metadata_path(record.id)))
353
- write_missing_exit_code(waiter, record.exit_code_path)
353
+ TaskRuntime.record_exit_status(waiter, record.exit_code_path, task_id: record.id)
354
354
  # Ended between the deadline and the adopt: the record says how.
355
355
  TaskRuntime.get_record(record.id) unless waiter.alive?
356
356
  lines = [handed_over_line(timeout_sec), "task_id: #{record.id}",
@@ -363,20 +363,6 @@ module Samagotchi
363
363
  end
364
364
  private_class_method :hand_over
365
365
 
366
- # A command that set its own EXIT trap replaced the wrapper's, so it
367
- # writes no exit_code: while this chi lives, the waiter's status fills
368
- # it in (a refresh in between may still read "no exit code").
369
- def self.write_missing_exit_code(waiter, path)
370
- Thread.new do
371
- Thread.current.report_on_exception = false
372
- code = waiter.value&.exitstatus
373
- File.write(path, code.to_s) if code && !File.exist?(path) && Dir.exist?(File.dirname(path))
374
- rescue StandardError => e
375
- Log.debug(:turn, "execute_exit_code_failed", error: e.class.name, msg: e.message)
376
- end
377
- end
378
- private_class_method :write_missing_exit_code
379
-
380
366
  # <state dir>/execute: every command's dir (LOGS_DIRNAME).
381
367
  def self.logs_root = File.join(Paths.state_dir, LOGS_DIRNAME)
382
368
 
@@ -6,6 +6,7 @@ require "securerandom"
6
6
  require "shellwords"
7
7
  require "time"
8
8
  require_relative "../atomic_file"
9
+ require_relative "../log"
9
10
  require_relative "../process_group"
10
11
  require_relative "shell"
11
12
  require_relative "task_record"
@@ -55,7 +56,9 @@ module Samagotchi
55
56
  task_id = generate_task_id
56
57
  task_dir = task_dir_for(task_id)
57
58
  output_path = File.join(task_dir, OUTPUT_FILENAME)
58
- exit_code_path = File.join(task_dir, EXIT_CODE_FILENAME)
59
+ # Absolute: the trap writes it wherever the shell is when it exits
60
+ # (a +cwd+, or a cd in the command). Ruby opens output_path here.
61
+ exit_code_path = File.expand_path(File.join(task_dir, EXIT_CODE_FILENAME))
59
62
 
60
63
  FileUtils.mkdir_p(task_dir)
61
64
 
@@ -72,7 +75,7 @@ module Samagotchi
72
75
  out: output_io,
73
76
  err: output_io
74
77
  )
75
- Process.detach(pid)
78
+ waiter = Process.detach(pid)
76
79
  # Read now, compared before each signal: a pid reused later is not this task.
77
80
  pid_started = ProcessGroup.start_time(pid)
78
81
 
@@ -81,6 +84,7 @@ module Samagotchi
81
84
  output_path: output_path, exit_code_path: exit_code_path)
82
85
 
83
86
  write_record(record)
87
+ record_exit_status(waiter, exit_code_path, task_id: task_id)
84
88
  [record, nil]
85
89
  rescue StandardError => e
86
90
  [nil, "Error: #{e.message}"]
@@ -88,6 +92,60 @@ module Samagotchi
88
92
  output_io&.close
89
93
  end
90
94
 
95
+ # The shell's status from chi's waiter (+waiter+, a Process.detach
96
+ # thread), for a shell whose EXIT trap wrote nothing: one killed by a
97
+ # signal (zsh's sh mode skips the trap on TERM, INT and KILL) gets
98
+ # 128 + the signal, one whose command replaced the trap gets its exit
99
+ # status. Only while this chi runs. A record a refresh finished in
100
+ # between (failed, no exit code) is corrected; a stopped task (its
101
+ # stop flag on disk) is left as task_stop records it.
102
+ # @return [Thread]
103
+ def record_exit_status(waiter, path, task_id:)
104
+ # Absolute now: safe against a later chdir.
105
+ record_path = File.expand_path(metadata_path(task_id))
106
+ path = File.expand_path(path)
107
+ Thread.new do
108
+ Thread.current.report_on_exception = false
109
+ code = status_code(waiter.value)
110
+ next if code.nil? || record_at(record_path)&.stop_requested_by
111
+
112
+ File.write(path, code.to_s) if !File.exist?(path) && Dir.exist?(File.dirname(path))
113
+ correct_finished_record(record_path, read_exit_code(path))
114
+ rescue StandardError => e
115
+ Log.debug(:turn, "task_exit_code_failed", id: task_id, error: e.class.name, msg: e.message)
116
+ end
117
+ end
118
+
119
+ # A Process::Status as a shell reports one: its exit status, or
120
+ # 128 + the signal that killed it.
121
+ def status_code(status)
122
+ return nil unless status
123
+
124
+ status.exitstatus || (status.termsig && (128 + status.termsig))
125
+ end
126
+
127
+ # Reasons a refresh finishes a record with when it read no exit code.
128
+ MISSING_CODE_REASONS = ["process_ended_without_exit_code", NOT_CHIS_PROCESS].freeze
129
+
130
+ # A record a refresh finished before the exit code was there (failed,
131
+ # exit_code nil, no stop) gets +code+; finished_at is kept.
132
+ def correct_finished_record(record_path, code)
133
+ record = record_at(record_path)
134
+ return unless code && record && !record.running? && record.exit_code.nil? && !record.stop_requested_by
135
+ return unless MISSING_CODE_REASONS.include?(record.stop_reason)
136
+
137
+ status = code.zero? ? "completed" : "failed"
138
+ AtomicFile.write(record_path, record_json(record.with(status: status, exit_code: code, stop_reason: nil)))
139
+ end
140
+
141
+ def record_at(path)
142
+ return nil unless File.file?(path)
143
+
144
+ TaskRecord.from_file(JSON.parse(File.read(path)))
145
+ rescue JSON::ParserError
146
+ nil
147
+ end
148
+
91
149
  # A record for a command chi already runs: an execute still running at
92
150
  # its limit, handed over as a task (Execute::HANDED_OVER), given as a
93
151
  # TaskRecord.running whose +started_at+ is the spawn's. Its logs and
@@ -321,9 +379,11 @@ module Samagotchi
321
379
  FileUtils.mkdir_p(dir)
322
380
 
323
381
  path = File.join(dir, METADATA_FILENAME)
324
- AtomicFile.write(path, JSON.pretty_generate(record.to_file) + "\n")
382
+ AtomicFile.write(path, record_json(record))
325
383
  end
326
384
 
385
+ def record_json(record) = JSON.pretty_generate(record.to_file) + "\n"
386
+
327
387
  def metadata_path(task_id)
328
388
  File.join(task_dir_for(task_id), METADATA_FILENAME)
329
389
  end
@@ -352,9 +412,12 @@ module Samagotchi
352
412
  # exit status to +exit_code_path+. A prefix, not lines after the
353
413
  # command: a trailing "\\" or "|" in the command would swallow those,
354
414
  # and an `exit N` or a `set -e` failure would skip them. A command that
355
- # sets its own EXIT trap replaces this one: no exit_code file then
356
- # (the task ends failed with process_ended_without_exit_code, unless
357
- # its starter's waiter writes the code: Execute's hand-over).
415
+ # sets its own EXIT trap replaces this one, and a shell killed by a
416
+ # signal skips it (zsh's sh mode, on TERM, INT and KILL): no exit_code
417
+ # file from the shell then. The waiter of the chi that started it
418
+ # writes the code instead (record_exit_status: the status, or
419
+ # 128 + the signal); with that chi gone too, the task ends failed with
420
+ # process_ended_without_exit_code.
358
421
  def wrapped_shell_command(command, exit_code_path)
359
422
  write = %(printf "%s" "$?" > #{Shellwords.escape(exit_code_path)})
360
423
  "trap #{Shellwords.escape(write)} EXIT\n#{command}\n"
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.72.0"
4
+ VERSION = "0.73.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -66,6 +66,9 @@ module Samagotchi
66
66
  BRIDGE_WAIT_TIMEOUT = 10.0
67
67
  # An uploaded image may be this big before it is downscaled.
68
68
  MAX_IMAGE_UPLOAD_BYTES = 20 * 1024 * 1024
69
+ # A POST /api/render body may be this big: a decision message is a few
70
+ # KB, and the cap bounds the work a page can ask for.
71
+ MAX_RENDER_BYTES = 64 * 1024
69
72
  SESSION_ID_RE = /\A[0-9A-Za-z-]{1,64}\z/
70
73
  IMAGE_NAME_RE = /\A[0-9a-f]{16}\.(png|jpe?g|gif|webp)\z/
71
74
  IMAGE_TYPES = { "png" => "image/png", "jpg" => "image/jpeg", "jpeg" => "image/jpeg", "gif" => "image/gif",
@@ -158,6 +161,7 @@ module Samagotchi
158
161
  ["GET", %r{\A/api/models\z}, :handle_models],
159
162
  ["GET", %r{\A/api/history\z}, :handle_history],
160
163
  ["GET", %r{\A/api/events\z}, :handle_events],
164
+ ["POST", %r{\A/api/render\z}, :handle_render],
161
165
  ["GET", %r{\A/api/sessions/([^/]+)/stream\z}, :handle_stream],
162
166
  ["GET", %r{\A/api/sessions/([^/]+)/output\z}, :handle_output],
163
167
  ["POST", %r{\A/api/sessions/([^/]+)/cancel\z}, :handle_cancel],
@@ -679,8 +683,9 @@ module Samagotchi
679
683
  # refused beside it (a create takes no reasoning_history). It runs on
680
684
  # model (or model_name) when given, else on the previous link's: one
681
685
  # whose host is gone falls back to the default, and the 201's
682
- # model_warning (null when none) says so. A model it can't start on is
683
- # 400 invalid_model, nothing archived.
686
+ # model_warning (null when none) says so. Its opener is the text the
687
+ # page prefills (SessionChain.opener_for; null: the page's constant).
688
+ # A model it can't start on is 400 invalid_model, nothing archived.
684
689
  # 409 continued (with next_id, the link that continues it already: the
685
690
  # page opens that one), open_children (ids), folder_gone, busy, scratch
686
691
  # or owned_by_tui.
@@ -707,7 +712,8 @@ module Samagotchi
707
712
  [session.continues, session.id, *moved].each { |sid| @hub&.touch(sid) }
708
713
  record_history(body, prompt) if !idle && PromptHistory.shell_line?(prompt.to_s.strip)
709
714
  json_response(201, session_to_json(session).merge(bridge_port: port, moved: moved,
710
- model_warning: session.model_warning))
715
+ model_warning: session.model_warning,
716
+ opener: session.opener))
711
717
  end
712
718
 
713
719
  # SessionManager.continue_session, its refusals as responses: [session,
@@ -1006,6 +1012,24 @@ module Samagotchi
1006
1012
  html || "<pre>#{CGI.escapeHTML(body)}</pre>"
1007
1013
  end
1008
1014
 
1015
+ # POST /api/render {text}: the page's markdown read for a text it
1016
+ # already shows (the stage's asking text, a resolved question card's
1017
+ # reply), session-free. {html: nil} when markdown is off or
1018
+ # commonmarker is missing: the page keeps the raw text.
1019
+ def handle_render(req)
1020
+ too_large = error_response(413, "too_large", "a text to render may be up to #{MAX_RENDER_BYTES / 1024} KB")
1021
+ return too_large if req.content_length.to_i > MAX_RENDER_BYTES
1022
+
1023
+ raw = request_body(req, MAX_RENDER_BYTES + 1).to_s
1024
+ return too_large if raw.bytesize > MAX_RENDER_BYTES
1025
+
1026
+ body = parse_json(raw)
1027
+ return error_response(400, "invalid_json", "give {text}") unless body.is_a?(Hash) && body["text"].is_a?(String)
1028
+
1029
+ html = @markdown_renderer.render(body["text"]) if @markdown_renderer.available?
1030
+ json_response(200, { html: html })
1031
+ end
1032
+
1009
1033
  def handle_question_answer(req, id)
1010
1034
  session = @session_class.load(id, state_dir: default_state_dir) rescue nil
1011
1035
  return error_response(404, "not_found", "Session not found: #{id}") unless session
@@ -20,6 +20,7 @@ import {
20
20
  getContext,
21
21
  detachContext,
22
22
  addContext,
23
+ renderText,
23
24
  } from "./data.js";
24
25
  import { createContextChips } from "./context_chips.js";
25
26
  import {
@@ -45,7 +46,7 @@ import { createBubbles, hintEl, outputBubbleTemplate, setBubbleDelivery } from "
45
46
  import { createCardsPlace } from "./cards_place.js";
46
47
  import { createStageView, FOLD_MS } from "./stage_view.js";
47
48
  import { reducedMotion, STAGE_GAP } from "./stage_frame.js";
48
- import { CONTINUE_OPENER, chainNeighbours, isOpener, continueRefusal, continuedText, freshLink, openChildrenTemplate } from "./chains.js";
49
+ import { chainNeighbours, continueRefusal, continuedText, freshLink, openChildrenTemplate, openerText, openTitle } from "./chains.js";
49
50
  import { createTaskStopper } from "./task_stop.js";
50
51
  import { appendQuote } from "./annotations.js";
51
52
  import { parsePresets } from "./annotate_presets.js";
@@ -231,8 +232,25 @@ const viewArgs = {
231
232
  taskStop: createTaskStopper({ sessionId: () => selected, features: () => current.workerFeatures }),
232
233
  };
233
234
  const turnDom = view === "stage"
234
- ? createStageView({ ...viewArgs, isNearBottom: () => atHistoryEnd(), dockEl: $("#dock"), centerEl: $("#center"), composerEl })
235
+ ? createStageView({
236
+ ...viewArgs, isNearBottom: () => atHistoryEnd(), dockEl: $("#dock"), centerEl: $("#center"), composerEl,
237
+ renderMarkdown, linkRefs: linkRefsIn,
238
+ })
235
239
  : createTurnView(viewArgs);
240
+ // A text the page already shows as its markdown HTML (POST /api/render,
241
+ // sanitised there), or null: markdown off here (no request), or a failed
242
+ // read (the raw text stays).
243
+ function renderMarkdown(text) {
244
+ if (pageCapabilities.display !== true) return Promise.resolve(null);
245
+ return renderText(text)
246
+ .then((d) => (typeof d?.html === "string" && d.html ? d.html : null))
247
+ .catch(() => null);
248
+ }
249
+ // The `path:line` refs in one rendered text (the stage's asking slot, a
250
+ // kept reply), as linkTextRefs links the answers'.
251
+ function linkRefsIn(el) {
252
+ linkRefs(el, touchedFiles(turnRoots()), current.dir);
253
+ }
236
254
  // Where a running turn's rows can be: the history, and the stage while it
237
255
  // holds a turn (lookups by id, copy, annotate, card actions).
238
256
  function turnRoots() {
@@ -293,6 +311,8 @@ const questionCards = createQuestionCards({
293
311
  scrollToEnd: () => { historyEl.scrollTop = historyEl.scrollHeight; },
294
312
  removeHintIfEmpty: () => removeHintIfEmpty(),
295
313
  askingText: () => turnDom.askingText(),
314
+ renderMarkdown,
315
+ linkRefs: linkRefsIn,
296
316
  });
297
317
  // The history's bubbles (bubbles.js): output, end lines, every client's
298
318
  // prompts, the recap, warnings and notices, command and note bubbles.
@@ -1107,12 +1127,12 @@ function updateInfoBar() {
1107
1127
 
1108
1128
  // The open session's title, once on screen (session_title.js): its strip
1109
1129
  // card says it when the strip is shown and has one; else the top bar. The
1110
- // browser tab names it too. It follows the card's: a chain link opened with
1111
- // the opener reads its chain's first link's title (cardTitle).
1130
+ // browser tab names it too. It follows the card's (openTitle): a chain link
1131
+ // reads its card's title (cardTitle), whatever opener it was sent.
1112
1132
  function updateSessionTitle() {
1113
1133
  const shown = selected ? lists.sessionsById().get(selected) : null;
1114
1134
  const first = current.firstPreview || "";
1115
- const raw = !selected ? "" : (shown && isOpener(first) ? lists.cardTitle(shown) : first);
1135
+ const raw = !selected ? "" : openTitle({ shown, first, cardTitle: (s) => lists.cardTitle(s) });
1116
1136
  const title = raw === "\u2014" ? "" : raw; // "—": no first message yet
1117
1137
  const text = topBarTitle({
1118
1138
  title,
@@ -1888,7 +1908,8 @@ $("#infoArchiveBtn").addEventListener("click", () => {
1888
1908
  // ── Continue (session chains) ───────────────────────────────────────────────
1889
1909
  // Continue → starts the next link of the open session's chain (the server
1890
1910
  // waits up to 30 s for its recap, archives it, starts the new one with the
1891
- // recap as a note) and opens it with the opener in the composer, not sent.
1911
+ // recap as a note) and opens it with the opener in the composer, not sent
1912
+ // (fillOpener: the previous link's own first message when it continues one).
1892
1913
  // A link continued already opens that next link; its open delegates move
1893
1914
  // to the new link (the toast counts them); a refusal for open delegates
1894
1915
  // that can't move names them, each a link.
@@ -1914,7 +1935,7 @@ async function continueSelected() {
1914
1935
  return;
1915
1936
  }
1916
1937
  await select(next);
1917
- if (selected === next) fillOpener();
1938
+ if (selected === next) fillOpener(created);
1918
1939
  showToast(continuedText(id, created), null, { sticky });
1919
1940
  } catch (e) {
1920
1941
  const refusal = continueRefusal(e, allSessions);
@@ -1932,10 +1953,12 @@ async function continueSelected() {
1932
1953
  }
1933
1954
  }
1934
1955
 
1935
- // The new link's composer: the opener, unless a draft is there (it stays).
1936
- function fillOpener() {
1956
+ // The new link's composer: the opener (openerText: the previous link's own,
1957
+ // else the constant), unless a draft is there (it stays). +created+: the
1958
+ // continue's 201.
1959
+ function fillOpener(created) {
1937
1960
  if (promptEl.value.trim() || chips.length) return;
1938
- promptEl.value = CONTINUE_OPENER;
1961
+ promptEl.value = openerText(created);
1939
1962
  fitPrompt();
1940
1963
  promptEl.focus();
1941
1964
  promptEl.setSelectionRange(promptEl.value.length, promptEl.value.length);
@@ -9,6 +9,7 @@ import { BADGES, elementOf, noteBubbleTemplate, stateBadgeTemplate, steerRowTemp
9
9
  import { thumbsTemplate } from "./images.js";
10
10
  import { appendAboveLiveTiming, STOPPED_LINE_CLASS } from "./timing.js";
11
11
  import { keepFollowing } from "./scroll.js";
12
+ import { linkedLine } from "./notice_links.js";
12
13
  import { hookNoticeLabel, isReportClient, promptOps } from "./turn_events.js";
13
14
  import { drawCommandBubble } from "./commands.js";
14
15
 
@@ -53,10 +54,11 @@ export function guardrailWarningTemplate(text, label = "guardrails") {
53
54
 
54
55
  // A hook's line to the user (event[:notify]), in the warning colour for
55
56
  // level warn; +cls+ adds a kind's class (llm_context_edited's). +bubble+:
56
- // one of the history's own, else a row of a turn's step.
57
+ // one of the history's own, else a row of a turn's step. Its http(s) URLs
58
+ // are links (notice_links.js).
57
59
  export function hookNoticeTemplate({ line, level = null, cls = null, title = null }, { bubble = true } = {}) {
58
60
  const klass = (bubble ? "bubble hook-notice" : "hook-notice") + (level === "warn" ? " warn" : "") + (cls ? ` ${cls}` : "");
59
- return html`<div class=${klass} title=${title || nothing}>${line}</div>`;
61
+ return html`<div class=${klass} title=${title || nothing}>${linkedLine(line)}</div>`;
60
62
  }
61
63
 
62
64
  // A hook notice's line: its own (a snapshot's notice), else "<hook>: text".
@@ -13,9 +13,18 @@ import { archivedBadge, rowBadgesTemplate, rowDotTemplate } from "./families_vie
13
13
  import { html, nothing } from "./vendor/lit-html.js";
14
14
 
15
15
  // The opener a new link's composer is filled with (never sent: the user
16
- // edits or sends it). The same for every chain.
16
+ // edits or sends it) when the continue brings none of its own.
17
17
  export const CONTINUE_OPENER = "Continue where we left off.";
18
18
 
19
+ // What a new link's composer is filled with: the continue's own opener
20
+ // (+created+, the 201: the previous link's first typed message, when that
21
+ // link continues one), else CONTINUE_OPENER. So whatever the user sent on
22
+ // the last link comes back on the next one.
23
+ export function openerText(created) {
24
+ const own = created?.opener;
25
+ return typeof own === "string" && own.trim() ? own : CONTINUE_OPENER;
26
+ }
27
+
19
28
  // Whether +text+ (a title or a first message) is the opener and nothing
20
29
  // else, trimmed: "Continue where we left off. Then fix X" is a title of its own.
21
30
  export function isOpener(text) {
@@ -41,6 +50,17 @@ export function chainTitle(s, byId) {
41
50
  return isOpener(at.first_preview || at.last_prompt) ? own : previewForCard(at);
42
51
  }
43
52
 
53
+ // The open session's title (app.js updateSessionTitle: the top bar and the
54
+ // tab) from +first+, its first message: it follows its card. A chain link
55
+ // (+shown+, its summary, has continues) reads +cardTitle+(shown) whatever
56
+ // its first message: a web link inherits its chain's title, and its first
57
+ // message is an opener (the constant or the user's own). Else +first+,
58
+ // unless it is the opener. +shown+ null: not listed yet, +first+.
59
+ export function openTitle({ shown, first, cardTitle }) {
60
+ if (shown && (shown.continues || isOpener(first))) return cardTitle(shown);
61
+ return first;
62
+ }
63
+
44
64
  const createdMs = (s) => {
45
65
  const ms = Date.parse(s?.created_at || "");
46
66
  return Number.isNaN(ms) ? 0 : ms;
@@ -120,6 +120,15 @@ export function sendCommand(id, line, { clientId, card = false, ...opts } = {})
120
120
  });
121
121
  }
122
122
 
123
+ // A text's markdown as sanitised HTML ({ html }; null with markdown off).
124
+ export function renderText(text, opts = {}) {
125
+ return api("/api/render", {
126
+ method: "POST",
127
+ body: JSON.stringify({ text }),
128
+ ...opts,
129
+ });
130
+ }
131
+
123
132
  export function cancelTurn(id, opts = {}) {
124
133
  return api(`/api/sessions/${id}/cancel`, {
125
134
  method: "POST",
@@ -326,6 +326,22 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
326
326
  .bubble.question>summary .question-said{max-width:72ch;margin:2px 0 1px 1.05em;font-size:12.5px;font-weight:400;line-height:1.5;color:var(--text-muted);white-space:normal;overflow-wrap:anywhere;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden}
327
327
  .bubble.question[open]>summary .question-said{display:none}
328
328
  .question-said-full{margin:4px 0 6px;font-size:13px;line-height:1.55;color:var(--text-muted);white-space:pre-wrap;overflow-wrap:anywhere}
329
+ /* Its markdown once the card is first opened (question_cards.js renderSaidOnOpen), as compact as the stage's. */
330
+ .question-said-full.markdown{white-space:normal}
331
+ .question-said-full.markdown :is(h1,h2,h3,h4,h5,h6){font-size:13.5px;font-weight:600;line-height:1.4;margin:8px 0 2px;color:var(--text-2)}
332
+ .question-said-full.markdown :is(p,ul,ol,blockquote,table,pre){margin:0 0 6px}
333
+ .question-said-full.markdown>:first-child{margin-top:0}
334
+ .question-said-full.markdown>:last-child{margin-bottom:0}
335
+ .question-said-full.markdown :is(ul,ol){padding-left:1.3em}
336
+ .question-said-full.markdown li>:is(ul,ol){margin:0}
337
+ .question-said-full.markdown hr{border:0;border-top:1px solid var(--border);margin:8px 0}
338
+ .question-said-full.markdown blockquote{border-left:3px solid var(--border-strong);padding-left:10px}
339
+ .question-said-full.markdown code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.88em}
340
+ .question-said-full.markdown :not(pre)>code{background:var(--bg-inset);border:1px solid var(--border);border-radius:4px;padding:0 4px}
341
+ .question-said-full.markdown pre{overflow:auto;padding:6px 8px;border-radius:6px;background:var(--bg-inset);border:1px solid var(--border);font-size:12px;line-height:1.45;white-space:pre}
342
+ .question-said-full.markdown table{border-collapse:collapse;display:block;max-width:100%;overflow:auto}
343
+ .question-said-full.markdown :is(th,td){border:1px solid var(--border-strong);padding:3px 7px;font-size:12.5px}
344
+ .question-said-full.markdown th{background:var(--bg-inset);font-weight:600}
329
345
  .bubble.command{border-left:3px solid var(--text-faint);background:var(--bg-panel);font-size:13px}
330
346
  .bubble.note{border-left:3px dotted var(--text-faint);background:transparent;font-size:12px;color:var(--text-muted);opacity:.85}
331
347
  .note-line{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--text-faint);margin-bottom:2px}
@@ -438,6 +454,8 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
438
454
  .gen .activity-body>.hook-notice:first-child{border-top:none}
439
455
  .gen .hook-notice.warn{border-left-color:var(--warn);color:var(--warn-text)}
440
456
  .hook-notice.llm-context{border-left-style:dashed;cursor:help}
457
+ .hook-notice a{color:inherit;text-decoration:underline;text-underline-offset:2px}
458
+ .hook-notice a:hover{text-decoration-thickness:2px}
441
459
  /* A plugin's steer (a nudge into the running turn): a row of its step, or a bubble; its text collapsed. */
442
460
  .steer-row{font-size:13px;color:var(--text-3);border-left:2px solid var(--accent-border)}
443
461
  .steer-row>summary{cursor:pointer;list-style:none;font-style:italic}
@@ -708,9 +726,28 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
708
726
  .ts-headline.thinking{font-size:13.5px;font-style:italic;color:var(--text-muted)}
709
727
  .ts-think-tag{font-style:normal;font-size:12.5px;color:var(--think-text);margin-right:6px}
710
728
  .ts-headline.fresh{animation:ts-fade .3s ease}
711
- /* While a question or approval waits: the whole reply that asked it, raw as a closed step's text, in the headline's
712
- place; capped so the card keeps its room, read from the top (stage_view.js drawAsking). */
729
+ /* While a question or approval waits: the whole reply that asked it, in the headline's place; raw as a closed step's
730
+ text, then its markdown when web.markdown is on (.markdown, stage_slots.js drawAsking); capped so the card keeps
731
+ its room, read from the top. */
713
732
  .ts-asking{max-height:calc(var(--stage-max,60vh) * .35);overflow:auto;scrollbar-width:thin;white-space:pre-wrap;overflow-wrap:anywhere;font-size:14px;line-height:1.55;color:var(--narr);border-left:2px solid var(--border-strong);padding-left:10px}
733
+ /* Its markdown, compact: about the raw text's metrics, so the swap barely moves the slot. */
734
+ .ts-asking.markdown{white-space:normal}
735
+ .ts-asking.markdown :is(h1,h2,h3,h4,h5,h6){font-size:14.5px;font-weight:600;line-height:1.4;margin:8px 0 2px;color:var(--text-bright)}
736
+ .ts-asking.markdown :is(h4,h5,h6){font-size:14px;color:var(--text-2)}
737
+ .ts-asking.markdown :is(p,ul,ol,blockquote,table,pre){margin:0 0 6px}
738
+ .ts-asking.markdown>:first-child{margin-top:0}
739
+ .ts-asking.markdown>:last-child{margin-bottom:0}
740
+ .ts-asking.markdown :is(ul,ol){padding-left:1.3em}
741
+ .ts-asking.markdown li>:is(ul,ol){margin:0}
742
+ .ts-asking.markdown strong{color:var(--text-bright)}
743
+ .ts-asking.markdown hr{border:0;border-top:1px solid var(--border);margin:8px 0}
744
+ .ts-asking.markdown blockquote{border-left:3px solid var(--border-strong);padding-left:10px;color:var(--text-muted)}
745
+ .ts-asking.markdown code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.88em}
746
+ .ts-asking.markdown :not(pre)>code{background:var(--bg-inset);border:1px solid var(--border);border-radius:4px;padding:0 4px;color:var(--text)}
747
+ .ts-asking.markdown pre{overflow:auto;padding:6px 8px;border-radius:6px;background:var(--bg-inset);border:1px solid var(--border);font-size:12.5px;line-height:1.45;white-space:pre}
748
+ .ts-asking.markdown table{border-collapse:collapse;display:block;max-width:100%;overflow:auto}
749
+ .ts-asking.markdown :is(th,td){border:1px solid var(--border-strong);padding:3px 7px;font-size:13px}
750
+ .ts-asking.markdown th{background:var(--bg-inset);font-weight:600}
714
751
  .turn-stage.asking .ts-headline{display:none}
715
752
  @keyframes ts-fade{from{opacity:.35}}
716
753
  .ts-now{height:var(--ts-now-h);display:flex;align-items:center;gap:8px;min-width:0;padding:0 9px;border-radius:8px;background:var(--accent-tint);font-size:13px}
@@ -0,0 +1,55 @@
1
+ // A hook notice line's http(s) URLs as links (a source-links `sources:`
2
+ // line with markdown off): the terminals need the line as text, the web can
3
+ // click it. Only http(s) URLs that refs.js httpUrl accepts (no userinfo, not
4
+ // over-long) are linked; everything else stays text, escaped by lit (no
5
+ // unsafeHTML). A link opens in a new tab: refs.js installRefClicks leaves a
6
+ // plain <a> (no data-ref-kind) to the browser.
7
+ import { html } from "./vendor/lit-html.js";
8
+ import { httpUrl } from "./refs.js";
9
+
10
+ // A candidate: the scheme, then anything up to a space or an angle bracket
11
+ // (`<https://x/a>` links https://x/a).
12
+ const CANDIDATE = /https?:\/\/[^\s<>]+/gi;
13
+ const TRAILING = /[.,;:!?'"]+$/;
14
+
15
+ // +url+ cut at its first ) that no ( inside it balances: `(https://x/a)`
16
+ // ends before the ), `…/Foo_(bar)` keeps it (source_links.rb trim_url_end).
17
+ function cutUnbalancedParen(url) {
18
+ let depth = 0;
19
+ for (let i = 0; i < url.length; i++) {
20
+ if (url[i] === "(") depth++;
21
+ else if (url[i] === ")") {
22
+ if (depth === 0) return url.slice(0, i);
23
+ depth--;
24
+ }
25
+ }
26
+ return url;
27
+ }
28
+
29
+ // A candidate without what ends the sentence around it: trailing
30
+ // punctuation (the sources line's ", "), an unbalanced ), a ] no [ opens.
31
+ function trimUrl(candidate) {
32
+ let url = cutUnbalancedParen(candidate.replace(TRAILING, ""));
33
+ if (url.endsWith("]") && !url.includes("[")) url = url.replace(/\]+$/, "");
34
+ return url.replace(TRAILING, "");
35
+ }
36
+
37
+ // +text+ with each http(s) URL a link (a lit template's array of strings
38
+ // and <a> parts); +text+ itself when it links nothing, so a URL-less line
39
+ // renders exactly as before.
40
+ export function linkedLine(text) {
41
+ if (typeof text !== "string" || !text) return text;
42
+ const parts = [];
43
+ let last = 0;
44
+ for (const match of text.matchAll(CANDIDATE)) {
45
+ const shown = trimUrl(match[0]);
46
+ const href = httpUrl(shown);
47
+ if (!href) continue;
48
+ if (match.index > last) parts.push(text.slice(last, match.index));
49
+ parts.push(html`<a href=${href} target="_blank" rel="noopener noreferrer">${shown}</a>`);
50
+ last = match.index + shown.length;
51
+ }
52
+ if (!parts.length) return text;
53
+ if (last < text.length) parts.push(text.slice(last));
54
+ return parts;
55
+ }
@@ -31,6 +31,9 @@ import { continueLine } from "./commands.js";
31
31
  // @param removeHintIfEmpty () drops the history's placeholder hint
32
32
  // @param askingText () => the running turn's asking reply (turn_model.js
33
33
  // askingText via the page's view), kept by a question or own approval card
34
+ // @param renderMarkdown (text) => Promise<html|null>: the kept reply's
35
+ // markdown (the page's POST /api/render; null keeps the raw text)
36
+ // @param linkRefs (el) the page's path:line ref pass on it
34
37
  // @param api {sendAnswer, dismissQuestion, sendCommand} (data.js)
35
38
  // @param doc the page's document
36
39
  // @return {renderQuestion(pq, {said}), resolveQuestion(id, opts), removeQuestion(),
@@ -38,6 +41,7 @@ import { continueLine } from "./commands.js";
38
41
  // renderContinue(offer), resolveContinue(data), forgetContinue()}
39
42
  export function createQuestionCards({
40
43
  sessionId, clientId, place, isNearBottom, scrollToEnd, removeHintIfEmpty, askingText = () => "",
44
+ renderMarkdown = () => Promise.resolve(null), linkRefs = () => {},
41
45
  api = { sendAnswer, dismissQuestion, sendCommand }, doc = globalThis.document,
42
46
  }) {
43
47
  // Live ask_user_question card, keyed by pending question id. Re-rendered from
@@ -403,13 +407,14 @@ export function createQuestionCards({
403
407
  // the toggle's label), and whole, selectable, at the top of the body
404
408
  // when it is open (a drag in the summary would toggle the card).
405
409
  const asked = saidOf.get(questionEl);
410
+ let full = null;
406
411
  if (summary && asked) {
407
412
  const line = doc.createElement("div");
408
413
  line.className = "question-said";
409
414
  line.setAttribute("aria-hidden", "true");
410
415
  line.textContent = asked;
411
416
  summary.appendChild(line);
412
- const full = doc.createElement("div");
417
+ full = doc.createElement("div");
413
418
  full.className = "question-said-full";
414
419
  full.textContent = asked;
415
420
  questionEl.insertBefore(full, summary.nextSibling);
@@ -421,9 +426,36 @@ export function createQuestionCards({
421
426
  const keepNote = questionEl.dataset.toggled || (!cancelled && deniedReason(pending, answer));
422
427
  if (summary && !keepNote) note.classList.add("hidden");
423
428
  if (!questionEl.dataset.toggled) questionEl.open = false;
429
+ // After the collapse: a card left open by hand renders at once.
430
+ if (full) renderSaidOnOpen(questionEl, full, asked);
424
431
  pending = null;
425
432
  }
426
433
 
434
+ // The body's copy as markdown, from the card's first open on (a reload
435
+ // draws every card in the history: rendering them all would ask once per
436
+ // card). Raw until then, and when the render answers null (then the next
437
+ // open asks again).
438
+ function renderSaidOnOpen(card, full, asked) {
439
+ let state = "raw";
440
+ const draw = () => {
441
+ if (!card.open || state !== "raw") return;
442
+ state = "asking";
443
+ Promise.resolve()
444
+ .then(() => renderMarkdown(asked))
445
+ .catch(() => null)
446
+ .then((html) => {
447
+ if (!html) { state = "raw"; return; }
448
+ state = "done";
449
+ // Sanitised on the server (POST /api/render).
450
+ full.innerHTML = html;
451
+ full.classList.add("markdown");
452
+ linkRefs(full);
453
+ });
454
+ };
455
+ card.addEventListener("toggle", draw);
456
+ draw();
457
+ }
458
+
427
459
  function markQuestionSelection(sel) {
428
460
  if (!questionEl || !sel.length) return;
429
461
  questionEl.querySelectorAll(".question-choice").forEach((button) => {
@@ -19,10 +19,19 @@ export function node(tag, cls, parent = null) {
19
19
  // +taskStop+: the page's stopper (task_stop.js), for the stop-task button
20
20
  // on a running task_wait; +gens()+ the running turn's gens (the button's
21
21
  // command, read at its click); +onChange+ redraws (the stage's
22
- // scheduleRefresh).
23
- export function createStageSlots({ headline, asking, now, trail, ticks, taskStop = null, gens, onChange }) {
22
+ // scheduleRefresh). +renderMarkdown(text)+: the asking text's HTML
23
+ // (Promise<string|null>; null keeps the raw text: markdown off, a failed
24
+ // read); +linkRefs(el)+: the page's path:line ref pass on the slot.
25
+ export function createStageSlots({
26
+ headline, asking, now, trail, ticks, taskStop = null, gens, onChange,
27
+ renderMarkdown = () => Promise.resolve(null), linkRefs = () => {},
28
+ }) {
24
29
  // What each slot shows, so an unchanged slot is left alone.
25
30
  const shown = { headline: null, now: null, trail: null, asking: null };
31
+ // The last asking text sent to render and its answer (in flight or
32
+ // done, HTML only): the same question drawn again (after a reset, a
33
+ // blank between) asks no second time.
34
+ let rendering = { text: null, html: null };
26
35
 
27
36
  function drawHeadline({ text, thinking }) {
28
37
  const key = `${thinking ? "t" : "n"}|${text}`;
@@ -42,13 +51,35 @@ export function createStageSlots({ headline, asking, now, trail, ticks, taskStop
42
51
 
43
52
  // Patched only when the text changes (a late chunk to the closed gen
44
53
  // rewrites the same text): a selection in it survives. A new text reads
45
- // from its top.
54
+ // from its top: drawn raw at once, then swapped for its markdown when
55
+ // that comes back while the slot still shows the same text (the text is
56
+ // final: it shows only once the call that asks runs).
46
57
  function drawAsking(text) {
47
58
  asking.hidden = !text;
48
59
  if (shown.asking === text) return;
49
60
  shown.asking = text;
61
+ asking.classList.remove("markdown");
50
62
  asking.textContent = text;
51
63
  asking.scrollTop = 0;
64
+ if (!text) return;
65
+ linkRefs(asking);
66
+ if (rendering.text !== text) {
67
+ let html;
68
+ try { html = Promise.resolve(renderMarkdown(text)).catch(() => null); } catch (_) { html = Promise.resolve(null); }
69
+ rendering = { text, html };
70
+ }
71
+ const pending = rendering;
72
+ pending.html.then((html) => {
73
+ // A null isn't kept: the next draw of this text asks again.
74
+ if (!html && rendering === pending) rendering = { text: null, html: null };
75
+ // Swapped once: a blank-then-same draw while in flight waits twice.
76
+ if (!html || shown.asking !== text || asking.classList.contains("markdown")) return;
77
+ // Sanitised on the server (POST /api/render), as an answer's HTML is.
78
+ asking.innerHTML = html;
79
+ asking.classList.add("markdown");
80
+ asking.scrollTop = 0;
81
+ linkRefs(asking);
82
+ });
52
83
  }
53
84
 
54
85
  function drawNow(tool, quiet) {
@@ -123,6 +154,7 @@ export function createStageSlots({ headline, asking, now, trail, ticks, taskStop
123
154
  function reset() {
124
155
  for (const slot of [headline, asking, now, trail, ticks]) slot.replaceChildren();
125
156
  asking.hidden = true;
157
+ asking.classList.remove("markdown");
126
158
  shown.headline = shown.now = shown.trail = shown.asking = null;
127
159
  }
128
160
 
@@ -48,8 +48,12 @@ function readCollapsed() {
48
48
  // the history only for a reader at its end). +dockEl+, +centerEl+ and
49
49
  // +composerEl+ size the stage's cap.
50
50
  // +taskStop+: the page's stopper (task_stop.js), for the stop-task button
51
- // on a running task_wait.
52
- export function createStageView({ historyEl, appendToHistory, isNearBottom: historyNearBottom, sawText, sessionId, dockEl, centerEl, composerEl, taskStop = null }) {
51
+ // on a running task_wait. +renderMarkdown+ and +linkRefs+: the asking
52
+ // slot's markdown read and ref pass (stage_slots.js).
53
+ export function createStageView({
54
+ historyEl, appendToHistory, isNearBottom: historyNearBottom, sawText, sessionId, dockEl, centerEl, composerEl, taskStop = null,
55
+ renderMarkdown, linkRefs,
56
+ }) {
53
57
  // The dock becomes a clear column: the stage's card, then the composer's
54
58
  // (its rows moved into .dock-card, which wears the glass and the focus ring).
55
59
  document.body.classList.add("stage-view");
@@ -82,7 +86,8 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
82
86
  headline.setAttribute("role", "button");
83
87
  headline.title = "Show this step's reasoning";
84
88
  // While a question or approval waits: the whole reply that asked it, in
85
- // the headline's place (the card says what to answer, this says why).
89
+ // the headline's place (the card says what to answer, this says why),
90
+ // as markdown when web.markdown is on.
86
91
  const asking = node("div", "ts-asking", live);
87
92
  asking.hidden = true;
88
93
  const now = node("div", "ts-now", live);
@@ -111,7 +116,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
111
116
  // "queued"), each to run as a turn of its own; they hand off last.
112
117
  const queuedSlot = node("div", "ts-queued", stage);
113
118
  const slotsView = createStageSlots({
114
- headline, asking, now, trail, ticks, taskStop,
119
+ headline, asking, now, trail, ticks, taskStop, renderMarkdown, linkRefs,
115
120
  gens: () => view.current().turn?.gens,
116
121
  onChange: () => scheduleRefresh(),
117
122
  });
@@ -37,6 +37,7 @@ import { createSentenceFeed } from "./sentences.js";
37
37
  import { createTicker } from "./thinking_ticker.js";
38
38
  import * as model from "./turn_model.js";
39
39
  import { createHold } from "./hold.js";
40
+ import { linkedLine } from "./notice_links.js";
40
41
  import { taskCommand, taskStopButton } from "./task_stop.js";
41
42
 
42
43
  // ---- The templates: one step, its thinking, its label ----
@@ -99,10 +100,10 @@ export function stepTemplate(gen, st = {}) {
99
100
 
100
101
  // A hook's line (hook_notice) as a step's row. data.line is a whole row of
101
102
  // chi's own (an empty-answer retry), shown as is; data.cls a class of its
102
- // own and data.title its hover (a ✂ row's).
103
+ // own and data.title its hover (a ✂ row's). Its http(s) URLs are links.
103
104
  function noticeTemplate(data) {
104
105
  const cls = "hook-notice" + (data.level === "warn" ? " warn" : "") + (data.cls ? ` ${data.cls}` : "");
105
- return html`<div class=${cls} title=${data.title || nothing}>${data.line || `${hookNoticeLabel(data.hook)}: ${data.text || ""}`}</div>`;
106
+ return html`<div class=${cls} title=${data.title || nothing}>${linkedLine(data.line || `${hookNoticeLabel(data.hook)}: ${data.text || ""}`)}</div>`;
106
107
  }
107
108
 
108
109
  // +template+ rendered as detached nodes (a fragment).
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.72.0
4
+ version: 0.73.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -614,6 +614,7 @@ files:
614
614
  - lib/samagotchi/web/public/llm_context_chip.js
615
615
  - lib/samagotchi/web/public/model_pick.js
616
616
  - lib/samagotchi/web/public/model_picker.js
617
+ - lib/samagotchi/web/public/notice_links.js
617
618
  - lib/samagotchi/web/public/notifications.js
618
619
  - lib/samagotchi/web/public/notify.js
619
620
  - lib/samagotchi/web/public/prompt_history.js