samagotchi 0.4.0 → 0.5.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -1
  3. data/README.md +13 -2
  4. data/bin/chi +29 -39
  5. data/docs/cli.md +135 -73
  6. data/docs/configuration.md +15 -20
  7. data/docs/hooks.md +1 -1
  8. data/docs/memory.md +40 -0
  9. data/docs/plugins.md +50 -0
  10. data/docs/releasing.md +9 -6
  11. data/docs/sessions.md +3 -3
  12. data/lib/samagotchi/bootstrap/config_writer.rb +1 -2
  13. data/lib/samagotchi/bridge/sse_writer.rb +0 -3
  14. data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
  15. data/lib/samagotchi/bridge.rb +16 -11
  16. data/lib/samagotchi/bundles/skills/manifest.yml +10 -0
  17. data/lib/samagotchi/bundles/skills/plugin.rb +419 -0
  18. data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -8
  19. data/lib/samagotchi/bundles/system/identity.md +5 -0
  20. data/lib/samagotchi/bundles/system/manifest.yml +5 -5
  21. data/lib/samagotchi/bundles/system/memory_guide.md +26 -0
  22. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  23. data/lib/samagotchi/client.rb +16 -20
  24. data/lib/samagotchi/config.rb +40 -100
  25. data/lib/samagotchi/engine.rb +50 -354
  26. data/lib/samagotchi/kernel_loop.rb +33 -44
  27. data/lib/samagotchi/live_versions.rb +7 -1
  28. data/lib/samagotchi/llm/errors.rb +17 -0
  29. data/lib/samagotchi/llm/http.rb +4 -18
  30. data/lib/samagotchi/llm/openai_chat.rb +17 -0
  31. data/lib/samagotchi/model_profile.rb +4 -10
  32. data/lib/samagotchi/note_command.rb +2 -1
  33. data/lib/samagotchi/reply_wait.rb +48 -4
  34. data/lib/samagotchi/self_report.rb +20 -2
  35. data/lib/samagotchi/send_command.rb +84 -6
  36. data/lib/samagotchi/session.rb +4 -2
  37. data/lib/samagotchi/session_manager.rb +18 -37
  38. data/lib/samagotchi/system_prompt.rb +403 -0
  39. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -3
  40. data/lib/samagotchi/terminal_ui/attached_loop.rb +120 -83
  41. data/lib/samagotchi/terminal_ui/attached_view.rb +27 -12
  42. data/lib/samagotchi/terminal_ui/event_renderer.rb +23 -7
  43. data/lib/samagotchi/terminal_ui/formatting.rb +32 -22
  44. data/lib/samagotchi/terminal_ui/input_support.rb +3 -4
  45. data/lib/samagotchi/terminal_ui/plain_surface.rb +13 -7
  46. data/lib/samagotchi/terminal_ui/status_row.rb +81 -0
  47. data/lib/samagotchi/terminal_ui/surface.rb +1 -1
  48. data/lib/samagotchi/terminal_ui.rb +95 -690
  49. data/lib/samagotchi/thinking.rb +11 -0
  50. data/lib/samagotchi/tool_activity.rb +52 -2
  51. data/lib/samagotchi/tool_runner.rb +3 -0
  52. data/lib/samagotchi/tools/execute.rb +3 -3
  53. data/lib/samagotchi/tools/output_guardrails.rb +8 -7
  54. data/lib/samagotchi/tools/read.rb +4 -4
  55. data/lib/samagotchi/update_command.rb +2 -1
  56. data/lib/samagotchi/version.rb +1 -1
  57. data/lib/samagotchi/web/app.rb +170 -35
  58. data/lib/samagotchi/web/lan.rb +99 -0
  59. data/lib/samagotchi/web/message_parts.rb +15 -11
  60. data/lib/samagotchi/web/public/activity.js +7 -0
  61. data/lib/samagotchi/web/public/app.js +99 -54
  62. data/lib/samagotchi/web/public/chat_view.js +5 -1
  63. data/lib/samagotchi/web/public/index.html +163 -17
  64. data/lib/samagotchi/web/public/model_pick.js +136 -0
  65. data/lib/samagotchi/web/public/model_picker.js +224 -0
  66. data/lib/samagotchi/web/public/notify.js +10 -0
  67. data/lib/samagotchi/web/public/stage_model.js +110 -0
  68. data/lib/samagotchi/web/public/stage_view.js +580 -0
  69. data/lib/samagotchi/web/public/timing.js +6 -2
  70. data/lib/samagotchi/web/public/turn_events.js +9 -5
  71. data/lib/samagotchi/web/public/turn_model.js +11 -3
  72. data/lib/samagotchi/web/public/turn_view.js +74 -19
  73. data/lib/samagotchi/web/qr.rb +40 -0
  74. data/lib/samagotchi/web/server.rb +101 -11
  75. data/lib/samagotchi/web/token.rb +97 -0
  76. metadata +27 -3
  77. data/lib/samagotchi/terminal_ui/legacy_surface.rb +0 -111
@@ -20,11 +20,15 @@ export function turnTimingText(number, durationMs, { running = false, canceled =
20
20
  return `${turn}${running ? " running" : ""} · ${formatDuration(durationMs)}${canceled ? " · canceled" : ""}`;
21
21
  }
22
22
 
23
- // The line a canceled turn ends with: "✕ canceled (user)", live from
23
+ // A cancel's reason as the terminal UIs name it too (Formatting::
24
+ // CANCEL_REASONS); anything else as it is.
25
+ const CANCEL_REASONS = { ctrl_c: "Ctrl-C", user: "stopped", hook: "by a hook" };
26
+
27
+ // The line a canceled turn ends with: "✕ canceled (stopped)", live from
24
28
  // turn_canceled's reason and after a re-render from the turn record's
25
29
  // cancellation_reason (records saved before it existed: no reason).
26
30
  export function cancelLineText(reason) {
27
- return "\u2715 canceled" + (reason ? ` (${reason})` : "");
31
+ return "\u2715 canceled" + (reason ? ` (${CANCEL_REASONS[reason] || reason})` : "");
28
32
  }
29
33
 
30
34
  // A re-rendered turn's cancel line (a .bubble.cancel), or "" when the turn
@@ -82,13 +82,16 @@ export function isCommandLine(text) {
82
82
  return /^[/!]/.test(String(text || "").trim());
83
83
  }
84
84
 
85
- // The terminal's own commands that have a button in the web: the reply
86
- // the page shows itself (they never go to the worker, which doesn't know
87
- // them), or null.
85
+ // The terminal's own commands (a button in the web, or none yet): the
86
+ // reply the page shows itself (they never go to the worker, which doesn't
87
+ // know them), or null.
88
88
  const WEB_LOCAL_REPLIES = {
89
89
  "/archive": "/archive: use the archive button in the session bar",
90
+ "/detach": "/detach: a terminal's command; close the tab to leave, the worker keeps running",
90
91
  "/exit": "/exit: close the tab to leave; stop in the session bar stops the worker",
91
92
  "/quit": "/quit: close the tab to leave; stop in the session bar stops the worker",
93
+ "/recap": "/recap: not in the web yet; a recap shows here by itself when you come back to an idle session, and /recap in a terminal (chi --attach) makes one now",
94
+ "/stats": "/stats: not in the web yet; each turn shows its time and the context use, and /stats in a terminal (chi --attach) has the totals",
92
95
  };
93
96
 
94
97
  export function webLocalReply(text) {
@@ -233,11 +236,12 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], started
233
236
  closeText();
234
237
  const call = { iteration: part.iteration, call_index: part.call_index, tool: part.tool };
235
238
  if (part.label) call.label = part.label;
236
- events.push({ type: "tool_call_started", ...call, params: part.params });
239
+ const title = part.title ? { title: part.title } : {};
240
+ events.push({ type: "tool_call_started", ...call, params: part.params, ...title });
237
241
  if (part.status !== "running") {
238
242
  const completed = {
239
243
  type: "tool_call_completed", ...call, output: part.output, output_truncated: !!part.output_truncated,
240
- activity: { status: part.status, params: part.params },
244
+ activity: { status: part.status, params: part.params, ...title },
241
245
  };
242
246
  if (part.diff) completed.diff = part.diff;
243
247
  events.push(withImages(completed, part.images));
@@ -169,18 +169,26 @@ export function applyEvent(turn, event) {
169
169
  }
170
170
  }
171
171
 
172
+ function cut(line) {
173
+ return line.length > LABEL_MAX ? `${line.slice(0, LABEL_MAX).trimEnd()}…` : line;
174
+ }
175
+
172
176
  function toolCallsText(n) {
173
177
  return `${n} tool call${n === 1 ? "" : "s"}`;
174
178
  }
175
179
 
176
- // The collapsed row of a gen: its narration's first line, else the tools it
177
- // worked with, else "thinking"; then its call count.
180
+ // The collapsed row of a gen: its narration's first line, else its first
181
+ // call's title ("edit lib/a.rb"), else the tools it worked with, else
182
+ // "thinking"; then its call count.
178
183
  export function genLabel(gen) {
179
184
  const line = String(gen.text || "").trim().split("\n")[0].trim();
180
185
  const calls = gen.tools.length;
186
+ const first = gen.tools[0];
181
187
  let head;
182
188
  if (line) {
183
- head = line.length > LABEL_MAX ? `${line.slice(0, LABEL_MAX).trimEnd()}…` : line;
189
+ head = cut(line);
190
+ } else if (first?.title) {
191
+ head = cut(`${toolName(first)} ${first.title}`);
184
192
  } else if (calls) {
185
193
  const names = [...new Set(gen.tools.map(toolName))];
186
194
  head = `working with ${names.slice(0, LABEL_TOOLS).join(", ")}${names.length > LABEL_TOOLS ? ", …" : ""}`;
@@ -23,7 +23,31 @@ import { createTicker } from "./thinking_ticker.js";
23
23
  import * as model from "./turn_model.js";
24
24
  import { createHold } from "./hold.js";
25
25
 
26
- export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawText, sessionId }) {
26
+ // The container seams (the stage view composes this view, stage_view.js):
27
+ // blockHost(el) puts the turn's block in the page (default: the history);
28
+ // follow() keeps the live part in view (default: the history's
29
+ // tail), called only when isNearBottom() said so before
30
+ // the change;
31
+ // placeAnswer(pieces, { plain, blockEl }) puts the promoted answer (a
32
+ // plain answer's thinking, cards, bubble) in the page
33
+ // (default: in the block's place for a plain answer,
34
+ // else after it in the history);
35
+ // useHold the answer's pop at the narration box's height (hold.js);
36
+ // newestFirst a new step goes on top of the block while the turn runs
37
+ // (chronological() restores the order);
38
+ // onChange() after anything in the turn changed (the stage redraws
39
+ // its slots from current());
40
+ // onThinkingLine(sentence) the live step's ticker showed a new line.
41
+ export function createTurnView({
42
+ historyEl, appendToHistory, isNearBottom, sawText, sessionId,
43
+ blockHost = appendToHistory,
44
+ follow = () => { historyEl.scrollTop = historyEl.scrollHeight; },
45
+ placeAnswer = defaultPlaceAnswer(appendToHistory),
46
+ useHold = true,
47
+ newestFirst = false,
48
+ onChange = () => {},
49
+ onThinkingLine = () => {},
50
+ }) {
27
51
  let turn = null;
28
52
  let blockEl = null;
29
53
  let blockSummaryEl = null;
@@ -36,7 +60,7 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
36
60
  let rafPending = false;
37
61
  // The promoted answer bubble while it is held at the box's height (the
38
62
  // pop, D6).
39
- const hold = createHold({ isNearBottom, follow: () => { historyEl.scrollTop = historyEl.scrollHeight; } });
63
+ const hold = createHold({ isNearBottom, follow });
40
64
 
41
65
  function removeHint() {
42
66
  const hint = historyEl.querySelector(".hint");
@@ -58,7 +82,7 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
58
82
  blockEl.appendChild(blockSummaryEl);
59
83
  rememberToggle(blockSummaryEl, () => { blockToggled = true; });
60
84
  updateBlockSummary();
61
- appendToHistory(blockEl);
85
+ blockHost(blockEl);
62
86
  }
63
87
 
64
88
  function updateBlockSummary() {
@@ -86,9 +110,10 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
86
110
  feed: createSentenceFeed(), box: null };
87
111
  rememberToggle(summary, () => { entry.toggled = true; });
88
112
  genEls.set(gen, entry);
89
- const follow = isNearBottom();
90
- blockEl.appendChild(el);
91
- if (follow) historyEl.scrollTop = historyEl.scrollHeight;
113
+ const stick = isNearBottom();
114
+ if (newestFirst) blockEl.insertBefore(el, blockSummaryEl.nextSibling);
115
+ else blockEl.appendChild(el);
116
+ if (stick) follow();
92
117
  return entry;
93
118
  }
94
119
 
@@ -135,6 +160,7 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
135
160
  entry.thinkingLine.classList.remove("thinking-fade");
136
161
  void entry.thinkingLine.offsetWidth;
137
162
  entry.thinkingLine.classList.add("thinking-fade");
163
+ onThinkingLine(sentence);
138
164
  }
139
165
 
140
166
  // Feed the step's ticker from its accumulated thinking; the caller's timer
@@ -228,10 +254,11 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
228
254
  rafPending = false;
229
255
  // Runs every animation frame while streaming — must not yank a user who
230
256
  // scrolled up to read; only follow when they were already near the bottom.
231
- const follow = isNearBottom();
257
+ const stick = isNearBottom();
232
258
  for (const gen of dirty) writeGen(gen, { follow: true });
233
259
  dirty.clear();
234
- if (follow) historyEl.scrollTop = historyEl.scrollHeight;
260
+ if (stick) follow();
261
+ onChange();
235
262
  }
236
263
 
237
264
  function scheduleFlush(gen) {
@@ -291,6 +318,7 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
291
318
  if (r.closed) closeGenEl(r.closed);
292
319
  if (r.opened) createGenEl(r.gen);
293
320
  if (r.closed || r.opened) updateBlockSummary();
321
+ onChange();
294
322
  }
295
323
 
296
324
  function toolEvent(r) {
@@ -311,14 +339,15 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
311
339
  }
312
340
  el.querySelector(".activity-output").style.display = "none";
313
341
  entry.rows.set(r.row.key, el);
314
- const follow = isNearBottom();
342
+ const stick = isNearBottom();
315
343
  body.appendChild(el);
316
344
  fillActivityRow(el, r.row, sessionId());
317
- if (follow) historyEl.scrollTop = historyEl.scrollHeight;
345
+ if (stick) follow();
318
346
  } else {
319
347
  fillActivityRow(el, r.row, sessionId());
320
348
  }
321
349
  updateBlockSummary();
350
+ onChange();
322
351
  }
323
352
 
324
353
  // A hook's line (hook_notice) as a row of the current step, above the row
@@ -333,9 +362,9 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
333
362
  const el = document.createElement("div");
334
363
  el.className = "hook-notice" + (data.level === "warn" ? " warn" : "");
335
364
  el.textContent = data.line || `${hookNoticeLabel(data.hook)}: ${data.text || ""}`;
336
- const follow = isNearBottom();
365
+ const stick = isNearBottom();
337
366
  activityEl(r.gen).appendChild(el);
338
- if (follow) historyEl.scrollTop = historyEl.scrollHeight;
367
+ if (stick) follow();
339
368
  return true;
340
369
  }
341
370
 
@@ -368,9 +397,9 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
368
397
  const entry = gen && genEls.get(gen);
369
398
  if (!entry) return false;
370
399
  if (!gen.text) entry.text.textContent = "";
371
- const follow = isNearBottom();
400
+ const stick = isNearBottom();
372
401
  activityEl(gen).appendChild(el);
373
- if (follow) historyEl.scrollTop = historyEl.scrollHeight;
402
+ if (stick) follow();
374
403
  return true;
375
404
  }
376
405
 
@@ -384,7 +413,7 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
384
413
  bubble.className = "bubble output";
385
414
  if (!gen.text) bubble.textContent = "";
386
415
  // A hidden tab gets no pop: nobody sees it, and its timers are throttled.
387
- const holding = heldHeight > 0 && !!gen.text && !document.hidden;
416
+ const holding = useHold && heldHeight > 0 && !!gen.text && !document.hidden;
388
417
  let holdHeight = 0;
389
418
  if (holding) {
390
419
  // Bubbles are border-box: the box's height plus the bubble's own
@@ -425,12 +454,11 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
425
454
  }
426
455
  pieces.push(...cards);
427
456
  if (gen.text) pieces.push(bubble);
428
- blockEl.replaceWith(...pieces);
457
+ placeAnswer(pieces, { plain: true, blockEl });
429
458
  blockEl = null;
430
459
  blockSummaryEl = null;
431
460
  } else {
432
- cards.forEach((el) => appendToHistory(el));
433
- if (gen.text) appendToHistory(bubble);
461
+ placeAnswer(gen.text ? [...cards, bubble] : cards, { plain: false, blockEl });
434
462
  // The last step, left with its thinking only, reads "thinking" once.
435
463
  if (!gen.tools.length && gen.thinking) unwrapThinking(entry);
436
464
  }
@@ -468,6 +496,7 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
468
496
  }
469
497
  dirty.clear();
470
498
  rafPending = false;
499
+ onChange();
471
500
  return kept;
472
501
  }
473
502
 
@@ -545,6 +574,30 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
545
574
  return !!entry && (entry.text.contains(el) || !!entry.thinking?.contains(el));
546
575
  },
547
576
  reset,
577
+ // For a view that composes this one (the stage): the turn's model and
578
+ // block, a step's elements by index, and the steps back in
579
+ // chronological order after newestFirst.
580
+ current() { return { turn, blockEl }; },
581
+ step(i) {
582
+ const entry = turn && genEls.get(turn.gens[i]);
583
+ return entry ? { el: entry.el, summary: entry.summary, thinkingWrap: entry.thinkingWrap } : null;
584
+ },
585
+ chronological() {
586
+ if (!blockEl || !turn) return;
587
+ for (const gen of turn.gens) {
588
+ const el = genEls.get(gen)?.el;
589
+ if (el?.parentNode === blockEl) blockEl.appendChild(el);
590
+ }
591
+ },
592
+ };
593
+ }
594
+
595
+ // The turn view's answer placement: a plain answer's pieces in its block's
596
+ // place (the block goes), else after the block in the history.
597
+ function defaultPlaceAnswer(appendToHistory) {
598
+ return (pieces, { plain, blockEl }) => {
599
+ if (plain) blockEl.replaceWith(...pieces);
600
+ else pieces.forEach((el) => appendToHistory(el));
548
601
  };
549
602
  }
550
603
 
@@ -555,7 +608,9 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
555
608
  // parts; without them the row has the record's name, status and duration
556
609
  // only. +thumbs(images)+ draws the images, as the live row does.
557
610
  function reloadRowHtml(r, thumbs) {
558
- const params = r.params ? `<span class="activity-params">${escapeHtml(r.params)}</span>` : "";
611
+ const shown = r.title || r.params;
612
+ const hover = r.title && r.params ? ` title="${escapeHtml(r.params)}"` : "";
613
+ const params = shown ? `<span class="activity-params"${hover}>${escapeHtml(shown)}</span>` : "";
559
614
  const duration = formatDuration(r.duration_ms);
560
615
  const full = String(r.output || "").replace(/^\[[^\]]*\]\s*/, "");
561
616
  const output = full
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rqrcode_core"
4
+
5
+ module Samagotchi
6
+ module Web
7
+ # A QR code for the terminal: two module rows per line in half blocks
8
+ # (▀ ▄ █), black on white whatever the terminal's theme, so a phone's
9
+ # camera reads it on a dark terminal too.
10
+ module QR
11
+ # Modules of white around the code. The standard asks for 4; the
12
+ # white background makes 2 enough for a phone at a screen.
13
+ QUIET = 2
14
+ COLORS = "\e[30;107m"
15
+ RESET = "\e[0m"
16
+
17
+ module_function
18
+
19
+ # @return [Array<String>] the lines, colors included
20
+ def lines(text, quiet: QUIET, color: true)
21
+ rows = matrix(text, quiet: quiet)
22
+ rows << Array.new(rows.first.size, false) if rows.size.odd?
23
+ rows.each_slice(2).map do |top, bottom|
24
+ line = top.zip(bottom).map { |t, b| t ? (b ? "█" : "▀") : (b ? "▄" : " ") }.join
25
+ color ? "#{COLORS}#{line}#{RESET}" : line
26
+ end
27
+ end
28
+
29
+ # Dark modules as true, the quiet zone around them.
30
+ # Level L: the code is read off a screen, never damaged, and stays small.
31
+ def matrix(text, quiet: QUIET)
32
+ code = RQRCodeCore::QRCode.new(text, level: :l)
33
+ size = code.module_count
34
+ blank = Array.new(size + (2 * quiet), false)
35
+ body = Array.new(size) { |r| Array.new(quiet, false) + Array.new(size) { |c| code.checked?(r, c) } + Array.new(quiet, false) }
36
+ Array.new(quiet) { blank.dup } + body + Array.new(quiet) { blank.dup }
37
+ end
38
+ end
39
+ end
40
+ end
@@ -8,7 +8,10 @@ require "uri"
8
8
  require "webrick"
9
9
 
10
10
  require_relative "app"
11
+ require_relative "lan"
12
+ require_relative "qr"
11
13
  require_relative "session_hub"
14
+ require_relative "token"
12
15
  require_relative "../config"
13
16
  require_relative "../project_scope"
14
17
  require_relative "../log"
@@ -18,7 +21,9 @@ module Samagotchi
18
21
  module Web
19
22
  # Launcher for the single-port Web UI.
20
23
  #
21
- # Binds strictly to 127.0.0.1 (localhost-only). Use `bin/chi web` to start.
24
+ # Binds to loopback (127.0.0.1 by default); with web.host lan (or a LAN
25
+ # address) also to that address, where requests need the access token.
26
+ # Use `bin/chi web` to start.
22
27
  class Server
23
28
  # WEBrick logs every exception out of its request loop as an ERROR with
24
29
  # a backtrace, among them a browser dropping a kept-alive connection
@@ -51,25 +56,44 @@ module Samagotchi
51
56
  # URL. If a chi web already runs on the port, print (and with --open,
52
57
  # open) its page for +dir+ and leave it be; else start one.
53
58
  # @param scope ["project", "all"] "all": the plain page, every session
59
+ # @param new_token [Boolean] replace the LAN access token first
60
+ # (--new-token): a running server takes the new one at once
54
61
  # @return [Integer] the exit status
55
- def self.launch(port: nil, host: nil, scope: "project", dir: Dir.pwd, open_browser: false, markdown: false, turn_view: true,
56
- annotate_presets: Config::BY_KEY["web.annotate_presets"].default)
57
- host = resolve_host(host)
62
+ def self.launch(port: nil, host: nil, scope: "project", dir: Dir.pwd, open_browser: false, markdown: false, view: "turn",
63
+ annotate_presets: Config::BY_KEY["web.annotate_presets"].default, new_token: false)
64
+ rotate_token if new_token
65
+ setting = host_setting(host)
66
+ lan = Lan.wanted?(setting) ? Lan.choose(setting) : nil
67
+ host = resolve_host(setting)
58
68
  port = resolve_port(port)
59
69
  url = scope_url(host, port, dir: dir, scope: scope)
60
70
  found = probe(host, port)
61
71
  case found
62
72
  when Hash
73
+ if lan && !found["lan"]
74
+ warn "chi web already runs on port #{port} without LAN access; stop it (Ctrl-C in its terminal, " \
75
+ "or kill #{found["pid"]}) and run this again"
76
+ return 1
77
+ end
63
78
  puts "chi web already runs on port #{port} (pid #{found["pid"]}): #{url}"
79
+ puts running_lan_lines(found["lan"], port) if found["lan"]
80
+ puts lan_off_line(setting) if new_token && !found["lan"]
64
81
  open_url(url) if open_browser
65
82
  0
66
83
  when :other
67
84
  warn in_use_message(port)
68
85
  1
69
86
  else
70
- start(port: port, host: host, url: url, open_browser: open_browser, markdown: markdown, turn_view: turn_view,
71
- annotate_presets: annotate_presets) ? 0 : 1
87
+ if new_token && !lan
88
+ puts lan_off_line(setting)
89
+ return 0
90
+ end
91
+ start(port: port, host: host, url: url, open_browser: open_browser, markdown: markdown, view: view,
92
+ annotate_presets: annotate_presets, lan: lan) ? 0 : 1
72
93
  end
94
+ rescue Lan::Error => e
95
+ warn "Error: #{e.message}"
96
+ 1
73
97
  rescue Interrupt
74
98
  # Ctrl-C: the server has stopped (start's ensure); one line, and the
75
99
  # status a shell gives a command it interrupted.
@@ -79,22 +103,32 @@ module Samagotchi
79
103
 
80
104
  # @param hub [SessionHub, nil] the session projection the page streams
81
105
  # from; built over the app's state dir unless given
106
+ # @param lan [Lan::Choice, nil] also listen on this LAN address, where
107
+ # every request but this machine's needs the access token
108
+ # @param token_path [String] the LAN access token's file
82
109
  # @return [Boolean] false when the port was taken (said so on stderr)
83
110
  def self.start(port: nil, host: nil, url: nil, open_browser: false, state_dir: nil, manager: nil, markdown: false,
84
- turn_view: true, annotate_presets: Config::BY_KEY["web.annotate_presets"].default, hub: nil)
111
+ view: "turn", annotate_presets: Config::BY_KEY["web.annotate_presets"].default, hub: nil, lan: nil,
112
+ token_path: Token.path)
85
113
  port = resolve_port(port)
86
114
  host = resolve_host(host)
87
115
  url ||= "http://#{url_host(host)}:#{port}/"
88
116
 
89
117
  hub ||= SessionHub.new(state_dir: state_dir || Session.default_state_dir, manager: manager || SessionManager)
90
- app = App.new(manager: manager, state_dir: state_dir, markdown: markdown, turn_view: turn_view,
91
- annotate_presets: annotate_presets, hub: hub)
118
+ if lan
119
+ Token.load_or_create(token_path)
120
+ lan_option = { ip: lan.ip, token: Token::Source.new(token_path) }
121
+ end
122
+ app = App.new(manager: manager, state_dir: state_dir, markdown: markdown, view: view,
123
+ annotate_presets: annotate_presets, hub: hub, lan: lan_option)
92
124
  Samagotchi::Log.info(:web, "start", url: "http://#{host}:#{port}", version: Samagotchi::VERSION)
125
+ Samagotchi::Log.info(:web, "lan", ip: lan.ip, interface: lan.interface) if lan
93
126
  hub.start
94
127
  # Said once the port is bound: a second chi web racing for it gets
95
128
  # the in-use line instead.
96
129
  started = lambda do
97
130
  puts "Chi Web on #{url} (public: #{File.expand_path("public", __dir__)})"
131
+ puts lan_lines(ip: lan.ip, port: port, token: Token.read(token_path), others: lan.others, public: lan.public) if lan
98
132
  puts "Press Ctrl-C to stop."
99
133
  $stdout.flush # a log file isn't line-buffered
100
134
  if open_browser
@@ -110,20 +144,44 @@ module Samagotchi
110
144
  Rackup::Handler::WEBrick.run(app, Host: host, Port: port, AccessLog: [], Logger: Log.new($stderr, WEBrick::Log::WARN),
111
145
  StartCallback: started) do |server|
112
146
  app.server_running = -> { server.status == :Running }
147
+ listen_lan(server, lan, port) if lan
113
148
  end
114
149
  true
115
150
  rescue Errno::EADDRINUSE
116
151
  warn in_use_message(port)
117
152
  false
153
+ rescue LanListenError => e
154
+ warn "Error: can't listen on #{lan.ip}:#{port} (#{e.message}); did the address change? Run chi web again"
155
+ false
118
156
  ensure
119
157
  hub&.stop
120
158
  Samagotchi::Log.info(:web, "stop") if app
121
159
  end
122
160
 
161
+ class LanListenError < StandardError; end
162
+
163
+ # The LAN address's listener, next to the loopback one WEBrick made.
164
+ # The loopback socket is bound by now: on a failure it is closed
165
+ # here, as WEBrick never starts.
166
+ def self.listen_lan(server, lan, port)
167
+ server.listen(lan.ip, port)
168
+ rescue Errno::EADDRNOTAVAIL, Errno::EADDRINUSE, SocketError => e
169
+ server.listeners.each(&:close)
170
+ raise LanListenError, e.message
171
+ end
172
+
173
+ def self.host_setting(host)
174
+ (host || Samagotchi::Config.get("web.host")).to_s.strip
175
+ end
176
+
177
+ # The address the server binds: loopback. For web.host lan or a LAN
178
+ # address it is 127.0.0.1, and the LAN address is a second listener
179
+ # (start's lan:).
123
180
  def self.resolve_host(host)
124
- host = (host || ENV.fetch("SAMAGOTCHI_WEB_HOST", DEFAULT_HOST)).to_s.strip
181
+ host = host_setting(host)
125
182
  host = DEFAULT_HOST if host.empty?
126
183
  return host if %w[127.0.0.1 ::1 localhost].include?(host)
184
+ return DEFAULT_HOST if Lan.wanted?(host)
127
185
 
128
186
  Samagotchi::Log.warn(:web, "bind_forced", echo: "Web server only binds to 127.0.0.1 (got #{host}); forcing 127.0.0.1", host: host.to_s)
129
187
  DEFAULT_HOST
@@ -162,12 +220,44 @@ module Samagotchi
162
220
  :other
163
221
  end
164
222
 
223
+ # What a phone needs: the LAN link with the token, the warnings, and
224
+ # the link's QR code (at a terminal only).
225
+ def self.lan_lines(ip:, port:, token:, others: [], public: false, qr: $stdout.tty?)
226
+ unless token
227
+ return ["LAN: http://#{ip}:#{port}/ (no access token: its file is gone; chi web --new-token makes one)"]
228
+ end
229
+
230
+ link = "http://#{ip}:#{port}/?token=#{token}"
231
+ lines = ["LAN: #{link} ← anyone with this link can run commands as you",
232
+ "Plain http: the link and your traffic can be read by anyone on this Wi-Fi."]
233
+ lines << "also: #{others.map(&:to_s).join(", ")}; set web.host to pick one" unless others.empty?
234
+ lines << "#{ip} isn't a private LAN address: anyone who can reach it can try to get in" if public
235
+ lines.concat(QR.lines(link)) if qr
236
+ lines
237
+ end
238
+
239
+ # A second chi web, when the running one has LAN access: the same
240
+ # lines, the token read from its file here.
241
+ def self.running_lan_lines(ip, port, token_path: Token.path)
242
+ lan_lines(ip: ip, port: port, token: Token.read(token_path))
243
+ end
244
+
245
+ def self.lan_off_line(setting)
246
+ "LAN access is off (web.host is #{setting.empty? ? DEFAULT_HOST : setting}): chi web --web-host lan uses the new token"
247
+ end
248
+
249
+ def self.rotate_token(path = Token.path)
250
+ Token.rotate(path)
251
+ Samagotchi::Log.info(:web, "token_rotated")
252
+ puts "New LAN access token: links and QR codes made with the old one stop working."
253
+ end
254
+
165
255
  def self.in_use_message(port)
166
256
  "Error: port #{port} is in use (an older chi web? restart it, or use --port)"
167
257
  end
168
258
 
169
259
  def self.resolve_port(port)
170
- raw = port || ENV.fetch("SAMAGOTCHI_WEB_PORT", DEFAULT_PORT.to_s)
260
+ raw = port || Samagotchi::Config.get("web.port")
171
261
  parsed = raw.to_s.to_i
172
262
  parsed.positive? ? parsed : DEFAULT_PORT
173
263
  end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "securerandom"
5
+
6
+ require_relative "../session"
7
+
8
+ module Samagotchi
9
+ module Web
10
+ # The access token of `chi web` on the LAN (web.host: lan): anyone
11
+ # who has it can run commands as you, so it lives in one file only you
12
+ # can read, $XDG_STATE_HOME/samagotchi/web-token (0600). It is made on
13
+ # the first LAN start and kept, so a phone's bookmark survives
14
+ # restarts; `chi web --new-token` replaces it. A loopback-only chi web
15
+ # never reads it.
16
+ module Token
17
+ FILE = "web-token"
18
+ # 32 random bytes: 43 URL-safe characters.
19
+ BYTES = 32
20
+
21
+ module_function
22
+
23
+ def path(env: ENV)
24
+ File.join(File.dirname(Session.default_state_dir(env: env)), FILE)
25
+ end
26
+
27
+ # @return [String, nil] the token in +path+, nil when there is none
28
+ def read(path = self.path)
29
+ token = File.read(path).strip
30
+ token.empty? ? nil : token
31
+ rescue Errno::ENOENT
32
+ nil
33
+ end
34
+
35
+ # @return [String] the token in +path+, made (and saved) if there is none
36
+ def load_or_create(path = self.path)
37
+ read(path) || write(path, generate)
38
+ end
39
+
40
+ # A new token in +path+: every link made with the old one stops working.
41
+ # @return [String] the new token
42
+ def rotate(path = self.path)
43
+ write(path, generate)
44
+ end
45
+
46
+ def generate
47
+ SecureRandom.urlsafe_base64(BYTES)
48
+ end
49
+
50
+ # Written whole under a temporary name, then renamed over +path+: a
51
+ # reader never sees half a token. The file is 0600 from the start,
52
+ # its folder 0700 when this makes it.
53
+ def write(path, token)
54
+ dir = File.dirname(path)
55
+ 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)
59
+ token
60
+ ensure
61
+ FileUtils.rm_f(tmp) if tmp && File.exist?(tmp)
62
+ end
63
+
64
+ # The token a running server checks against: the file is re-read when
65
+ # its mtime changes (one stat per check), so `chi web --new-token`
66
+ # takes effect at once, without a restart.
67
+ class Source
68
+ attr_reader :path
69
+
70
+ def initialize(path = Token.path)
71
+ @path = path
72
+ @mutex = Mutex.new
73
+ @stamp = nil
74
+ @token = nil
75
+ end
76
+
77
+ # @return [String, nil] the current token; nil when the file is gone
78
+ # (then nothing matches)
79
+ def current
80
+ stamp = begin
81
+ stat = File.stat(@path)
82
+ [stat.mtime, stat.size, stat.ino]
83
+ rescue Errno::ENOENT
84
+ nil
85
+ end
86
+ @mutex.synchronize do
87
+ unless stamp == @stamp
88
+ @token = stamp && Token.read(@path)
89
+ @stamp = stamp
90
+ end
91
+ @token
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end