@volter/world 2.0.0 → 2.0.2

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.
@@ -0,0 +1,203 @@
1
+ // `volter mcp` (docs/contributing/architecture.md, "The way in is the app's folder"): an MCP server over stdio whose tools
2
+ // are the `volter` command's own verbs. A tool that reads (status, log, diff, branches) asks the SDK; a tool that acts runs
3
+ // the command itself, as a child, so there is one implementation of each verb and an agent gets what a person would.
4
+ // Locally a World is reached with its own token (the command finds it); the platform with the person's token from
5
+ // `volter login`, which only the person can approve, in their browser.
6
+ import { spawn, spawnSync } from 'node:child_process';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { resolve } from 'node:path';
9
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
11
+ import { z } from 'zod';
12
+ import { World } from "./world.js";
13
+ import { agentPrompt } from "./agent-prompt.js";
14
+ /** This command's own entry, beside this module (src/cli.ts in a checkout, dist/src/cli.js when published). */
15
+ const CLI = fileURLToPath(new URL(`./cli${import.meta.url.endsWith('.ts') ? '.ts' : '.js'}`, import.meta.url));
16
+ const MAX_OUTPUT = 20_000;
17
+ /** Run `volter <args>` in `dir` to its end (or its timeout), its stdout and stderr together, the tail kept. */
18
+ function volter(args, dir, timeoutSeconds = 600) {
19
+ return new Promise((done) => {
20
+ const child = spawn(process.execPath, [CLI, ...args], { cwd: dir, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env: process.env });
21
+ let output = '';
22
+ const take = (d) => { output += d.toString(); if (output.length > MAX_OUTPUT * 2)
23
+ output = output.slice(-MAX_OUTPUT); };
24
+ child.stdout.on('data', take);
25
+ child.stderr.on('data', take);
26
+ const timer = setTimeout(() => { output += `\n(stopped after ${timeoutSeconds}s)`; child.kill(); }, timeoutSeconds * 1000);
27
+ child.on('error', (e) => { clearTimeout(timer); done({ code: 1, output: `${output}${e.message}` }); });
28
+ child.on('close', (code) => { clearTimeout(timer); done({ code: code ?? 1, output: output.length > MAX_OUTPUT ? `…${output.slice(-MAX_OUTPUT)}` : output }); });
29
+ });
30
+ }
31
+ /** A command that keeps running (the World's view, a sign-in waiting for approval): started once, answered as soon as
32
+ * its first lines say what the agent needs, left running for as long as this server runs. */
33
+ const background = new Map();
34
+ function startBackground(key, args, dir, ready, seconds = 60) {
35
+ background.get(key)?.kill();
36
+ background.delete(key);
37
+ return new Promise((done) => {
38
+ const child = spawn(process.execPath, [CLI, ...args], { cwd: dir, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env: process.env });
39
+ background.set(key, child);
40
+ let output = '';
41
+ let answered = false;
42
+ const answer = (code) => { if (!answered) {
43
+ answered = true;
44
+ clearTimeout(timer);
45
+ done({ code, output });
46
+ } };
47
+ const take = (d) => { output += d.toString(); if (ready.test(output))
48
+ answer(0); };
49
+ child.stdout.on('data', take);
50
+ child.stderr.on('data', take);
51
+ child.on('error', (e) => { output += e.message; answer(1); });
52
+ child.on('close', (code) => { background.delete(key); answer(code ?? 1); });
53
+ const timer = setTimeout(() => answer(0), seconds * 1000);
54
+ });
55
+ }
56
+ /** Everything this server left running, ended: a World under a dashboard is brought down through its own command first
57
+ * (its state kept, its teardown recorded), then the command itself. */
58
+ function stopAll() {
59
+ for (const [key, child] of background) {
60
+ if (key.startsWith('view:'))
61
+ spawnSync(process.execPath, [CLI, 'world', 'down'], { cwd: key.slice('view:'.length), windowsHide: true, stdio: 'ignore', timeout: 60_000 });
62
+ child.kill();
63
+ }
64
+ background.clear();
65
+ }
66
+ for (const signal of ['SIGINT', 'SIGTERM'])
67
+ process.on(signal, () => { stopAll(); process.exit(0); });
68
+ process.on('exit', stopAll);
69
+ const text = (s, isError = false) => ({ content: [{ type: 'text', text: s.trim() || '(no output)' }], ...(isError ? { isError: true } : {}) });
70
+ const ran = (r) => text(r.output, r.code !== 0);
71
+ const asJson = (v) => text(JSON.stringify(v, null, 2));
72
+ const fail = (e) => text(e instanceof Error ? e.message : String(e), true);
73
+ const INSTRUCTIONS = `Volter World runs an app against twins of the SaaS APIs it calls (Stripe, Slack, GitHub, OpenAI and about a hundred more): the app keeps its real SDKs and code, and every write lands in a World that can be branched, reset and looked at on each vendor's own screens.
74
+
75
+ Work in the app's folder (each tool takes "dir", defaulting to where this server was started).
76
+ - Set up: world_init detects the vendors from the app's manifests and env names; show the person the list before keeping it. Then world_up.
77
+ - Run the app or its tests inside the World with world_run (never bare against the real vendors).
78
+ - Look: world_log and world_diff say what the app changed at each vendor; world_view starts the dashboard and returns its link for the person.
79
+ - Branch and reset like a database: world_branch, world_checkout, world_reset.
80
+ - Share with a team: platform_login returns a link and a code the PERSON must approve in their browser (you cannot approve it); after they do, remote_link links this World to <org>/<world> on the platform (create: true makes it there from this World's vendors); world_changeset cuts the changes with a message, and world_push sends them.
81
+ The guide: https://github.com/volter-ai/twin/blob/main/docs/guides/use-with-a-coding-agent.md`;
82
+ export async function serveMcp(version) {
83
+ const server = new McpServer({ name: 'volter', title: 'Volter World', version }, { instructions: INSTRUCTIONS });
84
+ const here = (dir) => resolve(dir ?? process.cwd());
85
+ const dirArg = { dir: z.string().optional().describe("The app's folder (the World's root). Defaults to where this server was started.") };
86
+ server.registerTool('world_status', {
87
+ title: 'World status', description: 'The World of the app folder: its branch, whether it is running, each twin\'s URL, its origin (the remote it pushes to) and unpushed changes.',
88
+ inputSchema: dirArg, annotations: { readOnlyHint: true },
89
+ }, async ({ dir }) => { try {
90
+ return asJson(World.open({ root: here(dir) }).status());
91
+ }
92
+ catch (e) {
93
+ return fail(e);
94
+ } });
95
+ server.registerTool('world_init', {
96
+ title: 'Set up a World for the app', description: "Detect the vendors the app calls (from its package manifests and env var names), install their twins when none are installed yet, and write .volter/world.json beside the code. Show the person the detected vendors; pass allowUnknown to keep going when some have no twin yet.",
97
+ inputSchema: { ...dirArg, name: z.string().optional().describe('The World\'s name (defaults to the folder\'s).'), allowUnknown: z.boolean().optional(), force: z.boolean().optional().describe('Overwrite an existing .volter/world.json.') },
98
+ }, async ({ dir, name, allowUnknown, force }) => ran(await volter(['world', 'init', '--install', ...(name ? ['--name', name] : []), ...(allowUnknown ? ['--allow-unknown'] : []), ...(force ? ['--force'] : [])], here(dir))));
99
+ server.registerTool('world_up', {
100
+ title: 'Start the World', description: 'Start the twins on the current branch. A fresh branch loads its default data (the story); a stopped one resumes where it was.',
101
+ inputSchema: { ...dirArg, seed: z.boolean().optional().describe('Load the default data on a fresh branch (default true).') },
102
+ }, async ({ dir, seed }) => ran(await volter(['world', 'up', ...(seed === false ? ['--no-seed'] : [])], here(dir))));
103
+ server.registerTool('world_run', {
104
+ title: 'Run a command in the World', description: "Run the app or its tests inside the World: the vendors' hosts go to their twins, so the real SDKs work unchanged. Returns the command's exit code and the tail of its output.",
105
+ inputSchema: { ...dirArg, command: z.array(z.string()).min(1).describe('The command and its arguments, e.g. ["npm", "test"].'), timeoutSeconds: z.number().int().positive().max(3600).optional() },
106
+ }, async ({ dir, command, timeoutSeconds }) => { const r = await volter(['world', 'run', '--', ...command], here(dir), timeoutSeconds ?? 600); return text(`exit ${r.code}\n${r.output}`, r.code !== 0); });
107
+ server.registerTool('world_down', {
108
+ title: 'Stop the World', description: 'Stop the twins. Their state is kept for the next world_up unless purge is true.',
109
+ inputSchema: { ...dirArg, purge: z.boolean().optional() }, annotations: { destructiveHint: true },
110
+ }, async ({ dir, purge }) => ran(await volter(['world', 'down', ...(purge ? ['--purge'] : [])], here(dir))));
111
+ server.registerTool('world_view', {
112
+ title: 'Open the dashboard', description: "Run the World with its dashboard on this machine (each vendor's own screens, the calls, the changes, the branches) and return its link for the person. The dashboard runs the World itself: a World already started with world_up is stopped first (its state is kept) and comes back under the dashboard. It keeps running while this server does; world_run works against it meanwhile.",
113
+ inputSchema: dirArg,
114
+ }, async ({ dir }) => {
115
+ const root = here(dir);
116
+ let before = '';
117
+ try {
118
+ const s = World.open({ root }).status();
119
+ if (s.running && !s.served) {
120
+ const down = await volter(['world', 'down'], root);
121
+ if (down.code !== 0)
122
+ return ran(down);
123
+ before = `${down.output.trim()} (state kept; the dashboard runs it now)\n`;
124
+ }
125
+ }
126
+ catch (e) {
127
+ return fail(e);
128
+ }
129
+ const r = await startBackground(`view:${root}`, ['world', 'view', '--no-open'], root, /console\s+\S+/);
130
+ return text(`${before}${r.output}`, r.code !== 0);
131
+ });
132
+ server.registerTool('world_log', {
133
+ title: 'What the app changed', description: 'Every write the app made in this World, newest last: vendor, operation and subject.',
134
+ inputSchema: { ...dirArg, limit: z.number().int().positive().max(1000).optional().describe('The newest this many (default 50).') }, annotations: { readOnlyHint: true },
135
+ }, async ({ dir, limit }) => { try {
136
+ const rows = World.open({ root: here(dir) }).log();
137
+ return asJson(rows.slice(-(limit ?? 50)).map((r) => ({ vendor: r.service, operation: r.operation ?? r.op, subject: r.subject, ...(r.receipt ? { receipt: r.receipt } : {}) })));
138
+ }
139
+ catch (e) {
140
+ return fail(e);
141
+ } });
142
+ server.registerTool('world_diff', {
143
+ title: 'Changes since the default data', description: 'How many changes each vendor has since the default data (or the last mark).',
144
+ inputSchema: dirArg, annotations: { readOnlyHint: true },
145
+ }, async ({ dir }) => { try {
146
+ const d = World.open({ root: here(dir) }).diff();
147
+ return asJson({ base: d.base, vendors: d.vendors });
148
+ }
149
+ catch (e) {
150
+ return fail(e);
151
+ } });
152
+ server.registerTool('world_branch', {
153
+ title: 'List or make branches', description: 'Without a name, the branches (the checked-out one first). With a name, make a branch from here and check it out, like a database branch: try something, then check out the old one or reset.',
154
+ inputSchema: { ...dirArg, name: z.string().optional() },
155
+ }, async ({ dir, name }) => {
156
+ if (name)
157
+ return ran(await volter(['world', 'branch', name], here(dir)));
158
+ try {
159
+ const w = World.open({ root: here(dir) });
160
+ return asJson({ current: w.name, branches: w.branches() });
161
+ }
162
+ catch (e) {
163
+ return fail(e);
164
+ }
165
+ });
166
+ server.registerTool('world_checkout', {
167
+ title: 'Switch branch', description: 'Check out another branch of the World.',
168
+ inputSchema: { ...dirArg, name: z.string() },
169
+ }, async ({ dir, name }) => ran(await volter(['world', 'checkout', name], here(dir))));
170
+ server.registerTool('world_reset', {
171
+ title: 'Back to the default data', description: "Discard the current branch's changes and load its default data again.",
172
+ inputSchema: dirArg, annotations: { destructiveHint: true },
173
+ }, async ({ dir }) => ran(await volter(['world', 'reset'], here(dir))));
174
+ server.registerTool('platform_login', {
175
+ title: 'Sign in to a platform', description: 'Start signing this machine\'s `volter` command in to a Volter World platform. Returns a link and a code: give both to the PERSON, who approves it in their browser (you cannot). The sign-in completes by itself once they do; check with platform_whoami.',
176
+ inputSchema: { url: z.string().url().describe("The platform's address (https://…), as the person gave it") },
177
+ }, async ({ url }) => ran(await startBackground('login', ['login', url, '--no-open'], process.cwd(), /code\s+[A-Z]{4}-[A-Z]{4}/, 30)));
178
+ server.registerTool('platform_whoami', {
179
+ title: 'Who the command is signed in as', description: 'The platform this machine\'s `volter` command is signed in to, as whom, and their orgs.',
180
+ inputSchema: {}, annotations: { readOnlyHint: true },
181
+ }, async () => ran(await volter(['whoami'], process.cwd())));
182
+ server.registerTool('remote_link', {
183
+ title: 'Link the World to the platform', description: "Link this World to <org>/<world> on the platform the command is signed in to, as the remote it pushes to. create: true makes that World on the platform from this World's vendors when it does not exist.",
184
+ inputSchema: { ...dirArg, target: z.string().regex(/^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/).describe('<org>/<world>'), name: z.string().optional().describe('The remote\'s name (default origin).'), create: z.boolean().optional() },
185
+ }, async ({ dir, target, name, create }) => ran(await volter(['remote', 'add', name ?? 'origin', target, ...(create ? ['--create'] : [])], here(dir))));
186
+ server.registerTool('world_changeset', {
187
+ title: 'Cut a changeset', description: "Cut the World's unpushed changes into a reviewable changeset, with a message saying what and why; world_push sends it.",
188
+ inputSchema: { ...dirArg, message: z.string().min(1).describe('What the changes are and why.'), name: z.string().optional() },
189
+ }, async ({ dir, message, name }) => ran(await volter(['world', 'changeset', ...(name ? [name] : []), '-m', message], here(dir))));
190
+ server.registerTool('world_push', {
191
+ title: 'Push to the remote', description: "Send this World's unpushed changes to its remote (the platform's World after remote_link); the remote answers with a receipt for each change.",
192
+ inputSchema: { ...dirArg, changeset: z.string().optional().describe('Push one changeset by name; default all unpushed.') },
193
+ }, async ({ dir, changeset }) => ran(await volter(['world', 'push', ...(changeset ? [changeset] : [])], here(dir))));
194
+ server.registerPrompt('get-started', {
195
+ title: 'Set up Volter World for this app', description: 'Walks through setting up a World for the app in this folder, running its tests in it, and optionally sharing it on a platform.',
196
+ argsSchema: { platform: z.string().optional().describe("A platform to share the World on: its address"), world: z.string().optional().describe('<org>/<world> on that platform') },
197
+ }, ({ platform, world }) => ({ messages: [{ role: 'user', content: { type: 'text', text: agentPrompt({ ...(platform ? { platform } : {}), ...(world ? { world } : {}), tools: true }) } }] }));
198
+ // the client closing the connection (its stdin ends) ends this server, and the commands it left running with it
199
+ const stop = () => { stopAll(); process.exit(0); };
200
+ server.server.onclose = stop;
201
+ process.stdin.on('end', stop);
202
+ await server.connect(new StdioServerTransport());
203
+ }
@@ -0,0 +1,7 @@
1
+ /** This package's version, read from its own package.json. */
2
+ export declare function currentVersion(): string;
3
+ /** Whether `a` is a newer release than `b` (major.minor.patch; a prerelease is not offered). */
4
+ export declare function newer(a: string, b: string): boolean;
5
+ /** Start the day's check if one is due, while the command runs; the returned function, called when the command is
6
+ * done, waits a moment at most for it and prints the notice when there is one. Off, it does nothing and asks nothing. */
7
+ export declare function startUpdateCheck(args: string[], env?: NodeJS.ProcessEnv): () => Promise<void>;
@@ -0,0 +1,60 @@
1
+ // The update notice (docs/contributing/architecture.md, "The way in is the app's folder"): at most once a day the command
2
+ // asks the npm registry for @volter/world's latest version, while it does its own work, and says in one line on stderr
3
+ // when a newer one exists. Never in CI, never for --json, never when the person opted out (VOLTER_NO_UPDATE_NOTIFIER=1):
4
+ // then nothing is asked at all. The answer is kept beside the credentials, so a day's commands ask once.
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import { dirname, join } from 'node:path';
7
+ import { credentialsPath } from "./credentials.js";
8
+ const DAY = 86_400_000;
9
+ const REGISTRY = 'https://registry.npmjs.org/@volter/world/latest';
10
+ /** This package's version, read from its own package.json. */
11
+ export function currentVersion() {
12
+ try {
13
+ return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version ?? '0.0.0';
14
+ }
15
+ catch {
16
+ return '0.0.0';
17
+ }
18
+ }
19
+ /** Whether `a` is a newer release than `b` (major.minor.patch; a prerelease is not offered). */
20
+ export function newer(a, b) {
21
+ const parse = (v) => (/^\d+\.\d+\.\d+$/.test(v) ? v.split('.').map(Number) : null);
22
+ const x = parse(a);
23
+ const y = parse(b);
24
+ if (!x || !y)
25
+ return false;
26
+ for (let i = 0; i < 3; i++)
27
+ if (x[i] !== y[i])
28
+ return x[i] > y[i];
29
+ return false;
30
+ }
31
+ /** Start the day's check if one is due, while the command runs; the returned function, called when the command is
32
+ * done, waits a moment at most for it and prints the notice when there is one. Off, it does nothing and asks nothing. */
33
+ export function startUpdateCheck(args, env = process.env) {
34
+ if (env.VOLTER_NO_UPDATE_NOTIFIER || env.CI || args.includes('--json') || !process.stderr.isTTY)
35
+ return async () => undefined;
36
+ const path = join(dirname(credentialsPath()), 'update-check.json');
37
+ let kept = null;
38
+ try {
39
+ if (existsSync(path))
40
+ kept = JSON.parse(readFileSync(path, 'utf8'));
41
+ }
42
+ catch {
43
+ kept = null;
44
+ }
45
+ const due = !kept || Date.now() - Date.parse(kept.checkedAt) > DAY;
46
+ const check = due
47
+ ? fetch(REGISTRY, { signal: AbortSignal.timeout(1500) }).then(async (r) => (r.ok ? (await r.json()).version ?? null : null)).catch(() => null)
48
+ .then((latest) => { try {
49
+ mkdirSync(dirname(path), { recursive: true });
50
+ writeFileSync(path, `${JSON.stringify({ checkedAt: new Date().toISOString(), latest })}\n`);
51
+ }
52
+ catch { /* a read-only home only loses the cache */ } return latest; })
53
+ : Promise.resolve(kept?.latest ?? null);
54
+ return async () => {
55
+ const latest = await Promise.race([check, new Promise((r) => setTimeout(() => r(null), 800))]);
56
+ const here = currentVersion();
57
+ if (latest && newer(latest, here))
58
+ process.stderr.write(`\nvolter ${latest} is out (you have ${here}): npm install -g @volter/world\n`);
59
+ };
60
+ }
@@ -1,13 +1,11 @@
1
1
  import { rebaseChangeset, worldBootMarker, type Changeset, type LedgerDelta, type TwinAction, type WorldMarker, type ApplyReceipt, applyTwinWrite, type TwinResource } from '@volter/world-core';
2
- import { approveWorldChangeset, createWorldChangeset, downWorld, fetchFromOrigin, pushWorldChangeset, rebaseWorldChangeset, replayWorldChangeset, resetWorld, seedWorld, statusWorldChangeset, verifyWorldChangeset, type ServedWorld, type DeployTwinOutcome, type ChangesetLocation, type InitOptions, type InitResult, type WorldInstance } from '@volter/world-runtime';
2
+ import { approveWorldChangeset, createWorldChangeset, downWorld, fetchFromOrigin, pushWorldChangeset, rebaseWorldChangeset, replayWorldChangeset, resetWorld, seedWorld, statusWorldChangeset, verifyWorldChangeset, type ServedWorld, type ViewConsole, type WorldView, type DeployTwinOutcome, type ChangesetLocation, type InitOptions, type InitResult, type WorldInstance } from '@volter/world-runtime';
3
3
  import { rebaseBranch, type Receipt, type RootConfig } from '@volter/world-core';
4
4
  export type WorldRef = {
5
5
  name?: string;
6
6
  root?: string;
7
7
  };
8
8
  export type WorldMode = 'local' | 'share' | 'sealed';
9
- /** One change as `log` shows it: the write, and the receipt the vendor answered with once pushed. */
10
- /** A twin's branch log as the world reads it: the twin's name, the state service it records under, its control root. */
11
9
  export declare class TwinLog {
12
10
  readonly service: string;
13
11
  readonly stateService: string;
@@ -164,15 +162,23 @@ export declare class World {
164
162
  isMain(): boolean;
165
163
  /** What this branch's changes are measured from, as `diff` says it: the branch point, origin, or the story. */
166
164
  diffBase(): string;
167
- /** The world's clock: the frozen instant every twin stamps from, or the wall clock when unset. */
165
+ /** The world's clock (world-clock.cjs): its time now, and whether it is frozen at an instant, running (moved with
166
+ * `shiftClock`), or the wall clock (unset). */
168
167
  clock(): {
169
168
  at: string;
170
169
  frozen: boolean;
170
+ running: boolean;
171
171
  };
172
- /** Set the world's clock to an instant; every twin stamps from it until it moves. */
172
+ /** Set the world's clock to an instant; every twin, and the World's application, stamps from it until it moves. */
173
173
  setClock(iso: string): string;
174
- /** Move a set clock forward: `30d`, `12h`, `5m`, `90s`. */
174
+ /** Move a set clock forward, frozen or running: `30d`, `12h`, `5m`, `90s`. */
175
175
  advanceClock(by: string): string;
176
+ /** Move the World forward while its time keeps running (a World serving an application): from the wall clock, or a
177
+ * running clock further. */
178
+ shiftClock(by: string): string;
179
+ /** Return the World to the machine's time. */
180
+ clearClock(): string;
181
+ private writeClock;
176
182
  /** Rebase this branch onto its base's current position, every twin; conflicts named per subject and field. */
177
183
  rebaseBranch(): Array<{
178
184
  service: string;
@@ -192,6 +198,16 @@ export declare class World {
192
198
  console: string | null;
193
199
  }) => void;
194
200
  }): Promise<ServedWorld>;
201
+ /** `volter world view`: this World, its mirrors, its branches and the console on one origin (docs/contributing/architecture.md, "Viewing a World"). */
202
+ view(opts?: {
203
+ port?: number;
204
+ host?: string;
205
+ console?: ViewConsole;
206
+ announce?: (line: string) => void;
207
+ origins?: boolean;
208
+ }): Promise<WorldView>;
209
+ /** The vendors this world has a twin of, as world.json names them (a twin service's package, else its id). */
210
+ vendors(): string[];
195
211
  /** The remotes named in world.json (`volter remote add`), name → url or path. */
196
212
  remotes(): Record<string, string>;
197
213
  /** Name a remote; `origin` is the one fetch and push use by default. A token given is remembered for it. */
package/dist/src/world.js CHANGED
@@ -4,14 +4,22 @@
4
4
  // user reads. The `volter` command is one client of this class and adds nothing.
5
5
  import { rebaseChangeset, worldBootMarker, listActions, isTwinBookkeeping, pushablePendingActions, pendingActions, applyTwinWrite, twinResources } from '@volter/world-core';
6
6
  import { spawnSync } from 'node:child_process';
7
- import { activateScript, approveWorldChangeset, branchWorld, clockFile, checkoutWorld, createWorldChangeset, diffWorld, downWorld, fetchFromOrigin, findWorldChangeset, initWorld, listWorldChangesets, listWorldMarks, listWorlds, markWorld, pushWorldChangeset, rebaseWorldChangeset, replayWorldChangeset, resetWorld, runWithWorldEnv, seedWorld, shellWorld, statusWorld, statusWorldChangeset, upWorld, verifyWorldChangeset, worldLedgers, worldOrigin, deployWorld, loadWorldConfig, writeWorldConfig, readServeRecord, refreshTwin, serveWorld, findInstalledPackage, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, credentialPayloadFrom, materializeRoots } from '@volter/world-runtime';
8
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
7
+ import clockForm from '@volter/world-core/world-clock';
8
+ import { activateScript, approveWorldChangeset, branchWorld, clockFile, checkoutWorld, createWorldChangeset, diffWorld, downWorld, fetchFromOrigin, findWorldChangeset, initWorld, listWorldChangesets, listWorldMarks, listWorlds, markWorld, pushWorldChangeset, rebaseWorldChangeset, replayWorldChangeset, resetWorld, runWithWorldEnv, seedWorld, shellWorld, statusWorld, statusWorldChangeset, upWorld, verifyWorldChangeset, worldLedgers, worldOrigin, deployWorld, loadWorldConfig, writeWorldConfig, readServeRecord, refreshTwin, serveWorld, serveWorldView, findInstalledPackage, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, credentialPayloadFrom, materializeRoots } from '@volter/world-runtime';
9
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
9
10
  import { dirname, join, resolve } from 'node:path';
10
11
  import { requireToken, storeToken } from "./credentials.js";
11
12
  import { currentBranch, findWorldRoot, mainBranch, requireWorldRoot, setCurrentBranch, worldConfigRelative, worldEnvPath, worldSeedPath } from "./locate.js";
12
13
  import { parentEntries, rebaseBranch } from '@volter/world-core';
13
14
  /** One change as `log` shows it: the write, and the receipt the vendor answered with once pushed. */
14
15
  /** A twin's branch log as the world reads it: the twin's name, the state service it records under, its control root. */
16
+ /** `30d`, `12h`, `5m`, `90s` in ms. */
17
+ function durationMs(by) {
18
+ const m = /^(\d+(?:\.\d+)?)(s|m|h|d)$/.exec(by.trim());
19
+ if (!m)
20
+ throw new Error(`${JSON.stringify(by)} is not <N>(s|m|h|d)`);
21
+ return Number(m[1]) * { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2]];
22
+ }
15
23
  export class TwinLog {
16
24
  service;
17
25
  stateService;
@@ -86,7 +94,7 @@ export class World {
86
94
  if (opts.install !== false && !twins.every((t) => findInstalledPackage(dir, `@volter/twin-${t}`))) {
87
95
  // the runtime's own installer: bun under Bun, npm under Node — never assumed
88
96
  const cmd = typeof Bun !== 'undefined' ? ['bun', 'add', '-d'] : ['npm', 'install', '--save-dev'];
89
- const r = spawnSync(cmd[0], [...cmd.slice(1), ...twins.map((t) => `@volter/twin-${t}`)], { cwd: dir, stdio: 'inherit' });
97
+ const r = spawnSync(cmd[0], [...cmd.slice(1), ...twins.map((t) => `@volter/twin-${t}`)], { windowsHide: true, cwd: dir, stdio: 'inherit' });
90
98
  if (r.status !== 0)
91
99
  throw new Error(`installing the twins failed (${cmd.join(' ')} exited ${r.status})`);
92
100
  }
@@ -248,9 +256,19 @@ export class World {
248
256
  async branch(name, opts = {}) {
249
257
  if (listWorlds(this.root).some((w) => w.name === name))
250
258
  throw new Error(`A branch "${name}" already exists — \`volter world checkout ${name}\` switches to it`);
251
- await branchWorld(this.name, name, { root: this.root, envFile: worldEnvPath(this.root), ...(opts.at ? { at: opts.at } : {}) });
252
- if (this.instance()?.running)
259
+ // this branch stops first (a managed database is copied from a stopped World, and the two would share its port),
260
+ // and comes back if the new one fails
261
+ const wasRunning = !!this.instance()?.running;
262
+ if (wasRunning)
253
263
  await downWorld(this.name, this.root);
264
+ try {
265
+ await branchWorld(this.name, name, { root: this.root, envFile: worldEnvPath(this.root), ...(opts.at ? { at: opts.at } : {}) });
266
+ }
267
+ catch (error) {
268
+ if (wasRunning)
269
+ await checkoutWorld(this.name, { root: this.root });
270
+ throw error;
271
+ }
254
272
  setCurrentBranch(this.root, name);
255
273
  return new World(name, this.root);
256
274
  }
@@ -283,31 +301,54 @@ export class World {
283
301
  return existsSync(worldSeedPath(this.root)) ? 'the story' : 'the default data';
284
302
  }
285
303
  // ── the clock ───────────────────────────────────────────────────────────────────────────
286
- /** The world's clock: the frozen instant every twin stamps from, or the wall clock when unset. */
304
+ /** The world's clock (world-clock.cjs): its time now, and whether it is frozen at an instant, running (moved with
305
+ * `shiftClock`), or the wall clock (unset). */
287
306
  clock() {
288
307
  const file = clockFile(this.root, this.name);
289
- return existsSync(file) ? { at: readFileSync(file, 'utf8').trim(), frozen: true } : { at: new Date().toISOString(), frozen: false };
308
+ if (!existsSync(file))
309
+ return { at: new Date().toISOString(), frozen: false, running: false };
310
+ const c = clockForm.parseClock(readFileSync(file, 'utf8'));
311
+ return { at: new Date(clockForm.clockNowMs(c, Date.now())).toISOString(), frozen: c.kind === 'frozen', running: c.kind === 'running' };
290
312
  }
291
- /** Set the world's clock to an instant; every twin stamps from it until it moves. */
313
+ /** Set the world's clock to an instant; every twin, and the World's application, stamps from it until it moves. */
292
314
  setClock(iso) {
293
315
  const parsed = Date.parse(iso);
294
316
  if (Number.isNaN(parsed))
295
317
  throw new Error(`${JSON.stringify(iso)} is not an ISO-8601 instant`);
296
- const file = clockFile(this.root, this.name);
297
- mkdirSync(dirname(file), { recursive: true });
298
- writeFileSync(file, `${new Date(parsed).toISOString()}\n`);
318
+ this.writeClock({ kind: 'frozen', at: parsed });
299
319
  return new Date(parsed).toISOString();
300
320
  }
301
- /** Move a set clock forward: `30d`, `12h`, `5m`, `90s`. */
321
+ /** Move a set clock forward, frozen or running: `30d`, `12h`, `5m`, `90s`. */
302
322
  advanceClock(by) {
303
- const m = /^(\d+(?:\.\d+)?)(s|m|h|d)$/.exec(by.trim());
304
- if (!m)
305
- throw new Error(`${JSON.stringify(by)} is not <N>(s|m|h|d)`);
306
- const current = this.clock();
307
- if (!current.frozen)
308
- throw new Error('the clock is not set — `volter world clock set <iso>` first; advancing the wall clock would freeze time as a side effect');
309
- const ms = Number(m[1]) * { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2]];
310
- return this.setClock(new Date(Date.parse(current.at) + ms).toISOString());
323
+ const file = clockFile(this.root, this.name);
324
+ if (!existsSync(file))
325
+ throw new Error('the clock is not set — `volter world clock set <iso>` first; advancing the wall clock would freeze time as a side effect (`volter world clock shift` moves a running World)');
326
+ const c = clockForm.parseClock(readFileSync(file, 'utf8'));
327
+ this.writeClock({ ...c, at: c.at + durationMs(by) });
328
+ return this.clock().at;
329
+ }
330
+ /** Move the World forward while its time keeps running (a World serving an application): from the wall clock, or a
331
+ * running clock further. */
332
+ shiftClock(by) {
333
+ const file = clockFile(this.root, this.name);
334
+ const c = existsSync(file) ? clockForm.parseClock(readFileSync(file, 'utf8')) : null;
335
+ if (c?.kind === 'frozen')
336
+ throw new Error('the clock is frozen — `volter world clock advance` moves it');
337
+ const wall = Date.now();
338
+ this.writeClock(c ? { ...c, at: c.at + durationMs(by) } : { kind: 'running', at: wall + durationMs(by), since: wall });
339
+ return this.clock().at;
340
+ }
341
+ /** Return the World to the machine's time. */
342
+ clearClock() {
343
+ const file = clockFile(this.root, this.name);
344
+ if (existsSync(file))
345
+ rmSync(file);
346
+ return new Date().toISOString();
347
+ }
348
+ writeClock(c) {
349
+ const file = clockFile(this.root, this.name);
350
+ mkdirSync(dirname(file), { recursive: true });
351
+ writeFileSync(file, `${clockForm.formatClock(c)}\n`);
311
352
  }
312
353
  /** Rebase this branch onto its base's current position, every twin; conflicts named per subject and field. */
313
354
  rebaseBranch() { return this.repos().map((r) => ({ service: r.service, ...rebaseBranch(r.stateService, r.root) })); }
@@ -316,7 +357,14 @@ export class World {
316
357
  // ── serving ─────────────────────────────────────────────────────────────────────────────
317
358
  /** Serve this world on a port under `/<org>/<world>/`; returns when listening. */
318
359
  serve(opts = {}) { return serveWorld(this.name, { root: this.root, ...opts }); }
360
+ /** `volter world view`: this World, its mirrors, its branches and the console on one origin (docs/contributing/architecture.md, "Viewing a World"). */
361
+ view(opts = {}) { return serveWorldView(this.name, { root: this.root, ...opts }); }
319
362
  // ── remotes ─────────────────────────────────────────────────────────────────────────────
363
+ /** The vendors this world has a twin of, as world.json names them (a twin service's package, else its id). */
364
+ vendors() {
365
+ const { config } = loadWorldConfig(this.configRef(), this.root);
366
+ return [...new Set(config.services.filter((s) => (s.type ?? 'twin') === 'twin').map((s) => /^@volter\/twin-(.+)$/.exec(s.package ?? '')?.[1] ?? s.id))].sort();
367
+ }
320
368
  /** The remotes named in world.json (`volter remote add`), name → url or path. */
321
369
  remotes() { return { ...(loadWorldConfig(this.configRef(), this.root).config.remotes ?? {}) }; }
322
370
  /** Name a remote; `origin` is the one fetch and push use by default. A token given is remembered for it. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world",
3
- "version": "2.0.0",
3
+ "version": "2.0.2",
4
4
  "description": "Run your app against a world of twins. `World` and `Repo` — one method per verb: init, up, run, log, diff, reset, branch, checkout, clone, fetch, changeset, push — and the `volter` command, one client of them.",
5
5
  "keywords": [
6
6
  "volter",
@@ -13,6 +13,11 @@
13
13
  ],
14
14
  "author": "Volter (https://github.com/volter-ai)",
15
15
  "license": "Apache-2.0",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/volter-ai/twin.git",
19
+ "directory": "packages/cli"
20
+ },
16
21
  "publishConfig": {
17
22
  "access": "public"
18
23
  },
@@ -20,18 +25,32 @@
20
25
  "main": "dist/src/index.js",
21
26
  "files": [
22
27
  "src",
23
- "dist"
28
+ "dist",
29
+ "skills"
24
30
  ],
25
31
  "dependencies": {
26
- "@volter/world-core": "2.0.0",
27
- "@volter/world-runtime": "2.0.0"
32
+ "@modelcontextprotocol/sdk": "^1.30.1",
33
+ "@volter/world-core": "2.0.2",
34
+ "@volter/world-runtime": "2.0.2",
35
+ "zod": "^4.6.5"
36
+ },
37
+ "peerDependencies": {
38
+ "@volter/world-console": "2.0.2"
39
+ },
40
+ "peerDependenciesMeta": {
41
+ "@volter/world-console": {
42
+ "optional": true
43
+ }
44
+ },
45
+ "devDependencies": {
46
+ "@volter/world-console": "2.0.2"
28
47
  },
29
48
  "bin": {
30
49
  "volter": "dist/src/cli.js"
31
50
  },
32
51
  "scripts": {
33
52
  "build": "node ../../scripts/publish/build.mjs",
34
- "prepack": "node ../../scripts/publish/prepare-publish.mjs prepack",
35
- "postpack": "node ../../scripts/publish/prepare-publish.mjs postpack"
53
+ "prepack": "node ./scripts/ship-skill.mjs in && node ../../scripts/publish/prepare-publish.mjs prepack",
54
+ "postpack": "node ../../scripts/publish/prepare-publish.mjs postpack && node ./scripts/ship-skill.mjs out"
36
55
  }
37
56
  }