samagotchi 0.100.1 → 0.102.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +50 -1
- data/bin/chi +14 -10
- data/docs/architecture.md +10 -2
- data/docs/cli.md +100 -5
- data/docs/configuration.md +26 -4
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/engine.md +1 -0
- data/docs/internals/llm-context-forget.md +14 -0
- data/docs/internals/prompt-caching.md +8 -1
- data/docs/internals/working-set.md +205 -0
- data/docs/plugins.md +6 -3
- data/docs/sessions.md +28 -11
- data/docs/testing.md +8 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +16 -0
- data/lib/samagotchi/bridge.rb +9 -3
- data/lib/samagotchi/bundle_command/check.rb +5 -1
- data/lib/samagotchi/bundle_command/try/command.rb +6 -6
- data/lib/samagotchi/bundle_command/try/sandbox.rb +3 -3
- data/lib/samagotchi/bundle_command.rb +4 -4
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/commands/registry.rb +12 -1
- data/lib/samagotchi/config.rb +92 -53
- data/lib/samagotchi/engine.rb +153 -4
- data/lib/samagotchi/host_keys.rb +35 -18
- data/lib/samagotchi/idle_scheduler.rb +16 -1
- data/lib/samagotchi/llm_context_edit.rb +32 -10
- data/lib/samagotchi/llm_context_forget.rb +12 -3
- data/lib/samagotchi/llm_context_view.rb +19 -7
- data/lib/samagotchi/memory_bundle/installer.rb +2 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +13 -2
- data/lib/samagotchi/message_parts.rb +8 -3
- data/lib/samagotchi/plugin/context.rb +2 -2
- data/lib/samagotchi/prompt.rb +1 -1
- data/lib/samagotchi/recap_command.rb +68 -0
- data/lib/samagotchi/recap_setup.rb +27 -14
- data/lib/samagotchi/send_command.rb +14 -11
- data/lib/samagotchi/session.rb +19 -12
- data/lib/samagotchi/session_commands.rb +44 -2
- data/lib/samagotchi/session_config.rb +411 -0
- data/lib/samagotchi/session_setup.rb +9 -9
- data/lib/samagotchi/sessions_command.rb +6 -2
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +10 -3
- data/lib/samagotchi/terminal_ui.rb +8 -6
- data/lib/samagotchi/testing/bundle.rb +3 -2
- data/lib/samagotchi/testing/fake_executable.rb +6 -16
- data/lib/samagotchi/testing/fake_executable_launcher +6 -0
- data/lib/samagotchi/tool_response.rb +10 -6
- data/lib/samagotchi/tool_runner.rb +25 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +76 -11
- data/lib/samagotchi/web/public/app.js +35 -3
- data/lib/samagotchi/web/public/chip_popover.js +56 -17
- data/lib/samagotchi/web/public/commands.js +8 -2
- data/lib/samagotchi/web/public/data.js +7 -0
- data/lib/samagotchi/web/public/index.html +145 -1
- data/lib/samagotchi/web/public/info_bar.js +16 -4
- data/lib/samagotchi/web/public/info_bar_model.js +7 -3
- data/lib/samagotchi/web/public/refs.js +10 -1
- data/lib/samagotchi/web/public/stage_model.js +6 -5
- data/lib/samagotchi/web/public/stage_view.js +34 -2
- data/lib/samagotchi/web/public/stream_handlers.js +10 -1
- data/lib/samagotchi/web/public/turn_html.js +5 -0
- data/lib/samagotchi/web/public/turn_view.js +16 -4
- data/lib/samagotchi/web/public/working_set.js +612 -0
- data/lib/samagotchi/web/public/working_set_rail.js +278 -0
- data/lib/samagotchi/web/public/working_set_view.js +541 -0
- data/lib/samagotchi/web/server.rb +6 -4
- data/lib/samagotchi/worker.rb +9 -6
- data/lib/samagotchi/working_set.rb +238 -0
- data/lib/samagotchi/working_set_command.rb +119 -0
- data/lib/samagotchi/working_set_steer.rb +250 -0
- metadata +11 -2
- data/lib/samagotchi/bundle_session_settings.rb +0 -251
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
// The working set's page parts (web.working_set, the stage view): the turns'
|
|
2
|
+
// sets the page keeps (a reload's, then one per hand-off), the footprint
|
|
3
|
+
// line under each finished turn's block, the session bar's "set N" chip,
|
|
4
|
+
// the expanded view (a chip_popover.js popover; a bottom sheet on a phone)
|
|
5
|
+
// and the step tags' jump to a call. The rail is working_set_rail.js's; the
|
|
6
|
+
// rules are working_set.js's.
|
|
7
|
+
//
|
|
8
|
+
// Steering (P3a: drop / undrop): the list's rows and groups post
|
|
9
|
+
// `/working-set drop|undrop KEY…` lines (+sendLine+, the session command
|
|
10
|
+
// route). Between turns one runs at once: its command_ran re-reads the
|
|
11
|
+
// conversation and the re-emitted set strikes the item `dropped`. While a
|
|
12
|
+
// turn runs it is queued: its command_queued (waits: "turn_end"; any
|
|
13
|
+
// client's, and the snapshot's replay after a reload) makes it pending, a
|
|
14
|
+
// dashed-warn row with a pill and an undo (the inverse line, queued after
|
|
15
|
+
// it), until its command_ran. On a phone's sheet a tap on a row opens its
|
|
16
|
+
// action strip under it, one at a time.
|
|
17
|
+
//
|
|
18
|
+
// The server's projection (WorkingSet: a :working_set_updated event, a GET
|
|
19
|
+
// working_set when a session's set is seeded and when the expanded view
|
|
20
|
+
// opens) is one session-wide snapshot: an event always taken, a GET's
|
|
21
|
+
// answer unless an event overtook it (working_set.js newerSnapshot); every form
|
|
22
|
+
// overlays its state and tokens on the rows' items by key (working_set.js
|
|
23
|
+
// overlay), so a state is the item's now, not then. A new snapshot redraws
|
|
24
|
+
// them all: the chip, the open popover, the footprints in place and the rail
|
|
25
|
+
// (onServer).
|
|
26
|
+
//
|
|
27
|
+
// A row's place is ordinals, stamped on the DOM: data-turn on a turn's
|
|
28
|
+
// .turn-work block (its place in the page's turns) and data-at="step.row"
|
|
29
|
+
// on its tool rows, live (stampRows) and reloaded (stampHistory); a jump
|
|
30
|
+
// finds the row by them, never by a row key.
|
|
31
|
+
import { html, nothing, render, svg } from "./vendor/lit-html.js";
|
|
32
|
+
import { createChipPopover } from "./chip_popover.js";
|
|
33
|
+
import { showRow } from "./refs.js";
|
|
34
|
+
import {
|
|
35
|
+
BUDGETS, dropRefusal, fold, footprint, footprintText, GLYPHS, INVERSE, isFresh, itemsOfTurn, labelText, labelTitle, newerSnapshot,
|
|
36
|
+
opsText, overlay, parseSteerLine, pendingSteers, sessionItems, steerId, steerLine, stepRange, stepTag, tokensText,
|
|
37
|
+
} from "./working_set.js";
|
|
38
|
+
|
|
39
|
+
// The popover's files fold to this many lines (groups open in place, one at
|
|
40
|
+
// a time), its memories past this many to one group.
|
|
41
|
+
const POP_BUDGET = 6;
|
|
42
|
+
|
|
43
|
+
// A reloaded turn's items: its steps (timing.js turnGroups) as gens.
|
|
44
|
+
export function groupItems(group, ordinal) {
|
|
45
|
+
return itemsOfTurn({ gens: (group.steps || []).map((step) => ({ tools: step.tools || [] })) }, ordinal);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The live turn's block and rows stamped with their ordinals (+turn+: the
|
|
49
|
+
// turn's model; new rows only).
|
|
50
|
+
export function stampRows(blockEl, turn, ordinal) {
|
|
51
|
+
if (!blockEl || !turn) return;
|
|
52
|
+
if (blockEl.dataset.turn !== String(ordinal)) blockEl.dataset.turn = String(ordinal);
|
|
53
|
+
turn.gens.forEach((gen, step) => {
|
|
54
|
+
gen.tools.forEach((row, i) => {
|
|
55
|
+
const el = blockEl.querySelector(`.activity-row[data-key="${String(row.key).replace(/"/g, '\\"')}"]`);
|
|
56
|
+
if (el && !el.dataset.at) el.dataset.at = `${step}.${i}`;
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// A reloaded history's blocks stamped: +blocks+ the .turn-work elements,
|
|
62
|
+
// +ordinals+ each one's turn.
|
|
63
|
+
export function stampHistory(blocks, ordinals) {
|
|
64
|
+
blocks.forEach((block, i) => {
|
|
65
|
+
block.dataset.turn = String(ordinals[i]);
|
|
66
|
+
[...block.querySelectorAll(":scope > .gen")].forEach((gen, step) => {
|
|
67
|
+
[...gen.querySelectorAll(":scope > .activity-body > .activity-row")].forEach((row, j) => { row.dataset.at = `${step}.${j}`; });
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function atAttr(at) {
|
|
73
|
+
return `${at.turn}.${at.step}.${at.row}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// A label's step tag in the footprint: a memory's step, a memory group's
|
|
77
|
+
// range (it jumps to the first); none for files and URLs.
|
|
78
|
+
function footTag(label) {
|
|
79
|
+
if (label.kind !== "memory") return nothing;
|
|
80
|
+
const first = label.members.find((m) => m.at);
|
|
81
|
+
if (!first) return nothing;
|
|
82
|
+
const text = label.type === "leaf" ? stepTag(first) : stepRange(label.members);
|
|
83
|
+
const title = label.type === "leaf" ? `entered at step ${first.at.step + 1}: go to the call` : `entered at ${text}: go to the first call`;
|
|
84
|
+
return html` <button type="button" class="ws-step p" data-ws="jump" data-at=${atAttr(first.at)} title=${title}>${text}</button>`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The footprint line of a finished turn's +items+ (turn +ordinal+), nothing
|
|
88
|
+
// for a turn that touched nothing: "touched web/public/ · 9 files · ◆ 3
|
|
89
|
+
// memories s1–5 · +1 url · edited 2". A click opens the expanded view at
|
|
90
|
+
// that turn; a step tag jumps to its call.
|
|
91
|
+
export function footprintTemplate(items, ordinal) {
|
|
92
|
+
const fp = footprint(items, BUDGETS.footprint);
|
|
93
|
+
if (!fp) return nothing;
|
|
94
|
+
const sep = html` <span class="sep">·</span> `;
|
|
95
|
+
const parts = fp.labels.map((l, i) => html`${i ? sep : nothing}<span class=${`l ${l.kind}${l.state && l.state !== "live" ? ` ${l.state}` : ""}`} title=${labelTitle(l)}>${l.glyph ? html`<span class="k">${l.glyph}</span> ` : nothing}${labelText({ ...l, glyph: "" })}</span>${footTag(l)}`);
|
|
96
|
+
const text = footprintText(items, BUDGETS.footprint);
|
|
97
|
+
return html`<div class="ws-foot" role="button" tabindex="0" data-ws-turn=${ordinal} data-ws-sig=${footSig(items)} aria-label=${`Working set of this turn: ${text.replace(/^touched /, "")}. Open the list.`}><span class="w">touched</span> ${parts}${fp.tail ? html`${sep}<span class="w">${fp.tail}</span>` : nothing}${fp.edited ? html`${sep}<span class="e">edited ${fp.edited}</span>` : nothing}${fp.forgotten ? html`${sep}<span class="f">${fp.forgotten} forgotten</span>` : nothing}${fp.dropped ? html`${sep}<span class="f">${fp.dropped} dropped</span>` : nothing}</div>`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// What a footprint shows that can change after it is drawn: its items'
|
|
101
|
+
// states (the server's). The same signature draws nothing.
|
|
102
|
+
function footSig(items) {
|
|
103
|
+
return items.map((i) => `${i.kind}:${i.key}:${i.state || "live"}`).join("|");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The footprint as an element (the hand-off's), or null.
|
|
107
|
+
export function footprintNode(items, ordinal) {
|
|
108
|
+
const template = footprintTemplate(items, ordinal);
|
|
109
|
+
if (template === nothing) return null;
|
|
110
|
+
const frag = document.createDocumentFragment();
|
|
111
|
+
render(template, frag);
|
|
112
|
+
return frag.querySelector(".ws-foot");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function ringTemplate(pct) {
|
|
116
|
+
if (!Number.isFinite(pct)) return nothing;
|
|
117
|
+
const value = Math.max(0, Math.min(100, Math.round(pct)));
|
|
118
|
+
const len = 2 * Math.PI * 5.5;
|
|
119
|
+
const bucket = value >= 90 ? " danger" : value >= 70 ? " warn" : "";
|
|
120
|
+
return html`<svg class=${`ws-ring${bucket}`} viewBox="0 0 14 14" role="img">${svg`<title>context ${value}% used</title><circle class="track" cx="7" cy="7" r="5.5"></circle><circle class="fill" cx="7" cy="7" r="5.5" stroke-dasharray=${`${(len * value) / 100} ${len}`}></circle>`}</svg>`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// A group's member as the popover names it: its path below the group's
|
|
124
|
+
// directory ("adapters/by_api.rb"), else its leaf text.
|
|
125
|
+
function memberName(item, group) {
|
|
126
|
+
if (item.kind === "file" && group?.dir) return String(item.path || item.key).slice(group.dir.length + 1);
|
|
127
|
+
return item.label;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// +historyEl+: where finished turns are; +stageRoot()+: the stage (the live
|
|
131
|
+
// turn's block); +state()+: the open session's fields (session_state.js:
|
|
132
|
+
// ctxPct and the preloaded, muted and used memories); +session()+: its id;
|
|
133
|
+
// +infoEl+: the session bar's line (the chip's popover is delegated there);
|
|
134
|
+
// +onChange()+: the chip's count or dot changed (app.js updateInfoBar).
|
|
135
|
+
// +getWorkingSet(id)+: the server's projection (data.js; null: none asked).
|
|
136
|
+
// +sendLine(line)+: posts a steer line as the session's command (null: no
|
|
137
|
+
// steering, the list reads only).
|
|
138
|
+
export function createWorkingSet({ historyEl, stageRoot = () => null, state, session = () => null, infoEl, onChange = () => {}, getWorkingSet = null,
|
|
139
|
+
sendLine = null, doc = document }) {
|
|
140
|
+
// The finished turns' sets ({items, prompt}), the running turn's (null:
|
|
141
|
+
// none), the rail's first-sight times (its fresh cue), the session count
|
|
142
|
+
// at the last open (the chip's dot).
|
|
143
|
+
let turns = [];
|
|
144
|
+
let live = null;
|
|
145
|
+
let seenAt = () => undefined;
|
|
146
|
+
let openedCount = 0;
|
|
147
|
+
// The open view: its scope ("turn" / "session"), its turn, the group open
|
|
148
|
+
// in it.
|
|
149
|
+
let view = { scope: "session", turn: null };
|
|
150
|
+
let openGroup = null;
|
|
151
|
+
// The server's snapshot ({items, at}) and the session it is of; what
|
|
152
|
+
// redraws the rail when it changes. +events+ counts the events taken, so
|
|
153
|
+
// a GET knows whether one came while it was on its way.
|
|
154
|
+
let server = null;
|
|
155
|
+
let serverOf = null;
|
|
156
|
+
let events = 0;
|
|
157
|
+
// The steer lines queued for after the running turn, by command id, and
|
|
158
|
+
// the session they are of; the row whose action strip is open (a sheet).
|
|
159
|
+
const queued = new Map();
|
|
160
|
+
let queuedOf = null;
|
|
161
|
+
let stripOpen = null;
|
|
162
|
+
const listeners = new Set();
|
|
163
|
+
const lit = (items) => overlay(items, server);
|
|
164
|
+
|
|
165
|
+
const sessionSet = () => {
|
|
166
|
+
const st = state();
|
|
167
|
+
const set = sessionItems({
|
|
168
|
+
turns: turns.map((t) => t.items), live: live?.items || [], preloaded: st.preloadedMemories, muted: st.mutedMemories,
|
|
169
|
+
used: st.usedMemories,
|
|
170
|
+
});
|
|
171
|
+
return { items: lit(set.items), muted: set.muted };
|
|
172
|
+
};
|
|
173
|
+
const turnItems = (t) => lit(live && t === live.ordinal ? live.items : turns[t]?.items || []);
|
|
174
|
+
const promptOf = (t) => (live && t === live.ordinal ? live.prompt : turns[t]?.prompt || "");
|
|
175
|
+
|
|
176
|
+
const popover = createChipPopover({
|
|
177
|
+
id: "workingSetPopover", className: "ctx-popover ws-popover", width: 420, el: infoEl, anchor: ".ws-chip",
|
|
178
|
+
openers: ".ws-chip, .ts-ws, .ws-foot", sheet: true,
|
|
179
|
+
// The chip opens the session's scope; the rail and a footprint set
|
|
180
|
+
// theirs before they open it.
|
|
181
|
+
summary: (anchor) => {
|
|
182
|
+
if (anchor?.matches?.(".ws-chip")) {
|
|
183
|
+
view = { scope: "session", turn: live ? live.ordinal : turns.length - 1 };
|
|
184
|
+
// Opened: the chip's dot goes (chip()).
|
|
185
|
+
queueMicrotask(onChange);
|
|
186
|
+
}
|
|
187
|
+
return { view };
|
|
188
|
+
},
|
|
189
|
+
session,
|
|
190
|
+
// Opened: the server's set asked again (a file changed while the
|
|
191
|
+
// session sat idle shows stale).
|
|
192
|
+
show: (_data, opening) => {
|
|
193
|
+
draw();
|
|
194
|
+
if (opening) fetchServer();
|
|
195
|
+
},
|
|
196
|
+
onClose: () => { openGroup = null; onChange(); },
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const pending = () => pendingSteers([...queued.values()]);
|
|
200
|
+
|
|
201
|
+
// A steer button: +op+ on +items+ ("undo" sends the inverse of a pending op).
|
|
202
|
+
function actButton(op, items, { label = op, title = "", disabled = false } = {}) {
|
|
203
|
+
const ids = items.map(steerId).join(" ");
|
|
204
|
+
return html`<button type="button" class=${`ws-act-b ${op}`} data-ws="steer" data-op=${op} data-ids=${ids} title=${title} ?disabled=${disabled}>${label}</button>`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// What a row offers: a pending op's pill and undo, an undrop, a drop (or
|
|
208
|
+
// why not); nothing for an item no op applies to.
|
|
209
|
+
function rowActions(item, pend) {
|
|
210
|
+
if (!sendLine) return { pill: nothing, buttons: nothing };
|
|
211
|
+
const op = pend.get(steerId(item));
|
|
212
|
+
if (op) {
|
|
213
|
+
const pill = html`<span class="ws-pend" title="applies at the next turn's start">${op === "drop" ? "drops next turn" : "back next turn"}</span>`;
|
|
214
|
+
return { pill, buttons: actButton(INVERSE[op], [item], { label: "undo", title: `take back the queued ${op}` }) };
|
|
215
|
+
}
|
|
216
|
+
if (item.state === "dropped") {
|
|
217
|
+
return { pill: nothing, buttons: actButton("undrop", [item], { title: "send it whole again from the next request (a cache break)" }) };
|
|
218
|
+
}
|
|
219
|
+
const why = dropRefusal(item);
|
|
220
|
+
if (why === null) return { pill: nothing, buttons: nothing };
|
|
221
|
+
return { pill: nothing, buttons: actButton("drop", [item], { title: why || "send a stub instead of its reads from the next turn's start", disabled: !!why }) };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function rowTemplate(item, { session: inSession, group = null, now, pend = new Map() }) {
|
|
225
|
+
const fresh = isFresh(seenAt(item), now);
|
|
226
|
+
const tag = stepTag(item, { session: inSession });
|
|
227
|
+
const words = `${opsText(item)}${fresh ? " · just now" : ""}`;
|
|
228
|
+
const glyph = item.muted ? GLYPHS.muted : fresh ? "●" : GLYPHS[item.kind] || "";
|
|
229
|
+
const name = memberName(item, group);
|
|
230
|
+
const full = item.kind === "file" ? item.path || item.key : item.kind === "url" ? item.url || item.key : item.key;
|
|
231
|
+
let nameEl;
|
|
232
|
+
if (item.kind === "file") {
|
|
233
|
+
const ref = item.ref || { path: full };
|
|
234
|
+
nameEl = html`<span class="t ref" data-ref-kind="file" data-ref-path=${ref.path || full} data-ref-line=${ref.line || nothing} data-ref-end-line=${ref.end_line || nothing} title=${full}>${name}</span>`;
|
|
235
|
+
} else if (item.kind === "url" && /^https?:\/\//i.test(full)) {
|
|
236
|
+
nameEl = html`<a class="t" href=${full} target="_blank" rel="noopener noreferrer" title=${full}>${name}</a>`;
|
|
237
|
+
} else {
|
|
238
|
+
nameEl = html`<span class="t" title=${full}>${name}</span>`;
|
|
239
|
+
}
|
|
240
|
+
let tagEl = nothing;
|
|
241
|
+
if (tag === "prompt") tagEl = html`<span class="ws-step p" title="preloaded into the system prompt">prompt</span>`;
|
|
242
|
+
else if (tag) {
|
|
243
|
+
const prompt = inSession ? promptOf(item.at.turn) : "";
|
|
244
|
+
const where = inSession ? `turn ${item.at.turn + 1}${prompt ? ` (${prompt})` : ""}, step ${item.at.step + 1}` : `step ${item.at.step + 1}`;
|
|
245
|
+
tagEl = html`<button type="button" class=${`ws-step${item.kind === "memory" ? " p" : ""}`} data-ws="jump" data-at=${atAttr(item.at)} title=${`entered at ${where}: go to the call`}>${tag}</button>`;
|
|
246
|
+
}
|
|
247
|
+
const edited = item.kind === "file" && item.ops.includes("edit");
|
|
248
|
+
const state = item.state && item.state !== "live" ? ` ${item.state}` : "";
|
|
249
|
+
const tokens = tokensText(item.tokens);
|
|
250
|
+
const id = steerId(item);
|
|
251
|
+
const { pill, buttons } = rowActions(item, pend);
|
|
252
|
+
const isPending = pill !== nothing;
|
|
253
|
+
const strip = stripOpen === id && buttons !== nothing;
|
|
254
|
+
const open = item.kind === "file" ? html`<button type="button" class="ws-act-b open ref" data-ref-kind="file" data-ref-path=${item.ref?.path || full}>open</button>` : nothing;
|
|
255
|
+
const row = html`<div class=${`ws-row ${item.kind}${fresh ? " fresh" : ""}${item.muted ? " muted" : ""}${group ? " member" : ""}${state}${isPending ? " pending" : ""}${strip ? " strip-open" : ""}`} data-ws-id=${id}><span class="k">${glyph}</span>${nameEl}${edited ? html`<span class="e">✎</span>` : nothing}<span class="st">${words}</span>${pill}${tokens ? html`<span class="tk" title=${`about ${item.tokens} tokens of it are sent`}>${tokens}</span>` : nothing}${tagEl}${buttons !== nothing ? html`<span class="ws-act">${buttons}</span>` : nothing}</div>`;
|
|
256
|
+
// One template either way, so the tapped row stays the same node (a
|
|
257
|
+
// click outside the popover closes it).
|
|
258
|
+
return html`${row}${strip ? html`<div class="ws-strip">${buttons}${open}</div>` : nothing}`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// A section's labels: leaves as rows, groups as an opener row and, open,
|
|
262
|
+
// their members.
|
|
263
|
+
function labelsTemplate(labels, opts) {
|
|
264
|
+
return labels.map((label) => {
|
|
265
|
+
if (label.type === "leaf") return rowTemplate(label.members[0], opts);
|
|
266
|
+
const open = openGroup === label.id;
|
|
267
|
+
const state = label.state && label.state !== "live" ? ` ${label.state}` : "";
|
|
268
|
+
const head = html`<button type="button" class=${`ws-row ws-group-head ${label.kind}${state}`} data-ws="group" data-id=${label.id} aria-expanded=${String(open)} title=${labelTitle(label)}><span class="k">${open ? "▾" : "▸"}</span><span class="t">${label.glyph ? `${label.glyph} ` : ""}${label.text}</span>${label.count ? html`<span class="n">· ${label.count}</span>` : nothing}${label.edited ? html`<span class="e">✎${label.edited}</span>` : nothing}${label.cut ? html`<span class="c" title=${`${label.cut} cut (forgotten or dropped)`}>✂${label.cut}</span>` : nothing}<span class="chev">${open ? "close" : "open"}</span></button>`;
|
|
269
|
+
// A group drops its members that can be, not pending already.
|
|
270
|
+
const droppable = sendLine ? label.members.filter((m) => dropRefusal(m) === "" && !opts.pend.has(steerId(m))) : [];
|
|
271
|
+
const act = droppable.length ? html`<span class="ws-act">${actButton("drop", droppable, { label: `drop ${droppable.length}`, title: "send stubs instead of their reads from the next turn's start" })}</span>` : nothing;
|
|
272
|
+
return html`<div class="ws-ghead">${head}${act}</div>${open ? label.members.map((m) => rowTemplate(m, { ...opts, group: label })) : nothing}`;
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function draw() {
|
|
277
|
+
const now = Date.now();
|
|
278
|
+
const st = state();
|
|
279
|
+
const inSession = view.scope === "session";
|
|
280
|
+
const set = inSession ? sessionSet() : { items: turnItems(view.turn), muted: [] };
|
|
281
|
+
const files = set.items.filter((i) => i.kind === "file");
|
|
282
|
+
const memories = set.items.filter((i) => i.kind === "memory");
|
|
283
|
+
const urls = set.items.filter((i) => i.kind === "url");
|
|
284
|
+
const pend = pending();
|
|
285
|
+
const opts = { session: inSession, now, pend };
|
|
286
|
+
const fileLabels = fold(files, POP_BUDGET).labels;
|
|
287
|
+
// Memories are rows of their own; past the budget one group that opens.
|
|
288
|
+
const memoryLabels = fold(memories, memories.length > POP_BUDGET ? 1 : Infinity).labels;
|
|
289
|
+
const preloaded = memories.filter((m) => m.preloaded).length;
|
|
290
|
+
const memMeta = [`${memories.length - preloaded} used`, preloaded ? `${preloaded} preloaded` : "", set.muted.length ? `${set.muted.length} muted` : ""].filter(Boolean).join(" · ");
|
|
291
|
+
const turnLabel = view.turn !== null && view.turn >= 0 ? `turn ${view.turn + 1}` : "this turn";
|
|
292
|
+
const pct = Number.isFinite(st.ctxPct) ? ` · ctx ${Math.round(st.ctxPct)} %` : "";
|
|
293
|
+
const empty = !set.items.length && !set.muted.length;
|
|
294
|
+
const tpl = html`<div class="ctx-pop-head"><strong>working set</strong><span class="ws-scope" role="group" aria-label="Scope"><button type="button" class=${!inSession ? "on" : ""} aria-pressed=${String(!inSession)} data-ws="scope" data-scope="turn" ?disabled=${view.turn === null || view.turn < 0}>${turnLabel}</button><button type="button" class=${inSession ? "on" : ""} aria-pressed=${String(inSession)} data-ws="scope" data-scope="session">session</button></span><span class="ctx-pop-kind">${set.items.length} item${set.items.length === 1 ? "" : "s"}${pct}</span>${ringTemplate(st.ctxPct)}<button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div>
|
|
295
|
+
${empty ? html`<div class="ctx-pop-meta">Nothing yet: the files, memories and URLs ${inSession ? "this session's turns read" : "this turn reads"} show here.</div>` : nothing}
|
|
296
|
+
${files.length ? html`<div class="ctx-pop-meta">files · ${files.length}</div>${labelsTemplate(fileLabels, opts)}` : nothing}
|
|
297
|
+
${memories.length || set.muted.length ? html`<div class="ctx-pop-meta">memories${memMeta ? ` · ${memMeta}` : ""}</div>${labelsTemplate(memoryLabels, opts)}${set.muted.map((m) => rowTemplate(m, opts))}` : nothing}
|
|
298
|
+
${urls.length ? html`<div class="ctx-pop-meta">urls · ${urls.length}</div>${labelsTemplate(fold(urls, POP_BUDGET).labels, opts)}` : nothing}
|
|
299
|
+
${pend.size ? html`<div class="ws-pending" role="status">${pend.size} change${pend.size === 1 ? "" : "s"} pending · apply at the next turn's start · <button type="button" class="ws-act-b" data-ws="undo-all">undo all</button></div>` : nothing}
|
|
300
|
+
<div class="ctx-pop-meta ws-hint">A file opens its ref; a step tag goes to the call.${sendLine ? " Drop sends stubs instead of an item's reads from the next turn." : ""} Esc closes.</div>`;
|
|
301
|
+
render(tpl, popover.pop);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// The row at +at+ ({turn, step, row}): the stage's block for the live
|
|
305
|
+
// turn, else the history's.
|
|
306
|
+
function rowAt(at) {
|
|
307
|
+
const sel = `.turn-work[data-turn="${at.turn}"]`;
|
|
308
|
+
const blocks = [...(stageRoot()?.querySelectorAll(sel) || []), ...[...historyEl.querySelectorAll(sel)].reverse()];
|
|
309
|
+
for (const block of blocks) {
|
|
310
|
+
const row = block.querySelector(`.activity-row[data-at="${at.step}.${at.row}"]`);
|
|
311
|
+
if (row) return row;
|
|
312
|
+
}
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function jump(at) {
|
|
317
|
+
const row = rowAt(at);
|
|
318
|
+
if (!row) return false;
|
|
319
|
+
if (popover.isOpen()) popover.close();
|
|
320
|
+
showRow(row);
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const parseAt = (text) => {
|
|
325
|
+
const [turn, step, row] = String(text).split(".").map(Number);
|
|
326
|
+
return [turn, step, row].every(Number.isInteger) ? { turn, step, row } : null;
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
// Posts +op+ for the ids (the items' steer words, from the set).
|
|
330
|
+
function steer(op, ids) {
|
|
331
|
+
if (!sendLine || !ids.length) return;
|
|
332
|
+
const all = [...sessionSet().items, ...turnItems(view.turn)];
|
|
333
|
+
const byId = new Map(all.map((it) => [steerId(it), it]));
|
|
334
|
+
const items = ids.map((id) => byId.get(id)).filter(Boolean);
|
|
335
|
+
if (items.length) Promise.resolve(sendLine(steerLine(op, items))).catch(() => {});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
popover.pop.addEventListener("click", (e) => {
|
|
339
|
+
const act = e.target.closest("[data-ws]");
|
|
340
|
+
// A sheet's row: a tap outside its links and buttons opens its strip.
|
|
341
|
+
if (!act && popover.pop.classList.contains("sheet")) {
|
|
342
|
+
const row = e.target.closest(".ws-row[data-ws-id]");
|
|
343
|
+
if (row && !e.target.closest("a, button, .ref")) {
|
|
344
|
+
stripOpen = stripOpen === row.dataset.wsId ? null : row.dataset.wsId;
|
|
345
|
+
draw();
|
|
346
|
+
popover.replace();
|
|
347
|
+
}
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (!act) return;
|
|
351
|
+
if (act.dataset.ws === "steer") {
|
|
352
|
+
steer(act.dataset.op, act.dataset.ids.split(" ").filter(Boolean));
|
|
353
|
+
} else if (act.dataset.ws === "undo-all") {
|
|
354
|
+
const pend = pending();
|
|
355
|
+
for (const op of ["drop", "undrop"]) {
|
|
356
|
+
const ids = [...pend].filter(([, o]) => o === op).map(([id]) => id);
|
|
357
|
+
steer(INVERSE[op], ids);
|
|
358
|
+
}
|
|
359
|
+
} else if (act.dataset.ws === "jump") {
|
|
360
|
+
const at = parseAt(act.dataset.at);
|
|
361
|
+
if (at) jump(at);
|
|
362
|
+
} else if (act.dataset.ws === "scope") {
|
|
363
|
+
view = { ...view, scope: act.dataset.scope };
|
|
364
|
+
openGroup = null;
|
|
365
|
+
draw();
|
|
366
|
+
popover.replace();
|
|
367
|
+
} else if (act.dataset.ws === "group") {
|
|
368
|
+
openGroup = openGroup === act.dataset.id ? null : act.dataset.id;
|
|
369
|
+
draw();
|
|
370
|
+
popover.replace();
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// A footprint: its step tag jumps, the rest opens that turn's view.
|
|
375
|
+
function footClick(e) {
|
|
376
|
+
const foot = e.target.closest?.(".ws-foot");
|
|
377
|
+
if (!foot) return;
|
|
378
|
+
const tag = e.target.closest(".ws-step[data-at]");
|
|
379
|
+
if (tag) {
|
|
380
|
+
const at = parseAt(tag.dataset.at);
|
|
381
|
+
if (at) jump(at);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (popover.isOpen() && popover.opener() === foot) return popover.close();
|
|
385
|
+
view = { scope: "turn", turn: Number(foot.dataset.wsTurn) };
|
|
386
|
+
popover.open(foot);
|
|
387
|
+
}
|
|
388
|
+
doc.addEventListener("click", footClick);
|
|
389
|
+
doc.addEventListener("keydown", (e) => {
|
|
390
|
+
if ((e.key === "Enter" || e.key === " ") && e.target.matches?.(".ws-foot")) {
|
|
391
|
+
e.preventDefault();
|
|
392
|
+
footClick(e);
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
function changed() {
|
|
397
|
+
onChange();
|
|
398
|
+
if (popover.isOpen()) popover.refresh();
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// The footprints on the page (the history's, the hand-off's) drawn again
|
|
402
|
+
// in place when their items' states changed: the same node (its focus,
|
|
403
|
+
// the popover's opener), its children and label replaced.
|
|
404
|
+
function refreshFootprints() {
|
|
405
|
+
for (const root of [historyEl, stageRoot()]) {
|
|
406
|
+
for (const foot of root?.querySelectorAll(".ws-foot[data-ws-turn]") || []) {
|
|
407
|
+
const ordinal = Number(foot.dataset.wsTurn);
|
|
408
|
+
if (!Number.isInteger(ordinal)) continue;
|
|
409
|
+
const items = turnItems(ordinal);
|
|
410
|
+
if (!items.length || foot.dataset.wsSig === footSig(items)) continue;
|
|
411
|
+
const next = footprintNode(items, ordinal);
|
|
412
|
+
if (!next) continue;
|
|
413
|
+
foot.replaceChildren(...next.childNodes);
|
|
414
|
+
foot.setAttribute("aria-label", next.getAttribute("aria-label"));
|
|
415
|
+
foot.dataset.wsSig = next.dataset.wsSig;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// A newer server snapshot redraws every form; an older one (a GET that
|
|
421
|
+
// answered after a newer event) is dropped. +how+: newerSnapshot's.
|
|
422
|
+
function takeServer(snapshot, how) {
|
|
423
|
+
const next = newerSnapshot(server, snapshot, how);
|
|
424
|
+
if (next === server) return;
|
|
425
|
+
server = next;
|
|
426
|
+
serverOf = session();
|
|
427
|
+
changed();
|
|
428
|
+
refreshFootprints();
|
|
429
|
+
for (const fn of listeners) fn();
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// The server's set for the open session, asked again; an answer for a
|
|
433
|
+
// session no longer open is dropped.
|
|
434
|
+
function fetchServer() {
|
|
435
|
+
const id = session();
|
|
436
|
+
if (!getWorkingSet || !id) return;
|
|
437
|
+
const asked = events;
|
|
438
|
+
Promise.resolve()
|
|
439
|
+
.then(() => getWorkingSet(id))
|
|
440
|
+
.then((snapshot) => { if (session() === id) takeServer(snapshot, { overtaken: events !== asked }); })
|
|
441
|
+
.catch(() => {});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return {
|
|
445
|
+
// A session's history drawn anew: its turns' sets (+list+: [{items,
|
|
446
|
+
// prompt}]), no running turn yet; the chip's dot starts off.
|
|
447
|
+
seed(list) {
|
|
448
|
+
turns = list.slice();
|
|
449
|
+
live = null;
|
|
450
|
+
// The snapshot's replay (after this) brings the queued lines again.
|
|
451
|
+
queued.clear();
|
|
452
|
+
queuedOf = session();
|
|
453
|
+
stripOpen = null;
|
|
454
|
+
// Another session's snapshot goes; the same session's (a resync, after
|
|
455
|
+
// a steer from the list) stays until the newer one comes, and so does
|
|
456
|
+
// the open list.
|
|
457
|
+
if (serverOf !== session()) {
|
|
458
|
+
if (popover.isOpen()) popover.close();
|
|
459
|
+
server = null;
|
|
460
|
+
serverOf = session();
|
|
461
|
+
} else if (popover.isOpen()) {
|
|
462
|
+
if (view.turn !== null && view.turn >= turns.length) view = { scope: "session", turn: turns.length - 1 };
|
|
463
|
+
popover.refresh();
|
|
464
|
+
}
|
|
465
|
+
openedCount = sessionSet().items.length;
|
|
466
|
+
refreshFootprints();
|
|
467
|
+
fetchServer();
|
|
468
|
+
},
|
|
469
|
+
// A :working_set_updated event's set ({items, at}).
|
|
470
|
+
update(snapshot) {
|
|
471
|
+
events += 1;
|
|
472
|
+
takeServer(snapshot, { event: true });
|
|
473
|
+
},
|
|
474
|
+
// +fn()+ runs after each new server snapshot (the rail's redraw).
|
|
475
|
+
onServer(fn) { listeners.add(fn); },
|
|
476
|
+
// A command queued for after the running turn (its command_queued,
|
|
477
|
+
// waits "turn_end"): a steer line is pending until its command_ran.
|
|
478
|
+
commandQueued(event) {
|
|
479
|
+
if (queuedOf !== session()) {
|
|
480
|
+
queued.clear();
|
|
481
|
+
queuedOf = session();
|
|
482
|
+
}
|
|
483
|
+
if (event?.waits !== "turn_end" || !event.command_id || !parseSteerLine(event.line)) return;
|
|
484
|
+
queued.set(event.command_id, event.line);
|
|
485
|
+
changed();
|
|
486
|
+
},
|
|
487
|
+
// A command ran (or was dropped): its line is no longer pending.
|
|
488
|
+
commandRan(event) {
|
|
489
|
+
if (event?.command_id && queued.delete(event.command_id)) changed();
|
|
490
|
+
},
|
|
491
|
+
// The steer lines pending (for the specs and the rail).
|
|
492
|
+
pending: () => pending(),
|
|
493
|
+
// The running turn's items (its model +turn+), its ordinal the next
|
|
494
|
+
// turn's; the chip follows.
|
|
495
|
+
liveItems(turn, prompt = "") {
|
|
496
|
+
const ordinal = turns.length;
|
|
497
|
+
const items = itemsOfTurn(turn, ordinal);
|
|
498
|
+
const before = live?.items.length ?? -1;
|
|
499
|
+
live = { ordinal, items, prompt };
|
|
500
|
+
if (items.length !== before) changed();
|
|
501
|
+
return { items: lit(items), ordinal };
|
|
502
|
+
},
|
|
503
|
+
// The rail's first-sight times, for the popover's fresh rows.
|
|
504
|
+
setSeen(fn) { seenAt = fn; },
|
|
505
|
+
// The running turn hands off: its set joins the turns; its footprint
|
|
506
|
+
// line (null: it touched nothing) goes after its block.
|
|
507
|
+
handOff(turn, prompt = "") {
|
|
508
|
+
const ordinal = turns.length;
|
|
509
|
+
const items = itemsOfTurn(turn, ordinal);
|
|
510
|
+
turns.push({ items, prompt });
|
|
511
|
+
live = null;
|
|
512
|
+
changed();
|
|
513
|
+
return footprintNode(lit(items), ordinal);
|
|
514
|
+
},
|
|
515
|
+
// The running turn left without a hand-off (a reset): forgotten.
|
|
516
|
+
release() {
|
|
517
|
+
if (!live) return;
|
|
518
|
+
live = null;
|
|
519
|
+
changed();
|
|
520
|
+
},
|
|
521
|
+
// The rail opens the running turn's view.
|
|
522
|
+
openTurn(anchor) {
|
|
523
|
+
if (popover.isOpen() && popover.opener() === anchor) return popover.close();
|
|
524
|
+
view = { scope: "turn", turn: live ? live.ordinal : turns.length - 1 };
|
|
525
|
+
popover.open(anchor);
|
|
526
|
+
openedCount = sessionSet().items.length;
|
|
527
|
+
onChange();
|
|
528
|
+
},
|
|
529
|
+
// The session bar's chip: {n, changed} (the dot), null when the set is
|
|
530
|
+
// empty (no chip). Opening it clears the dot.
|
|
531
|
+
chip() {
|
|
532
|
+
const n = sessionSet().items.length;
|
|
533
|
+
if (popover.isOpen()) openedCount = n;
|
|
534
|
+
return n ? { n, changed: n !== openedCount } : null;
|
|
535
|
+
},
|
|
536
|
+
jump,
|
|
537
|
+
ctxPct: () => state().ctxPct,
|
|
538
|
+
isOpen: () => popover.isOpen(),
|
|
539
|
+
popover,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
@@ -66,7 +66,8 @@ module Samagotchi
|
|
|
66
66
|
# @return [Integer] the exit status
|
|
67
67
|
def self.launch(port: nil, host: nil, scope: "project", dir: Dir.pwd, open_browser: false, markdown: false,
|
|
68
68
|
view: Config::BY_KEY["web.view"].default, annotate_presets: Config::BY_KEY["web.annotate_presets"].default,
|
|
69
|
-
editor: Config::BY_KEY["web.editor"].default, new_token: false
|
|
69
|
+
editor: Config::BY_KEY["web.editor"].default, new_token: false,
|
|
70
|
+
working_set: Config::BY_KEY["web.working_set"].default)
|
|
70
71
|
rotate_token if new_token
|
|
71
72
|
setting = host_setting(host)
|
|
72
73
|
lan = Lan.wanted?(setting) ? Lan.choose(setting) : nil
|
|
@@ -95,7 +96,7 @@ module Samagotchi
|
|
|
95
96
|
return 0
|
|
96
97
|
end
|
|
97
98
|
if start(port: port, host: host, url: url, open_browser: open_browser, markdown: markdown, view: view,
|
|
98
|
-
annotate_presets: annotate_presets, editor: editor, lan: lan)
|
|
99
|
+
annotate_presets: annotate_presets, editor: editor, lan: lan, working_set: working_set)
|
|
99
100
|
0
|
|
100
101
|
else
|
|
101
102
|
1
|
|
@@ -119,7 +120,8 @@ module Samagotchi
|
|
|
119
120
|
# @return [Boolean] false when the port was taken (said so on stderr)
|
|
120
121
|
def self.start(port: nil, host: nil, url: nil, open_browser: false, state_dir: nil, manager: nil, markdown: false,
|
|
121
122
|
view: Config::BY_KEY["web.view"].default, annotate_presets: Config::BY_KEY["web.annotate_presets"].default, hub: nil, lan: nil,
|
|
122
|
-
editor: Config::BY_KEY["web.editor"].default, token_path: Token.path
|
|
123
|
+
editor: Config::BY_KEY["web.editor"].default, token_path: Token.path,
|
|
124
|
+
working_set: Config::BY_KEY["web.working_set"].default)
|
|
123
125
|
config_restart_unsubscribe = ConfigFile.watch_restart_settings!(process: :web) do |message|
|
|
124
126
|
Samagotchi::Log.warn(:config, "restart_required", echo: "Warning: config: #{message}")
|
|
125
127
|
end
|
|
@@ -133,7 +135,7 @@ module Samagotchi
|
|
|
133
135
|
lan_option = { ip: lan.ip, token: Token::Source.new(token_path) }
|
|
134
136
|
end
|
|
135
137
|
app = App.new(manager: manager, state_dir: state_dir, markdown: markdown, view: view,
|
|
136
|
-
annotate_presets: annotate_presets, editor: editor, hub: hub, lan: lan_option)
|
|
138
|
+
annotate_presets: annotate_presets, editor: editor, hub: hub, lan: lan_option, working_set: working_set)
|
|
137
139
|
Samagotchi::Log.info(:web, "start", url: "http://#{host}:#{port}", version: Samagotchi::VERSION)
|
|
138
140
|
Samagotchi::Log.info(:web, "lan", ip: lan.ip, interface: lan.interface) if lan
|
|
139
141
|
# Subscribed before the first scan, which makes the first check.
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -259,7 +259,7 @@ module Samagotchi
|
|
|
259
259
|
})
|
|
260
260
|
# A recap written while a continue offer waits says the turn stopped
|
|
261
261
|
# unfinished (before the idle jobs start).
|
|
262
|
-
@engine.
|
|
262
|
+
@engine.recap_awaiting_continue = -> { @turn_flow.awaiting_continue? }
|
|
263
263
|
# The seq of the last turn's end event: a command queued before it was
|
|
264
264
|
# queued while that turn ran.
|
|
265
265
|
@turn_end_seq = 0
|
|
@@ -943,14 +943,17 @@ module Samagotchi
|
|
|
943
943
|
# finishes) comes after its command_queued. The cards it shows go out at
|
|
944
944
|
# once (btw's "thinking…" card), after its command_queued, which the
|
|
945
945
|
# UIs draw its line at (Engine#running_anytime). Its handler reads
|
|
946
|
-
# copies (ctx.messages) and shows things through ctx only. One
|
|
947
|
-
# /title,
|
|
948
|
-
#
|
|
949
|
-
#
|
|
950
|
-
#
|
|
946
|
+
# copies (ctx.messages) and shows things through ctx only. One that
|
|
947
|
+
# changed the session (/title, /recap off: fields no turn writes) is
|
|
948
|
+
# saved here, as a queued one is (#run_command), through Session#save,
|
|
949
|
+
# which takes the session's lock and writes a running turn's checkpoint
|
|
950
|
+
# (as WorkerSignals' save off the turn thread does), so the turn's own
|
|
951
|
+
# saves keep it.
|
|
951
952
|
def start_anytime_command(command)
|
|
952
953
|
@engine.spawn_anytime do
|
|
953
954
|
result = command_result(command) { @engine.running_anytime { @commands.run(command.line, confirm: confirm?(command)) } }
|
|
955
|
+
# Saved before its command_ran: a client that reads the file on the reply finds it.
|
|
956
|
+
save_or_log(:command) { save_session } unless Array(result.changed).empty?
|
|
954
957
|
announce_command(command, status: result.status.to_s, output: result.output, changed: Array(result.changed),
|
|
955
958
|
anytime: true, confirm: result.confirm)
|
|
956
959
|
rescue StandardError => e
|