openclacky 1.5.18 → 1.5.20

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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +87 -0
  3. data/README.md +1 -1
  4. data/README_CN.md +1 -1
  5. data/README_JA.md +1 -1
  6. data/docs/session-observation-hooks.md +86 -0
  7. data/lib/clacky/agent/chunk_index.rb +4 -1
  8. data/lib/clacky/agent/history_navigation.rb +6 -1
  9. data/lib/clacky/agent/hook_manager.rb +11 -4
  10. data/lib/clacky/agent/llm_caller.rb +16 -3
  11. data/lib/clacky/agent/message_compressor_helper.rb +8 -0
  12. data/lib/clacky/agent/session_serializer.rb +25 -2
  13. data/lib/clacky/agent.rb +139 -36
  14. data/lib/clacky/billing/platform_billing.rb +10 -0
  15. data/lib/clacky/cli.rb +2 -2
  16. data/lib/clacky/client.rb +7 -0
  17. data/lib/clacky/default_extensions/advisor/hooks/advisor.rb +2 -1
  18. data/lib/clacky/default_extensions/advisor/hooks/advisor_complete.rb +3 -2
  19. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +77 -20
  20. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -0
  21. data/lib/clacky/default_skills/channel-manager/SKILL.md +70 -9
  22. data/lib/clacky/default_skills/channel-manager/qq_setup.rb +79 -0
  23. data/lib/clacky/default_skills/media-gen/SKILL.md +45 -14
  24. data/lib/clacky/default_skills/onboard/SKILL.md +1 -1
  25. data/lib/clacky/locales/en.rb +2 -2
  26. data/lib/clacky/locales/zh.rb +2 -2
  27. data/lib/clacky/media/openai_compat.rb +89 -2
  28. data/lib/clacky/message_format/bedrock.rb +5 -2
  29. data/lib/clacky/message_history.rb +24 -1
  30. data/lib/clacky/providers.rb +54 -144
  31. data/lib/clacky/server/channel/adapters/qq/adapter.rb +662 -0
  32. data/lib/clacky/server/channel/adapters/qq/api_client.rb +245 -0
  33. data/lib/clacky/server/channel/adapters/qq/gateway_client.rb +340 -0
  34. data/lib/clacky/server/channel/adapters/qq/qr_binder.rb +140 -0
  35. data/lib/clacky/server/channel/channel_config.rb +9 -0
  36. data/lib/clacky/server/channel/channel_ui_controller.rb +6 -2
  37. data/lib/clacky/server/channel.rb +1 -0
  38. data/lib/clacky/server/http_server.rb +235 -45
  39. data/lib/clacky/server/model_prices.rb +14 -3
  40. data/lib/clacky/server/session_registry.rb +21 -3
  41. data/lib/clacky/server/web_ui_controller.rb +49 -0
  42. data/lib/clacky/session_manager.rb +21 -5
  43. data/lib/clacky/shell_hook_loader.rb +3 -1
  44. data/lib/clacky/utils/model_pricing.rb +254 -30
  45. data/lib/clacky/version.rb +1 -1
  46. data/lib/clacky/web/app.css +1133 -308
  47. data/lib/clacky/web/app.js +82 -13
  48. data/lib/clacky/web/components/chat-navigator.js +154 -171
  49. data/lib/clacky/web/components/code-editor.js +6 -2
  50. data/lib/clacky/web/components/composer.js +11 -2
  51. data/lib/clacky/web/components/model-picker.js +167 -64
  52. data/lib/clacky/web/components/sidebar.js +6 -2
  53. data/lib/clacky/web/core/aside.js +72 -0
  54. data/lib/clacky/web/features/archive/store.js +182 -0
  55. data/lib/clacky/web/features/archive/view.js +399 -0
  56. data/lib/clacky/web/features/backup/view.js +12 -2
  57. data/lib/clacky/web/features/billing/store.js +1 -1
  58. data/lib/clacky/web/features/billing/view.js +207 -41
  59. data/lib/clacky/web/features/channels/store.js +25 -0
  60. data/lib/clacky/web/features/channels/view.js +168 -5
  61. data/lib/clacky/web/features/new-session/view.js +1 -0
  62. data/lib/clacky/web/features/skills/view.js +12 -0
  63. data/lib/clacky/web/features/trash/store.js +4 -4
  64. data/lib/clacky/web/features/trash/view.js +57 -9
  65. data/lib/clacky/web/features/workspace/view.js +509 -28
  66. data/lib/clacky/web/i18n.js +186 -48
  67. data/lib/clacky/web/index.html +224 -87
  68. data/lib/clacky/web/projects.js +131 -3
  69. data/lib/clacky/web/sessions.js +416 -131
  70. data/lib/clacky/web/settings.js +62 -24
  71. data/lib/clacky/web/ws-dispatcher.js +24 -8
  72. data/lib/clacky.rb +3 -2
  73. metadata +9 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2b46c172f8f8a61908656ba6537751f506ae575c4f6d1f50436d87de72355e30
4
- data.tar.gz: 58460eb829fcc0cc8326f170b7faa69b2bb60c6ceeca974c2cc98b14406e8dc4
3
+ metadata.gz: a87f3bff7a9f13d77ae6ae11ccc67a67a6429d596d922309e3f3847469b240f4
4
+ data.tar.gz: 6f7043a63e27dfb922bac4e586ec19c6d0659efb75ba9ef7e1b522b1a16b9410
5
5
  SHA512:
6
- metadata.gz: 422feaf84c1f8739a547595a907a0b49ee948cc175c2d87b30ba910fb916cdf4b7dd7a491e589b1852179a3fe6e191090eb2d3467cbd7e92a31c5c0f2869a0b7
7
- data.tar.gz: 5a94a088d54c4cec2250ecacad63f8397eb744a8f84e593ee9da0b1b45fdb9e741933ec865131357a6f78e1b091e8fe1ce6dd21b35765a95ce0c77cc5fd5bdd5
6
+ metadata.gz: 23cc0c1d6902579a09f2cb3c9b9f692c61dcb485daca62ff226ea6fa1c165afa141fbee8ee51de24e97ec4d7060bc872988c6f8b785575180f821a52e68acc26
7
+ data.tar.gz: d198a4ed2d6887094fe8f90fc1d13a429cd06008f47859acb7b334804d5d8dfc727f31dba6486ae02d11613bfd0dbe2f0004c83090a8e7f457d31891b4e29fae
data/CHANGELOG.md CHANGED
@@ -4,6 +4,93 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
 
7
+ ## [1.5.20] - 2026-10-15
8
+
9
+ ### Added
10
+ - Add a data management page with session archiving and a recycle bin
11
+ - Add a tab context menu and reopen tabs after a reload
12
+ - Preview audio and video files in the workspace (#634)
13
+ - Preview pending image attachments before sending (#629)
14
+ - Zoom and pan in the viewer's image preview
15
+ - Move the todo list into a session bar popover (#619)
16
+ - Add opt-in per-task feedback countdowns (#633 - @no7dw)
17
+ - Name a session automatically from its first message
18
+ - Show the active session title in the top header
19
+ - Show the QQ bind QR inline with expiry refresh and a sticky success state (#621)
20
+ - Add the oc-grok-4.7, abs-claude-haiku-5-5 and or-gemini-4-argon aliases
21
+
22
+ ### Improved
23
+ - Lead model picker rows with their provider
24
+ - Retune the model series discounts and say where they come from
25
+ - Point the openclacky lite tier at claude-haiku-5-5
26
+ - Soften the segmented control hover
27
+ - Tidy the top-up label and the model row spacing
28
+ - Put the model remark under the API key and align it left
29
+
30
+ ### Fixed
31
+ - Pair replayed tool results with their own calls so a refreshed session no longer strands them in the wrong tool item (#640)
32
+ - Keep the assistant timestamp when a session is archived and replayed from chunk history (#639 - @BoyYangzai)
33
+ - Stop dropping attachment messages while a task is being processed (#628)
34
+ - Handle a missing workspace path in the directory picker (#623)
35
+ - Restore the settings tab after switching sessions (#626)
36
+ - Send model picker actions to the right settings tab (#624)
37
+ - Stop session action buttons from squeezing the session title (#625)
38
+ - Improve the mobile billing chart usability (#627)
39
+ - Stop truncating the navigator preview too early (#632)
40
+ - Refresh the Files tree after restoring a task from the timeline (#638)
41
+ - Checkpoint the latest task after a session reload (#637)
42
+ - Localize screenshot upload errors and retry after binding a device (#636)
43
+ - Persist run errors in the session history
44
+ - Stop pinning an auto-routed task to a dead lane when a failure carries no tier
45
+ - Reword network failures and link them to the key console
46
+ - Align the sidebar toggle buttons and unify their icons at 16px (#622)
47
+ - Fade the collapsed aside opener icon
48
+
49
+ ### More
50
+ - Retire gemini-3.7-flash and drop the oc-minimax-m2.7 alias from the model picker
51
+ - Remove the OrcaRouter and Requesty provider presets
52
+
53
+ ## [1.5.19] - 2026-10-10
54
+
55
+ ### Added
56
+ - Add the built-in QQ official-bot channel (#615 - @chengoak)
57
+ - Drag a session onto a project to move it (#610)
58
+ - Add bookmarks to the chat navigator and tighten its layout (#617)
59
+ - Scroll an overflowing session title on hover so it can be read in full (#618)
60
+ - Add session lifecycle hooks (#607)
61
+ - Rework the billing charts with a year heatmap and a 365-day range
62
+ - Add an "Add Model" button to the model picker
63
+ - Show an API Key hint in the model dialog and restyle the key link
64
+ - Add oc-glm-5.3 and oc-kimi-k3 aliases with pricing and capability caps
65
+ - Add abs-claude-sonnet-5-5 and abs-gpt-6.1-sol with corrected pricing
66
+ - Add Xiaomi MiMo V2.6 models (#614)
67
+ - Add the oc-minimax-m2.7 alias with its launch discount
68
+ - Show a GLM/Kimi series promotion with a badge in the model picker
69
+ - Forward Seedance reference images over the OpenAI-compatible video gateway (#616 - @codevoyager1984)
70
+ - Document the input image limits and the content_filter retry in the media skill (#593 - @codevoyager1984)
71
+
72
+ ### Improved
73
+ - Make the model picker visually distinct from the variant switcher
74
+ - Explain model price discounts with a tooltip
75
+ - Soften the chat navigator tick color
76
+ - Hide the skills tab strip when only one tab is available
77
+ - Tune the billing chart axis labels
78
+ - Clarify ambient audio generation in the media skill (#606 - @YeEmrick)
79
+
80
+ ### Fixed
81
+ - Stop the workspace file tree from flickering on reload
82
+ - Deliver web-originated replies to IM after a finished progress card (#603, #605 - @chengoak)
83
+ - Fall back to gem update when the OSS CDN fails (#583, #608 - @theluckystrike)
84
+ - Keep the chat position when toggling preview fullscreen (#613)
85
+ - Stop opening an empty variant panel for custom models
86
+ - Use a white background for the context bar popovers
87
+ - Point a rate limit error at switching models
88
+ - Skip reasoning fields for non-Anthropic Converse models
89
+
90
+ ### More
91
+ - Rename the billing panel title to Usage
92
+ - Use 服务商 instead of 供应商 in model settings
93
+
7
94
  ## [1.5.18] - 2026-10-02
8
95
 
9
96
  ### Fixed
data/README.md CHANGED
@@ -205,7 +205,7 @@ $ openclacky
205
205
 
206
206
  Set your **API Key**, **Model**, and **Base URL** (any OpenAI-compatible provider).
207
207
 
208
- Supported out of the box: **Claude (Anthropic) · GPT (OpenAI) · DeepSeek · Kimi (Moonshot) · MiniMax · OpenRouter · OrcaRouter · Requesty** — or any custom endpoint.
208
+ Supported out of the box: **Claude (Anthropic) · GPT (OpenAI) · DeepSeek · Kimi (Moonshot) · MiniMax · OpenRouter** — or any custom endpoint.
209
209
 
210
210
  ## Coding use case
211
211
 
data/README_CN.md CHANGED
@@ -189,7 +189,7 @@ $ openclacky
189
189
 
190
190
  设置你的 **API Key**、**模型**和 **Base URL**(任意 OpenAI 兼容提供商)。
191
191
 
192
- 开箱即支持:**Claude (Anthropic) · GPT (OpenAI) · DeepSeek · Kimi (Moonshot) · MiniMax · OpenRouter · OrcaRouter · Requesty**,或任意自定义端点。
192
+ 开箱即支持:**Claude (Anthropic) · GPT (OpenAI) · DeepSeek · Kimi (Moonshot) · MiniMax · OpenRouter**,或任意自定义端点。
193
193
 
194
194
  ## 代码开发场景
195
195
 
data/README_JA.md CHANGED
@@ -194,7 +194,7 @@ $ openclacky
194
194
 
195
195
  **API Key**、**Model**、**Base URL**(任意の OpenAI 互換プロバイダー)を設定します。
196
196
 
197
- 標準でサポート: **Claude (Anthropic) · GPT (OpenAI) · DeepSeek · Kimi (Moonshot) · MiniMax · OpenRouter · OrcaRouter · Requesty** — または任意のカスタムエンドポイント。
197
+ 標準でサポート: **Claude (Anthropic) · GPT (OpenAI) · DeepSeek · Kimi (Moonshot) · MiniMax · OpenRouter** — または任意のカスタムエンドポイント。
198
198
 
199
199
  ## コーディングのユースケース
200
200
 
@@ -0,0 +1,86 @@
1
+ # Agent task lifecycle hooks
2
+
3
+ ## Task lifecycle: on_start / on_complete
4
+
5
+ > Availability: the additional context and abnormal completion paths require
6
+ > the runtime change on branch `session-lifecycle-hooks`. They are not
7
+ > available in 1.5.16. Upgrade the runtime before relying on these fields.
8
+
9
+ Use the existing events without extra configuration. `on_start` runs after task
10
+ and history initialization, before execution, and retains `deny` / `handled`.
11
+ `on_complete` observes the outcome of each Agent turn, including normal completion,
12
+ waiting, handled starts, denial, exceptions and interruption. Its return value is
13
+ ignored, including `deny` and `handled`; all completion callbacks can run.
14
+
15
+ Ruby callbacks receive `(input, agent, context)` or `(result, agent, context)`.
16
+ The first two positions are unchanged; ordinary two-argument Ruby blocks ignore
17
+ the extra argument. Existing completion callbacks must check `result[:status] ==
18
+ :success` before doing success-only work. A handled result has `context[:handled]`
19
+ set to true; skip follow-up work already performed by the handler.
20
+
21
+ These are Agent-turn events for server, CLI and channel execution, not server
22
+ persistence notifications. Queued input emits start only when consumed; steering
23
+ inside an active turn does not create an extra start or completion. Automatic goal
24
+ continuations have their own turn events. Normal completion fires before goal
25
+ continuation, and exceptions still propagate after notification. A superseded
26
+ turn's exception cleanup cannot settle its successor.
27
+
28
+ Context contains `session_id`, `run_id` (process-local task ID, not durable),
29
+ `source` (execution source, not authenticated identity), `input_created_at` (the
30
+ optional timestamp passed to `run`), `observed_at` (UTC ISO8601), and `status`.
31
+ Start status is `running`. No input text is added to context; input and result
32
+ arguments can contain sensitive data.
33
+
34
+ | Completion context status | Meaning |
35
+ |---------------------------|---------|
36
+ | `completed` | Result explicitly has `status: :success`, without a feedback request. This does not certify App generation or validation. |
37
+ | `awaiting_user` | Successful result requests `awaiting_user_feedback`. |
38
+ | `failed` | Exception or non-success result. Structured exceptions can supply `error_code`. |
39
+ | `cancelled` | Interruption or denial. `reason` may be `user`, `replacement`, `hook_denied` or `tool_denied`. |
40
+
41
+ Cancelled completion notifications carry `result[:status] == :cancelled`, even
42
+ when the original denial return value remains unchanged for queue compatibility.
43
+ An execution start alone does not establish recovery from a previous failure.
44
+
45
+ ```yaml
46
+ id: session-observer
47
+ name: Session Observer
48
+ version: "0.1.0"
49
+ contributes:
50
+ hooks:
51
+ - event: on_start
52
+ file: hooks/start.rb
53
+ - event: on_complete
54
+ file: hooks/complete.rb
55
+ ```
56
+
57
+ ```ruby
58
+ # hooks/start.rb
59
+ Clacky::ExtensionHookRegistry.add do |_input, _agent, context|
60
+ next unless context
61
+ Clacky::Logger.debug("[session-observer] started", session_id: context[:session_id],
62
+ run_id: context[:run_id])
63
+ nil
64
+ end
65
+ ```
66
+
67
+ ```ruby
68
+ # hooks/complete.rb
69
+ Clacky::ExtensionHookRegistry.add do |_result, _agent, context|
70
+ next unless context
71
+ Clacky::Logger.debug("[session-observer] settled", session_id: context[:session_id],
72
+ status: context[:status], run_id: context[:run_id])
73
+ nil
74
+ end
75
+ ```
76
+
77
+ Use separate files for separate registrations: each Ruby file is required only
78
+ once. Programmatic registration uses `agent.add_hook(:on_complete) { |result,
79
+ agent, context| ... }`. Shell hooks keep their JSON payload shape; neither the
80
+ Agent nor context is serialized, while abnormal completion results are now reported.
81
+
82
+ Callbacks execute synchronously; keep them short and dispatch network work
83
+ asynchronously. `StandardError` is logged without stopping later callbacks.
84
+ The host does not persist, retry, replay or acknowledge hook delivery. Consumers
85
+ must tolerate missing events and cannot infer inactivity solely from missing data.
86
+ `agent.emit_event(persist: true)` is a separate frontend-history feature.
@@ -41,6 +41,7 @@ module Clacky
41
41
  nested = []
42
42
  section_count = 0
43
43
  user_start = nil
44
+ user_task_id = nil
44
45
  archived_at = nil
45
46
  front_matter = false
46
47
  directory = File.dirname(path)
@@ -61,6 +62,7 @@ module Clacky
61
62
  section_count += 1
62
63
  nested << header[:nested_chunk] if header[:nested_chunk]
63
64
  user_start = header[:role] == "user" ? start : nil
65
+ user_task_id = header[:role] == "user" ? header[:task_id] : nil
64
66
  elsif user_start && !text.strip.empty?
65
67
  # Match replay's empty/metadata-only user handling without
66
68
  # retaining the rest of the user message or any assistant text.
@@ -69,8 +71,9 @@ module Clacky
69
71
  next if visible.empty? && events.empty? && files.empty?
70
72
 
71
73
  rounds.last[:length] = user_start - rounds.last[:start] if rounds.any?
72
- rounds << { start: user_start }
74
+ rounds << { start: user_start, task_id: user_task_id }
73
75
  user_start = nil
76
+ user_task_id = nil
74
77
  end
75
78
  end
76
79
  rounds.last[:length] = position - rounds.last[:start] if rounds.any?
@@ -12,7 +12,12 @@ module Clacky
12
12
  manifest = sources.map do |source|
13
13
  entry = { key: source[:key], version: source[:version], count: navigation_source_count(source),
14
14
  volatile: !!source[:rounds] || source[:continuation].to_a.any? }
15
- entry[:identities] = source[:rounds].map { |round| round[:user_msg].values_at(:created_at, :task_id) } if source[:rounds]
15
+ if source[:rounds]
16
+ entry[:identities] = source[:rounds].map { |round| round[:user_msg].values_at(:created_at, :task_id) }
17
+ else
18
+ task_ids = source[:index][:rounds].map { |round| round[:task_id] }
19
+ entry[:task_ids] = task_ids if task_ids.any?
20
+ end
16
21
  entry
17
22
  end
18
23
  { sources: manifest, total: manifest.sum { |source| source[:count] } }
@@ -26,20 +26,27 @@ module Clacky
26
26
 
27
27
  # @return [Hash] `{action: :allow}`, `{action: :deny, reason:}`, or
28
28
  # `{action: :handled, result:}` when a hook fulfilled the event itself.
29
- # on_start receives (user_input, agent) after task/history initialization.
29
+ # on_start receives (user_input, agent, context) after task/history initialization.
30
30
  # Its handled result is returned unchanged from Agent#run; the extension
31
31
  # supplies the run-result status and any queue/feedback flags it needs.
32
32
  # on_start is not fired for steering input inside an already running turn.
33
- # Extra trailing arg: the agent that owns this hook chain, so ext hooks can
33
+ # The agent follows the event payload, so ext hooks can
34
34
  # call `agent.emit_event(...)`. Blocks are procs — those declaring fewer
35
- # params (`|call|`, `|call, result|`) silently ignore it.
35
+ # params (`|call|`, `|call, result|`) silently ignore extra arguments.
36
+ # Start/complete append context after the agent. Completion is observation-only.
36
37
  def trigger(event, *args)
37
38
  validate_event!(event)
39
+ # Keep payload hashes positional: Ruby 2.6 otherwise promotes their keys
40
+ # into keyword arguments, breaking existing tool hooks.
41
+ context = args.pop if [:on_start, :on_complete].include?(event) && args.length == 2
38
42
  result = { action: :allow }
39
43
 
40
44
  @hooks[event].each do |hook|
41
45
  begin
42
- hook_result = hook.call(*args, @agent)
46
+ callback_args = [*args, @agent]
47
+ callback_args << context.dup if context
48
+ hook_result = hook.call(*callback_args)
49
+ next if event == :on_complete
43
50
  next unless hook_result.is_a?(Hash)
44
51
  # First deny wins and stops the chain: a weaker later verdict must
45
52
  # never clobber a stronger earlier one, and the first deny's reason
@@ -56,8 +56,15 @@ module Clacky
56
56
  retries = 0
57
57
  # Per-call tier attribution for auto routing: each retryable failure is
58
58
  # charged to the tier the gateway actually routed that call to (echoed
59
- # in X-Clacky-Routed-Tier). Failures without a tier (network layer,
60
- # timeouts — the gateway never answered) are not attributed to either.
59
+ # in X-Clacky-Routed-Tier). A tier-less failure is charged to the floor
60
+ # when the call went out as "auto": the gateway only stamps the tier
61
+ # header after it has routed the call, so a missing tier means the
62
+ # failure happened before routing (HTML error page, truncated body,
63
+ # rejection at the auth layer) — and the gateway defaults to the floor
64
+ # lane in exactly that case. Leaving it uncharged would keep
65
+ # agent_upstream_fails at 0 and pin the task to a dead floor lane until
66
+ # its retry budget ran out. Non-auto calls and unknown tier values are
67
+ # attributed to neither lane.
61
68
  floor_fails = 0
62
69
  upgrade_fails = 0
63
70
 
@@ -247,7 +254,7 @@ module Clacky
247
254
  else
248
255
  # Don't show_error here — let the outer rescue block handle it to avoid duplicates.
249
256
  # Progress cleanup is the caller's responsibility (via its own ensure block).
250
- raise AgentError.new("#{I18n.t("llm.error.network_failed", retries: max_retries)}", raw_message: e.message)
257
+ raise AgentError.new("#{I18n.t("llm.error.network_failed", retries: max_retries)}", raw_message: e.message, action_url: @client.account_url)
251
258
  end
252
259
 
253
260
  rescue RetryableError => e
@@ -257,6 +264,12 @@ module Clacky
257
264
  case e.routed_tier
258
265
  when "floor" then floor_fails += 1
259
266
  when "upgrade" then upgrade_fails += 1
267
+ when nil then floor_fails += 1 if api_call_model == "auto"
268
+ else
269
+ if defined?(Clacky::Logger)
270
+ Clacky::Logger.warn("llm_caller.unknown_routed_tier",
271
+ tier: e.routed_tier, model: api_call_model)
272
+ end
260
273
  end
261
274
 
262
275
  # Probing failure: primary still down — renew cooling-off and retry with fallback.
@@ -641,6 +641,7 @@ module Clacky
641
641
  lines << "## Assistant"
642
642
  end
643
643
  lines << ""
644
+ append_created_at_line(lines, msg)
644
645
  # Include tool calls summary if present
645
646
  # Format: "_Tool calls: name | {args_json}_" so replay can restore args for WebUI display.
646
647
  if msg[:tool_calls]&.any?
@@ -733,6 +734,13 @@ module Clacky
733
734
  lines << ""
734
735
  end
735
736
 
737
+ def append_created_at_line(lines, msg)
738
+ return unless msg[:created_at].is_a?(Numeric)
739
+
740
+ lines << "_Created at: #{msg[:created_at]}_"
741
+ lines << ""
742
+ end
743
+
736
744
  # Serialize persisted extension events into the chunk MD so they survive
737
745
  # compression and can be replayed in place. Mirrors the "_Tool calls:"
738
746
  # convention; parse_chunk_md_to_rounds reads it back.
@@ -17,6 +17,7 @@ module Clacky
17
17
  @session_id = session_data[:session_id]
18
18
  @name = session_data[:name] || ""
19
19
  @pinned = session_data[:pinned] || false
20
+ @archived_at = session_data[:archived_at]
20
21
  @history = MessageHistory.new(session_data[:messages] || [])
21
22
  @input_queue = (session_data[:pending_inputs] || []).map do |entry|
22
23
  entry.reject { |key, _| key == :steer_target }.merge(delivery: "queue")
@@ -75,6 +76,9 @@ module Clacky
75
76
  @task_parents = raw_parents.each_with_object({}) { |(k, v), h| h[k.to_s.to_i] = v.to_i }
76
77
  @current_task_id = (session_data.dig(:time_machine, :current_task_id) || 0).to_i
77
78
  @active_task_id = (session_data.dig(:time_machine, :active_task_id) || 0).to_i
79
+ # Not persisted: assume the latest task's AFTER may be stale so the next
80
+ # task start re-checkpoints it instead of skipping it.
81
+ @latest_after_dirty = true
78
82
 
79
83
  raw_meta = session_data.dig(:time_machine, :task_meta) || {}
80
84
  @task_meta = raw_meta.each_with_object({}) do |(k, v), h|
@@ -99,7 +103,20 @@ module Clacky
99
103
  last_error = session_data.dig(:stats, :last_error)
100
104
 
101
105
  if last_status == "error" && last_error
102
- @pending_error_rollback = true
106
+ real_users = @history.real_user_messages
107
+ task_tagged_history = real_users.any? { |message| !message[:task_id].nil? }
108
+ failed_turn_persisted = real_users.any? do |message|
109
+ message[:task_id].to_s == @current_task_id.to_s
110
+ end
111
+
112
+ # Modern sessions identify each user turn by task_id. Only schedule
113
+ # the legacy deferred rollback when the failed task's user message
114
+ # actually survived in history. Attachment setup can now roll its
115
+ # staged turn back before the error snapshot is saved; blindly
116
+ # removing the last user message after restore would then delete the
117
+ # preceding successful turn. Sessions without task ids keep the old
118
+ # fallback for backward compatibility.
119
+ @pending_error_rollback = true if !task_tagged_history || failed_turn_persisted
103
120
  end
104
121
 
105
122
  saved_reasoning = session_data.dig(:config, :reasoning_effort)
@@ -244,6 +261,9 @@ module Clacky
244
261
  session_id: @session_id,
245
262
  name: @name,
246
263
  pinned: @pinned,
264
+ # Kept next to the other scalars so SessionManager's header-only
265
+ # reader can pick it up without parsing the message payload.
266
+ archived_at: @archived_at,
247
267
  created_at: @created_at,
248
268
  updated_at: stamp,
249
269
  working_dir: @working_dir,
@@ -543,12 +563,15 @@ module Clacky
543
563
  # Old: "_Tool calls: name1, name2_" (backward compat)
544
564
  remaining_lines = []
545
565
  pending_tool_entries = [] # [{name:, args:}]
566
+ created_at = nil
546
567
 
547
568
  text.each_line do |line|
548
569
  stripped = line.strip
549
570
  if (m = stripped.match(/\A_Tool calls?:\s*(.+?)_?\z/i))
550
571
  raw = m[1]
551
572
  pending_tool_entries.concat(parse_tool_calls_line(raw))
573
+ elsif (m = stripped.match(/\A_Created at: (\d+(?:\.\d+)?)_\z/))
574
+ created_at = m[1].to_f
552
575
  else
553
576
  remaining_lines << line
554
577
  end
@@ -557,7 +580,7 @@ module Clacky
557
580
  # Flush any plain text
558
581
  plain_text = remaining_lines.join.strip
559
582
  unless plain_text.empty?
560
- current_round[:events] << { role: "assistant", content: plain_text, interim: !pending_tool_entries.empty? }
583
+ current_round[:events] << { role: "assistant", content: plain_text, interim: !pending_tool_entries.empty?, created_at: created_at }
561
584
  end
562
585
 
563
586
  # Emit one synthetic tool_calls message per detected tool