samagotchi 0.3.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 (121) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +162 -1
  3. data/README.md +29 -2
  4. data/bin/chi +60 -69
  5. data/docs/cli.md +211 -77
  6. data/docs/configuration.md +118 -21
  7. data/docs/desktop.md +39 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +89 -7
  10. data/docs/memory.md +40 -0
  11. data/docs/plugins.md +50 -0
  12. data/docs/releasing.md +15 -12
  13. data/docs/sessions.md +20 -18
  14. data/lib/samagotchi/bootstrap/config_writer.rb +1 -2
  15. data/lib/samagotchi/bridge/sse_writer.rb +0 -3
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
  17. data/lib/samagotchi/bridge.rb +20 -12
  18. data/lib/samagotchi/bundles/skills/manifest.yml +10 -0
  19. data/lib/samagotchi/bundles/skills/plugin.rb +419 -0
  20. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +178 -5
  21. data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
  22. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  23. data/lib/samagotchi/bundles/system/config_modification_protocol.md +9 -10
  24. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  25. data/lib/samagotchi/bundles/system/identity.md +5 -0
  26. data/lib/samagotchi/bundles/system/manifest.yml +6 -6
  27. data/lib/samagotchi/bundles/system/memory_guide.md +26 -0
  28. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  29. data/lib/samagotchi/client.rb +25 -26
  30. data/lib/samagotchi/commands/registry.rb +8 -0
  31. data/lib/samagotchi/config.rb +97 -113
  32. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  33. data/lib/samagotchi/desktop/macos/ChiRunner.swift +4 -2
  34. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  35. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  36. data/lib/samagotchi/desktop/macos/Panel.swift +112 -9
  37. data/lib/samagotchi/desktop/macos.rb +59 -8
  38. data/lib/samagotchi/desktop_command.rb +6 -3
  39. data/lib/samagotchi/edit_preview.rb +82 -0
  40. data/lib/samagotchi/engine.rb +236 -443
  41. data/lib/samagotchi/gem_update.rb +89 -0
  42. data/lib/samagotchi/guardrails/approval.rb +26 -4
  43. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  44. data/lib/samagotchi/host_registry.rb +8 -12
  45. data/lib/samagotchi/idle_client.rb +24 -15
  46. data/lib/samagotchi/idle_reminders.rb +2 -2
  47. data/lib/samagotchi/image_store.rb +10 -6
  48. data/lib/samagotchi/kernel_loop.rb +59 -123
  49. data/lib/samagotchi/live_versions.rb +65 -0
  50. data/lib/samagotchi/llm/api_key.rb +41 -0
  51. data/lib/samagotchi/llm/chat_loop.rb +77 -13
  52. data/lib/samagotchi/llm/errors.rb +38 -7
  53. data/lib/samagotchi/llm/http.rb +19 -22
  54. data/lib/samagotchi/llm/openai_chat.rb +22 -26
  55. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  56. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  57. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  58. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  59. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  60. data/lib/samagotchi/model_profile.rb +27 -10
  61. data/lib/samagotchi/note_command.rb +2 -1
  62. data/lib/samagotchi/prompt.rb +4 -2
  63. data/lib/samagotchi/reminder_store.rb +1 -9
  64. data/lib/samagotchi/reply_wait.rb +48 -4
  65. data/lib/samagotchi/self_report.rb +37 -5
  66. data/lib/samagotchi/send_command.rb +190 -17
  67. data/lib/samagotchi/session.rb +4 -2
  68. data/lib/samagotchi/session_commands.rb +38 -8
  69. data/lib/samagotchi/session_manager.rb +19 -53
  70. data/lib/samagotchi/system_prompt.rb +403 -0
  71. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -3
  72. data/lib/samagotchi/terminal_ui/attached_loop.rb +141 -108
  73. data/lib/samagotchi/terminal_ui/attached_view.rb +27 -12
  74. data/lib/samagotchi/terminal_ui/event_renderer.rb +29 -8
  75. data/lib/samagotchi/terminal_ui/formatting.rb +41 -22
  76. data/lib/samagotchi/terminal_ui/input_support.rb +7 -23
  77. data/lib/samagotchi/terminal_ui/plain_surface.rb +13 -7
  78. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  79. data/lib/samagotchi/terminal_ui/status_row.rb +81 -0
  80. data/lib/samagotchi/terminal_ui/surface.rb +1 -1
  81. data/lib/samagotchi/terminal_ui.rb +142 -923
  82. data/lib/samagotchi/text_diff.rb +181 -0
  83. data/lib/samagotchi/thinking.rb +126 -0
  84. data/lib/samagotchi/tool_activity.rb +52 -2
  85. data/lib/samagotchi/tool_runner.rb +37 -1
  86. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  87. data/lib/samagotchi/tools/edit.rb +23 -9
  88. data/lib/samagotchi/tools/execute.rb +3 -3
  89. data/lib/samagotchi/tools/output_guardrails.rb +8 -7
  90. data/lib/samagotchi/tools/read.rb +4 -4
  91. data/lib/samagotchi/tools/write.rb +4 -0
  92. data/lib/samagotchi/turn_flow.rb +12 -2
  93. data/lib/samagotchi/update_command.rb +309 -0
  94. data/lib/samagotchi/update_hint.rb +59 -0
  95. data/lib/samagotchi/version.rb +1 -1
  96. data/lib/samagotchi/vision_support.rb +6 -4
  97. data/lib/samagotchi/web/app.rb +173 -38
  98. data/lib/samagotchi/web/lan.rb +99 -0
  99. data/lib/samagotchi/web/message_parts.rb +19 -10
  100. data/lib/samagotchi/web/public/activity.js +10 -0
  101. data/lib/samagotchi/web/public/app.js +135 -78
  102. data/lib/samagotchi/web/public/chat_view.js +8 -1
  103. data/lib/samagotchi/web/public/data.js +2 -0
  104. data/lib/samagotchi/web/public/diff_view.js +58 -0
  105. data/lib/samagotchi/web/public/index.html +185 -18
  106. data/lib/samagotchi/web/public/model_pick.js +136 -0
  107. data/lib/samagotchi/web/public/model_picker.js +224 -0
  108. data/lib/samagotchi/web/public/notify.js +10 -0
  109. data/lib/samagotchi/web/public/question_card.js +3 -1
  110. data/lib/samagotchi/web/public/stage_model.js +110 -0
  111. data/lib/samagotchi/web/public/stage_view.js +580 -0
  112. data/lib/samagotchi/web/public/timing.js +6 -2
  113. data/lib/samagotchi/web/public/turn_events.js +38 -10
  114. data/lib/samagotchi/web/public/turn_model.js +11 -3
  115. data/lib/samagotchi/web/public/turn_view.js +76 -20
  116. data/lib/samagotchi/web/qr.rb +40 -0
  117. data/lib/samagotchi/web/server.rb +101 -11
  118. data/lib/samagotchi/web/token.rb +97 -0
  119. data/lib/samagotchi/worker.rb +5 -4
  120. metadata +38 -3
  121. data/lib/samagotchi/terminal_ui/legacy_surface.rb +0 -111
@@ -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 ? ", …" : ""}`;
@@ -13,6 +13,7 @@ import { toolName } from "./activity.js";
13
13
  import { leavesBlock } from "./card.js";
14
14
  import { activityStatusClass, activityStatusLabel, fillActivityRow } from "./chat_view.js";
15
15
  import { copySourceAttr } from "./copy.js";
16
+ import { activityDiffHtml } from "./diff_view.js";
16
17
  import { escapeHtml, messageBodyHtml, noteHtml, normalize, steerRowHtml } from "./format.js";
17
18
  import { shouldFollowScroll } from "./scroll.js";
18
19
  import { hookNoticeLabel } from "./turn_events.js";
@@ -22,7 +23,31 @@ import { createTicker } from "./thinking_ticker.js";
22
23
  import * as model from "./turn_model.js";
23
24
  import { createHold } from "./hold.js";
24
25
 
25
- 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
+ }) {
26
51
  let turn = null;
27
52
  let blockEl = null;
28
53
  let blockSummaryEl = null;
@@ -35,7 +60,7 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
35
60
  let rafPending = false;
36
61
  // The promoted answer bubble while it is held at the box's height (the
37
62
  // pop, D6).
38
- const hold = createHold({ isNearBottom, follow: () => { historyEl.scrollTop = historyEl.scrollHeight; } });
63
+ const hold = createHold({ isNearBottom, follow });
39
64
 
40
65
  function removeHint() {
41
66
  const hint = historyEl.querySelector(".hint");
@@ -57,7 +82,7 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
57
82
  blockEl.appendChild(blockSummaryEl);
58
83
  rememberToggle(blockSummaryEl, () => { blockToggled = true; });
59
84
  updateBlockSummary();
60
- appendToHistory(blockEl);
85
+ blockHost(blockEl);
61
86
  }
62
87
 
63
88
  function updateBlockSummary() {
@@ -85,9 +110,10 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
85
110
  feed: createSentenceFeed(), box: null };
86
111
  rememberToggle(summary, () => { entry.toggled = true; });
87
112
  genEls.set(gen, entry);
88
- const follow = isNearBottom();
89
- blockEl.appendChild(el);
90
- 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();
91
117
  return entry;
92
118
  }
93
119
 
@@ -134,6 +160,7 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
134
160
  entry.thinkingLine.classList.remove("thinking-fade");
135
161
  void entry.thinkingLine.offsetWidth;
136
162
  entry.thinkingLine.classList.add("thinking-fade");
163
+ onThinkingLine(sentence);
137
164
  }
138
165
 
139
166
  // Feed the step's ticker from its accumulated thinking; the caller's timer
@@ -227,10 +254,11 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
227
254
  rafPending = false;
228
255
  // Runs every animation frame while streaming — must not yank a user who
229
256
  // scrolled up to read; only follow when they were already near the bottom.
230
- const follow = isNearBottom();
257
+ const stick = isNearBottom();
231
258
  for (const gen of dirty) writeGen(gen, { follow: true });
232
259
  dirty.clear();
233
- if (follow) historyEl.scrollTop = historyEl.scrollHeight;
260
+ if (stick) follow();
261
+ onChange();
234
262
  }
235
263
 
236
264
  function scheduleFlush(gen) {
@@ -290,6 +318,7 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
290
318
  if (r.closed) closeGenEl(r.closed);
291
319
  if (r.opened) createGenEl(r.gen);
292
320
  if (r.closed || r.opened) updateBlockSummary();
321
+ onChange();
293
322
  }
294
323
 
295
324
  function toolEvent(r) {
@@ -310,14 +339,15 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
310
339
  }
311
340
  el.querySelector(".activity-output").style.display = "none";
312
341
  entry.rows.set(r.row.key, el);
313
- const follow = isNearBottom();
342
+ const stick = isNearBottom();
314
343
  body.appendChild(el);
315
344
  fillActivityRow(el, r.row, sessionId());
316
- if (follow) historyEl.scrollTop = historyEl.scrollHeight;
345
+ if (stick) follow();
317
346
  } else {
318
347
  fillActivityRow(el, r.row, sessionId());
319
348
  }
320
349
  updateBlockSummary();
350
+ onChange();
321
351
  }
322
352
 
323
353
  // A hook's line (hook_notice) as a row of the current step, above the row
@@ -332,9 +362,9 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
332
362
  const el = document.createElement("div");
333
363
  el.className = "hook-notice" + (data.level === "warn" ? " warn" : "");
334
364
  el.textContent = data.line || `${hookNoticeLabel(data.hook)}: ${data.text || ""}`;
335
- const follow = isNearBottom();
365
+ const stick = isNearBottom();
336
366
  activityEl(r.gen).appendChild(el);
337
- if (follow) historyEl.scrollTop = historyEl.scrollHeight;
367
+ if (stick) follow();
338
368
  return true;
339
369
  }
340
370
 
@@ -367,9 +397,9 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
367
397
  const entry = gen && genEls.get(gen);
368
398
  if (!entry) return false;
369
399
  if (!gen.text) entry.text.textContent = "";
370
- const follow = isNearBottom();
400
+ const stick = isNearBottom();
371
401
  activityEl(gen).appendChild(el);
372
- if (follow) historyEl.scrollTop = historyEl.scrollHeight;
402
+ if (stick) follow();
373
403
  return true;
374
404
  }
375
405
 
@@ -383,7 +413,7 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
383
413
  bubble.className = "bubble output";
384
414
  if (!gen.text) bubble.textContent = "";
385
415
  // A hidden tab gets no pop: nobody sees it, and its timers are throttled.
386
- const holding = heldHeight > 0 && !!gen.text && !document.hidden;
416
+ const holding = useHold && heldHeight > 0 && !!gen.text && !document.hidden;
387
417
  let holdHeight = 0;
388
418
  if (holding) {
389
419
  // Bubbles are border-box: the box's height plus the bubble's own
@@ -424,12 +454,11 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
424
454
  }
425
455
  pieces.push(...cards);
426
456
  if (gen.text) pieces.push(bubble);
427
- blockEl.replaceWith(...pieces);
457
+ placeAnswer(pieces, { plain: true, blockEl });
428
458
  blockEl = null;
429
459
  blockSummaryEl = null;
430
460
  } else {
431
- cards.forEach((el) => appendToHistory(el));
432
- if (gen.text) appendToHistory(bubble);
461
+ placeAnswer(gen.text ? [...cards, bubble] : cards, { plain: false, blockEl });
433
462
  // The last step, left with its thinking only, reads "thinking" once.
434
463
  if (!gen.tools.length && gen.thinking) unwrapThinking(entry);
435
464
  }
@@ -467,6 +496,7 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
467
496
  }
468
497
  dirty.clear();
469
498
  rafPending = false;
499
+ onChange();
470
500
  return kept;
471
501
  }
472
502
 
@@ -544,6 +574,30 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
544
574
  return !!entry && (entry.text.contains(el) || !!entry.thinking?.contains(el));
545
575
  },
546
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));
547
601
  };
548
602
  }
549
603
 
@@ -554,7 +608,9 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
554
608
  // parts; without them the row has the record's name, status and duration
555
609
  // only. +thumbs(images)+ draws the images, as the live row does.
556
610
  function reloadRowHtml(r, thumbs) {
557
- 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>` : "";
558
614
  const duration = formatDuration(r.duration_ms);
559
615
  const full = String(r.output || "").replace(/^\[[^\]]*\]\s*/, "");
560
616
  const output = full
@@ -562,7 +618,7 @@ function reloadRowHtml(r, thumbs) {
562
618
  : "";
563
619
  return `<div class="activity-row" data-key="${escapeHtml(r.key)}"><span class="activity-status ${activityStatusClass(r.status)}">${activityStatusLabel(r.status)}</span>` +
564
620
  `<span class="activity-tool">${escapeHtml(toolName(r))}</span>${params}${duration ? `<span class="activity-duration">${escapeHtml(duration)}</span>` : ""}${output}` +
565
- `${r.images?.length ? thumbs(r.images) : ""}</div>`;
621
+ `${r.images?.length ? thumbs(r.images) : ""}${r.diff ? activityDiffHtml(r.diff) : ""}</div>`;
566
622
  }
567
623
 
568
624
  // A reloaded history in the turn view (renderHistory's object path): per
@@ -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
@@ -318,6 +318,7 @@ module Samagotchi
318
318
  @engine.clear_due_reminder_names!
319
319
  return false unless @engine.reminders_due?
320
320
 
321
+ drop_continue_offer({ client_id: SessionManager::REMINDER_CLIENT_ID })
321
322
  @session.status = Session::STATUS_RUNNING
322
323
  @session.save(state_dir: @state_dir)
323
324
  begin
@@ -331,8 +332,8 @@ module Samagotchi
331
332
  ensure
332
333
  refuse_queued_commands
333
334
  end
334
- # A pending continue offer stays (the REPL rule); otherwise the
335
- # rollback window closes.
335
+ # The offer went before the turn (drop_continue_offer); the rollback
336
+ # window closes.
336
337
  @turn_flow.after_reminder_turn
337
338
  @session.save(state_dir: @state_dir) unless stopped_on_disk?
338
339
  true
@@ -476,8 +477,8 @@ module Samagotchi
476
477
  @engine.announce(type: :continue_offered, context: offer[:context], no_interrupt: offer[:no_interrupt])
477
478
  end
478
479
 
479
- # A prompt taken while a continue is offered replaces the answer (D2):
480
- # the offer goes, the partial turn stays.
480
+ # A prompt (or a reminder turn) taken while a continue is offered
481
+ # replaces the answer (D2): the offer goes, the partial turn stays.
481
482
  def drop_continue_offer(origin)
482
483
  return unless @turn_flow.awaiting_continue?
483
484