samagotchi 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -1
  3. data/README.md +7 -3
  4. data/docs/architecture.md +4 -4
  5. data/docs/cli.md +19 -4
  6. data/docs/guardrails.md +2 -2
  7. data/docs/hooks.md +2 -2
  8. data/docs/internals/context-telemetry.md +3 -1
  9. data/docs/memory.md +48 -1
  10. data/docs/plugins.md +7 -5
  11. data/docs/releasing.md +5 -1
  12. data/docs/sessions.md +2 -2
  13. data/lib/samagotchi/archive_store.rb +3 -3
  14. data/lib/samagotchi/atomic_file.rb +34 -0
  15. data/lib/samagotchi/bootstrap/bundles.rb +37 -0
  16. data/lib/samagotchi/bootstrap/config_writer.rb +4 -6
  17. data/lib/samagotchi/bootstrap/probe.rb +9 -5
  18. data/lib/samagotchi/bootstrap_command.rb +78 -8
  19. data/lib/samagotchi/bridge/pending_card.rb +2 -3
  20. data/lib/samagotchi/bridge.rb +56 -116
  21. data/lib/samagotchi/bridge_client.rb +7 -11
  22. data/lib/samagotchi/bundle_command.rb +81 -21
  23. data/lib/samagotchi/bundle_needs.rb +2 -11
  24. data/lib/samagotchi/bundles/core/manifest.yml +11 -0
  25. data/lib/samagotchi/bundles/dev/manifest.yml +13 -0
  26. data/lib/samagotchi/bundles/system/identity.md +1 -0
  27. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  28. data/lib/samagotchi/bundles/system/memory_guide.md +3 -2
  29. data/lib/samagotchi/client.rb +3 -23
  30. data/lib/samagotchi/config.rb +6 -11
  31. data/lib/samagotchi/context_note.rb +1 -1
  32. data/lib/samagotchi/context_status.rb +255 -0
  33. data/lib/samagotchi/desktop/macos.rb +2 -3
  34. data/lib/samagotchi/engine.rb +6 -7
  35. data/lib/samagotchi/guardrail_wiring.rb +2 -5
  36. data/lib/samagotchi/guardrails/approvals.rb +2 -7
  37. data/lib/samagotchi/hooks/bundle_loader.rb +6 -3
  38. data/lib/samagotchi/hooks/loader.rb +2 -1
  39. data/lib/samagotchi/hooks/registry.rb +21 -0
  40. data/lib/samagotchi/image_store.rb +2 -3
  41. data/lib/samagotchi/kernel_loop.rb +21 -283
  42. data/lib/samagotchi/live_versions.rb +9 -13
  43. data/lib/samagotchi/llm/chat_loop.rb +1 -1
  44. data/lib/samagotchi/llm/native_backend.rb +8 -8
  45. data/lib/samagotchi/llm/openai_chat.rb +4 -17
  46. data/lib/samagotchi/llm/utf8_scrub.rb +32 -0
  47. data/lib/samagotchi/log_path.rb +2 -3
  48. data/lib/samagotchi/memory_bundle/index_updater.rb +2 -11
  49. data/lib/samagotchi/memory_bundle/installer.rb +5 -7
  50. data/lib/samagotchi/memory_bundle/listing.rb +27 -20
  51. data/lib/samagotchi/memory_bundle/manifest.rb +32 -1
  52. data/lib/samagotchi/memory_bundle/profile.rb +145 -0
  53. data/lib/samagotchi/memory_bundle/provenance.rb +42 -53
  54. data/lib/samagotchi/memory_bundle/shipped_update.rb +52 -27
  55. data/lib/samagotchi/memory_bundle/source.rb +2 -0
  56. data/lib/samagotchi/memory_bundle/status.rb +1 -2
  57. data/lib/samagotchi/memory_bundle.rb +1 -0
  58. data/lib/samagotchi/note_command.rb +5 -4
  59. data/lib/samagotchi/owner_lock.rb +0 -6
  60. data/lib/samagotchi/paths.rb +32 -0
  61. data/lib/samagotchi/plugin/api.rb +3 -6
  62. data/lib/samagotchi/plugin/context.rb +2 -3
  63. data/lib/samagotchi/plugin/loader.rb +1 -1
  64. data/lib/samagotchi/question_desk.rb +3 -2
  65. data/lib/samagotchi/recap_store.rb +2 -2
  66. data/lib/samagotchi/reply_wait.rb +2 -1
  67. data/lib/samagotchi/self_report.rb +2 -3
  68. data/lib/samagotchi/send_command.rb +3 -2
  69. data/lib/samagotchi/session.rb +41 -143
  70. data/lib/samagotchi/session_inbox.rb +175 -0
  71. data/lib/samagotchi/session_manager.rb +62 -363
  72. data/lib/samagotchi/session_metrics.rb +2 -3
  73. data/lib/samagotchi/session_retention.rb +259 -0
  74. data/lib/samagotchi/sessions_command.rb +3 -2
  75. data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -2
  76. data/lib/samagotchi/terminal_ui/input_support.rb +2 -9
  77. data/lib/samagotchi/terminal_ui.rb +10 -20
  78. data/lib/samagotchi/tools/memory.rb +3 -2
  79. data/lib/samagotchi/tools/send_note.rb +4 -3
  80. data/lib/samagotchi/tools/task_runtime.rb +2 -3
  81. data/lib/samagotchi/turn_note.rb +13 -2
  82. data/lib/samagotchi/update_command.rb +9 -2
  83. data/lib/samagotchi/version.rb +1 -1
  84. data/lib/samagotchi/web/app.rb +44 -76
  85. data/lib/samagotchi/web/info_probe.rb +33 -0
  86. data/lib/samagotchi/web/public/app.js +1 -1
  87. data/lib/samagotchi/web/public/index.html +1 -0
  88. data/lib/samagotchi/web/public/stage_model.js +22 -3
  89. data/lib/samagotchi/web/public/stage_view.js +25 -10
  90. data/lib/samagotchi/web/server.rb +8 -11
  91. data/lib/samagotchi/web/session_hub.rb +8 -7
  92. data/lib/samagotchi/web/session_summary.rb +4 -3
  93. data/lib/samagotchi/web/token.rb +2 -5
  94. data/lib/samagotchi/worker.rb +34 -20
  95. data/lib/samagotchi/worker_sidecar.rb +42 -0
  96. metadata +13 -1
@@ -121,6 +121,31 @@ module Samagotchi
121
121
 
122
122
  private
123
123
 
124
+ # The routes, first match wins: a handler takes the request and the
125
+ # path's captures (the session id, an image's name).
126
+ ROUTES = [
127
+ ["GET", %r{\A/(?:index\.html)?\z}, :serve_index],
128
+ ["GET", %r{\A/api/sessions\z}, :handle_list],
129
+ ["POST", %r{\A/api/sessions\z}, :handle_create],
130
+ ["GET", %r{\A/api/info\z}, :handle_info],
131
+ ["GET", %r{\A/api/models\z}, :handle_models],
132
+ ["GET", %r{\A/api/events\z}, :handle_events],
133
+ ["GET", %r{\A/api/sessions/([^/]+)/stream\z}, :handle_stream],
134
+ ["GET", %r{\A/api/sessions/([^/]+)/output\z}, :handle_output],
135
+ ["POST", %r{\A/api/sessions/([^/]+)/cancel\z}, :handle_cancel],
136
+ ["POST", %r{\A/api/sessions/([^/]+)/stop\z}, :handle_stop],
137
+ ["POST", %r{\A/api/sessions/([^/]+)/archive\z}, :handle_archive],
138
+ ["POST", %r{\A/api/sessions/([^/]+)/unarchive\z}, :handle_unarchive],
139
+ ["POST", %r{\A/api/sessions/([^/]+)/turn\z}, :handle_turn],
140
+ ["POST", %r{\A/api/sessions/([^/]+)/answer\z}, :handle_question_answer],
141
+ ["POST", %r{\A/api/sessions/([^/]+)/question/dismiss\z}, :handle_question_dismiss],
142
+ ["POST", %r{\A/api/sessions/([^/]+)/command\z}, :handle_command],
143
+ ["POST", %r{\A/api/sessions/([^/]+)/images\z}, :handle_image_upload],
144
+ ["GET", %r{\A/api/sessions/([^/]+)/images/([^/]+)\z}, :handle_image],
145
+ ["GET", %r{\A/api/sessions/([^/]+)\z}, :handle_show],
146
+ ["DELETE", %r{\A/api/sessions/([^/]+)\z}, :handle_delete]
147
+ ].freeze
148
+
124
149
  def route(env)
125
150
  req = Rack::Request.new(env)
126
151
  return forbidden unless local_host_header?(env)
@@ -130,69 +155,19 @@ module Samagotchi
130
155
  return denied if denied
131
156
  end
132
157
 
133
- case [req.request_method, req.path_info]
134
- when ["GET", "/"], ["GET", "/index.html"]
135
- serve_index(req)
136
- when ["GET", "/api/sessions"]
137
- handle_list(req)
138
- when ["POST", "/api/sessions"]
139
- handle_create(req)
140
- when ["GET", "/api/info"]
141
- handle_info
142
- when ["GET", "/api/models"]
143
- handle_models
144
- when ["GET", "/api/events"]
145
- handle_events(req)
146
- else
147
- # Dynamic routes
148
- if (m = %r{\A/api/sessions/([^/]+)/stream\z}.match(req.path_info)) && req.get?
149
- return handle_stream(req, m[1])
150
- end
151
- if (m = %r{\A/api/sessions/([^/]+)/output\z}.match(req.path_info)) && req.get?
152
- return handle_output(req, m[1])
153
- end
154
- if (m = %r{\A/api/sessions/([^/]+)/cancel\z}.match(req.path_info)) && req.post?
155
- return handle_cancel(req, m[1])
156
- end
157
- if (m = %r{\A/api/sessions/([^/]+)/stop\z}.match(req.path_info)) && req.post?
158
- return handle_stop(req, m[1])
159
- end
160
- if (m = %r{\A/api/sessions/([^/]+)/(archive|unarchive)\z}.match(req.path_info)) && req.post?
161
- return m[2] == "archive" ? handle_archive(req, m[1]) : handle_unarchive(req, m[1])
162
- end
163
- if (m = %r{\A/api/sessions/([^/]+)/turn\z}.match(req.path_info)) && req.post?
164
- return handle_turn(req, m[1])
165
- end
166
- if (m = %r{\A/api/sessions/([^/]+)/answer\z}.match(req.path_info)) && req.post?
167
- return handle_question_answer(req, m[1])
168
- end
169
- if (m = %r{\A/api/sessions/([^/]+)/question/dismiss\z}.match(req.path_info)) && req.post?
170
- return handle_question_dismiss(req, m[1])
171
- end
172
- if (m = %r{\A/api/sessions/([^/]+)/command\z}.match(req.path_info)) && req.post?
173
- return handle_command(req, m[1])
158
+ ROUTES.each do |verb, pattern, handler|
159
+ next unless req.request_method == verb && (m = pattern.match(req.path_info))
160
+ # Every /api/sessions/:id route: an id that isn't one (Session.valid_id?,
161
+ # "..", a %2F) is an unknown session before any handler makes a path.
162
+ if req.path_info.start_with?("/api/sessions/") && !Session.valid_id?(m.captures.first)
163
+ return error_response(404, "not_found", "Session not found: #{m.captures.first.to_s[0, 80]}")
174
164
  end
175
- if (m = %r{\A/api/sessions/([^/]+)/images\z}.match(req.path_info)) && req.post?
176
- return handle_image_upload(req, m[1])
177
- end
178
- if (m = %r{\A/api/sessions/([^/]+)/images/([^/]+)\z}.match(req.path_info)) && req.get?
179
- return handle_image(req, m[1], m[2])
180
- end
181
- if (m = %r{\A/api/sessions/([^/]+)\z}.match(req.path_info)) && req.get?
182
- return handle_show(req, m[1])
183
- end
184
- if (m = %r{\A/api/sessions/([^/]+)\z}.match(req.path_info)) && req.delete?
185
- return handle_delete(req, m[1])
186
- end
187
- if req.path_info.start_with?("/assets/") || req.path_info.start_with?("/public/")
188
- return serve_static(req)
189
- end
190
- # Fallback static file serve from public_dir (e.g. /style.css)
191
- maybe_static = serve_static(req)
192
- return maybe_static if maybe_static[0] != 404
193
165
 
194
- not_found(path: req.path_info)
166
+ return send(handler, req, *m.captures)
195
167
  end
168
+ # Anything else is a file under public_dir (/assets/*, /public/* or
169
+ # a bare path such as /style.css), or 404.
170
+ serve_static(req)
196
171
  rescue StandardError => e
197
172
  Log.exception(:web, "request_failed", e, method: env["REQUEST_METHOD"], path: env["PATH_INFO"])
198
173
  error_response(500, "internal_error", e.message)
@@ -429,7 +404,7 @@ module Samagotchi
429
404
 
430
405
  # What a second `chi web` probes before starting its own server: this
431
406
  # is chi web, and it knows ?dir (features).
432
- def handle_info
407
+ def handle_info(_req)
433
408
  json_response(200, { app: "chi-web", version: Samagotchi::VERSION, pid: Process.pid, cwd: Dir.pwd,
434
409
  features: ["dir"], lan: @lan && @lan[:ip] })
435
410
  end
@@ -442,7 +417,7 @@ module Samagotchi
442
417
  # a bounded time for a fresh listing and answers with what it has, a
443
418
  # host that failed or the wait running out noted in +warning+, never a
444
419
  # failure: the default alone is enough for the page.
445
- def handle_models
420
+ def handle_models(_req)
446
421
  default = begin
447
422
  ModelProfile.required_model_name
448
423
  rescue StandardError
@@ -586,12 +561,14 @@ module Samagotchi
586
561
  snapshot = live && live["session_state_snapshot"]
587
562
  last_event_seq = snapshot ? snapshot["event_seq"] : bridge_event_seq(id)
588
563
  current_turn = turn_snapshot && turn_snapshot["current_turn"]
564
+ owner = session_owner(id)
565
+ # The file's question only with a live owner: one a dead worker
566
+ # saved can't be answered (its turn is gone).
589
567
  pending = if turn_snapshot
590
568
  current_turn && current_turn["pending_question"]
591
- elsif session.respond_to?(:pending_question)
569
+ elsif owner && session.respond_to?(:pending_question)
592
570
  session.pending_question
593
571
  end
594
- owner = session_owner(id)
595
572
  # A /model in the worker changes it before the file catches up.
596
573
  session.model_name = snapshot["model_name"] if snapshot && !snapshot["model_name"].to_s.empty?
597
574
  session_json = session_to_json(session, status: displayed_status(session, snapshot, owner: owner), owner: owner)
@@ -673,16 +650,9 @@ module Samagotchi
673
650
  unless body.is_a?(Hash)
674
651
  return error_response(400, "invalid_json", "invalid JSON body")
675
652
  end
676
- qid = body["id"] || body[:id] || body["question_id"] || body[:question_id]
677
- selected = body["selected"] || body[:selected] || body["selection"] || body[:selection]
678
- freeform = body["freeform"] || body[:freeform] || body["other"] || body[:other]
679
- # Allow payload nested under answer
680
- if body["answer"].is_a?(Hash)
681
- ans = body["answer"]
682
- qid ||= ans["id"] || ans[:id]
683
- selected ||= ans["selected"] || ans[:selected]
684
- freeform ||= ans["freeform"] || ans[:freeform]
685
- end
653
+ qid = body["id"]
654
+ selected = body["selected"]
655
+ freeform = body["freeform"]
686
656
  if qid.to_s.strip.empty?
687
657
  return error_response(400, "missing_fields", "id is required")
688
658
  end
@@ -817,8 +787,6 @@ module Samagotchi
817
787
  end
818
788
  rescue SessionManager::OwnedByTUI => e
819
789
  error_response(409, "owned_by_tui", e.message)
820
- rescue SessionManager::ImagesUnsupported => e
821
- error_response(409, "images_unsupported", e.message)
822
790
  rescue ArgumentError => e
823
791
  error_response(404, "not_found", e.message)
824
792
  end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+
6
+ module Samagotchi
7
+ module Web
8
+ # What answers GET /api/info on host:port: the one probe for a running
9
+ # chi web (chi web's own port check, chi update, chi self).
10
+ module InfoProbe
11
+ module_function
12
+
13
+ # @return [Hash, :free, :other] a chi web's /api/info; :free when
14
+ # nothing listens; :other for anything else (another program, an
15
+ # error status, a body that isn't a chi web's)
16
+ def call(host, port, timeout:)
17
+ response = Net::HTTP.start(host, port, open_timeout: timeout, read_timeout: timeout) { |http| http.get("/api/info") }
18
+ verdict(response.code.to_i, response.body)
19
+ rescue Errno::ECONNREFUSED, Errno::EADDRNOTAVAIL
20
+ :free
21
+ rescue StandardError
22
+ :other
23
+ end
24
+
25
+ def verdict(status, body)
26
+ info = status == 200 ? JSON.parse(body.to_s) : nil
27
+ info.is_a?(Hash) && info["app"] == "chi-web" ? info : :other
28
+ rescue JSON::ParserError
29
+ :other
30
+ end
31
+ end
32
+ end
33
+ end
@@ -2018,7 +2018,7 @@ async function handleSendTurn() {
2018
2018
  } catch (e) {
2019
2019
  echo.remove();
2020
2020
  // The chips stay (uploaded ones keep their ref) for another try.
2021
- if (/images_unsupported|bad_image|too_large|restart it/.test(e.message)) {
2021
+ if (/bad_image|too_large/.test(e.message)) {
2022
2022
  alert(e.message);
2023
2023
  return;
2024
2024
  }
@@ -482,6 +482,7 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
482
482
  .ts-trail-item{flex:none;max-width:36ch;overflow:hidden;text-overflow:ellipsis}
483
483
  .ts-trail-item.error{color:var(--danger-text)}
484
484
  .ts-trail-item.warn{color:var(--warn-text)}
485
+ .ts-trail-item.info,.ts-trail-item.steer{color:var(--accent)}
485
486
  .ts-trail-item.none{font-style:italic}
486
487
  /* A card waits: the tool row and trail give it their room. */
487
488
  .turn-stage.waiting .ts-now,.turn-stage.waiting .ts-trail{display:none}
@@ -4,6 +4,7 @@
4
4
  // covers the rules; stage_view.js draws them.
5
5
  import { toolName } from "./activity.js";
6
6
  import { splitSentences } from "./sentences.js";
7
+ import { hookNoticeLabel } from "./turn_events.js";
7
8
 
8
9
  const READ_LIKE = new Set(["read", "memory_read", "web_fetch", "task_get", "task_list", "list_sessions"]);
9
10
  const EDITS = new Set(["edit", "write", "memory_write"]);
@@ -48,12 +49,19 @@ export function plainHeadline(line) {
48
49
 
49
50
  // The step's newest narration sentence, as plain text: the end of a
50
51
  // finished text is a boundary (its last line may have no period), a live
51
- // one's not yet. A sentence that is only markdown syntax is skipped.
52
+ // one's not yet. A sentence that is only markdown syntax is skipped, and so
53
+ // is code: the lines between a fence and its close (or the end, while one
54
+ // is open).
52
55
  export function headlineOf(text, done) {
53
56
  const { sentences, rest } = splitSentences(text, { atEnd: true });
54
- const fragment = plainHeadline(rest);
57
+ let fenced = false;
58
+ const plain = [];
59
+ for (const sentence of sentences) {
60
+ if (/^(```|~~~)/.test(sentence)) fenced = !fenced;
61
+ else if (!fenced && plainHeadline(sentence)) plain.push(plainHeadline(sentence));
62
+ }
63
+ const fragment = fenced ? "" : plainHeadline(rest);
55
64
  if (done && fragment) return fragment;
56
- const plain = sentences.map(plainHeadline).filter(Boolean);
57
65
  if (plain.length) return plain[plain.length - 1];
58
66
  return fragment.length > 160 ? fragment : "";
59
67
  }
@@ -148,6 +156,17 @@ export function handOffOrder({ prompt = null, block = null, extras = [], answer
148
156
  // Where a row app.js would append goes: the stage's extras while a turn is
149
157
  // live (and not handing off), else the history; the recap always the
150
158
  // history (it is idle-only).
159
+ // What flashes in the trail for a few seconds while the turn runs (the
160
+ // rows themselves go into their step inside the closed cloud): a hook's
161
+ // notice, warn or info, as its row reads, and a plugin's nudge (steer).
162
+ // chi's own asking-again row (a notice with a line and no hook) does not:
163
+ // the status line tells that already. null: nothing flashes.
164
+ export function flashOf(what, data = {}) {
165
+ if (what === "steer") return { kind: "steer", text: data.source ? `${data.source} nudged the model` : "nudged the model" };
166
+ if (what !== "notice" || !data.hook) return null;
167
+ return { kind: data.level === "warn" ? "warn" : "info", text: data.line || `${hookNoticeLabel(data.hook)}: ${data.text || ""}` };
168
+ }
169
+
151
170
  export function placeFor({ live, handingOff, kind }) {
152
171
  if (kind === "recap") return "history";
153
172
  return live && !handingOff ? "stage" : "history";
@@ -11,7 +11,7 @@
11
11
  // reload. Slots are patched in place, never rebuilt from HTML: a selection
12
12
  // and annotate survive. The rules are pure in stage_model.js.
13
13
  import { createTurnView } from "./turn_view.js";
14
- import { handOffDue, handOffOrder, headlineOf, inUseFrom, isPlain, liveSlots, placeFor } from "./stage_model.js";
14
+ import { flashOf, handOffDue, handOffOrder, headlineOf, inUseFrom, isPlain, liveSlots, placeFor } from "./stage_model.js";
15
15
  import { formatDuration } from "./timing.js";
16
16
 
17
17
  const COLLAPSE_KEY = "chi_stage_collapsed";
@@ -27,6 +27,9 @@ const PENDING = ".bubble.question:not(.answered):not(.cancelled), .bubble.contin
27
27
  // and never holds the hand-off (a turn cut short can leave it open).
28
28
  const ASKS = ".plugin-card[data-in-turn] .card-action";
29
29
  const MARKS = { answered: "✓", canceled: "■", failed: "✕" };
30
+ // The mark of a flash in the trail: a warn notice, a hook's info notice, a
31
+ // plugin's nudge (steer).
32
+ const FLASH_MARKS = { warn: "⚠", info: "ℹ", steer: "↪" };
30
33
 
31
34
  function node(tag, cls, parent = null) {
32
35
  const el = document.createElement(tag);
@@ -95,7 +98,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
95
98
  let startedAt = 0;
96
99
  let endedAt = 0;
97
100
  let thinkingLine = null; // { step, text } from the live step's ticker
98
- let flash = null; // { text, until } a warn notice in the trail
101
+ let flash = null; // { kind, text, until } a notice or nudge in the trail
99
102
  let collapsed = readCollapsed();
100
103
  let cloudOpen = false;
101
104
  let rafPending = false;
@@ -236,12 +239,12 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
236
239
  }
237
240
 
238
241
  function drawTrail(items) {
239
- const flashing = flash && flash.until > Date.now() ? flash.text : "";
240
- const key = JSON.stringify([flashing, items]);
242
+ const flashing = flash && flash.until > Date.now() ? flash : null;
243
+ const key = JSON.stringify([flashing?.kind, flashing?.text, items]);
241
244
  if (shown.trail === key) return;
242
245
  shown.trail = key;
243
246
  trail.replaceChildren();
244
- if (flashing) node("span", "ts-trail-item warn", trail).textContent = `⚠ ${flashing}`;
247
+ if (flashing) node("span", `ts-trail-item ${flashing.kind}`, trail).textContent = `${FLASH_MARKS[flashing.kind]} ${flashing.text}`;
245
248
  for (const item of items) {
246
249
  const failed = item.status === "error";
247
250
  const el = node("span", `ts-trail-item${failed ? " error" : ""}`, trail);
@@ -250,6 +253,15 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
250
253
  if (!flashing && !items.length) node("span", "ts-trail-item none", trail).textContent = "no tool calls yet";
251
254
  }
252
255
 
256
+ // One line in the trail for FLASH_MS, ahead of the calls (a newer flash
257
+ // takes its place).
258
+ function flashTrail(what) {
259
+ if (!what) return;
260
+ flash = { ...what, until: Date.now() + FLASH_MS };
261
+ scheduleRefresh();
262
+ setTimeout(scheduleRefresh, FLASH_MS + 50);
263
+ }
264
+
253
265
  function drawTicks(list) {
254
266
  list.forEach((tick, i) => {
255
267
  let el = ticks.children[i];
@@ -540,15 +552,18 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
540
552
  startWatch();
541
553
  return kept;
542
554
  },
555
+ // A hook's notice and a plugin's nudge go into their step inside the
556
+ // closed cloud; they flash in the trail meanwhile (flashOf).
543
557
  notice(data) {
544
558
  const placed = view.notice(data);
545
- if (placed && data.level === "warn") {
546
- flash = { text: data.line || data.text || "", until: Date.now() + FLASH_MS };
547
- setTimeout(scheduleRefresh, FLASH_MS + 50);
548
- }
559
+ if (placed) flashTrail(flashOf("notice", data));
560
+ return placed;
561
+ },
562
+ steer(data) {
563
+ const placed = view.steer(data);
564
+ if (placed) flashTrail(flashOf("steer", data));
549
565
  return placed;
550
566
  },
551
- steer: (data) => view.steer(data),
552
567
  // A warn card (a plugin's warning) stays in sight: false puts it in the
553
568
  // extras (place), not in its step inside the closed cloud.
554
569
  card: (el) => !el.classList.contains("warn") && view.card(el),
@@ -1,13 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
- require "net/http"
5
4
  require "rack"
6
5
  require "rackup/handler/webrick"
7
6
  require "uri"
8
7
  require "webrick"
9
8
 
10
9
  require_relative "app"
10
+ require_relative "info_probe"
11
11
  require_relative "lan"
12
12
  require_relative "qr"
13
13
  require_relative "session_hub"
@@ -206,21 +206,18 @@ module Samagotchi
206
206
  # ?dir, :free when nothing listens, :other for anything else (an older
207
207
  # chi web, another program).
208
208
  def self.probe(host, port, timeout: PROBE_TIMEOUT)
209
- response = Net::HTTP.start(host, port, open_timeout: timeout, read_timeout: timeout) { |http| http.get("/api/info") }
210
- probe_verdict(response.code.to_i, response.body)
211
- rescue Errno::ECONNREFUSED, Errno::EADDRNOTAVAIL
212
- :free
213
- rescue StandardError
214
- :other
209
+ knows_dir(InfoProbe.call(host, port, timeout: timeout))
215
210
  end
216
211
 
217
212
  def self.probe_verdict(status, body)
218
- info = status == 200 ? JSON.parse(body.to_s) : nil
219
- info.is_a?(Hash) && info["app"] == "chi-web" && Array(info["features"]).include?("dir") ? info : :other
220
- rescue JSON::ParserError
221
- :other
213
+ knows_dir(InfoProbe.verdict(status, body))
222
214
  end
223
215
 
216
+ def self.knows_dir(info)
217
+ info.is_a?(Hash) && !Array(info["features"]).include?("dir") ? :other : info
218
+ end
219
+ private_class_method :knows_dir
220
+
224
221
  # What a phone needs: the LAN link with the token, the warnings, and
225
222
  # the link's QR code (at a terminal only).
226
223
  def self.lan_lines(ip:, port:, token:, others: [], public: false, qr: $stdout.tty?)
@@ -7,6 +7,7 @@ require "time"
7
7
  require_relative "../session"
8
8
  require_relative "../session_manager"
9
9
  require_relative "../bridge_client"
10
+ require_relative "../worker_sidecar"
10
11
  require_relative "../log"
11
12
  require_relative "session_summary"
12
13
 
@@ -20,7 +21,7 @@ module Samagotchi
20
21
  # unlink bump the parent dir's mtime, so one stat of the state dir says
21
22
  # "some session file changed" and one stat of `<id>/` says "recap.json
22
23
  # or bridge.json appeared, changed or went". Nothing in-place is
23
- # watched: the pid file and owner.lock are read by the owner probes.
24
+ # watched: owner.lock is read by the owner probes.
24
25
  #
25
26
  # #scan does one pass and is driven by the tick thread (#start) or by a
26
27
  # spec. Scan, #touch, the projection and the deliveries all run under
@@ -127,8 +128,10 @@ module Samagotchi
127
128
  # Rescan one session now (its file, its folder, its owner), for the
128
129
  # page's own actions: no waiting for the next tick.
129
130
  def touch(id)
131
+ return nil unless Session.valid_id?(id)
132
+
130
133
  @monitor.synchronize do
131
- path = File.join(@state_dir, "#{id}#{Session::FILE_EXT}")
134
+ path = Session.session_file(id, state_dir: @state_dir)
132
135
  if File.file?(path)
133
136
  @sessions[id] ||= Entry.new
134
137
  refresh(id, probe: true)
@@ -220,7 +223,7 @@ module Samagotchi
220
223
  @dir_mtime = dir_stat.mtime
221
224
  on_disk = Dir.glob(File.join(@state_dir, "*#{Session::FILE_EXT}")).to_h do |path|
222
225
  [File.basename(path, Session::FILE_EXT), path]
223
- end
226
+ end.select { |id, _| Session.valid_id?(id) }
224
227
  (@sessions.keys - on_disk.keys).each { |id| drop(id) }
225
228
  on_disk.each_key { |id| @sessions[id] ||= Entry.new }
226
229
  end
@@ -231,7 +234,7 @@ module Samagotchi
231
234
  # and when its folder changed (a new owner.lock, a bridge.json).
232
235
  def refresh(id, probe: false)
233
236
  entry = @sessions[id]
234
- path = File.join(@state_dir, "#{id}#{Session::FILE_EXT}")
237
+ path = Session.session_file(id, state_dir: @state_dir)
235
238
  stat = begin
236
239
  File.stat(path)
237
240
  rescue SystemCallError
@@ -305,9 +308,7 @@ module Samagotchi
305
308
  end
306
309
 
307
310
  def bridge_started_at(dir)
308
- JSON.parse(File.read(File.join(dir, BridgeClient::SIDECAR_FILE)))["started_at"]
309
- rescue StandardError
310
- nil
311
+ WorkerSidecar.read(dir)&.started_at
311
312
  end
312
313
 
313
314
  def emit(type, data)
@@ -5,6 +5,7 @@ require_relative "../recap_store"
5
5
  require_relative "../archive_store"
6
6
  require_relative "../session_metrics"
7
7
  require_relative "../bridge_client"
8
+ require_relative "../worker_sidecar"
8
9
  require_relative "../bridge/pending_card"
9
10
 
10
11
  module Samagotchi
@@ -60,8 +61,8 @@ module Samagotchi
60
61
  bridge_up: up,
61
62
  # For the tab's notifications (notify.js): an open question, the
62
63
  # running turn's open card with actions, and how the last turn
63
- # ended.
64
- pending_question: pending_question_for(session),
64
+ # ended. A question saved by a worker that died is not open.
65
+ pending_question: owner ? pending_question_for(session) : nil,
65
66
  pending_card: up ? Bridge::PendingCard.read(session_dir) : nil,
66
67
  last_turn: session.respond_to?(:last_turn) ? session.last_turn : nil
67
68
  }
@@ -89,7 +90,7 @@ module Samagotchi
89
90
  # @return [Boolean] the sidecar file is there and the session has a
90
91
  # live owner (the file alone says nothing: the worker may be gone)
91
92
  def bridge_up?(session_dir, owner)
92
- !owner.nil? && File.file?(File.join(session_dir, BridgeClient::SIDECAR_FILE))
93
+ !owner.nil? && File.file?(WorkerSidecar.path(session_dir))
93
94
  rescue StandardError
94
95
  false
95
96
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "fileutils"
4
4
  require "securerandom"
5
+ require_relative "../atomic_file"
5
6
 
6
7
  require_relative "../session"
7
8
 
@@ -53,12 +54,8 @@ module Samagotchi
53
54
  def write(path, token)
54
55
  dir = File.dirname(path)
55
56
  FileUtils.mkdir_p(dir, mode: 0o700)
56
- tmp = File.join(dir, ".#{File.basename(path)}.#{Process.pid}.#{SecureRandom.hex(4)}")
57
- File.open(tmp, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |io| io.write("#{token}\n") }
58
- File.rename(tmp, path)
57
+ AtomicFile.write(path, "#{token}\n", perm: 0o600)
59
58
  token
60
- ensure
61
- FileUtils.rm_f(tmp) if tmp && File.exist?(tmp)
62
59
  end
63
60
 
64
61
  # The token a running server checks against: the file is re-read when