@saadnvd1/agent-os 0.3.742 → 0.3.744
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.
- package/components/Analyst/PairChip.tsx +8 -1
- package/lib/chat/demo.ts +42 -4
- package/lib/chat/drivers/demo-script.ts +148 -0
- package/lib/chat/drivers/demo.ts +21 -4
- package/lib/chat/drivers/index.ts +2 -2
- package/lib/chat/runner-watch.ts +5 -2
- package/lib/security/demo.ts +8 -0
- package/lib/tasks/move-testing.ts +6 -1
- package/package.json +1 -1
- package/scripts/screenshots/run.ts +27 -3
- package/scripts/screenshots/running-turn.ts +54 -0
- package/scripts/screenshots/seed.ts +15 -3
- package/scripts/screenshots/shoot.ts +3 -1
- package/scripts/screenshots/shots.ts +5 -0
- package/server.ts +10 -2
|
@@ -121,9 +121,16 @@ export function PairChip({
|
|
|
121
121
|
<DropdownMenuSeparator />
|
|
122
122
|
<DropdownMenuItem
|
|
123
123
|
variant="destructive"
|
|
124
|
+
className="min-h-11"
|
|
124
125
|
onSelect={() => end.mutate(true, { onError: fail })}
|
|
125
126
|
>
|
|
126
|
-
|
|
127
|
+
<span className="flex flex-col gap-0.5">
|
|
128
|
+
<span>End analyst</span>
|
|
129
|
+
<span className="text-muted-foreground text-[11px] font-normal whitespace-normal">
|
|
130
|
+
Stops the reviews and moves the analyst to Done. Its conversation
|
|
131
|
+
is kept; nothing is deleted.
|
|
132
|
+
</span>
|
|
133
|
+
</span>
|
|
127
134
|
</DropdownMenuItem>
|
|
128
135
|
</DropdownMenuContent>
|
|
129
136
|
</DropdownMenu>
|
package/lib/chat/demo.ts
CHANGED
|
@@ -7,10 +7,18 @@
|
|
|
7
7
|
import { randomUUID } from "crypto";
|
|
8
8
|
import { clientSend } from "./client-send";
|
|
9
9
|
import type { ChatConversation } from "./driver";
|
|
10
|
-
import type {
|
|
10
|
+
import type {
|
|
11
|
+
ChatClientMessage,
|
|
12
|
+
ChatServerMessage,
|
|
13
|
+
ChatState,
|
|
14
|
+
ModelWait,
|
|
15
|
+
} from "./events";
|
|
11
16
|
import { demoConversation } from "./drivers/demo";
|
|
17
|
+
import { DEMO_SCRIPT_PROMPT, parseDemoScript } from "./drivers/demo-script";
|
|
18
|
+
import { ModelWaitTracker } from "./model-wait";
|
|
19
|
+
import type { WorkerEvent } from "./worker/protocol";
|
|
12
20
|
import { emit, getSession } from "./registry";
|
|
13
|
-
import { saveItem } from "./store";
|
|
21
|
+
import { deleteItems, saveItem } from "./store";
|
|
14
22
|
import { DEMO_REFUSAL, DEMO_VISITOR_TEXT } from "../security/demo";
|
|
15
23
|
|
|
16
24
|
const conversations = new Map<string, ChatConversation>();
|
|
@@ -21,13 +29,34 @@ export const setDemoReplyStep = (ms: number) => (stepMs = ms);
|
|
|
21
29
|
export const MAX_SENDS = 50;
|
|
22
30
|
const sends = new Map<string, number>();
|
|
23
31
|
|
|
32
|
+
// What a demo chat is doing, for a watcher that connects mid-turn (the stored
|
|
33
|
+
// items alone would show a running step as stopped).
|
|
34
|
+
const live = new Map<string, { state: ChatState; wait: ModelWait | null }>();
|
|
35
|
+
|
|
36
|
+
/** The demo chat's running turn, or null with none. */
|
|
37
|
+
export function demoLive(sessionId: string) {
|
|
38
|
+
return live.get(sessionId) ?? null;
|
|
39
|
+
}
|
|
40
|
+
|
|
24
41
|
function conversation(sessionId: string): ChatConversation {
|
|
25
42
|
let c = conversations.get(sessionId);
|
|
26
43
|
if (c) return c;
|
|
27
44
|
c = demoConversation(stepMs);
|
|
28
45
|
conversations.set(sessionId, c);
|
|
46
|
+
let tracker = new ModelWaitTracker();
|
|
29
47
|
void (async () => {
|
|
30
48
|
for await (const e of c.events) {
|
|
49
|
+
// Each turn follows its own waits, whatever the last one left.
|
|
50
|
+
if (e.type === "state" && e.state === "running")
|
|
51
|
+
tracker = new ModelWaitTracker();
|
|
52
|
+
if (tracker.on(e as WorkerEvent))
|
|
53
|
+
emit(sessionId, { type: "model_wait", wait: tracker.wait });
|
|
54
|
+
if (e.type === "state") {
|
|
55
|
+
if (e.state === "idle") live.delete(sessionId);
|
|
56
|
+
else live.set(sessionId, { state: e.state, wait: tracker.wait });
|
|
57
|
+
}
|
|
58
|
+
const state = live.get(sessionId);
|
|
59
|
+
if (state) state.wait = tracker.wait;
|
|
31
60
|
if (e.type === "item") {
|
|
32
61
|
if (!("streaming" in e.item && e.item.streaming))
|
|
33
62
|
saveItem(sessionId, e.item);
|
|
@@ -49,16 +78,24 @@ export function sendDemoChat(
|
|
|
49
78
|
const count = sends.get(sessionId) ?? 0;
|
|
50
79
|
if (count >= MAX_SENDS) throw new Error(DEMO_REFUSAL);
|
|
51
80
|
sends.set(sessionId, count + 1);
|
|
81
|
+
// The scripted turn (drivers/demo-script) is canned text a visitor can only
|
|
82
|
+
// trigger, never change, so its prompt is shown as is.
|
|
83
|
+
const script = parseDemoScript(trimmed) !== null;
|
|
84
|
+
// A scripted turn starts a clean chat, so screenshots never show the last.
|
|
85
|
+
if (script) {
|
|
86
|
+
deleteItems(sessionId);
|
|
87
|
+
live.delete(sessionId);
|
|
88
|
+
}
|
|
52
89
|
const item = {
|
|
53
90
|
id,
|
|
54
91
|
kind: "user" as const,
|
|
55
|
-
text: DEMO_VISITOR_TEXT,
|
|
92
|
+
text: script ? DEMO_SCRIPT_PROMPT : DEMO_VISITOR_TEXT,
|
|
56
93
|
tagged: true as const,
|
|
57
94
|
createdAt: Date.now(),
|
|
58
95
|
};
|
|
59
96
|
saveItem(sessionId, item);
|
|
60
97
|
emit(sessionId, { type: "item", item });
|
|
61
|
-
conversation(sessionId).send(DEMO_VISITOR_TEXT);
|
|
98
|
+
conversation(sessionId).send(script ? trimmed : DEMO_VISITOR_TEXT);
|
|
62
99
|
}
|
|
63
100
|
|
|
64
101
|
/** After a re-seed: stub conversations and send counts start over. */
|
|
@@ -69,6 +106,7 @@ export function resetDemoChat(): void {
|
|
|
69
106
|
emit(sessionId, { type: "state", state: "idle" });
|
|
70
107
|
}
|
|
71
108
|
conversations.clear();
|
|
109
|
+
live.clear();
|
|
72
110
|
sends.clear();
|
|
73
111
|
}
|
|
74
112
|
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The demo's scripted running turn: typed into a demo chat, it holds a state
|
|
3
|
+
* a screenshot needs (a tool streaming partial output, a long wait on the
|
|
4
|
+
* model, a retry) for a set time, then finishes. Demo only: the demo driver
|
|
5
|
+
* is the one place that reads it (AGENTOS_DEMO=1; no real session gets here).
|
|
6
|
+
*
|
|
7
|
+
* demo: running turn [step|wait|retry] [hold=<seconds>]
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { DriverEvent } from "../events";
|
|
11
|
+
|
|
12
|
+
export type DemoPhase = "step" | "wait" | "retry";
|
|
13
|
+
|
|
14
|
+
export interface DemoScript {
|
|
15
|
+
// Hold only this state; none runs all three, one after the other.
|
|
16
|
+
phase?: DemoPhase;
|
|
17
|
+
holdMs: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_HOLD_S = 30;
|
|
21
|
+
export const MAX_HOLD_S = 120;
|
|
22
|
+
const TRIGGER = /^demo: running turn(?: (step|wait|retry))?(?: hold=(\d+))?$/;
|
|
23
|
+
|
|
24
|
+
export function parseDemoScript(text: string): DemoScript | null {
|
|
25
|
+
const m = TRIGGER.exec(text.trim());
|
|
26
|
+
if (!m) return null;
|
|
27
|
+
const seconds = m[2] === undefined ? DEFAULT_HOLD_S : Number(m[2]);
|
|
28
|
+
return {
|
|
29
|
+
phase: m[1] as DemoPhase | undefined,
|
|
30
|
+
holdMs: Math.min(seconds, MAX_HOLD_S) * 1000,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const DEMO_SCRIPT_PROMPT =
|
|
35
|
+
"Run the checkout tests and fix whatever fails.";
|
|
36
|
+
|
|
37
|
+
const COMMAND = "npx vitest run src/checkout";
|
|
38
|
+
const LINES = [
|
|
39
|
+
" RUN v3.2.4 ~/code/storefront",
|
|
40
|
+
" ✓ src/checkout/discount.test.ts (6 tests) 12ms",
|
|
41
|
+
" ✓ src/checkout/tax.test.ts (9 tests) 18ms",
|
|
42
|
+
" ✓ src/checkout/cart.test.ts (14 tests) 41ms",
|
|
43
|
+
" ✓ src/checkout/shipping.test.ts (8 tests) 22ms",
|
|
44
|
+
" ✓ src/checkout/coupons.test.ts (11 tests) 35ms",
|
|
45
|
+
" ✓ src/checkout/currency.test.ts (7 tests) 9ms",
|
|
46
|
+
" ❯ src/checkout/totals.test.ts (12 tests | 1 running)",
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
export interface ScriptRun {
|
|
50
|
+
push: (e: DriverEvent) => void;
|
|
51
|
+
stopped: () => boolean;
|
|
52
|
+
sleep: (ms: number) => Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Waits `ms`, in slices so an interrupt ends it.
|
|
56
|
+
async function hold(run: ScriptRun, ms: number) {
|
|
57
|
+
for (let left = ms; left > 0 && !run.stopped(); left -= 100)
|
|
58
|
+
await run.sleep(Math.min(100, left));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function runningStep(run: ScriptRun, id: string, holdMs: number) {
|
|
62
|
+
const tool = {
|
|
63
|
+
id,
|
|
64
|
+
kind: "tool" as const,
|
|
65
|
+
name: "Bash",
|
|
66
|
+
title: COMMAND,
|
|
67
|
+
input: { command: COMMAND },
|
|
68
|
+
createdAt: Date.now(),
|
|
69
|
+
};
|
|
70
|
+
const show = (n: number) =>
|
|
71
|
+
run.push({
|
|
72
|
+
type: "item",
|
|
73
|
+
item: {
|
|
74
|
+
...tool,
|
|
75
|
+
status: "running",
|
|
76
|
+
output: LINES.slice(0, Math.max(1, n)).join("\n"),
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
// A line every half second, then the last line stays until the hold ends.
|
|
80
|
+
const end = Date.now() + holdMs;
|
|
81
|
+
show(1);
|
|
82
|
+
for (let n = 2; n <= LINES.length && !run.stopped(); n++) {
|
|
83
|
+
if (Date.now() >= end) break;
|
|
84
|
+
await hold(run, 500);
|
|
85
|
+
show(n);
|
|
86
|
+
}
|
|
87
|
+
await hold(run, Math.max(0, end - Date.now()));
|
|
88
|
+
const stopped = run.stopped();
|
|
89
|
+
run.push({
|
|
90
|
+
type: "item",
|
|
91
|
+
item: {
|
|
92
|
+
...tool,
|
|
93
|
+
status: stopped ? "stopped" : "done",
|
|
94
|
+
output: stopped
|
|
95
|
+
? undefined
|
|
96
|
+
: `${LINES.slice(0, -1).join("\n")}\n 1 passed`,
|
|
97
|
+
endedAt: Date.now(),
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function runDemoScript(
|
|
103
|
+
script: DemoScript,
|
|
104
|
+
run: ScriptRun,
|
|
105
|
+
runId = String(Date.now())
|
|
106
|
+
): Promise<void> {
|
|
107
|
+
const started = Date.now();
|
|
108
|
+
const phases: DemoPhase[] = script.phase
|
|
109
|
+
? [script.phase]
|
|
110
|
+
: ["step", "wait", "retry"];
|
|
111
|
+
run.push({ type: "state", state: "running" });
|
|
112
|
+
run.push({ type: "turn_start" });
|
|
113
|
+
run.push({
|
|
114
|
+
type: "item",
|
|
115
|
+
item: {
|
|
116
|
+
id: `demo-a-${runId}`,
|
|
117
|
+
kind: "assistant",
|
|
118
|
+
text: "I'll run the checkout tests first.",
|
|
119
|
+
createdAt: started,
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
run.push({ type: "prompt_tokens", tokens: 48_200 });
|
|
123
|
+
for (const phase of phases) {
|
|
124
|
+
if (run.stopped()) break;
|
|
125
|
+
if (phase === "step")
|
|
126
|
+
await runningStep(run, `demo-t-${runId}`, script.holdMs);
|
|
127
|
+
else {
|
|
128
|
+
if (phase === "retry")
|
|
129
|
+
run.push({
|
|
130
|
+
type: "model_retry",
|
|
131
|
+
attempt: 2,
|
|
132
|
+
message: "The model is overloaded",
|
|
133
|
+
});
|
|
134
|
+
await hold(run, script.holdMs);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
run.push({
|
|
138
|
+
type: "item",
|
|
139
|
+
item: {
|
|
140
|
+
id: `demo-end-${runId}`,
|
|
141
|
+
kind: "turn_end",
|
|
142
|
+
durationMs: Date.now() - started,
|
|
143
|
+
interrupted: run.stopped() || undefined,
|
|
144
|
+
createdAt: Date.now(),
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
run.push({ type: "state", state: "idle" });
|
|
148
|
+
}
|
package/lib/chat/drivers/demo.ts
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The demo's stand-in for an agent (AGENTOS_DEMO=1): no process, no tools,
|
|
3
3
|
* just a short canned reply streamed the way a real one is, so the chat
|
|
4
|
-
* flow still works end to end.
|
|
4
|
+
* flow still works end to end. A typed `demo: running turn` runs the
|
|
5
|
+
* scripted turn in demo-script.ts instead.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import type { ChatConversation, ChatDriver } from "../driver";
|
|
8
9
|
import type { DriverEvent } from "../events";
|
|
10
|
+
import { parseDemoScript, runDemoScript, type DemoScript } from "./demo-script";
|
|
9
11
|
|
|
10
12
|
export const DEMO_REPLY =
|
|
11
13
|
"This is a demo, so agents don't run here. On your own machine, this is where the agent would answer and get to work.";
|
|
12
14
|
|
|
13
|
-
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
15
|
+
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
|
14
16
|
|
|
15
17
|
// Events pushed by sends and read by one consumer, in order.
|
|
16
18
|
function channel() {
|
|
@@ -89,12 +91,27 @@ export function demoConversation(stepMs = 40): ChatConversation {
|
|
|
89
91
|
});
|
|
90
92
|
out.push({ type: "state", state: "idle" });
|
|
91
93
|
};
|
|
94
|
+
// A scripted turn that a newer one took over from says nothing more.
|
|
95
|
+
let generation = 0;
|
|
96
|
+
const script = async (s: DemoScript, mine: number) => {
|
|
97
|
+
if (closed) return;
|
|
98
|
+
stopped = false;
|
|
99
|
+
await runDemoScript(s, {
|
|
100
|
+
push: (e) => mine === generation && out.push(e),
|
|
101
|
+
stopped: () => stopped || closed,
|
|
102
|
+
sleep,
|
|
103
|
+
});
|
|
104
|
+
};
|
|
92
105
|
const refuse = async () => {
|
|
93
106
|
throw new Error("Not available in the demo.");
|
|
94
107
|
};
|
|
95
108
|
return {
|
|
96
|
-
send() {
|
|
97
|
-
|
|
109
|
+
send(text) {
|
|
110
|
+
const s = parseDemoScript(text);
|
|
111
|
+
// A scripted turn takes over from one still running.
|
|
112
|
+
const mine = s ? ++generation : generation;
|
|
113
|
+
if (s) stopped = true;
|
|
114
|
+
turn = turn.then(s ? () => script(s, mine) : reply);
|
|
98
115
|
return undefined;
|
|
99
116
|
},
|
|
100
117
|
async interrupt() {
|
|
@@ -6,7 +6,7 @@ import { openCodeDriver } from "./opencode";
|
|
|
6
6
|
import { piDriver } from "./pi";
|
|
7
7
|
import { copilotDriver } from "./copilot";
|
|
8
8
|
import { demoDriver } from "./demo";
|
|
9
|
-
import { demoMode } from "../../security/demo";
|
|
9
|
+
import { demoChatMode, demoMode } from "../../security/demo";
|
|
10
10
|
|
|
11
11
|
// One driver per agent CLI. Adding a CLI means a driver here and its agent
|
|
12
12
|
// type in CHAT_AGENT_TYPES.
|
|
@@ -21,5 +21,5 @@ const DRIVERS: Record<(typeof CHAT_AGENT_TYPES)[number], ChatDriver> = {
|
|
|
21
21
|
export function chatDriverFor(agentType: string): ChatDriver | null {
|
|
22
22
|
const driver = DRIVERS[agentType as keyof typeof DRIVERS] ?? null;
|
|
23
23
|
// A demo never starts a real agent (lib/chat/demo runs this one).
|
|
24
|
-
return driver && demoMode() ? demoDriver : driver;
|
|
24
|
+
return driver && (demoMode() || demoChatMode()) ? demoDriver : driver;
|
|
25
25
|
}
|
package/lib/chat/runner-watch.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { recentTasks, settle } from "./store";
|
|
|
5
5
|
import { readPage, toolBody, type ItemPage } from "./page";
|
|
6
6
|
import { listQueue } from "./queued";
|
|
7
7
|
import { runningWorkers } from "./worker/client";
|
|
8
|
+
import { demoLive } from "./demo";
|
|
8
9
|
|
|
9
10
|
function savedContext(sessionId: string): ChatContext | null {
|
|
10
11
|
const raw = getSession(sessionId).chat_context;
|
|
@@ -21,6 +22,7 @@ function savedContext(sessionId: string): ChatContext | null {
|
|
|
21
22
|
function present(sessionId: string, items: ChatItem[]): ChatItem[] {
|
|
22
23
|
const live = registry.live.get(sessionId);
|
|
23
24
|
if (live) return items.map((i) => live.streaming.get(i.id) ?? i);
|
|
25
|
+
if (demoLive(sessionId)) return items;
|
|
24
26
|
return runningWorkers().includes(sessionId) ? items : settle(items);
|
|
25
27
|
}
|
|
26
28
|
|
|
@@ -28,6 +30,7 @@ function present(sessionId: string, items: ChatItem[]): ChatItem[] {
|
|
|
28
30
|
// client gets the latest page and asks for older ones.
|
|
29
31
|
export function watchChat(sessionId: string, listener: Listener): () => void {
|
|
30
32
|
const live = registry.live.get(sessionId);
|
|
33
|
+
const demo = demoLive(sessionId);
|
|
31
34
|
const page = readPage(sessionId);
|
|
32
35
|
const onPage = new Set(page.items.map((i) => i.id));
|
|
33
36
|
// The composer lists background work still going or just done, even when
|
|
@@ -40,9 +43,9 @@ export function watchChat(sessionId: string, listener: Listener): () => void {
|
|
|
40
43
|
cursor: page.cursor,
|
|
41
44
|
hasMore: page.hasMore,
|
|
42
45
|
tasks: present(sessionId, tasks),
|
|
43
|
-
state: live?.state ?? "idle",
|
|
46
|
+
state: live?.state ?? demo?.state ?? "idle",
|
|
44
47
|
compacting: live?.compacting ?? null,
|
|
45
|
-
modelWait: live?.modelWait ?? null,
|
|
48
|
+
modelWait: live?.modelWait ?? demo?.wait ?? null,
|
|
46
49
|
queue: listQueue(sessionId),
|
|
47
50
|
suggestion: getSession(sessionId).chat_suggestion ?? null,
|
|
48
51
|
});
|
package/lib/security/demo.ts
CHANGED
|
@@ -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, {
|
|
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
|
@@ -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
|
+
];
|
|
@@ -44,6 +44,9 @@ interface SessionSeed {
|
|
|
44
44
|
minutesAgo: number;
|
|
45
45
|
task?: { prompt: string; status: "running" | "merged"; branch: string };
|
|
46
46
|
chat?: (start: number) => ChatItem[];
|
|
47
|
+
// Seeded only for the on-demand shots (shots.ts), so it never shows in the
|
|
48
|
+
// README's sidebar.
|
|
49
|
+
onDemand?: boolean;
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
const WORKSPACES = [
|
|
@@ -55,6 +58,14 @@ const WORKSPACES = [
|
|
|
55
58
|
];
|
|
56
59
|
|
|
57
60
|
const SESSIONS: SessionSeed[] = [
|
|
61
|
+
{
|
|
62
|
+
key: "running",
|
|
63
|
+
name: "running-turn",
|
|
64
|
+
project: "storefront",
|
|
65
|
+
view: "chat",
|
|
66
|
+
minutesAgo: 1,
|
|
67
|
+
onDemand: true,
|
|
68
|
+
},
|
|
58
69
|
{
|
|
59
70
|
key: "checkout",
|
|
60
71
|
name: "checkout-totals",
|
|
@@ -241,7 +252,7 @@ function tmux(args: string[]): void {
|
|
|
241
252
|
execFileSync("tmux", demoTmuxArgs(args), { env: demoEnv(), stdio: "pipe" });
|
|
242
253
|
}
|
|
243
254
|
|
|
244
|
-
async function seedDb(): Promise<void> {
|
|
255
|
+
async function seedDb(onDemand: boolean): Promise<void> {
|
|
245
256
|
process.env.DB_PATH = DB_PATH;
|
|
246
257
|
const { getDb, insertSessionRow } = await import("../../lib/db");
|
|
247
258
|
const db = getDb();
|
|
@@ -266,6 +277,7 @@ async function seedDb(): Promise<void> {
|
|
|
266
277
|
const sessionIds = new Map<string, string>();
|
|
267
278
|
const now = Date.now();
|
|
268
279
|
for (const s of SESSIONS) {
|
|
280
|
+
if (s.onDemand && !onDemand) continue;
|
|
269
281
|
const id = randomUUID();
|
|
270
282
|
sessionIds.set(s.key, id);
|
|
271
283
|
const tmuxName = `claude-${id}`;
|
|
@@ -441,14 +453,14 @@ function seedLinkedMachine(db: Db): void {
|
|
|
441
453
|
).run(PEER_HOST.id, `http://127.0.0.1:${PEER_PORT}`);
|
|
442
454
|
}
|
|
443
455
|
|
|
444
|
-
export async function seed(): Promise<void> {
|
|
456
|
+
export async function seed(onDemand = false): Promise<void> {
|
|
445
457
|
teardownTmux();
|
|
446
458
|
fs.rmSync(ROOT, { recursive: true, force: true });
|
|
447
459
|
fs.mkdirSync(SCREENS, { recursive: true });
|
|
448
460
|
fs.mkdirSync(TMUX_TMPDIR, { recursive: true, mode: 0o700 });
|
|
449
461
|
writeHome();
|
|
450
462
|
createRepos();
|
|
451
|
-
await seedDb();
|
|
463
|
+
await seedDb(onDemand);
|
|
452
464
|
console.log(` seeded ${DB_PATH}`);
|
|
453
465
|
}
|
|
454
466
|
|
|
@@ -104,7 +104,9 @@ export async function shoot(only?: string[]): Promise<Raw[]> {
|
|
|
104
104
|
const browser = await launch();
|
|
105
105
|
const results: Raw[] = [];
|
|
106
106
|
try {
|
|
107
|
-
for (const shot of SHOTS.filter((s) =>
|
|
107
|
+
for (const shot of SHOTS.filter((s) =>
|
|
108
|
+
only ? only.includes(s.name) : !s.onDemand
|
|
109
|
+
)) {
|
|
108
110
|
results.push({ name: shot.name, files: await take(browser, shot) });
|
|
109
111
|
}
|
|
110
112
|
} finally {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Page } from "playwright";
|
|
2
|
+
import { RUNNING_TURN_SHOTS } from "./running-turn";
|
|
2
3
|
|
|
3
4
|
// Each shot is one or more raw captures; frame.ts composes them.
|
|
4
5
|
export interface Part {
|
|
@@ -15,6 +16,9 @@ export interface Part {
|
|
|
15
16
|
export interface Shot {
|
|
16
17
|
name: string;
|
|
17
18
|
parts: Part[];
|
|
19
|
+
// Only with --only <name>: it changes the demo (a session is added, a turn
|
|
20
|
+
// is started) and is not one of the README's pictures.
|
|
21
|
+
onDemand?: boolean;
|
|
18
22
|
}
|
|
19
23
|
|
|
20
24
|
const CHAT = "checkout-totals";
|
|
@@ -57,6 +61,7 @@ async function openDialog(page: Page, button: RegExp): Promise<void> {
|
|
|
57
61
|
}
|
|
58
62
|
|
|
59
63
|
export const SHOTS: Shot[] = [
|
|
64
|
+
...RUNNING_TURN_SHOTS,
|
|
60
65
|
{
|
|
61
66
|
name: "hero",
|
|
62
67
|
parts: [
|
package/server.ts
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
assertDemoSandbox,
|
|
40
40
|
demoAllowsUpgrade,
|
|
41
41
|
admitDemoSocket,
|
|
42
|
+
demoChatMode,
|
|
42
43
|
demoMode,
|
|
43
44
|
gateDemoRequest,
|
|
44
45
|
refuseDemoUpgrade,
|
|
@@ -116,7 +117,9 @@ if (!live) {
|
|
|
116
117
|
claimAgentosEnv(port);
|
|
117
118
|
// Demo mode: seeded data, nothing that runs code (lib/security/demo).
|
|
118
119
|
const demo = demoMode();
|
|
119
|
-
|
|
120
|
+
// The screenshot server's scripted chat turns: the sandbox, not the lockdown.
|
|
121
|
+
const demoChat = demoChatMode();
|
|
122
|
+
if (demo || demoChat) assertDemoSandbox();
|
|
120
123
|
// Fixed for this process and handed to the chat workers it starts.
|
|
121
124
|
buildId();
|
|
122
125
|
if (!demo) {
|
|
@@ -252,7 +255,12 @@ app.prepare().then(async () => {
|
|
|
252
255
|
});
|
|
253
256
|
chatWss.on("connection", (ws: WebSocket, request: IncomingMessage) => {
|
|
254
257
|
const params = new URL(request.url ?? "", "http://x").searchParams;
|
|
255
|
-
serveChatSocket(
|
|
258
|
+
serveChatSocket(
|
|
259
|
+
ws,
|
|
260
|
+
params,
|
|
261
|
+
(json) => sendBounded(ws, json),
|
|
262
|
+
demo || demoChat
|
|
263
|
+
);
|
|
256
264
|
});
|
|
257
265
|
|
|
258
266
|
// Handle WebSocket upgrades
|