@frockbot/plugin-shell 0.3.11 → 0.3.13

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 (42) hide show
  1. package/package.json +35 -33
  2. package/src/agent.test.ts +78 -0
  3. package/src/agent.ts +130 -2
  4. package/src/backend-configuration.test.ts +26 -26
  5. package/src/backend-recovery-integration.test.ts +10 -10
  6. package/src/backend-runner.ts +19 -2
  7. package/src/backend.ts +85 -18
  8. package/src/client/AppletCanvas.vue +19 -6
  9. package/src/client/FrockBotApp.vue +405 -75
  10. package/src/client/activity-trail.test.ts +205 -0
  11. package/src/client/activity-trail.ts +227 -0
  12. package/src/client/applets-client.test.ts +62 -0
  13. package/src/client/applets-client.ts +19 -0
  14. package/src/client/index.test.ts +128 -21
  15. package/src/client/index.ts +359 -114
  16. package/src/client/model-presentation.test.ts +3 -3
  17. package/src/client/no-bot-model-label.test.ts +7 -7
  18. package/src/client/skill-invocation.test.ts +34 -0
  19. package/src/client/skill-invocation.ts +22 -0
  20. package/src/client/styles.css +69 -19
  21. package/src/client/transcript-cache.test.ts +125 -0
  22. package/src/client/transcript-cache.ts +190 -0
  23. package/src/compaction-scheduler.test.ts +96 -0
  24. package/src/compaction-scheduler.ts +108 -0
  25. package/src/compaction-transcript.test.ts +174 -0
  26. package/src/compaction.test.ts +596 -0
  27. package/src/compaction.ts +539 -0
  28. package/src/focus.test.ts +222 -0
  29. package/src/focus.ts +93 -0
  30. package/src/history.ts +86 -8
  31. package/src/legacy-frock-model-id.test.ts +148 -0
  32. package/src/notification-id.ts +0 -0
  33. package/src/run-failure-copy.test.ts +150 -0
  34. package/src/run-failure-copy.ts +110 -0
  35. package/src/run-protocol.test.ts +50 -7
  36. package/src/run-protocol.ts +152 -43
  37. package/src/settings-links.test.ts +8 -2
  38. package/src/settings-links.ts +11 -2
  39. package/src/shared.ts +36 -0
  40. package/tsconfig.json +1 -2
  41. package/src/client/activity-ring.test.ts +0 -89
  42. package/src/client/activity-ring.ts +0 -94
@@ -0,0 +1,205 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ ACTIVITY_TRAIL_CHARACTERS_PER_PARTICLE_V1,
4
+ ACTIVITY_TRAIL_MAX_BURSTS_PER_STEP_V1,
5
+ ACTIVITY_TRAIL_MAX_RATE_V1,
6
+ ACTIVITY_TRAIL_QUIET_AFTER_MS_V1,
7
+ ACTIVITY_TRAIL_SEND_BURST_V1,
8
+ ACTIVITY_TRAIL_TOOL_BURST_V1,
9
+ ACTIVITY_TRAIL_TRICKLE_RATE_V1,
10
+ activityTrailBeginV1,
11
+ activityTrailSampleV1,
12
+ activityTrailStepV1,
13
+ type ActivityTrailSampleV1,
14
+ } from "./activity-trail.js";
15
+
16
+ function sample(
17
+ overrides: Partial<ActivityTrailSampleV1> = {},
18
+ ): ActivityTrailSampleV1 {
19
+ return {
20
+ characters: 0,
21
+ toolStarts: 0,
22
+ toolSettles: 0,
23
+ sends: 0,
24
+ status: "streaming",
25
+ ...overrides,
26
+ };
27
+ }
28
+
29
+ describe("what the transcript says a Turn is doing", () => {
30
+ test("a tool with no result yet has started but not settled", () => {
31
+ const reading = activityTrailSampleV1({
32
+ text: "half a rep",
33
+ toolStatuses: ["completed", "running"],
34
+ sends: 1,
35
+ status: "streaming",
36
+ });
37
+ expect(reading.characters).toBe("half a rep".length);
38
+ expect(reading.toolStarts).toBe(2);
39
+ expect(reading.toolSettles).toBe(1);
40
+ expect(reading.sends).toBe(1);
41
+ });
42
+ });
43
+
44
+ describe("the comet trail's emission plan", () => {
45
+ test("a Turn that has produced nothing yet still emits", () => {
46
+ // Liveness before pace: the first moment of a Turn is exactly when a
47
+ // person needs to see that something is happening.
48
+ const memory = activityTrailBeginV1(sample(), 0);
49
+ const { plan } = activityTrailStepV1(memory, sample(), 16);
50
+ expect(plan.active).toBe(true);
51
+ expect(plan.state).toBe("running");
52
+ expect(plan.rate).toBe(ACTIVITY_TRAIL_TRICKLE_RATE_V1);
53
+ expect(plan.bursts).toEqual([]);
54
+ });
55
+
56
+ test("the density tracks how fast text is arriving", () => {
57
+ // Two hundred characters inside the averaging window is well above the
58
+ // trickle and below saturation, so the rate is the chunk rate itself.
59
+ let memory = activityTrailBeginV1(sample(), 0);
60
+ const stepped = activityTrailStepV1(
61
+ memory,
62
+ sample({ characters: 200 }),
63
+ 200,
64
+ );
65
+ memory = stepped.memory;
66
+ expect(stepped.plan.rate).toBeGreaterThan(ACTIVITY_TRAIL_TRICKLE_RATE_V1);
67
+ expect(stepped.plan.rate).toBeLessThan(ACTIVITY_TRAIL_MAX_RATE_V1);
68
+ expect(stepped.plan.rate).toBeCloseTo(
69
+ 200 / 1.2 / ACTIVITY_TRAIL_CHARACTERS_PER_PARTICLE_V1,
70
+ 5,
71
+ );
72
+
73
+ // Half as much text in the same window is half the density.
74
+ const slower = activityTrailStepV1(
75
+ activityTrailBeginV1(sample(), 0),
76
+ sample({ characters: 100 }),
77
+ 200,
78
+ );
79
+ expect(slower.plan.rate).toBeCloseTo(stepped.plan.rate / 2, 5);
80
+ });
81
+
82
+ test("a torrent of text saturates rather than running away", () => {
83
+ const { plan } = activityTrailStepV1(
84
+ activityTrailBeginV1(sample(), 0),
85
+ sample({ characters: 100_000 }),
86
+ 100,
87
+ );
88
+ expect(plan.rate).toBe(ACTIVITY_TRAIL_MAX_RATE_V1);
89
+ });
90
+
91
+ test("a tool call starting throws a burst, and settling throws another", () => {
92
+ let memory = activityTrailBeginV1(sample(), 0);
93
+ const started = activityTrailStepV1(memory, sample({ toolStarts: 1 }), 100);
94
+ memory = started.memory;
95
+ expect(started.plan.bursts).toEqual([
96
+ { count: ACTIVITY_TRAIL_TOOL_BURST_V1, speed: 1.8, brightness: 1.15 },
97
+ ]);
98
+
99
+ const settled = activityTrailStepV1(
100
+ memory,
101
+ sample({ toolStarts: 1, toolSettles: 1 }),
102
+ 200,
103
+ );
104
+ expect(settled.plan.bursts).toHaveLength(1);
105
+ expect(settled.plan.bursts[0]?.count).toBe(ACTIVITY_TRAIL_TOOL_BURST_V1);
106
+ });
107
+
108
+ test("a send landing is a brighter puff than a tool call", () => {
109
+ const { plan } = activityTrailStepV1(
110
+ activityTrailBeginV1(sample(), 0),
111
+ sample({ sends: 1 }),
112
+ 100,
113
+ );
114
+ expect(plan.bursts).toEqual([
115
+ { count: ACTIVITY_TRAIL_SEND_BURST_V1, speed: 1.2, brightness: 1.9 },
116
+ ]);
117
+ });
118
+
119
+ test("a whole Turn arriving at once does not fire a hundred bursts", () => {
120
+ // What a reconnect looks like: the projection replays every step of a Turn
121
+ // in one update. The trail says "a lot just happened", not two hundred
122
+ // separate shots into one frame.
123
+ const { plan } = activityTrailStepV1(
124
+ activityTrailBeginV1(sample(), 0),
125
+ sample({ toolStarts: 60, toolSettles: 60, sends: 20 }),
126
+ 100,
127
+ );
128
+ expect(plan.bursts).toHaveLength(ACTIVITY_TRAIL_MAX_BURSTS_PER_STEP_V1);
129
+ });
130
+
131
+ test("an open Turn that has gone quiet trickles rather than stopping", () => {
132
+ let memory = activityTrailBeginV1(sample(), 0);
133
+ memory = activityTrailStepV1(
134
+ memory,
135
+ sample({ characters: 40 }),
136
+ 100,
137
+ ).memory;
138
+
139
+ const soon = activityTrailStepV1(
140
+ memory,
141
+ sample({ characters: 40 }),
142
+ 100 + ACTIVITY_TRAIL_QUIET_AFTER_MS_V1,
143
+ );
144
+ expect(soon.plan.state).toBe("running");
145
+
146
+ const later = activityTrailStepV1(
147
+ memory,
148
+ sample({ characters: 40 }),
149
+ 101 + ACTIVITY_TRAIL_QUIET_AFTER_MS_V1,
150
+ );
151
+ expect(later.plan.active).toBe(true);
152
+ expect(later.plan.state).toBe("waiting");
153
+ expect(later.plan.rate).toBe(ACTIVITY_TRAIL_TRICKLE_RATE_V1);
154
+ expect(later.plan.bursts).toEqual([]);
155
+ });
156
+
157
+ test("a settled Turn emits nothing at all", () => {
158
+ for (const status of [
159
+ "completed",
160
+ "aborted",
161
+ "error",
162
+ "interrupted",
163
+ "reconciliation-required",
164
+ "a status this file has never heard of",
165
+ ]) {
166
+ const { plan } = activityTrailStepV1(
167
+ activityTrailBeginV1(sample(), 0),
168
+ sample({ characters: 400, toolStarts: 3, toolSettles: 3, status }),
169
+ 100,
170
+ );
171
+ expect(plan.active).toBe(false);
172
+ expect(plan.state).toBe("ended");
173
+ expect(plan.rate).toBe(0);
174
+ expect(plan.bursts).toEqual([]);
175
+ }
176
+ });
177
+
178
+ test("text the Turn takes back is no work rather than negative work", () => {
179
+ // A delivered send supersedes the model's own longer draft, so the
180
+ // transcript's text can shrink between two samples.
181
+ const memory = activityTrailBeginV1(sample({ characters: 500 }), 0);
182
+ const { plan } = activityTrailStepV1(
183
+ memory,
184
+ sample({ characters: 4, sends: 1 }),
185
+ 100,
186
+ );
187
+ expect(plan.rate).toBe(ACTIVITY_TRAIL_TRICKLE_RATE_V1);
188
+ expect(plan.bursts).toHaveLength(1);
189
+ });
190
+
191
+ test("the rate window forgets text that arrived long ago", () => {
192
+ let memory = activityTrailBeginV1(sample(), 0);
193
+ memory = activityTrailStepV1(
194
+ memory,
195
+ sample({ characters: 400 }),
196
+ 100,
197
+ ).memory;
198
+ const { plan } = activityTrailStepV1(
199
+ memory,
200
+ sample({ characters: 400 }),
201
+ 100 + ACTIVITY_TRAIL_QUIET_AFTER_MS_V1 + 1,
202
+ );
203
+ expect(plan.rate).toBe(ACTIVITY_TRAIL_TRICKLE_RATE_V1);
204
+ });
205
+ });
@@ -0,0 +1,227 @@
1
+ /**
2
+ * The comet trail: what a running Turn is actually doing, drawn as motion.
3
+ *
4
+ * A Turn can spend a minute streaming tokens and calling tools, and the thread
5
+ * used to answer that with a stroke around the avatar that ticked once per
6
+ * settled step. The stroke was honest but coarse — it said "a step happened"
7
+ * and nothing about the rate work was arriving at. The trail is the finer
8
+ * answer: particles stream off the right of the working Bot's avatar, and
9
+ * their density is the Turn's own pace. Text arriving quickly is a dense
10
+ * stream; a tool call starting or settling throws a burst; a reply landing is
11
+ * a bright puff; a Turn waiting on the model still breathes, faintly, so the
12
+ * app never reads as dead.
13
+ *
14
+ * This module is the whole mapping, kept out of the component so it is
15
+ * testable without a canvas or a mounted Vue tree. It takes samples of what
16
+ * the client already knows about the open Bot's Turn — the transcript's text,
17
+ * its tool activity, its sends, its status — and returns the emission plan for
18
+ * the moment between two samples. It never touches the DOM and never keeps a
19
+ * clock of its own: the caller supplies `now`.
20
+ */
21
+
22
+ /** The most particles a second the trail will ever ask for. */
23
+ export const ACTIVITY_TRAIL_MAX_RATE_V1 = 40;
24
+
25
+ /**
26
+ * The floor while a Turn is open. A Turn waiting on a model that has not sent
27
+ * a token yet is still working, and a trail that stops entirely reads as an
28
+ * app that has crashed.
29
+ */
30
+ export const ACTIVITY_TRAIL_TRICKLE_RATE_V1 = 6;
31
+
32
+ /** Silence longer than this is a wait, not a pause between chunks. */
33
+ export const ACTIVITY_TRAIL_QUIET_AFTER_MS_V1 = 1500;
34
+
35
+ /** How far back the chunk-rate average looks. */
36
+ export const ACTIVITY_TRAIL_RATE_WINDOW_MS_V1 = 1200;
37
+
38
+ /**
39
+ * Characters of streamed text one particle stands for. At the cap above this
40
+ * makes a full stream about 240 characters a second — faster than that and the
41
+ * trail is simply saturated, which is the right thing for it to say.
42
+ */
43
+ export const ACTIVITY_TRAIL_CHARACTERS_PER_PARTICLE_V1 = 6;
44
+
45
+ /** A tool call starting, or its result settling. */
46
+ export const ACTIVITY_TRAIL_TOOL_BURST_V1 = 15;
47
+
48
+ /** A payload reaching the person. */
49
+ export const ACTIVITY_TRAIL_SEND_BURST_V1 = 14;
50
+
51
+ /**
52
+ * The most burst events one step will honour. A transcript that arrives in one
53
+ * lump — a reconnect replaying a whole Turn — must not fire two hundred bursts
54
+ * into the same frame.
55
+ */
56
+ export const ACTIVITY_TRAIL_MAX_BURSTS_PER_STEP_V1 = 4;
57
+
58
+ /**
59
+ * What the trail is saying, and the value the row carries as `data-state` so a
60
+ * spec can assert on it.
61
+ *
62
+ * `running` is work arriving now, `waiting` is an open Turn that has gone
63
+ * quiet, and `ended` is a settled Turn: nothing new is emitted and whatever is
64
+ * on screen drains.
65
+ */
66
+ export type ActivityTrailStateV1 = "running" | "waiting" | "ended";
67
+
68
+ /** One reading of the open Turn, as the client's projection has it. */
69
+ export interface ActivityTrailSampleV1 {
70
+ /** Characters of assistant text in the Turn so far. Monotonic. */
71
+ characters: number;
72
+ /** Tool calls this Turn has started, settled or not. Monotonic. */
73
+ toolStarts: number;
74
+ /** Tool calls whose result has settled. Monotonic. */
75
+ toolSettles: number;
76
+ /** Payloads this Turn has delivered to the person. Monotonic. */
77
+ sends: number;
78
+ /**
79
+ * The Turn's status, as the whole string rather than a union: only
80
+ * `streaming` means the Turn is still going, and every other value —
81
+ * including one a newer backend invents — is an ending. The trail is driven
82
+ * off that one positive test, so it cannot be left emitting forever by a
83
+ * status this file has never heard of.
84
+ */
85
+ status: string;
86
+ }
87
+
88
+ /** A one-off shot of particles, over and above the steady stream. */
89
+ export interface ActivityTrailBurstV1 {
90
+ /** Particles to spawn at once. */
91
+ count: number;
92
+ /** Multiplier on the stream's rightward speed. */
93
+ speed: number;
94
+ /** Multiplier on the particles' opacity. Above one reads as a flash. */
95
+ brightness: number;
96
+ }
97
+
98
+ /** What to emit for the moment between two samples. */
99
+ export interface ActivityTrailPlanV1 {
100
+ /** Whether the emitter runs at all. A settled Turn emits nothing. */
101
+ active: boolean;
102
+ /** What the trail is saying, for the row's `data-state`. */
103
+ state: ActivityTrailStateV1;
104
+ /** Steady particles per second, `0 … ACTIVITY_TRAIL_MAX_RATE_V1`. */
105
+ rate: number;
106
+ /** Shots to fire once, now. */
107
+ bursts: readonly ActivityTrailBurstV1[];
108
+ }
109
+
110
+ /** What the mapping remembers between two samples. Opaque to the caller. */
111
+ export interface ActivityTrailMemoryV1 {
112
+ sample: ActivityTrailSampleV1;
113
+ /** When something last changed, so a wait can be told from a pause. */
114
+ lastEventAt: number;
115
+ /** Recent character deltas, for the rate average. */
116
+ window: ReadonlyArray<{ at: number; characters: number }>;
117
+ }
118
+
119
+ /**
120
+ * A sample from the shapes the transcript already carries.
121
+ *
122
+ * Kept here rather than in the component so the Turn's vocabulary — a tool
123
+ * that has not settled is `running`, the text is the whole bubble so far —
124
+ * lives with the rule that reads it.
125
+ */
126
+ export function activityTrailSampleV1(input: {
127
+ text: string;
128
+ toolStatuses: readonly string[];
129
+ sends: number;
130
+ status: string;
131
+ }): ActivityTrailSampleV1 {
132
+ return {
133
+ characters: input.text.length,
134
+ toolStarts: input.toolStatuses.length,
135
+ toolSettles: input.toolStatuses.filter((status) => status !== "running")
136
+ .length,
137
+ sends: input.sends,
138
+ status: input.status,
139
+ };
140
+ }
141
+
142
+ /** The memory a Turn starts with. Its first sample is the baseline. */
143
+ export function activityTrailBeginV1(
144
+ sample: ActivityTrailSampleV1,
145
+ now: number,
146
+ ): ActivityTrailMemoryV1 {
147
+ return { sample, lastEventAt: now, window: [] };
148
+ }
149
+
150
+ function clamp(value: number, low: number, high: number): number {
151
+ return Math.min(high, Math.max(low, value));
152
+ }
153
+
154
+ /**
155
+ * The plan for the moment between the remembered sample and this one.
156
+ *
157
+ * Deltas are floored at zero: a projection that replaces a Turn's text with a
158
+ * shorter final version — a send superseding the model's own draft — is not a
159
+ * negative amount of work, it is no work.
160
+ */
161
+ export function activityTrailStepV1(
162
+ memory: ActivityTrailMemoryV1,
163
+ sample: ActivityTrailSampleV1,
164
+ now: number,
165
+ ): { memory: ActivityTrailMemoryV1; plan: ActivityTrailPlanV1 } {
166
+ const characters = Math.max(0, sample.characters - memory.sample.characters);
167
+ const startedTools = Math.max(
168
+ 0,
169
+ sample.toolStarts - memory.sample.toolStarts,
170
+ );
171
+ const settledTools = Math.max(
172
+ 0,
173
+ sample.toolSettles - memory.sample.toolSettles,
174
+ );
175
+ const delivered = Math.max(0, sample.sends - memory.sample.sends);
176
+
177
+ if (sample.status !== "streaming") {
178
+ return {
179
+ memory: { sample, lastEventAt: memory.lastEventAt, window: [] },
180
+ plan: { active: false, state: "ended", rate: 0, bursts: [] },
181
+ };
182
+ }
183
+
184
+ const window = [...memory.window, { at: now, characters }].filter(
185
+ (entry) => now - entry.at <= ACTIVITY_TRAIL_RATE_WINDOW_MS_V1,
186
+ );
187
+ const streamed = window.reduce((total, entry) => total + entry.characters, 0);
188
+ const streamRate =
189
+ streamed /
190
+ (ACTIVITY_TRAIL_RATE_WINDOW_MS_V1 / 1000) /
191
+ ACTIVITY_TRAIL_CHARACTERS_PER_PARTICLE_V1;
192
+
193
+ const moved =
194
+ characters > 0 || startedTools > 0 || settledTools > 0 || delivered > 0;
195
+ const lastEventAt = moved ? now : memory.lastEventAt;
196
+ const quiet = now - lastEventAt > ACTIVITY_TRAIL_QUIET_AFTER_MS_V1;
197
+
198
+ const bursts: ActivityTrailBurstV1[] = [];
199
+ for (let index = 0; index < startedTools + settledTools; index += 1) {
200
+ bursts.push({
201
+ count: ACTIVITY_TRAIL_TOOL_BURST_V1,
202
+ speed: 1.8,
203
+ brightness: 1.15,
204
+ });
205
+ }
206
+ for (let index = 0; index < delivered; index += 1) {
207
+ bursts.push({
208
+ count: ACTIVITY_TRAIL_SEND_BURST_V1,
209
+ speed: 1.2,
210
+ brightness: 1.9,
211
+ });
212
+ }
213
+
214
+ return {
215
+ memory: { sample, lastEventAt, window },
216
+ plan: {
217
+ active: true,
218
+ state: quiet ? "waiting" : "running",
219
+ rate: clamp(
220
+ Math.max(streamRate, ACTIVITY_TRAIL_TRICKLE_RATE_V1),
221
+ 0,
222
+ ACTIVITY_TRAIL_MAX_RATE_V1,
223
+ ),
224
+ bursts: bursts.slice(0, ACTIVITY_TRAIL_MAX_BURSTS_PER_STEP_V1),
225
+ },
226
+ };
227
+ }
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import {
3
+ appletSourceFingerprintV1,
3
4
  mostRecentlyChangedFileV1,
4
5
  readAppletList,
5
6
  readAppletSource,
@@ -119,6 +120,67 @@ describe("Applet routes", () => {
119
120
  }),
120
121
  ).toBe("ui.tsx");
121
122
  });
123
+
124
+ test("the source fingerprint is the same for a re-read of the same files", () => {
125
+ // The canvas moves the User to the code when a Turn *writes* source. It
126
+ // re-reads the source on every poll and gets a fresh view object each
127
+ // time, so the identity that decides has to be the files themselves —
128
+ // otherwise a Turn that only called an Applet's own tool throws the User
129
+ // off the live Applet.
130
+ const file = {
131
+ path: "server.ts",
132
+ text: "export default class {}",
133
+ generationId: "w-1",
134
+ changedAt: "2026-09-03T00:01:00.000Z",
135
+ };
136
+ const view = {
137
+ appletId: "u1abc.todo",
138
+ truncated: false,
139
+ files: [file, { ...file, path: "ui.tsx", generationId: "w-2" }],
140
+ };
141
+ expect(appletSourceFingerprintV1(view)).toBe(
142
+ appletSourceFingerprintV1(structuredClone(view)),
143
+ );
144
+ // Order is not a change either: two reads may sort the store differently.
145
+ expect(
146
+ appletSourceFingerprintV1({ ...view, files: view.files.toReversed() }),
147
+ ).toBe(appletSourceFingerprintV1(view));
148
+ expect(appletSourceFingerprintV1(undefined)).toBe("");
149
+ });
150
+
151
+ test("the source fingerprint changes when a Turn writes a file", () => {
152
+ const view = {
153
+ appletId: "u1abc.todo",
154
+ truncated: false,
155
+ files: [
156
+ {
157
+ path: "server.ts",
158
+ text: "",
159
+ generationId: "w-1",
160
+ changedAt: "2026-09-03T00:01:00.000Z",
161
+ },
162
+ ],
163
+ };
164
+ expect(
165
+ appletSourceFingerprintV1({
166
+ ...view,
167
+ files: [
168
+ {
169
+ ...view.files[0]!,
170
+ generationId: "w-2",
171
+ changedAt: "2026-09-03T00:02:00.000Z",
172
+ },
173
+ ],
174
+ }),
175
+ ).not.toBe(appletSourceFingerprintV1(view));
176
+ // A new file is a write too.
177
+ expect(
178
+ appletSourceFingerprintV1({
179
+ ...view,
180
+ files: [...view.files, { ...view.files[0]!, path: "ui.tsx" }],
181
+ }),
182
+ ).not.toBe(appletSourceFingerprintV1(view));
183
+ });
122
184
  });
123
185
 
124
186
  describe("the applets feed a page receives", () => {
@@ -137,3 +137,22 @@ export function mostRecentlyChangedFileV1(
137
137
  });
138
138
  return ordered[0]?.path;
139
139
  }
140
+
141
+ /**
142
+ * A stable identity for the source the canvas is showing.
143
+ *
144
+ * The canvas follows the Turn: a Turn that writes source lands the User on the
145
+ * code. "Wrote source" has to be a fact about the files, though, not about the
146
+ * store having been re-read — `refreshAppletCanvas` assigns a fresh view object
147
+ * on every poll, so a watcher on the array itself fired on Turns that touched
148
+ * no file at all and yanked the User off the live Applet.
149
+ */
150
+ export function appletSourceFingerprintV1(
151
+ source: AppletSourceViewV1 | undefined,
152
+ ): string {
153
+ if (!source) return "";
154
+ return source.files
155
+ .map((file) => `${file.path}@${file.generationId}@${file.changedAt ?? ""}`)
156
+ .toSorted()
157
+ .join("\n");
158
+ }