samagotchi 0.18.1 → 0.19.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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +85 -1
  3. data/README.md +4 -3
  4. data/docs/architecture.md +15 -2
  5. data/docs/cli.md +34 -8
  6. data/docs/configuration.md +8 -4
  7. data/docs/memory.md +4 -0
  8. data/docs/sessions.md +6 -4
  9. data/lib/samagotchi/bridge.rb +25 -9
  10. data/lib/samagotchi/bridge_client/event_stream.rb +37 -1
  11. data/lib/samagotchi/bridge_client.rb +9 -5
  12. data/lib/samagotchi/bundle_command.rb +2 -1
  13. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -4
  14. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  15. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  17. data/lib/samagotchi/engine.rb +9 -3
  18. data/lib/samagotchi/guardrail_wiring.rb +8 -2
  19. data/lib/samagotchi/guardrails/approval.rb +10 -4
  20. data/lib/samagotchi/guardrails/approvals.rb +60 -12
  21. data/lib/samagotchi/host_registry.rb +22 -4
  22. data/lib/samagotchi/idle_scheduler.rb +1 -1
  23. data/lib/samagotchi/installed_versions.rb +91 -0
  24. data/lib/samagotchi/live_versions.rb +5 -3
  25. data/lib/samagotchi/memory_bundle/builder.rb +24 -1
  26. data/lib/samagotchi/memory_bundle/index_sync.rb +1 -9
  27. data/lib/samagotchi/memory_bundle/installer.rb +33 -0
  28. data/lib/samagotchi/memory_bundle/status.rb +5 -1
  29. data/lib/samagotchi/memory_bundle/uninstaller.rb +6 -2
  30. data/lib/samagotchi/model_overlay.rb +39 -0
  31. data/lib/samagotchi/model_profile.rb +4 -0
  32. data/lib/samagotchi/relay_desk.rb +9 -0
  33. data/lib/samagotchi/session_manager.rb +166 -11
  34. data/lib/samagotchi/session_metrics.rb +36 -13
  35. data/lib/samagotchi/sessions_command.rb +43 -4
  36. data/lib/samagotchi/system_prompt.rb +10 -1
  37. data/lib/samagotchi/terminal_ui/attached_loop.rb +48 -3
  38. data/lib/samagotchi/terminal_ui/image_input.rb +11 -4
  39. data/lib/samagotchi/terminal_ui/version_lines.rb +48 -0
  40. data/lib/samagotchi/tools/builtins.rb +21 -5
  41. data/lib/samagotchi/turn_note.rb +3 -2
  42. data/lib/samagotchi/update_command.rb +33 -12
  43. data/lib/samagotchi/version.rb +1 -1
  44. data/lib/samagotchi/web/app.rb +64 -11
  45. data/lib/samagotchi/web/public/app.js +102 -29
  46. data/lib/samagotchi/web/public/data.js +6 -0
  47. data/lib/samagotchi/web/public/format.js +4 -2
  48. data/lib/samagotchi/web/public/index.html +11 -0
  49. data/lib/samagotchi/web/public/question_card.js +12 -0
  50. data/lib/samagotchi/web/public/question_cards.js +17 -11
  51. data/lib/samagotchi/web/public/scope.js +15 -6
  52. data/lib/samagotchi/web/public/session_state.js +4 -0
  53. data/lib/samagotchi/web/public/turn_events.js +107 -3
  54. data/lib/samagotchi/web/public/update_notice.js +87 -14
  55. data/lib/samagotchi/web/server.rb +28 -1
  56. data/lib/samagotchi/web/session_hub.rb +30 -3
  57. data/lib/samagotchi/web/session_summary.rb +5 -0
  58. data/lib/samagotchi/worker.rb +25 -8
  59. data/lib/samagotchi/worker_idle_exit.rb +31 -2
  60. data/lib/samagotchi/worker_sidecar.rb +7 -5
  61. metadata +3 -1
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../installed_versions"
4
+ require_relative "../version"
5
+
6
+ module Samagotchi
7
+ class TerminalUI
8
+ # The line an attaching terminal shows when chi versions differ: the
9
+ # session's worker (its snapshot's chi_version), this terminal (VERSION)
10
+ # and the newest installed. Neither process can reload itself: the worker
11
+ # moves with `chi sessions restart`, the terminal by attaching again.
12
+ module VersionLines
13
+ module_function
14
+
15
+ # @param worker [String, nil] the worker's chi_version (nil: a worker
16
+ # from before it said so; nothing is said then)
17
+ # @param installed [String, nil] InstalledVersions#newest
18
+ # @return [String, nil] one "chi> ..." line, or nil when all is current
19
+ def at_attach(worker:, installed:, session_id:, terminal: VERSION)
20
+ return nil if worker.to_s.empty?
21
+
22
+ newest = [installed, terminal].select { |v| Gem::Version.correct?(v.to_s) && !v.to_s.empty? }
23
+ .max_by { |v| Gem::Version.new(v) }
24
+ worker_old = InstalledVersions.newer?(newest, worker)
25
+ terminal_old = InstalledVersions.newer?(newest, terminal)
26
+ return nil unless worker_old || terminal_old
27
+
28
+ id = session_id.to_s[0, 8]
29
+ restart = "chi sessions restart #{id}"
30
+ attach = "/detach, then chi --attach #{id}"
31
+ if worker_old && terminal_old
32
+ "chi> chi #{newest} is installed; this session's worker runs #{worker} and this terminal #{terminal}. " \
33
+ "#{restart} moves the worker; #{attach} for the terminal."
34
+ elsif worker_old
35
+ "chi> chi #{newest} is installed; this session's worker runs #{worker}. #{restart} moves it there " \
36
+ "(this terminal follows)."
37
+ else
38
+ "chi> chi #{newest} is installed; this terminal runs #{terminal}. #{attach} to use it."
39
+ end
40
+ end
41
+
42
+ # After the stream moved to a new worker (a restart).
43
+ def restarted(worker)
44
+ worker.to_s.empty? ? "chi> the session's worker restarted" : "chi> the session's worker restarted on chi #{worker}"
45
+ end
46
+ end
47
+ end
48
+ end
@@ -22,6 +22,7 @@ require_relative "delegate"
22
22
  require_relative "delegate_result"
23
23
  require_relative "ask_user_question"
24
24
  require_relative "../guardrails/parent_approvals"
25
+ require_relative "../model_profile"
25
26
 
26
27
  module Samagotchi
27
28
  module Tools
@@ -73,7 +74,8 @@ module Samagotchi
73
74
  TaskCreate::NAME => lambda do |call, kctx|
74
75
  next Execute::NOT_RUN_ON_STOP if cancelled_proc(kctx).call
75
76
 
76
- TaskCreate.call(call[:content], cwd: call[:cwd], env: call[:env], marker_env: parent_env(kctx))
77
+ TaskCreate.call(call[:content], cwd: call[:cwd], env: call[:env],
78
+ marker_env: task_marker_env(kctx, call[:env]))
77
79
  end,
78
80
  TaskWait::NAME => lambda do |call, kctx|
79
81
  TaskWait.call(call[:content], timeout: call[:timeout], tail_lines: call[:tail_lines],
@@ -108,17 +110,31 @@ module Samagotchi
108
110
  # wrapper. The session's id, "chi" without one. And the model the
109
111
  # session runs on (SAMAGOTCHI_SESSION_MODEL, read live, so right after
110
112
  # /model), which chi self reports; unset when unknown, so a value
111
- # inherited from an outer session never passes for this one.
112
- def parent_env(kctx)
113
+ # inherited from an outer session never passes for this one. A
114
+ # worker's `chi --model X` default (ModelProfile::MODEL_FROM_CLI_ENV)
115
+ # is unset: it was that run's, not the default of a chi run here.
116
+ def parent_env(kctx, env: ENV)
113
117
  peers = kctx.peers
114
118
  id = peers.respond_to?(:session_id) ? peers.session_id.to_s : ""
115
119
  model = peers.respond_to?(:model_ref) ? peers.model_ref.to_s : ""
116
- { Guardrails::ParentApprovals::PARENT_SESSION_ENV => id.empty? ? "chi" : id,
117
- SESSION_MODEL_ENV => model.empty? ? nil : model }
120
+ vars = { Guardrails::ParentApprovals::PARENT_SESSION_ENV => id.empty? ? "chi" : id,
121
+ SESSION_MODEL_ENV => model.empty? ? nil : model }
122
+ if env[ModelProfile::MODEL_FROM_CLI_ENV]
123
+ vars[ModelProfile::MODEL_ENV] = nil
124
+ vars[ModelProfile::MODEL_FROM_CLI_ENV] = nil
125
+ end
126
+ vars
118
127
  end
119
128
 
120
129
  SESSION_MODEL_ENV = "SAMAGOTCHI_SESSION_MODEL"
121
130
 
131
+ # task_create's parent_env, less the unsets of a variable the model
132
+ # set in its env (a SAMAGOTCHI_DEFAULT_MODEL it asked for stays).
133
+ def task_marker_env(kctx, env)
134
+ given = env.is_a?(Hash) ? env.keys.map(&:to_s) : []
135
+ parent_env(kctx).reject { |key, value| value.nil? && given.include?(key) }
136
+ end
137
+
122
138
  # Stop flips the turn's controller, seen through the Engine's PeerView
123
139
  # (as DelegateWait does); a bare kernel has no peers: never cancelled.
124
140
  def cancelled_proc(kctx)
@@ -37,7 +37,8 @@ module Samagotchi
37
37
  # @param reason [Symbol, String, nil] the cancel reason (:ctrl_c, :user…)
38
38
  # @param seconds [Numeric, nil] how long the turn had run
39
39
  # @param shown [Boolean] visible text had streamed (an `[interrupted]`
40
- # model message precedes this note)
40
+ # model message precedes this note): an answer, or only narration
41
+ # before a tool call, so the note says "reply"
41
42
  # @param running_tasks [Array<Hash>] {id:, command:} of this session's
42
43
  # background tasks still running: a Stop doesn't end them
43
44
  # @param stopped_by [Hash, nil] {by:, reason:} of a hook that stopped
@@ -45,7 +46,7 @@ module Samagotchi
45
46
  def cancelled(reason, seconds: nil, shown: false, running_tasks: [], stopped_by: nil)
46
47
  why = reason.to_s.empty? ? "" : " (#{reason.to_s.tr("_", "-")}#{stopper(stopped_by)})"
47
48
  after = seconds ? " after #{seconds.round}s" : ""
48
- what = shown ? "the answer above ends where it was cut off." : "no answer had been shown."
49
+ what = shown ? "the reply above ends where it was cut off." : "no answer had been shown."
49
50
  message("the previous turn was cancelled#{why}#{after}; #{what}#{still_running(running_tasks)}")
50
51
  end
51
52
 
@@ -34,8 +34,9 @@ module Samagotchi
34
34
  --no-gem don't install a newer gem (config update.gem: false)
35
35
  --no-bundles leave the shipped bundles alone (config update.bundles: false)
36
36
  --no-desktop leave the desktop helper alone (config update.desktop: false)
37
- Running sessions keep their chi until they idle out (30 min) or
38
- chi sessions stop ID; a running chi web needs a restart.
37
+ Running sessions keep their chi until restarted (chi sessions
38
+ restart ID) or until they idle out with nothing attached; a running
39
+ chi web needs a restart (the sessions it starts run the new chi).
39
40
  TEXT
40
41
 
41
42
  # Help is a flag here: it doesn't stop the parse (an unknown option
@@ -238,22 +239,42 @@ module Samagotchi
238
239
  def live_rows
239
240
  rows = []
240
241
  stale = LiveVersions.stale_workers(VERSION, state_dir: @state_dir)
241
- unless stale.empty?
242
- versions = stale.map { |w| w.version || "an older chi" }.uniq.join(", ")
243
- ids = stale.map { |w| w.session_id[0, 8] }.join(" ")
244
- rows << Row.new(component: "workers",
245
- status: "#{stale.size} live on #{versions}: they move to #{VERSION} at idle exit (30 min) " \
246
- "or chi sessions stop #{ids}")
247
- end
242
+ rows << Row.new(component: "workers", status: workers_status(stale)) unless stale.empty?
248
243
  host, port = @web
249
- web = LiveVersions.web_version(host, port)
250
- if web && web != VERSION
244
+ info = LiveVersions.web_info(host, port)
245
+ web = info && info["version"].to_s
246
+ if web && !web.empty? && web != VERSION
247
+ # Since newest_workers, the sessions it starts run the newest chi.
248
+ spreads = !Array(info["features"]).include?("newest_workers")
251
249
  rows << Row.new(component: "chi web :#{port}", from: web,
252
- status: "running old code: new sessions it starts run #{web} too; restart it")
250
+ status: "running old code#{": new sessions it starts run #{web} too" if spreads}; restart it")
253
251
  end
254
252
  rows
255
253
  end
256
254
 
255
+ # A worker keeps its chi until restarted: `chi sessions restart` for
256
+ # one that can, a stop for an older one (the next prompt starts it on
257
+ # the newest). It idles out on its own only with nothing attached.
258
+ def workers_status(stale)
259
+ versions = stale.map { |w| w.version || "an older chi" }.uniq.join(", ")
260
+ restart, stop = stale.partition(&:restart).map { |ws| ws.map { |w| w.session_id[0, 8] }.join(" ") }
261
+ moves = []
262
+ moves << "chi sessions restart #{restart}" unless restart.empty?
263
+ moves << "chi sessions stop #{stop}" unless stop.empty?
264
+ "#{stale.size} live on #{versions}: they keep it until restarted (#{moves.join("; ")})#{idle_note}"
265
+ end
266
+
267
+ def idle_note
268
+ minutes = begin
269
+ Config.get("session.idle_exit_minutes").to_f
270
+ rescue StandardError
271
+ 30.0
272
+ end
273
+ return "; they never idle out (session.idle_exit_minutes: 0)" unless minutes.positive?
274
+
275
+ "; or they idle out after #{minutes.round(1).to_s.delete_suffix(".0")} min with nothing attached"
276
+ end
277
+
257
278
  def footers(options)
258
279
  return [] if off(options, "bundles")
259
280
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.18.1"
4
+ VERSION = "0.19.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -82,7 +82,7 @@ module Samagotchi
82
82
  # lists and GET /api/events streams from (Server always builds one);
83
83
  # without one both routes answer 503
84
84
  # @param registry [HostRegistry, nil] the hosts GET /api/models lists
85
- # (built from the config on first use)
85
+ # (nil: built from config.yml's hosts:, again when they change)
86
86
  # @param models_wait_timeout [Float] bounded seconds GET /api/models
87
87
  # waits for the hosts' lists
88
88
  # @param lan [Hash, nil] LAN mode (web.host: lan): { ip:, token: },
@@ -96,6 +96,7 @@ module Samagotchi
96
96
  registry: nil, models_wait_timeout: MODELS_WAIT_TIMEOUT, lan: nil)
97
97
  @manager = manager || SessionManager
98
98
  @registry = registry
99
+ @registry_given = !registry.nil?
99
100
  @models_wait_timeout = models_wait_timeout
100
101
  @models_mutex = Mutex.new
101
102
  @models_thread = nil
@@ -138,6 +139,7 @@ module Samagotchi
138
139
  ["GET", %r{\A/api/sessions/([^/]+)/output\z}, :handle_output],
139
140
  ["POST", %r{\A/api/sessions/([^/]+)/cancel\z}, :handle_cancel],
140
141
  ["POST", %r{\A/api/sessions/([^/]+)/stop\z}, :handle_stop],
142
+ ["POST", %r{\A/api/sessions/([^/]+)/restart\z}, :handle_restart],
141
143
  ["POST", %r{\A/api/sessions/([^/]+)/archive\z}, :handle_archive],
142
144
  ["POST", %r{\A/api/sessions/([^/]+)/unarchive\z}, :handle_unarchive],
143
145
  ["POST", %r{\A/api/sessions/([^/]+)/turn\z}, :handle_turn],
@@ -375,10 +377,12 @@ module Samagotchi
375
377
  end
376
378
 
377
379
  # What a second `chi web` probes before starting its own server: this
378
- # is chi web, and it knows ?dir (features).
380
+ # is chi web, and it knows ?dir (features). installed: the newest chi
381
+ # on this machine (nil until the hub has looked); newest_workers: the
382
+ # workers it starts run that one, not this chi web's version.
379
383
  def handle_info(_req)
380
- json_response(200, { app: "chi-web", version: Samagotchi::VERSION, pid: Process.pid, cwd: Dir.pwd,
381
- features: ["dir"], lan: @lan && @lan[:ip] })
384
+ json_response(200, { app: "chi-web", version: Samagotchi::VERSION, installed: @hub&.installed, pid: Process.pid,
385
+ cwd: Dir.pwd, features: %w[dir newest_workers], lan: @lan && @lan[:ip] })
382
386
  end
383
387
 
384
388
  # The models a new session can start on, spelled as chi spells them
@@ -441,8 +445,21 @@ module Samagotchi
441
445
  json_response(200, payload)
442
446
  end
443
447
 
448
+ # Built again when config.yml's hosts: changed (compared as read: the
449
+ # YAML read is mtime-cached), so the model picker lists a host added
450
+ # while chi web runs. An injected registry (specs) stays.
444
451
  def host_registry
445
- @models_mutex.synchronize { @registry ||= HostRegistry.new }
452
+ @models_mutex.synchronize do
453
+ next @registry if @registry_given
454
+
455
+ hosts = ConfigFile.hosts_config
456
+ if @registry.nil? || hosts != @registry_hosts
457
+ @registry = HostRegistry.new(hosts_config: hosts)
458
+ @registry_hosts = hosts
459
+ @models_thread = nil # a listing of the old hosts
460
+ end
461
+ @registry
462
+ end
446
463
  end
447
464
 
448
465
  # The hosts' model lists (cached ones as they are), or the last complete
@@ -785,8 +802,10 @@ module Samagotchi
785
802
  # worker_timeout (+what+ didn't happen), a 404 for a route the worker
786
803
  # doesn't have → 501 not_supported (it runs an older chi that +cant+;
787
804
  # 501, not 503: it is live, and the page reads 503 as "not running"),
788
- # and no live bridge, a refused connection or anything else → 503
789
- # not_live.
805
+ # another unmapped status → 502 bridge_error with the worker's detail
806
+ # (a live worker that failed the request; never 503, which the page
807
+ # reads as "session not running"), and only no live bridge, a refused
808
+ # connection or one that says it isn't this session's → 503 not_live.
790
809
  # @param request [#call] sends with the client, answers its Response
791
810
  def relay(id, client, request, what:, cant:)
792
811
  return not_live(id) unless client
@@ -801,7 +820,7 @@ module Samagotchi
801
820
  return not_live(id) if reply.json&.dig("error") == "unknown_session"
802
821
 
803
822
  error_response(501, "not_supported", BridgeClient.stale_worker_message(id, cant: cant))
804
- else not_live(id)
823
+ else bridge_error(reply, what)
805
824
  end
806
825
  rescue Errno::ETIMEDOUT
807
826
  worker_timeout(what)
@@ -813,6 +832,15 @@ module Samagotchi
813
832
  error_response(503, "not_live", "no live bridge for session #{id}")
814
833
  end
815
834
 
835
+ # A live worker refused or failed the request (a 500 in its handler, a
836
+ # status this route doesn't know): 502, with its detail when it sent
837
+ # one. 503 would have the page say the session isn't running, which is
838
+ # untrue and sends the user to restart a healthy worker.
839
+ def bridge_error(reply, what)
840
+ detail = reply_detail(reply, "#{what}: the session's worker answered #{reply.status}")
841
+ error_response(502, "bridge_error", detail)
842
+ end
843
+
816
844
  # The Bridge's detail for a refusal, or +fallback+.
817
845
  def reply_detail(reply, fallback)
818
846
  reply.json&.dig("detail") || fallback
@@ -976,6 +1004,26 @@ module Samagotchi
976
1004
  error_response(404, "not_found", e.message)
977
1005
  end
978
1006
 
1007
+ # POST /api/sessions/:id/restart: hand the session to a new worker on
1008
+ # the newest chi installed (SessionManager.restart_session). 200
1009
+ # {status: "restarted", session_id, from_version, version} (version nil:
1010
+ # the new worker is still starting; its bridge_up follows); 409
1011
+ # {error: "held" | "not_running" | "unsupported" | "failed", reason, detail} in words; 409
1012
+ # owned_by_tui; 404.
1013
+ def handle_restart(_req, id)
1014
+ result = @manager.restart_session(id, state_dir: @state_dir, client_id: "web:restart")
1015
+ @hub&.touch(id)
1016
+ json_response(200, { status: "restarted", session_id: id, from_version: result.from_version,
1017
+ version: result.version })
1018
+ rescue SessionManager::RestartRefused => e
1019
+ error = %i[not_running unsupported failed].include?(e.reason) ? e.reason.to_s : "held"
1020
+ json_response(409, { error: error, reason: e.reason.to_s, detail: e.message, session_id: id })
1021
+ rescue SessionManager::OwnedByTUI => e
1022
+ error_response(409, "owned_by_tui", e.message)
1023
+ rescue ArgumentError => e
1024
+ error_response(404, "not_found", e.message)
1025
+ end
1026
+
979
1027
  # POST /api/sessions/:id/archive: hide it (and its delegates) from the
980
1028
  # lists, keep it for good. A live idle worker is stopped first; a turn
981
1029
  # running in it or a delegate, a chi REPL's session and a scratch one
@@ -1062,7 +1110,8 @@ module Samagotchi
1062
1110
  # GET /api/events: the session list as one SSE stream per tab. The
1063
1111
  # first frame is the hub's snapshot (scoped by ?dir= as the list is,
1064
1112
  # with chi's version),
1065
- # then `session` for an upsert and `session_gone` for a removal, with
1113
+ # then `session` for an upsert, `session_gone` for a removal and `chi`
1114
+ # when the newest installed chi changed, with
1066
1115
  # a `: ping` while idle. Frame ids are the hub's seq, for the log's
1067
1116
  # sake: there is no replay, a reconnect starts with a fresh snapshot.
1068
1117
  def handle_events(req)
@@ -1598,11 +1647,15 @@ module Samagotchi
1598
1647
  @server_running = server_running
1599
1648
  @queue = Bridge::BoundedQueue.new(capacity: capacity)
1600
1649
  @handle, @snapshot = @hub.subscribe(->(event) { @queue.push(event) }, snapshot: true, project_root: project_root)
1650
+ # Read after subscribing: a change in between is also a queued event.
1651
+ @installed = @hub.installed
1601
1652
  end
1602
1653
 
1603
1654
  def each
1604
- # version: the page compares it with the one it was served by.
1605
- yield frame(nil, "snapshot", sessions: @snapshot, version: Samagotchi::VERSION)
1655
+ # version: the page compares it with the one it was served by;
1656
+ # installed: the newest chi on this machine (a later change is a
1657
+ # `chi` event).
1658
+ yield frame(nil, "snapshot", sessions: @snapshot, version: Samagotchi::VERSION, installed: @installed)
1606
1659
  last_write = monotonic
1607
1660
  loop do
1608
1661
  event = @queue.pop([@heartbeat, EVENTS_POLL].min)
@@ -4,6 +4,7 @@ import {
4
4
  sendTurn,
5
5
  cancelTurn,
6
6
  stopSession,
7
+ restartSession,
7
8
  deleteSession,
8
9
  archiveSession,
9
10
  unarchiveSession,
@@ -39,12 +40,12 @@ import { shouldFollowScroll, keepFollowing } from "./scroll.js";
39
40
  import { SHORT_WINDOW_QUERY, stripAutoHidden, stripColumns, stripShowParts } from "./strip.js";
40
41
  import { applySessionEvent, heldOrder, listedSessions, sortedByUpdated, waitingBadge, waitingFirst, waitingSearchText, withChildrenAfterParents } from "./sessions_list.js";
41
42
  import { createNotifications } from "./notifications.js";
42
- import { updateNotice } from "./update_notice.js";
43
+ import { restartConfirmText, versionNotice, workerBadge } from "./update_notice.js";
43
44
  import {
44
45
  appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, mergeTiming, normalizeTiming, turnTimingText,
45
46
  } from "./timing.js";
46
47
  import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
47
- import { clientLabel, commandView, emptyAnswerLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, restoreOnAck, retryStatusLine, snapshotEvents, startPageReply, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
48
+ import { clientLabel, commandView, displayWait, dropEarlyRestores, emptyAnswerLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, retryStatusLine, snapshotEvents, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
48
49
 
49
50
  const $ = (s) => document.querySelector(s);
50
51
  const topStripEl = $("#topStrip");
@@ -179,7 +180,7 @@ async function refresh() {
179
180
  function openSessionEvents() {
180
181
  eventsControl = openEvents({
181
182
  snapshot: (data) => {
182
- noticeUpdate(data.version);
183
+ noticeVersions(data);
183
184
  notifications.noticeSessions("snapshot", data);
184
185
  allSessions = applySessionEvent(allSessions, "snapshot", data);
185
186
  render();
@@ -201,6 +202,7 @@ function openSessionEvents() {
201
202
  }
202
203
  leaveGoneSession(data.id, "deleted");
203
204
  },
205
+ chi: (data) => noticeVersions(data),
204
206
  }, {
205
207
  dir: scope.dir,
206
208
  onError: () => {
@@ -210,15 +212,24 @@ function openSessionEvents() {
210
212
  });
211
213
  }
212
214
 
213
- // chi web was upgraded while this tab stayed open (a snapshot on the
214
- // reconnect names another version than the one that served the page):
215
- // a toast offers the reload, never forces it.
216
- let updateShownFor = null;
217
- function noticeUpdate(served) {
218
- const text = updateNotice(document.body.dataset.version, served, updateShownFor);
219
- if (!text) return;
220
- updateShownFor = served;
221
- showToast(text, { label: "Reload", run: () => location.reload() }, { sticky: true });
215
+ // Which chi serves this page now and which is the newest installed (the
216
+ // events snapshot, and `chi` frames when that changes). A toast says when
217
+ // chi web was upgraded while this tab stayed open (offering the reload,
218
+ // never forcing it) or when a newer chi is installed than chi web runs.
219
+ // Once per notice per tab: every reconnect's snapshot says it again.
220
+ const versions = { served: null, installed: null };
221
+ // The session whose Restart is under way (its button says so).
222
+ let restartingId = null;
223
+ const versionNoticesShown = new Set();
224
+ function noticeVersions(data) {
225
+ if (typeof data.version === "string") versions.served = data.version;
226
+ versions.installed = typeof data.installed === "string" ? data.installed : null;
227
+ const notice = versionNotice({ loaded: document.body.dataset.version, served: versions.served,
228
+ installed: versions.installed, shown: versionNoticesShown });
229
+ updateInfoBar(); // the worker badge compares with the newest installed
230
+ if (!notice) return;
231
+ versionNoticesShown.add(notice.key);
232
+ showToast(notice.text, notice.reload ? { label: "Reload", run: () => location.reload() } : null, { sticky: true });
222
233
  }
223
234
 
224
235
  // The selected session's card changed: its owner and, without a live
@@ -227,6 +238,8 @@ function noticeUpdate(served) {
227
238
  // gets the stream attached through one resync.
228
239
  function followSelectedSession(sess) {
229
240
  current.owner = sess.owner || null;
241
+ current.workerVersion = sess.worker_version || null;
242
+ current.workerFeatures = Array.isArray(sess.worker_features) ? sess.worker_features : [];
230
243
  const streaming = !!(streamControl && streamControl.live);
231
244
  if (!streaming) current.status = sess.status;
232
245
  updateInfoBar();
@@ -240,7 +253,7 @@ function followSelectedSession(sess) {
240
253
  // new chat starts on the start page.
241
254
  function renderScope() {
242
255
  const el = $("#scope");
243
- const allLink = `<a class="scope-all" href="${escapeHtml(allScopeHref(location.hash, scope.dir))}" title="Sessions of every project">all</a>`;
256
+ const allLink = `<a class="scope-all" href="${escapeHtml(allScopeHref(location.hash, scope.dir, location.search))}" title="Sessions of every project">all</a>`;
244
257
  if (scopeError) {
245
258
  el.innerHTML = `<span class="scope-name error" title="${escapeHtml(scopeError)}">folder not found</span>${allLink}`;
246
259
  } else if (scope.projectName) {
@@ -248,7 +261,7 @@ function renderScope() {
248
261
  } else {
249
262
  const where = scope.dir || scope.serverDir;
250
263
  const backLink = scope.backName
251
- ? `<a class="scope-all scope-back" href="${escapeHtml(projectScopeHref(scope.backDir, location.hash))}" title="Sessions of ${escapeHtml(scope.backName)} only">← ${escapeHtml(scope.backName)}</a>`
264
+ ? `<a class="scope-all scope-back" href="${escapeHtml(projectScopeHref(scope.backDir, location.hash, location.search))}" title="Sessions of ${escapeHtml(scope.backName)} only">← ${escapeHtml(scope.backName)}</a>`
252
265
  : "";
253
266
  el.innerHTML = `<span class="scope-name" title="Sessions of every project">all projects</span>${backLink}${where ? `<span class="scope-where" title="New chats start in ${escapeHtml(where)}">new chat in<span class="path"><bdi>${escapeHtml(where)}</bdi></span></span>` : ""}`;
254
267
  }
@@ -256,10 +269,10 @@ function renderScope() {
256
269
  notifications.setBaseTitle(scope.projectName ? `Chi · ${scope.projectName}` : "Chi");
257
270
  // The links follow the hash (an open session, the all-sessions view).
258
271
  el.querySelector(".scope-all:not(.scope-back)")?.addEventListener("click", (e) => {
259
- e.currentTarget.href = allScopeHref(location.hash, scope.dir);
272
+ e.currentTarget.href = allScopeHref(location.hash, scope.dir, location.search);
260
273
  });
261
274
  el.querySelector(".scope-back")?.addEventListener("click", (e) => {
262
- e.currentTarget.href = projectScopeHref(scope.backDir, location.hash);
275
+ e.currentTarget.href = projectScopeHref(scope.backDir, location.hash, location.search);
263
276
  });
264
277
  }
265
278
 
@@ -388,7 +401,7 @@ function renderTop(sessions) {
388
401
  topStripEl.classList.remove("loading");
389
402
  renderStripShow();
390
403
  if (scopeError) {
391
- topStripEl.innerHTML = `<div class="hint scope-error">${escapeHtml(scopeError)} · <a href="${escapeHtml(allScopeHref(""))}">show every session</a></div>`;
404
+ topStripEl.innerHTML = `<div class="hint scope-error">${escapeHtml(scopeError)} · <a href="${escapeHtml(allScopeHref("", null, location.search))}">show every session</a></div>`;
392
405
  return;
393
406
  }
394
407
  // Only archived sessions: no cards, but the tile stays, the way to them.
@@ -686,6 +699,7 @@ function resetTurnView() {
686
699
  questionCards.forgetContinue();
687
700
  unmatchedMerge = false;
688
701
  setTurnRunning(false);
702
+ releaseDisplayWait();
689
703
  }
690
704
 
691
705
  // (Re)render the selected session from the server and stream on from the
@@ -711,6 +725,7 @@ async function resync({ endText = null } = {}) {
711
725
  return;
712
726
  }
713
727
  if (id !== selected) return;
728
+ releaseDisplayWait();
714
729
  resetTurnView();
715
730
  renderSessionView(data);
716
731
  // Preview is read-only: a worker is woken only by POST /turn, so attach
@@ -842,9 +857,17 @@ function setMarkdownWarning(warning) {
842
857
  // markdown, the status and the timing. Kept as turnEndRead, so an
843
858
  // answer_display re-read waits for it.
844
859
  let turnEndRead = Promise.resolve();
845
- // Resolves the wait of a turn_completed with display_pending (its
846
- // answer_display landed).
847
- let displayLanded = null;
860
+ // The wait of a turn_completed with display_pending for its answer_display
861
+ // (turn_events.js displayWait). The worker may die in between and never send
862
+ // one, so workerGone (through resetTurnView) and a re-render release it as
863
+ // well as its own timeout: it must never hold the turn's answer forever.
864
+ let displayPending = null;
865
+
866
+ function releaseDisplayWait() {
867
+ displayPending?.finish();
868
+ displayPending = null;
869
+ }
870
+
848
871
  async function readTurnEnd() {
849
872
  if (!selected) return;
850
873
  const id = selected;
@@ -1322,6 +1345,9 @@ function removeRecap() {
1322
1345
  // The info bar's "copy chi --attach": the full id, which the page shows
1323
1346
  // only short; "copied ✓" for 2 s.
1324
1347
  let attachCopiedAt = 0;
1348
+ infoTextEl.addEventListener("click", (e) => {
1349
+ if (e.target.closest(".worker-restart")) restartWorker();
1350
+ });
1325
1351
  infoTextEl.addEventListener("click", async (e) => {
1326
1352
  if (!e.target.closest(".copy-attach") || !selected) return;
1327
1353
  if (!(await copyText(`chi --attach ${selected}`))) return;
@@ -1330,6 +1356,44 @@ infoTextEl.addEventListener("click", async (e) => {
1330
1356
  setTimeout(updateInfoBar, 2000);
1331
1357
  });
1332
1358
 
1359
+ // The open session's worker runs an older chi than the newest installed:
1360
+ // a badge, with Restart when the worker can (else the stop command).
1361
+ function currentWorkerBadge() {
1362
+ if (!selected || current.owner !== "worker") return null;
1363
+ return workerBadge({ workerVersion: current.workerVersion, features: current.workerFeatures,
1364
+ installed: versions.installed, served: versions.served, sessionId: selected });
1365
+ }
1366
+
1367
+ function workerBadgeHtml() {
1368
+ const badge = currentWorkerBadge();
1369
+ if (!badge) return "";
1370
+ const busy = restartingId === selected;
1371
+ const button = badge.restart
1372
+ ? `<button type="button" class="worker-restart"${busy ? " disabled" : ""}>${busy ? "restarting…" : "Restart"}</button>`
1373
+ : "";
1374
+ return `<span class="worker-badge" title="${escapeHtml(badge.title)}">${escapeHtml(badge.text)}${button}</span>`;
1375
+ }
1376
+
1377
+ // Restart: the session moves to a new worker on the newest chi; the page
1378
+ // follows it (its bridge_up), the composer's draft stays as it is.
1379
+ async function restartWorker() {
1380
+ const badge = currentWorkerBadge();
1381
+ if (!badge || !badge.restart || restartingId) return;
1382
+ if (!confirm(restartConfirmText(current.workerVersion, badge.newest))) return;
1383
+ const id = selected;
1384
+ restartingId = id;
1385
+ updateInfoBar();
1386
+ try {
1387
+ const result = await restartSession(id);
1388
+ showToast(result.version ? `Restarted on chi ${result.version}` : "Restarting: the new worker is starting");
1389
+ } catch (e) {
1390
+ showToast(`Can't restart: ${String(e.message).replace(/ \(\d+\)$/, "")}`, null, { sticky: true });
1391
+ } finally {
1392
+ restartingId = null;
1393
+ updateInfoBar();
1394
+ }
1395
+ }
1396
+
1333
1397
  function updateInfoBar() {
1334
1398
  const stopBtn = document.getElementById("infoStopBtn");
1335
1399
  const deleteBtn = document.getElementById("infoDeleteBtn");
@@ -1362,7 +1426,7 @@ function updateInfoBar() {
1362
1426
  const attachCmd = `chi --attach ${selected}`;
1363
1427
  const copied = Date.now() - attachCopiedAt < 2000;
1364
1428
  const copyHtml = `<button type="button" class="copy-attach" title="Copy &quot;${escapeHtml(attachCmd)}&quot; to attach a terminal">${copied ? "copied ✓" : "copy chi --attach"}</button>`;
1365
- const metaHtml = `<div class="meta"><span class="id" title="${escapeHtml(selected)}">${escapeHtml(idShort)}</span>${copyHtml}<span class="${statusClass}">${escapeHtml(current.status||"")}</span><span class="model${label.mismatch ? " served-mismatch" : ""}" title="${escapeHtml(label.title)}">${escapeHtml(label.text)}</span>${parentHtml}<span class="dir" title="${escapeHtml(current.dir||"")}">${escapeHtml((current.dir||"").slice(0,48))}</span>${ctxText ? `<span class="model">${escapeHtml(ctxText)}</span>` : ""}${timingText ? `<span class="model">${escapeHtml(timingText)}</span>` : ""}</div>`;
1429
+ const metaHtml = `<div class="meta"><span class="id" title="${escapeHtml(selected)}">${escapeHtml(idShort)}</span>${copyHtml}<span class="${statusClass}">${escapeHtml(current.status||"")}</span><span class="model${label.mismatch ? " served-mismatch" : ""}" title="${escapeHtml(label.title)}">${escapeHtml(label.text)}</span>${parentHtml}<span class="dir" title="${escapeHtml(current.dir||"")}">${escapeHtml((current.dir||"").slice(0,48))}</span>${ctxText ? `<span class="model">${escapeHtml(ctxText)}</span>` : ""}${timingText ? `<span class="model">${escapeHtml(timingText)}</span>` : ""}${workerBadgeHtml()}</div>`;
1366
1430
  const previewHtml = `<div class="preview-line"><span class="preview">${escapeHtml(current.firstPreview || "—")}</span> · <span class="mem" title="${escapeHtml(current.usedMemories.join(", "))}">mem: ${escapeHtml(memText)}</span></div>`;
1367
1431
  infoTextEl.innerHTML = metaHtml + previewHtml;
1368
1432
  infoTextEl.title = memoriesTooltip(current.usedMemories, current.preloadedMemories, current.mutedMemories);
@@ -1618,8 +1682,11 @@ const streamHandlers = {
1618
1682
  const out = turnOutput(data);
1619
1683
  if (out) appendChunk(out);
1620
1684
  // after_turn hooks are running and may present the answer: its
1621
- // answer_display (display: null when they didn't) comes next.
1622
- const display = data.display_pending ? new Promise((resolve) => { displayLanded = resolve; }) : null;
1685
+ // answer_display (display: null when they didn't) comes next. A worker
1686
+ // that dies before it never sends one: workerGone releases the wait, and
1687
+ // displayWait's timeout ends it whatever happens (4.14).
1688
+ const display = data.display_pending ? displayWait() : null;
1689
+ if (display) displayPending = display;
1623
1690
  turnEndRead = readTurnEnd();
1624
1691
  await turnEndRead;
1625
1692
  // The answer bubble the turn view held at its box's height pops now
@@ -1627,7 +1694,7 @@ const streamHandlers = {
1627
1694
  // plain text pops), and the display if one was coming, so the links
1628
1695
  // never swap in after the pop. The hold reveals by itself after
1629
1696
  // HOLD_MS whatever comes. The stage has no hold.
1630
- if (display) await display;
1697
+ if (display) await display.promise;
1631
1698
  turnDom.answerReady?.();
1632
1699
  },
1633
1700
  // An after_turn hook presented the answer (AnswerDisplay): it comes after
@@ -1640,8 +1707,7 @@ const streamHandlers = {
1640
1707
  if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
1641
1708
  } catch (_) {
1642
1709
  } finally {
1643
- displayLanded?.();
1644
- displayLanded = null;
1710
+ releaseDisplayWait();
1645
1711
  }
1646
1712
  },
1647
1713
  turn_canceled: (data) => {
@@ -1822,7 +1888,9 @@ async function handleSendTurn() {
1822
1888
  // An image-only send's text is its placeholders: not for ↑.
1823
1889
  const typed = promptEl.value.trim() !== "";
1824
1890
  if (!selected) {
1825
- const local = startPageReply(prompt, { images: chips.length });
1891
+ // The start page's own reply (a web-local command) or the hint for a
1892
+ // typo: neither needs a session, so none is made for it (2.29).
1893
+ const local = startPageReplyOrHint(prompt, { commands: current.commands, images: chips.length });
1826
1894
  if (local) {
1827
1895
  promptEl.value = "";
1828
1896
  fitPrompt();
@@ -1885,9 +1953,10 @@ async function handleSendTurn() {
1885
1953
  if (ack?.enqueued_id) sentIds.add(ack.enqueued_id);
1886
1954
  refreshHistory();
1887
1955
  // A turn that failed fast was restored before this ack: its prompt
1888
- // (and images) go back into the composer.
1956
+ // (and images) go back into the composer, without replacing text typed
1957
+ // since (4.13). The composer still holds the text just sent: it goes.
1889
1958
  const restored = restoreOnAck(ack?.enqueued_id, earlyRestores, { myId: clientId, sentIds });
1890
- promptEl.value = restored?.refill || "";
1959
+ promptEl.value = composerAfterAck({ sent: prompt, current: promptEl.value, refill: restored?.refill || "" });
1891
1960
  fitPrompt();
1892
1961
  clearChips();
1893
1962
  if (restored?.images?.length) setChips(restoredChips(selected, restored.images));
@@ -1903,6 +1972,10 @@ async function handleSendTurn() {
1903
1972
  if (!streamControl?.live) startStream(selected, selectSeq ?? 0);
1904
1973
  } catch (e) {
1905
1974
  echo.remove();
1975
+ // The turn never reached the worker's queue: an early restore waiting
1976
+ // for its ack is never taken back (4.13), so drop it here. The prompt
1977
+ // is still in the composer, as the echo was removed.
1978
+ dropEarlyRestores(earlyRestores);
1906
1979
  // The chips stay (uploaded ones keep their ref) for another try.
1907
1980
  if (/bad_image|too_large/.test(e.message)) {
1908
1981
  alert(e.message);