@saadnvd1/agent-os 0.3.738 → 0.3.743

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 (41) hide show
  1. package/components/Chat/Activity.test.ts +2 -1
  2. package/components/Chat/Activity.tsx +102 -64
  3. package/components/Chat/ChatPanel.tsx +4 -0
  4. package/components/Chat/LiveOutput.test.ts +36 -0
  5. package/components/Chat/Tools.tsx +42 -5
  6. package/data/chat/useChat.ts +10 -1
  7. package/lib/chat/demo.ts +42 -4
  8. package/lib/chat/drivers/claude-mapper.ts +10 -0
  9. package/lib/chat/drivers/claude.ts +9 -1
  10. package/lib/chat/drivers/demo-script.ts +148 -0
  11. package/lib/chat/drivers/demo.ts +21 -4
  12. package/lib/chat/drivers/edit-guard.ts +311 -0
  13. package/lib/chat/drivers/index.ts +2 -2
  14. package/lib/chat/drivers/opencode-config.ts +111 -0
  15. package/lib/chat/drivers/opencode-mapper.ts +125 -13
  16. package/lib/chat/drivers/opencode-plugin.ts +78 -0
  17. package/lib/chat/drivers/opencode-server.ts +7 -2
  18. package/lib/chat/drivers/opencode.ts +108 -9
  19. package/lib/chat/drivers/pi-edit-guard.ts +87 -0
  20. package/lib/chat/drivers/pi-mapper.ts +74 -1
  21. package/lib/chat/drivers/pi.ts +82 -3
  22. package/lib/chat/drivers/transient-retry.ts +87 -0
  23. package/lib/chat/drivers/turn-items.ts +74 -1
  24. package/lib/chat/drivers/turn-outcome.ts +48 -1
  25. package/lib/chat/events.ts +15 -1
  26. package/lib/chat/model-wait-live.ts +19 -0
  27. package/lib/chat/model-wait.ts +88 -0
  28. package/lib/chat/progress.ts +169 -0
  29. package/lib/chat/registry.ts +5 -0
  30. package/lib/chat/runner-watch.ts +71 -0
  31. package/lib/chat/runner.ts +4 -70
  32. package/lib/chat/tools.ts +14 -0
  33. package/lib/security/demo.ts +8 -0
  34. package/lib/tasks/move-testing.ts +6 -1
  35. package/package.json +1 -1
  36. package/scripts/screenshots/run.ts +27 -3
  37. package/scripts/screenshots/running-turn.ts +54 -0
  38. package/scripts/screenshots/seed.ts +15 -3
  39. package/scripts/screenshots/shoot.ts +3 -1
  40. package/scripts/screenshots/shots.ts +5 -0
  41. package/server.ts +10 -2
@@ -0,0 +1,88 @@
1
+ import type { ModelWait } from "./events";
2
+ import type { WorkerEvent } from "./worker/protocol";
3
+
4
+ // Follows a running turn's events to know when it is waiting on a model
5
+ // call: no tool running, nothing streaming, not compacting. Only what a
6
+ // driver measured is kept as the prompt size; nothing is guessed.
7
+ export class ModelWaitTracker {
8
+ wait: ModelWait | null = null;
9
+ private lastPrompt: number | undefined;
10
+ private running = new Set<string>();
11
+ private active = false;
12
+
13
+ // Returns whether `wait` changed.
14
+ on(e: WorkerEvent, now = Date.now()): boolean {
15
+ const before = this.wait;
16
+ switch (e.type) {
17
+ case "state":
18
+ if (e.state === "running") {
19
+ if (!this.active) {
20
+ this.active = true;
21
+ this.begin(now);
22
+ }
23
+ } else this.end();
24
+ break;
25
+ case "item": {
26
+ const i = e.item;
27
+ if (i.kind === "turn_end") this.end();
28
+ else if (i.kind === "compacted") this.lastPrompt = undefined;
29
+ else if (i.kind === "tool") {
30
+ if (i.status === "running") {
31
+ this.running.add(i.id);
32
+ this.wait = null;
33
+ } else {
34
+ this.running.delete(i.id);
35
+ if (this.active && !this.running.size) this.begin(now);
36
+ }
37
+ } else if (i.kind === "assistant" || i.kind === "reasoning") {
38
+ if (i.streaming) this.wait = null;
39
+ else if (this.active && !this.running.size && !this.wait)
40
+ this.begin(now);
41
+ }
42
+ break;
43
+ }
44
+ case "delta":
45
+ this.wait = null;
46
+ break;
47
+ case "compacting":
48
+ if (e.compacting) {
49
+ this.wait = null;
50
+ this.lastPrompt = undefined;
51
+ } else if (this.active && !this.running.size) this.begin(now);
52
+ break;
53
+ case "prompt_tokens":
54
+ this.lastPrompt = e.tokens;
55
+ break;
56
+ case "context":
57
+ this.lastPrompt = e.context.usedTokens || this.lastPrompt;
58
+ break;
59
+ case "model_retry":
60
+ if (this.active)
61
+ this.wait = {
62
+ ...(this.wait ?? this.fresh(now)),
63
+ retry: { attempt: e.attempt, message: e.message },
64
+ };
65
+ break;
66
+ }
67
+ return (
68
+ before !== this.wait &&
69
+ JSON.stringify(before) !== JSON.stringify(this.wait)
70
+ );
71
+ }
72
+
73
+ private fresh(now: number): ModelWait {
74
+ return this.lastPrompt
75
+ ? { since: now, promptTokens: this.lastPrompt }
76
+ : { since: now };
77
+ }
78
+
79
+ private begin(now: number) {
80
+ this.wait = this.fresh(now);
81
+ }
82
+
83
+ private end() {
84
+ this.active = false;
85
+ this.wait = null;
86
+ this.running.clear();
87
+ }
88
+ }
@@ -0,0 +1,169 @@
1
+ import type { ChatItem, ChatState, Compacting, ModelWait } from "./events";
2
+ import { formatTokens } from "./context";
3
+
4
+ type Tool = Extract<ChatItem, { kind: "tool" }>;
5
+
6
+ export type Phase = "planning" | "coding" | "testing" | "error";
7
+
8
+ // A wait on one model call this long gets a hint that it may be normal.
9
+ export const LONG_WAIT_MS = 2 * 60_000;
10
+ export const LONG_WAIT_HINT =
11
+ "Still waiting on the model; large prompts on local models can take minutes";
12
+
13
+ const TEST_RE =
14
+ /\b(test|tests|vitest|jest|pytest|mocha|playwright|tsc|typecheck|lint|eslint|build|check)\b|cargo (test|build|check)|go (test|build|vet)/;
15
+ const EDIT_TOOLS = new Set([
16
+ "Edit",
17
+ "Write",
18
+ "MultiEdit",
19
+ "NotebookEdit",
20
+ "apply_patch",
21
+ ]);
22
+ const VERBS: Record<string, string> = {
23
+ Read: "Reading",
24
+ Edit: "Editing",
25
+ Write: "Writing",
26
+ MultiEdit: "Editing",
27
+ NotebookEdit: "Editing",
28
+ Search: "Searching",
29
+ Find: "Finding",
30
+ Fetch: "Fetching",
31
+ List: "Listing",
32
+ Run: "Running",
33
+ };
34
+
35
+ const str = (v: unknown) => (typeof v === "string" ? v : "");
36
+ const input = (t: Tool) => (t.input ?? {}) as Record<string, unknown>;
37
+ const command = (t: Tool) => str(input(t).command);
38
+
39
+ // "ollama/qwen3-coder:30b" -> "qwen3-coder:30b"; nothing known -> "the model".
40
+ export function shortModel(label: string | undefined): string {
41
+ let m = (label ?? "").trim().split("/").at(-1) ?? "";
42
+ m = m.replace(/:latest$/, "").replace(/-\d{8}$/, "");
43
+ if (!m || m.toLowerCase() === "default") return "the model";
44
+ return m.length > 24 ? `${m.slice(0, 23)}…` : m;
45
+ }
46
+
47
+ const tail = (path: string) => path.split("/").slice(-3).join("/");
48
+ const clip = (s: string, n = 60) =>
49
+ s.length > n ? `${s.slice(0, n - 1)}…` : s;
50
+
51
+ // A tool call in plain words: "Running npm test", "Editing src/a/b.ts".
52
+ export function stepLabel(t: Tool): string {
53
+ const cmd = command(t);
54
+ if (cmd) return `Running ${clip(cmd.replace(/\s+/g, " ").trim())}`;
55
+ const path = str(input(t).file_path);
56
+ if (path && EDIT_TOOLS.has(t.name)) {
57
+ const verb = VERBS[t.name] ?? "Editing";
58
+ return `${verb} ${tail(path)}`;
59
+ }
60
+ const [first, ...rest] = t.title.split(" ");
61
+ return VERBS[first] && rest.length
62
+ ? `${VERBS[first]} ${rest.join(" ")}`
63
+ : t.title;
64
+ }
65
+
66
+ export function turnTools(items: ChatItem[]): Tool[] {
67
+ let start = 0;
68
+ items.forEach((i, n) => {
69
+ if (i.kind === "user" && !i.midTurn) start = n + 1;
70
+ });
71
+ return items.slice(start).filter((i): i is Tool => i.kind === "tool");
72
+ }
73
+
74
+ const edited = (t: Tool) => EDIT_TOOLS.has(t.name) || !!t.diff;
75
+
76
+ // Planning, coding, testing or error, from the turn's tools: the current
77
+ // step (running, else the last one) decides error and testing; having
78
+ // edited anything in the turn makes the rest coding.
79
+ export function phaseOf(tools: Tool[]): Phase {
80
+ const step = tools.findLast((t) => t.status === "running") ?? tools.at(-1);
81
+ if (!step) return "planning";
82
+ if (step.status === "error") return "error";
83
+ if (TEST_RE.test(command(step))) return "testing";
84
+ return tools.some(edited) ? "coding" : "planning";
85
+ }
86
+
87
+ export interface Activity {
88
+ label: string;
89
+ since?: number;
90
+ compacting?: boolean;
91
+ kind: "answer" | "compacting" | "tool" | "writing" | "waiting" | "retry";
92
+ // The last measured prompt, when a driver reported one.
93
+ tokens?: number;
94
+ phase?: Phase;
95
+ steps: number;
96
+ // The last finished step, while no tool runs.
97
+ lastStep?: string;
98
+ turnSince?: number;
99
+ hint?: string;
100
+ }
101
+
102
+ // What the turn is doing now and for how long, from its items.
103
+ export function currentActivity(
104
+ items: ChatItem[],
105
+ state: ChatState,
106
+ compacting: Compacting | null = null,
107
+ modelWait: ModelWait | null = null,
108
+ model?: string,
109
+ now: number = Date.now()
110
+ ): Activity | null {
111
+ if (state === "waiting")
112
+ return { label: "Waiting for your answer", kind: "answer", steps: 0 };
113
+ if (state !== "running") return null;
114
+ if (compacting)
115
+ return {
116
+ label: `Compacting context…${compacting.before ? ` ${formatTokens(compacting.before)} tokens` : ""}`,
117
+ since: compacting.since,
118
+ compacting: true,
119
+ kind: "compacting",
120
+ steps: 0,
121
+ };
122
+ const lastUser = items.findLast((i) => i.kind === "user" && !i.midTurn);
123
+ const tools = turnTools(items);
124
+ const base = {
125
+ steps: tools.length,
126
+ turnSince: lastUser?.createdAt,
127
+ phase: tools.length ? phaseOf(tools) : undefined,
128
+ };
129
+ const tool = tools.findLast((t) => t.status === "running");
130
+ if (tool)
131
+ return {
132
+ ...base,
133
+ label: stepLabel(tool),
134
+ since: tool.createdAt,
135
+ kind: "tool",
136
+ };
137
+ const last = items.at(-1);
138
+ if (
139
+ last &&
140
+ (last.kind === "assistant" || last.kind === "reasoning") &&
141
+ last.streaming
142
+ )
143
+ return {
144
+ ...base,
145
+ label: last.kind === "assistant" ? "Writing" : "Thinking",
146
+ since: lastUser?.createdAt,
147
+ kind: "writing",
148
+ };
149
+ const done = tools.findLast((t) => t.status !== "running");
150
+ const since =
151
+ modelWait?.since ?? done?.endedAt ?? done?.createdAt ?? lastUser?.createdAt;
152
+ const retry = modelWait?.retry;
153
+ return {
154
+ ...base,
155
+ label: retry
156
+ ? `Retrying model request (attempt ${retry.attempt})`
157
+ : `Waiting for ${shortModel(model)}`,
158
+ since,
159
+ kind: retry ? "retry" : "waiting",
160
+ tokens: modelWait?.promptTokens,
161
+ lastStep: done
162
+ ? `${stepLabel(done)}${done.status === "error" ? " failed" : ""}`
163
+ : undefined,
164
+ hint:
165
+ since !== undefined && now - since > LONG_WAIT_MS
166
+ ? LONG_WAIT_HINT
167
+ : undefined,
168
+ };
169
+ }
@@ -10,9 +10,11 @@ import type {
10
10
  ChatServerMessage,
11
11
  ChatState,
12
12
  Compacting,
13
+ ModelWait,
13
14
  } from "./events";
14
15
  import { markStatusDirty } from "../status/dirty";
15
16
  import { FLUSH_MS } from "./store";
17
+ import type { ModelWaitTracker } from "./model-wait";
16
18
 
17
19
  // Long enough for the worker's buffered write of an item to land.
18
20
  const EMIT_SETTLE_MS = 2 * FLUSH_MS;
@@ -23,6 +25,9 @@ export interface Live {
23
25
  state: ChatState;
24
26
  // Set while its agent compacts the context, over a "running" state.
25
27
  compacting?: Compacting | null;
28
+ // The model call the turn is waiting on, if any (lib/chat/model-wait).
29
+ modelWait?: ModelWait | null;
30
+ waitTracker?: ModelWaitTracker;
26
31
  // Streamed items carry their latest text here, ahead of SQLite.
27
32
  streaming: Map<string, ChatItem>;
28
33
  // What the turn is doing right now, for the session list.
@@ -0,0 +1,71 @@
1
+ import type { ChatContext, ChatItem } from "./events";
2
+ import { getSession, registry, type Listener } from "./registry";
3
+ import { releaseUnwatchedSoon, sendCapabilities } from "./settings";
4
+ import { recentTasks, settle } from "./store";
5
+ import { readPage, toolBody, type ItemPage } from "./page";
6
+ import { listQueue } from "./queued";
7
+ import { runningWorkers } from "./worker/client";
8
+ import { demoLive } from "./demo";
9
+
10
+ function savedContext(sessionId: string): ChatContext | null {
11
+ const raw = getSession(sessionId).chat_context;
12
+ if (!raw) return null;
13
+ try {
14
+ return JSON.parse(raw) as ChatContext;
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ // What a watcher is sent of stored items: the live text of what's still
21
+ // streaming, or with no conversation running, nothing left running.
22
+ function present(sessionId: string, items: ChatItem[]): ChatItem[] {
23
+ const live = registry.live.get(sessionId);
24
+ if (live) return items.map((i) => live.streaming.get(i.id) ?? i);
25
+ if (demoLive(sessionId)) return items;
26
+ return runningWorkers().includes(sessionId) ? items : settle(items);
27
+ }
28
+
29
+ // Snapshot then live updates. Returns the unsubscribe function. Every
30
+ // client gets the latest page and asks for older ones.
31
+ export function watchChat(sessionId: string, listener: Listener): () => void {
32
+ const live = registry.live.get(sessionId);
33
+ const demo = demoLive(sessionId);
34
+ const page = readPage(sessionId);
35
+ const onPage = new Set(page.items.map((i) => i.id));
36
+ // The composer lists background work still going or just done, even when
37
+ // it started before the page.
38
+ const recent = Date.now() - 60 * 60 * 1000;
39
+ const tasks = recentTasks(sessionId, recent).filter((t) => !onPage.has(t.id));
40
+ listener({
41
+ type: "snapshot",
42
+ items: present(sessionId, page.items),
43
+ cursor: page.cursor,
44
+ hasMore: page.hasMore,
45
+ tasks: present(sessionId, tasks),
46
+ state: live?.state ?? demo?.state ?? "idle",
47
+ compacting: live?.compacting ?? null,
48
+ modelWait: live?.modelWait ?? demo?.wait ?? null,
49
+ queue: listQueue(sessionId),
50
+ suggestion: getSession(sessionId).chat_suggestion ?? null,
51
+ });
52
+ listener({ type: "context", context: savedContext(sessionId) });
53
+ sendCapabilities(sessionId, listener).catch((error) =>
54
+ console.error(`[chat ${sessionId}] capabilities not sent:`, error)
55
+ );
56
+ let set = registry.listeners.get(sessionId);
57
+ if (!set) registry.listeners.set(sessionId, (set = new Set()));
58
+ set.add(listener);
59
+ return () => {
60
+ set?.delete(listener);
61
+ releaseUnwatchedSoon();
62
+ };
63
+ }
64
+
65
+ // The page before `before`, as the reader scrolls up.
66
+ export function chatHistory(sessionId: string, before: number): ItemPage {
67
+ const page = readPage(sessionId, before);
68
+ return { ...page, items: present(sessionId, page.items) };
69
+ }
70
+
71
+ export const chatToolBody = toolBody;
@@ -16,9 +16,9 @@ import { randomUUID } from "crypto";
16
16
  import os from "os";
17
17
  import { db, type Session } from "../db";
18
18
  import type { CompactFirst } from "./stale";
19
+ import { trackModelWait } from "./model-wait-live";
19
20
  import type {
20
21
  ApprovalDecision,
21
- ChatContext,
22
22
  ChatFile,
23
23
  ChatImage,
24
24
  ChatServerMessage,
@@ -34,26 +34,17 @@ import {
34
34
  type Listener,
35
35
  type Live,
36
36
  } from "./registry";
37
- import {
38
- capsKey,
39
- emitCapabilities,
40
- releaseUnwatchedSoon,
41
- sendCapabilities,
42
- setChatPlan,
43
- } from "./settings";
37
+ import { capsKey, emitCapabilities, setChatPlan } from "./settings";
44
38
  import {
45
39
  endOrphanedTasks,
46
40
  endRunningTasks,
47
41
  getItem,
48
42
  hasItem,
49
43
  itemsOfKind,
50
- recentTasks,
51
44
  sessionsWithRunningTasks,
52
45
  saveItem,
53
- settle,
54
46
  } from "./store";
55
47
  import { noteToolResult } from "../health/env/recordHit";
56
- import { readPage, toolBody, type ItemPage } from "./page";
57
48
  import {
58
49
  deleteQueued,
59
50
  editQueued,
@@ -101,16 +92,6 @@ export {
101
92
  setChatPlan,
102
93
  } from "./settings";
103
94
 
104
- function savedContext(sessionId: string): ChatContext | null {
105
- const raw = getSession(sessionId).chat_context;
106
- if (!raw) return null;
107
- try {
108
- return JSON.parse(raw) as ChatContext;
109
- } catch {
110
- return null;
111
- }
112
- }
113
-
114
95
  const CARRY_OUT = "Carry out the plan.";
115
96
 
116
97
  // Leaves plan mode and starts the plan. The message's id comes from the
@@ -192,6 +173,7 @@ function emitEnded(
192
173
 
193
174
  function onWorkerEvent(sessionId: string, live: Live, e: WorkerEvent): void {
194
175
  track(live, e);
176
+ if (trackModelWait(sessionId, live, e)) return;
195
177
  if (e.type === "item" && e.item.kind === "tool")
196
178
  noteToolResult(sessionId, e.item);
197
179
  if (
@@ -968,55 +950,7 @@ export function chatTaskOutput(
968
950
  return taskOutputTail(taskId, task.outputFile);
969
951
  }
970
952
 
971
- // What a watcher is sent of stored items: the live text of what's still
972
- // streaming, or with no conversation running, nothing left running.
973
- function present(sessionId: string, items: ChatItem[]): ChatItem[] {
974
- const live = registry.live.get(sessionId);
975
- if (live) return items.map((i) => live.streaming.get(i.id) ?? i);
976
- return runningWorkers().includes(sessionId) ? items : settle(items);
977
- }
978
-
979
- // Snapshot then live updates. Returns the unsubscribe function. Every
980
- // client gets the latest page and asks for older ones.
981
- export function watchChat(sessionId: string, listener: Listener): () => void {
982
- const live = registry.live.get(sessionId);
983
- const page = readPage(sessionId);
984
- const onPage = new Set(page.items.map((i) => i.id));
985
- // The composer lists background work still going or just done, even when
986
- // it started before the page.
987
- const recent = Date.now() - 60 * 60 * 1000;
988
- const tasks = recentTasks(sessionId, recent).filter((t) => !onPage.has(t.id));
989
- listener({
990
- type: "snapshot",
991
- items: present(sessionId, page.items),
992
- cursor: page.cursor,
993
- hasMore: page.hasMore,
994
- tasks: present(sessionId, tasks),
995
- state: live?.state ?? "idle",
996
- compacting: live?.compacting ?? null,
997
- queue: listQueue(sessionId),
998
- suggestion: getSession(sessionId).chat_suggestion ?? null,
999
- });
1000
- listener({ type: "context", context: savedContext(sessionId) });
1001
- sendCapabilities(sessionId, listener).catch((error) =>
1002
- console.error(`[chat ${sessionId}] capabilities not sent:`, error)
1003
- );
1004
- let set = registry.listeners.get(sessionId);
1005
- if (!set) registry.listeners.set(sessionId, (set = new Set()));
1006
- set.add(listener);
1007
- return () => {
1008
- set?.delete(listener);
1009
- releaseUnwatchedSoon();
1010
- };
1011
- }
1012
-
1013
- // The page before `before`, as the reader scrolls up.
1014
- export function chatHistory(sessionId: string, before: number): ItemPage {
1015
- const page = readPage(sessionId, before);
1016
- return { ...page, items: present(sessionId, page.items) };
1017
- }
1018
-
1019
- export const chatToolBody = toolBody;
953
+ export { chatHistory, chatToolBody, watchChat } from "./runner-watch";
1020
954
 
1021
955
  // After a restart: reconnect to every worker still running a conversation,
1022
956
  // and send the queues of conversations whose worker is gone.
package/lib/chat/tools.ts CHANGED
@@ -81,3 +81,17 @@ export function clipOutput(text: string): string {
81
81
  ? `${text.slice(0, MAX_TOOL_OUTPUT)}\n… (${text.length - MAX_TOOL_OUTPUT} more characters)`
82
82
  : text;
83
83
  }
84
+
85
+ export const LIVE_OUTPUT_LINES = 200;
86
+
87
+ // A running step's output so far: the last lines, never more than a
88
+ // finished step keeps. The end of a long build is what the reader wants.
89
+ export function tailOutput(text: string): string {
90
+ const lines = text.split("\n");
91
+ let tail =
92
+ lines.length > LIVE_OUTPUT_LINES
93
+ ? lines.slice(-LIVE_OUTPUT_LINES).join("\n")
94
+ : text;
95
+ if (tail.length > MAX_TOOL_OUTPUT) tail = tail.slice(-MAX_TOOL_OUTPUT);
96
+ return tail === text ? tail : `…\n${tail}`;
97
+ }
@@ -23,6 +23,14 @@ type Env = Record<string, string | undefined>;
23
23
 
24
24
  export const demoMode = (env: Env = process.env) => env.AGENTOS_DEMO === "1";
25
25
 
26
+ /**
27
+ * Only the chat is a stand-in (AGENTOS_DEMO_CHAT=1): the screenshot server's
28
+ * scripted turns (lib/chat/drivers/demo-script). The rest of the server is a
29
+ * normal one, so it is held to the same sandbox as the demo (server.ts).
30
+ */
31
+ export const demoChatMode = (env: Env = process.env) =>
32
+ env.AGENTOS_DEMO_CHAT === "1";
33
+
26
34
  const within = (child: string, parent: string) => {
27
35
  const rel = path.relative(parent, child);
28
36
  return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
@@ -57,7 +57,12 @@ export function setupMoveRepo(): MoveFixture {
57
57
  restore: () => {
58
58
  process.env.HOME = saved.HOME;
59
59
  delete process.env.CLAUDE_CONFIG_DIR;
60
- fs.rmSync(tmp, { recursive: true, force: true });
60
+ fs.rmSync(tmp, {
61
+ recursive: true,
62
+ force: true,
63
+ maxRetries: 5,
64
+ retryDelay: 100,
65
+ });
61
66
  },
62
67
  };
63
68
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.738",
3
+ "version": "0.3.743",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
@@ -5,6 +5,7 @@
5
5
  * npm run screenshots # everything
6
6
  * npm run screenshots -- --keep # leave the demo server up afterwards
7
7
  * npm run screenshots -- --only hero,tasks
8
+ * npm run screenshots -- --only running-turn # a held running turn
8
9
  */
9
10
  import fs from "fs";
10
11
  import path from "path";
@@ -13,6 +14,7 @@ import { spawnServer } from "@/lib/dev-instance";
13
14
  import { BASE_URL, PORT, REPO, ROOT, demoEnv } from "./config";
14
15
  import { seed, teardownTmux } from "./seed";
15
16
  import { shoot } from "./shoot";
17
+ import { SHOTS } from "./shots";
16
18
  import { frame } from "./frame";
17
19
  import { startPeer } from "./peer";
18
20
 
@@ -37,7 +39,7 @@ async function waitForServer(server: ChildProcess): Promise<void> {
37
39
  throw new Error(`Demo server did not answer on ${BASE_URL}`);
38
40
  }
39
41
 
40
- function startServer(): ChildProcess {
42
+ function startServer(demoChat: boolean): ChildProcess {
41
43
  const log = fs.openSync(path.join(ROOT, "server.log"), "w");
42
44
  // Loopback only: the demo never listens on another network.
43
45
  return spawnServer({
@@ -49,6 +51,8 @@ function startServer(): ChildProcess {
49
51
  AGENTOS_BIND: "127.0.0.1",
50
52
  AGENTOS_ORCHESTRATOR: "off",
51
53
  AGENTOS_STACKS: "off",
54
+ // Scripted chat turns (lib/chat/drivers/demo-script), for on-demand shots.
55
+ ...(demoChat ? { AGENTOS_DEMO_CHAT: "1" } : {}),
52
56
  }),
53
57
  stdio: ["ignore", log, log],
54
58
  detached: true,
@@ -64,21 +68,41 @@ function stopServer(server: ChildProcess): void {
64
68
  }
65
69
  }
66
70
 
71
+ // An on-demand shot has no README frame; its raw captures would go with the
72
+ // demo root, so they are copied to .agentos/screenshots (git-ignored there).
73
+ function keepOnDemand(raw: Awaited<ReturnType<typeof shoot>>): void {
74
+ const names = new Set(SHOTS.filter((s) => s.onDemand).map((s) => s.name));
75
+ const dir = path.join(process.cwd(), ".agentos", "screenshots");
76
+ for (const shot of raw) {
77
+ if (!names.has(shot.name)) continue;
78
+ fs.mkdirSync(dir, { recursive: true });
79
+ fs.writeFileSync(path.join(path.dirname(dir), ".gitignore"), "*\n");
80
+ for (const file of shot.files) {
81
+ fs.copyFileSync(file, path.join(dir, path.basename(file)));
82
+ console.log(` .agentos/screenshots/${path.basename(file)}`);
83
+ }
84
+ }
85
+ }
86
+
67
87
  async function main() {
68
88
  const only = arg("--only")?.split(",");
89
+ const onDemand = !!only?.some(
90
+ (n) => SHOTS.find((s) => s.name === n)?.onDemand
91
+ );
69
92
  const reuse = has("--reuse");
70
93
  let server: ChildProcess | null = null;
71
94
  const peer = reuse ? null : startPeer();
72
95
  try {
73
96
  if (!reuse) {
74
97
  console.log("Seeding demo data");
75
- await seed();
98
+ await seed(onDemand);
76
99
  console.log(`Starting demo server on :${PORT}`);
77
- server = startServer();
100
+ server = startServer(onDemand);
78
101
  await waitForServer(server);
79
102
  }
80
103
  console.log("Shooting");
81
104
  const raw = await shoot(only);
105
+ keepOnDemand(raw);
82
106
  console.log("Framing");
83
107
  await frame(raw);
84
108
  } finally {
@@ -0,0 +1,54 @@
1
+ import type { Page } from "playwright";
2
+ import type { Part, Shot } from "./shots";
3
+
4
+ // A running turn, held on screen by the demo's scripted turn
5
+ // (lib/chat/drivers/demo-script): `npm run screenshots -- --only running-turn`.
6
+ // Each part sends the trigger for one state and waits for it to show.
7
+
8
+ const SESSION = "running-turn";
9
+ const HOLD_S = 60;
10
+
11
+ const PHASES = [
12
+ { phase: "step", marker: /npx vitest run src\/checkout/ },
13
+ { phase: "wait", marker: /Waiting for/ },
14
+ { phase: "retry", marker: /Retrying model request/ },
15
+ ] as const;
16
+
17
+ async function startTurn(
18
+ page: Page,
19
+ phase: string,
20
+ marker: RegExp,
21
+ phone: boolean
22
+ ): Promise<void> {
23
+ if (phone) {
24
+ await page.locator("button:has(svg.lucide-menu)").first().click();
25
+ await page.waitForTimeout(800);
26
+ }
27
+ await page.getByText(SESSION, { exact: true }).first().click();
28
+ await page.waitForTimeout(2500);
29
+ await page.locator(".chat-composer [contenteditable]").first().click();
30
+ await page.keyboard.type(`demo: running turn ${phase} hold=${HOLD_S}`);
31
+ // Enter is a new line on a phone: the send button sends.
32
+ if (phone)
33
+ await page.locator("button:has(svg.lucide-arrow-up)").last().click();
34
+ else await page.keyboard.press("Enter");
35
+ await page.getByText(marker).first().waitFor({ timeout: 30000 });
36
+ await page.waitForTimeout(1500);
37
+ }
38
+
39
+ const parts = (device: Part["device"]): Part[] =>
40
+ PHASES.map(({ phase, marker }) => ({
41
+ id: `${phase}-${device}`,
42
+ device,
43
+ themes: ["dark", "light"],
44
+ keepFocus: true,
45
+ setup: (page) => startTurn(page, phase, marker, device === "phone"),
46
+ }));
47
+
48
+ export const RUNNING_TURN_SHOTS: Shot[] = [
49
+ {
50
+ name: "running-turn",
51
+ onDemand: true,
52
+ parts: [...parts("desktop"), ...parts("phone")],
53
+ },
54
+ ];