dreamteamer 0.28.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -67,6 +67,15 @@ schema:
67
67
  have lived in `modules/<module>/<kind>/` since the 2026-08-05 flatten, and a `system/`
68
68
  prefix is only how `runtime.js` recognises a RUNTIME collection in a descriptor compiled
69
69
  by a pre-flatten engine.
70
+ driver:
71
+ type: string
72
+ description: >-
73
+ The name of an engine DRIVER that answers this collection's verbs instead of a folder of
74
+ files — `docker` is the one shipped (src/containers.js: `containers`, `images`). A driver
75
+ collection has no records on disk: its derived `path` names a folder that never exists,
76
+ so check, commit and the store read zero records and never write one; the CLI and the REST
77
+ route dispatch to the driver first. A string checked against the drivers the engine has,
78
+ not an enum — one implementation, and a second is a code change, not a vocabulary change.
70
79
  codec:
71
80
  type: string
72
81
  enum: [md, yaml, json, file]
@@ -0,0 +1,81 @@
1
+ name: containers
2
+ description: >-
3
+ A workspace running as a container — one person's editor, harness and compiled workspace over a
4
+ named volume set, answered over the Docker Engine API rather than from a folder of files.
5
+ use_when: >-
6
+ a workspace has to RUN somewhere for someone — `dt start container <name> --template <t>` makes
7
+ one and prints its editor URL; `dt list containers` says what is running and where; `stop`, `open`,
8
+ `rm` are the lifecycle. Not a record: nothing lands under data/, nothing is committed, and Docker
9
+ keeps the history
10
+ # MACHINERY WITH A DRIVER, NOT A RECORD COLLECTION. `storage.driver: docker` says every verb on this
11
+ # collection is answered by src/containers.js over the Engine API: `list` is GET /containers/json,
12
+ # `get` is an inspect, `add`/`start` create-and-start, `rm` removes. The derived storage.path names a
13
+ # folder that never exists, so every walker (check, commit, the store's index) reads zero records here
14
+ # and never writes one; the REST route and the CLI dispatch to the driver before the store is asked.
15
+ # `group: system` folds it out of the orientation block's domain listing, beside `repos` and the
16
+ # compiled kinds, which is where a stranger's mental model puts "the thing my workspace runs in".
17
+ storage:
18
+ driver: docker
19
+ schema:
20
+ type: object
21
+ required: [name, template]
22
+ properties:
23
+ # ---- identity ----
24
+ name:
25
+ type: string
26
+ description: The workspace name — also the container name and the stem of its three volumes (`dreamteamer-<name>-workspace` · `-home` · `-files`).
27
+ template:
28
+ type: string
29
+ description: The template it was made from — an image carrying `dreamteamer.template`, `dreamteamer.ports` and `dreamteamer.modules` labels; `--template hq` resolves to `<DT_REGISTRY>/hq:<DT_TEMPLATE_TAG>` or to `DT_IMAGE_hq`.
30
+ image:
31
+ type: string
32
+ description: The image reference the container runs.
33
+ id:
34
+ type: string
35
+ description: Docker's short id.
36
+ # ---- runtime ----
37
+ state:
38
+ type: string
39
+ enum: [created, running, paused, restarting, exited, dead]
40
+ description: Docker's container state.
41
+ status:
42
+ type: string
43
+ description: Docker's own phrase — `Up 2 hours`, `Exited (0) 3 minutes ago`.
44
+ started:
45
+ type: string
46
+ format: date-time
47
+ description: When it last started.
48
+ # ---- network ----
49
+ editor_url:
50
+ type: string
51
+ description: Where the person opens the editor — `http://localhost:<port>/?folder=<workspace dir>`; the host port comes from DT_PORT_BASE upward, bound to DT_BIND (127.0.0.1).
52
+ port:
53
+ type: integer
54
+ description: The host port the editor is published on.
55
+ bind:
56
+ type: string
57
+ description: The host address it is bound to — loopback unless the host .env says otherwise.
58
+ # ---- storage ----
59
+ workspace_dir:
60
+ type: string
61
+ description: Where the workspace is mounted inside — `/workspaces/<name>`, the dev-container convention.
62
+ volumes:
63
+ type: object
64
+ description: The three named volumes — `workspace` (the repo), `home` (the person's logins and editor settings), `files` (FILES_FOLDER). Plain `rm` keeps them; `rm --force` removes them.
65
+ mounts:
66
+ type: array
67
+ items: { type: string }
68
+ description: Extra bind or volume mounts passed as `--mount <host-path|volume>:<container-path>[:ro]`.
69
+ # ---- person ----
70
+ person:
71
+ type: string
72
+ description: Whose container this is — the git identity injected as env; the harness login happens INSIDE, once, and is never injected.
73
+ # ---- provenance ----
74
+ created:
75
+ type: string
76
+ format: date-time
77
+ description: When Docker created it.
78
+ order: 146
79
+ list_fields: [name, template, state, editor_url, person]
80
+ icon: deployed_code
81
+ group: system
@@ -0,0 +1,46 @@
1
+ name: images
2
+ description: >-
3
+ A template a workspace container is made from — a Docker image carrying the `dreamteamer.template`,
4
+ `dreamteamer.ports` and `dreamteamer.modules` labels — as the local Docker holds it.
5
+ use_when: >-
6
+ choosing what a container runs — `dt list images` shows the templates present, `dt add image
7
+ --template <t>` pulls one, `dt rm image <ref>` removes one; a template is an IMAGE WITH LABELS, not
8
+ a record here, so there is nothing to author — publish an image with the labels and it appears
9
+ # The second driver-backed collection (see containers.collection.yaml for what that means). `list` is
10
+ # GET /images/json filtered to the template label, `get` an inspect, `add --template` a pull, `rm` a
11
+ # delete. No records, no folder, no commit.
12
+ storage:
13
+ driver: docker
14
+ schema:
15
+ type: object
16
+ required: [image]
17
+ properties:
18
+ # ---- identity ----
19
+ image:
20
+ type: string
21
+ description: The image reference — `<registry>/<template>:<tag>`.
22
+ template:
23
+ type: string
24
+ description: The `dreamteamer.template` label — the word `--template` takes.
25
+ id:
26
+ type: string
27
+ description: Docker's image id.
28
+ # ---- what it runs ----
29
+ ports:
30
+ type: string
31
+ description: The `dreamteamer.ports` label — the in-container port the editor listens on (8080 by convention).
32
+ modules:
33
+ type: string
34
+ description: The `dreamteamer.modules` label — the modules the template installs, comma-separated.
35
+ # ---- size and time ----
36
+ size_mb:
37
+ type: integer
38
+ description: Image size in megabytes.
39
+ created:
40
+ type: string
41
+ format: date
42
+ description: When the image was built.
43
+ order: 147
44
+ list_fields: [template, image, size_mb, created]
45
+ icon: layers
46
+ group: system
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dreamteamer",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "A workspace compiler for coding agents — schema-validated records as plain files over git, compiled into every harness",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Gilad Khen <giladkhen@gmail.com>",
package/src/cli.js CHANGED
@@ -247,15 +247,22 @@ workspace verbs:
247
247
  containers — a workspace as a running container (Docker Engine API over its socket, no dependency;
248
248
  these verbs work with NO workspace, so npm i -g dreamteamer and Docker Desktop are enough):
249
249
  setup make THIS MACHINE ready: checks Docker, writes ~/.dreamteamer/.env with its defaults
250
- (DT_PORT_BASE 8100 · DT_BIND 127.0.0.1 · DT_REGISTRY · DT_TEMPLATE_TAG), lists the
251
- templates present, pulls one on request [--template <t>] [--json]
250
+ (DT_PORT_BASE 8100 · DT_BIND 127.0.0.1 · DT_REGISTRY · DT_TEMPLATE_TAG ·
251
+ DT_DOCKER_TIMEOUT 30 — seconds a request to Docker may sit idle before the verb
252
+ fails), lists the templates present, pulls one on request [--template <t>] [--json]
252
253
  start container <name> --template <t> create-if-absent and start: a code-server editor at
253
254
  http://localhost:<port>/?folder=/workspace over a compiled workspace, three named volumes
254
255
  (workspace · home · files), image <DT_REGISTRY>/<template>:<tag> or DT_IMAGE_<template>.
255
- Idempotent. NO token is ever injected — log in INSIDE, once; the home volume keeps it.
256
+ The workspace is mounted at /workspaces/<name>. Idempotent. NO token is ever injected —
257
+ log in INSIDE, once; the home volume keeps it.
258
+ [--repo <git url>] clone an EXISTING workspace into the volume on first start, instead
259
+ of laying the template down — how a person joins one on GitHub
260
+ [--mount <host-path|volume>:<container-path>[:ro]] extra mounts, repeatable
256
261
  [--name <git name>] [--email <git email>] [--no-open] [--json]
257
262
  stop container <name> stop it; every volume kept [--json]
258
263
  open container <name> print (and open) its editor URL [--no-open]
264
+ [--vscode] print (and open) the Dev Containers attach URI instead — the host's own
265
+ VS Code inside the container, extensions from the image's metadata label
259
266
  list containers | images the record verbs, answered over Docker instead of a
260
267
  get container <name> | image <ref> folder — singular or plural, either spelling.
261
268
  rm container <name> [--force] plain rm keeps the volumes; --force removes them too
@@ -318,7 +325,7 @@ export const WORKSPACE_FLAGS = {
318
325
  // `start` is TWO forms: bare, the REST api (--port); with a `container <name>` target, the
319
326
  // lifecycle verb — whose flags are the driver's. One table, because `flags-honoured` reads it.
320
327
  start: ['port', ...CONTAINER_FLAGS], compile: ['watch'], check: [], status: ['strict'],
321
- setup: ['template', 'json'], stop: ['json'], open: ['json', 'no-open'],
328
+ setup: ['template', 'json'], stop: ['json'], open: ['json', 'no-open', 'vscode'],
322
329
  changes: ['since', 'json'], commit: ['dry-run', 'json'],
323
330
  export: EXPORT_FLAGS,
324
331
  // the UNION of every form's flags — the outer typo gate. Which flags each FORM takes is refused
package/src/containers.js CHANGED
@@ -21,6 +21,14 @@
21
21
  // `.dreamteamer/collections` either — these two nouns resolve HERE, ahead of workspace discovery,
22
22
  // so `dt list containers` answers identically inside a workspace and on a bare host.
23
23
  //
24
+ // EVERY REQUEST CARRIES A TIMER. Docker Desktop paused, or still starting, ACCEPTS the socket and
25
+ // says nothing — and a client with no timer then hangs every verb, and everything waiting on it,
26
+ // forever (measured 2026-09-24: a fake that accepts and never answers held `dt list containers`
27
+ // until the harness killed it at 20 s). So `api()` sets an IDLE timer of `DT_DOCKER_TIMEOUT`
28
+ // seconds (default 30, host `.env` or env) on the socket: idle, not total, so a pull that keeps
29
+ // streaming progress lines is never cut off, while a silent daemon fails the verb with the knob
30
+ // named. `DT_HEALTH_TIMEOUT` (default 90) bounds the other wait, code-server's /healthz.
31
+ //
24
32
  // TEST KNOBS, stated once: `DT_DOCKER_SOCKET` points the client at any socket (a fake in tests);
25
33
  // `DT_HOME` relocates `~/.dreamteamer`; `DT_HEALTH_TIMEOUT=0` skips the wait for code-server's
26
34
  // /healthz. None is documented in help — they are how the suite drives this file without Docker.
@@ -54,8 +62,9 @@ export function driverTarget(word) {
54
62
  export const HOST_DEFAULTS = {
55
63
  DT_PORT_BASE: '8100', // NOT 8080: that is code-server's in-container port, `dt start`'s REST default, and the old dev image's exposed port — three things on one number
56
64
  DT_BIND: '127.0.0.1', // loopback only; a remote tier puts auth in front before this changes
57
- DT_REGISTRY: 'dreamteamer', // `<registry>/<template>:<tag>` is the image a template name resolves to
65
+ DT_REGISTRY: 'ghcr.io/dreamteamer', // `<registry>/<template>:<tag>` is the image a template name resolves to — the public images repo publishes here
58
66
  DT_TEMPLATE_TAG: 'latest',
67
+ DT_DOCKER_TIMEOUT: '30', // seconds a request to Docker may sit IDLE before the verb fails — see the header
59
68
  };
60
69
 
61
70
  export function hostDir() { return process.env.DT_HOME ?? path.join(os.homedir(), '.dreamteamer'); }
@@ -87,8 +96,15 @@ function unreachable(sock) {
87
96
 
88
97
  /** One request. Resolves { status, body } where body is parsed JSON when the response is JSON,
89
98
  * else the raw text. `onLine` receives each JSON line of a streaming response (a pull). */
99
+ /** Seconds a request may sit idle before it fails; 0 disables — `DT_DOCKER_TIMEOUT`, env over file over default. */
100
+ export function dockerTimeoutSeconds() {
101
+ const n = Number(hostEnv().DT_DOCKER_TIMEOUT);
102
+ return Number.isFinite(n) && n >= 0 ? n : Number(HOST_DEFAULTS.DT_DOCKER_TIMEOUT);
103
+ }
104
+
90
105
  export function api(method, urlPath, body, { onLine } = {}) {
91
106
  const sock = socketPath();
107
+ const seconds = dockerTimeoutSeconds();
92
108
  return new Promise((resolve, reject) => {
93
109
  const payload = body === undefined ? undefined : JSON.stringify(body);
94
110
  const req = http.request({
@@ -114,6 +130,9 @@ export function api(method, urlPath, body, { onLine } = {}) {
114
130
  });
115
131
  });
116
132
  req.on('error', (e) => reject(e.code === 'ENOENT' || e.code === 'ECONNREFUSED' ? unreachable(sock) : e));
133
+ // idle-based: fires only when NOTHING has moved on the socket for `seconds` — a streaming
134
+ // pull resets it with every progress line, a paused daemon never does
135
+ if (seconds) req.setTimeout(seconds * 1000, () => req.destroy(new Error(`${method} ${urlPath}: Docker did not answer within ${seconds}s — is Docker Desktop paused or still starting? DT_DOCKER_TIMEOUT=<seconds> in ${path.join(hostDir(), '.env')} changes the wait (0 disables it)`)));
117
136
  if (payload) req.write(payload);
118
137
  req.end();
119
138
  });
@@ -126,7 +145,7 @@ function ok(res, what) {
126
145
  throw new Error(`${what}: Docker answered ${res.status}${msg ? ` — ${msg}` : ''}`);
127
146
  }
128
147
 
129
- const LABEL = { workspace: 'dreamteamer.workspace', template: 'dreamteamer.template', person: 'dreamteamer.person', ports: 'dreamteamer.ports', modules: 'dreamteamer.modules' };
148
+ const LABEL = { workspace: 'dreamteamer.workspace', template: 'dreamteamer.template', person: 'dreamteamer.person', ports: 'dreamteamer.ports', modules: 'dreamteamer.modules', workdir: 'dreamteamer.workdir' };
130
149
  const filters = (label) => encodeURIComponent(JSON.stringify({ label: [label] }));
131
150
 
132
151
  // ---- images ------------------------------------------------------------------------------------
@@ -177,11 +196,29 @@ const containerRow = (c) => {
177
196
  const port = (c.Ports ?? []).find((p) => p.PublicPort)?.PublicPort;
178
197
  return {
179
198
  name, template: c.Labels?.[LABEL.template] ?? '', state: c.State, status: c.Status,
180
- editor_url: port ? editorUrl(port) : '', image: c.Image, person: c.Labels?.[LABEL.person] ?? '',
199
+ editor_url: port ? editorUrl(port, name) : '', image: c.Image, person: c.Labels?.[LABEL.person] ?? '',
181
200
  created: c.Created ? new Date(c.Created * 1000).toISOString().slice(0, 16).replace('T', ' ') : '', id: c.Id,
182
201
  };
183
202
  };
184
- const editorUrl = (port) => `http://localhost:${port}/?folder=/workspace`;
203
+ /** The dev-container convention: the workspace is mounted at `/workspaces/<name>`, so the folder the
204
+ * editor opens, the URL, and a VS Code attach all name the workspace rather than a fixed word. */
205
+ export const workspaceDir = (name) => `/workspaces/${name}`;
206
+ const editorUrl = (port, name) => `http://localhost:${port}/?folder=${workspaceDir(name)}`;
207
+ /** `--mount <host-path|volume>:<container-path>[:ro]` → a Docker Mount. A source starting with `/`,
208
+ * `~` or `.` is a bind mount of a host path (resolved against cwd); anything else is a named volume. */
209
+ export function parseMount(spec) {
210
+ const parts = String(spec).split(':');
211
+ if (parts.length < 2 || parts.length > 3 || !parts[0] || !parts[1].startsWith('/')) throw new Error(`--mount takes <host-path|volume>:<container-path>[:ro] — got "${spec}"`);
212
+ const [src, target, mode] = parts;
213
+ if (mode !== undefined && mode !== 'ro' && mode !== 'rw') throw new Error(`--mount "${spec}": the third part is ro or rw`);
214
+ const isPath = /^[/~.]/.test(src);
215
+ const source = isPath ? path.resolve(src.replace(/^~(?=\/|$)/, os.homedir())) : src;
216
+ if (!isPath && !/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(src)) throw new Error(`--mount "${spec}": "${src}" is neither a path nor a volume name`);
217
+ return { Type: isPath ? 'bind' : 'volume', Source: source, Target: target, ReadOnly: mode === 'ro' };
218
+ }
219
+ /** The URI VS Code on the host opens to attach to this container (Dev Containers extension). The
220
+ * container name is hex-encoded, as the extension spells it. */
221
+ export const attachUri = (name) => `vscode-remote://attached-container+${Buffer.from(name, 'utf8').toString('hex')}${workspaceDir(name)}`;
185
222
  const volumeNames = (name) => ({ workspace: `dreamteamer-${name}-workspace`, home: `dreamteamer-${name}-home`, files: `dreamteamer-${name}-files` });
186
223
 
187
224
  export async function listContainers() {
@@ -200,14 +237,21 @@ export async function inspectContainer(name) {
200
237
  /** The shape `dt get container <name>` prints: the categorised view over `docker inspect`. */
201
238
  export function containerDetail(c) {
202
239
  const binding = Object.values(c.HostConfig?.PortBindings ?? {}).flat()[0];
240
+ const name = c.Name.replace(/^\//, '');
241
+ const wsDir = c.Config.Labels?.[LABEL.workdir] ?? workspaceDir(name);
242
+ const own = new Set([wsDir, '/home/node', '/files']);
203
243
  const mounts = Object.fromEntries((c.Mounts ?? []).filter((m) => m.Type === 'volume').map((m) => [m.Destination, m.Name]));
204
244
  return {
205
- name: c.Name.replace(/^\//, ''), id: c.Id.slice(0, 12),
245
+ name, id: c.Id.slice(0, 12),
206
246
  template: c.Config.Labels[LABEL.template] ?? '', image: c.Config.Image,
207
247
  state: c.State?.Status, started: c.State?.StartedAt, restarts: c.RestartCount ?? 0,
208
248
  bind: binding?.HostIp ?? '', port: binding ? Number(binding.HostPort) : undefined,
209
- editor_url: binding ? editorUrl(binding.HostPort) : '',
210
- volumes: { workspace: mounts['/workspace'] ?? '', home: mounts['/home/node'] ?? '', files: mounts['/files'] ?? '' },
249
+ editor_url: binding ? editorUrl(binding.HostPort, name) : '',
250
+ attach_uri: attachUri(name),
251
+ workspace_dir: wsDir,
252
+ volumes: { workspace: mounts[wsDir] ?? '', home: mounts['/home/node'] ?? '', files: mounts['/files'] ?? '' },
253
+ mounts: (c.Mounts ?? []).filter((m) => !own.has(m.Destination)).map((m) => `${m.Type === 'bind' ? m.Source : m.Name}:${m.Destination}${m.RW === false ? ':ro' : ''}`),
254
+ repo: (c.Config.Env ?? []).find((e) => e.startsWith('DT_REPO='))?.slice(8) ?? '',
211
255
  person: c.Config.Labels[LABEL.person] ?? '', created: c.Created, labels: c.Config.Labels,
212
256
  };
213
257
  }
@@ -254,11 +298,21 @@ export async function startContainer(name, flags, log = console.log) {
254
298
  const port = await allocatePort(env);
255
299
  const who = person(flags, env);
256
300
  const vols = volumeNames(name);
301
+ const wsDir = workspaceDir(name);
302
+ // `--mount` adds bind or volume mounts beside the three the container always has; a mount aimed
303
+ // at one of those three targets is refused rather than silently shadowing the volume.
304
+ const extra = (flags.mount ?? []).map(parseMount);
305
+ for (const m of extra) if ([wsDir, '/home/node', '/files'].includes(m.Target)) throw new Error(`--mount cannot target ${m.Target} — that is one of the container's own volumes (${wsDir} · /home/node · /files)`);
306
+ // `--repo <url>` clones an EXISTING workspace into the workspace volume on first start instead of
307
+ // laying the template down — the way a person joins a workspace that already lives on GitHub.
308
+ const repo = typeof flags.repo === 'string' ? flags.repo : undefined;
309
+ if (repo !== undefined && !/^(https?:\/\/|git@|ssh:\/\/|file:\/\/|\/)/.test(repo)) throw new Error(`--repo takes a git URL or an absolute path — got "${repo}"`);
257
310
  const body = {
258
311
  Image: ref,
259
- Labels: { [LABEL.workspace]: name, [LABEL.template]: template, [LABEL.person]: who.name },
312
+ Labels: { [LABEL.workspace]: name, [LABEL.template]: template, [LABEL.person]: who.name, [LABEL.workdir]: wsDir },
260
313
  Env: [
261
- `DT_WORKSPACE=${name}`, `DT_TEMPLATE=${template}`, 'FILES_FOLDER=/files',
314
+ `DT_WORKSPACE=${name}`, `DT_TEMPLATE=${template}`, `DT_WORKSPACE_DIR=${wsDir}`, 'FILES_FOLDER=/files',
315
+ ...(repo ? [`DT_REPO=${repo}`] : []),
262
316
  ...(who.name ? [`GIT_AUTHOR_NAME=${who.name}`, `GIT_COMMITTER_NAME=${who.name}`] : []),
263
317
  ...(who.email ? [`GIT_AUTHOR_EMAIL=${who.email}`, `GIT_COMMITTER_EMAIL=${who.email}`] : []),
264
318
  ],
@@ -266,16 +320,17 @@ export async function startContainer(name, flags, log = console.log) {
266
320
  HostConfig: {
267
321
  PortBindings: { [`${inner}/tcp`]: [{ HostIp: env.DT_BIND, HostPort: String(port) }] },
268
322
  Mounts: [
269
- { Type: 'volume', Source: vols.workspace, Target: '/workspace' },
323
+ { Type: 'volume', Source: vols.workspace, Target: wsDir },
270
324
  { Type: 'volume', Source: vols.home, Target: '/home/node' },
271
325
  { Type: 'volume', Source: vols.files, Target: '/files' },
326
+ ...extra,
272
327
  ],
273
328
  RestartPolicy: { Name: 'unless-stopped' },
274
329
  },
275
330
  };
276
331
  ok(await api('POST', `/containers/create?name=${encodeURIComponent(name)}`, body), `create container ${name}`);
277
332
  c = await inspectContainer(name);
278
- log(`✔ created ${name} from ${ref} · ${env.DT_BIND}:${port} → ${inner} · volumes ${Object.values(vols).join(', ')}`);
333
+ log(`✔ created ${name} from ${ref} · ${env.DT_BIND}:${port} → ${inner} · ${wsDir} · volumes ${Object.values(vols).join(', ')}${extra.length ? ` · mounts ${extra.map((m) => `${m.Source}→${m.Target}${m.ReadOnly ? ' (ro)' : ''}`).join(', ')}` : ''}${repo ? ` · clones ${repo} on first start` : ''}`);
279
334
  }
280
335
  if (c.State?.Status !== 'running') {
281
336
  const res = await api('POST', `/containers/${c.Id}/start`);
@@ -399,7 +454,20 @@ export async function driverCommand(verb, target, args) {
399
454
  if (verb === 'get') { const c = await inspectContainer(id); if (!c) throw new Error(`no container "${id}" — dt list containers`); emit(JSON.stringify(json ? c : containerDetail(c), null, 2)); return 0; }
400
455
  if (verb === 'start' || verb === 'add') { const d = await startContainer(id, flags); if (json) emit(JSON.stringify(d, null, 2)); return 0; }
401
456
  if (verb === 'stop') { const d = await stopContainer(id); console.log(`✔ stopped ${id} · volumes kept`); if (json) emit(JSON.stringify(d, null, 2)); return 0; }
402
- if (verb === 'open') { const c = await inspectContainer(id); if (!c) throw new Error(`no container "${id}"`); const d = containerDetail(c); if (!d.editor_url) throw new Error(`${id} publishes no port`); console.log(d.editor_url); if (!flags['no-open']) openUrl(d.editor_url); return 0; }
457
+ if (verb === 'open') {
458
+ const c = await inspectContainer(id); if (!c) throw new Error(`no container "${id}"`);
459
+ const d = containerDetail(c);
460
+ if (flags.vscode) {
461
+ // Dev Containers attach: the host's own VS Code opens the workspace INSIDE the container, and
462
+ // installs the extensions the image's `devcontainer.metadata` label names into the container's
463
+ // VS Code Server — a second extension host beside code-server's, over the same files.
464
+ console.log(d.attach_uri);
465
+ if (!flags['no-open']) { try { spawn('code', ['--folder-uri', d.attach_uri], { stdio: 'ignore', detached: true }).unref(); } catch { /* the URI is printed either way */ } }
466
+ return 0;
467
+ }
468
+ if (!d.editor_url) throw new Error(`${id} publishes no port`);
469
+ console.log(d.editor_url); if (!flags['no-open']) openUrl(d.editor_url); return 0;
470
+ }
403
471
  if (verb === 'rm') { await removeContainer(id, { force: flags.force === true }); return 0; }
404
472
  return 1;
405
473
  }
@@ -408,15 +476,17 @@ export async function driverCommand(verb, target, args) {
408
476
  * record parser's promotion rules — a repeated flag on these verbs is a mistake, not an array. */
409
477
  export function parseFlags(args) {
410
478
  const flags = {}; const pos = [];
479
+ // `--mount` is the one flag that repeats — every other repeat is a mistake and the LAST wins.
480
+ const put = (k, v) => { if (k === 'mount') flags.mount = [...(flags.mount ?? []), v]; else flags[k] = v; };
411
481
  for (let i = 0; i < args.length; i++) {
412
482
  const a = args[i];
413
483
  if (!a.startsWith('--')) { pos.push(a); continue; }
414
484
  const eq = a.indexOf('=');
415
- if (eq > -1) flags[a.slice(2, eq)] = a.slice(eq + 1);
416
- else if (i + 1 < args.length && !args[i + 1].startsWith('--')) flags[a.slice(2)] = args[++i];
417
- else flags[a.slice(2)] = true;
485
+ if (eq > -1) put(a.slice(2, eq), a.slice(eq + 1));
486
+ else if (i + 1 < args.length && !args[i + 1].startsWith('--')) put(a.slice(2), args[++i]);
487
+ else put(a.slice(2), true);
418
488
  }
419
489
  return { flags, pos };
420
490
  }
421
491
 
422
- export const CONTAINER_FLAGS = ['template', 'name', 'email', 'no-open', 'json', 'force'];
492
+ export const CONTAINER_FLAGS = ['template', 'name', 'email', 'no-open', 'json', 'force', 'mount', 'repo', 'vscode'];
package/src/harnesses.js CHANGED
@@ -295,6 +295,7 @@ function buildCollectionsIndex(entries) {
295
295
  // the domain listing — the visible failure rather than the silent one.
296
296
  systemGroup: d.group === 'system',
297
297
  generated: d.storage?.base === 'runtime',
298
+ driver: d.storage?.driver ?? null,
298
299
  description: flat(d.description),
299
300
  useWhen: flat(d.use_when),
300
301
  module: d.module ?? '',
@@ -418,7 +419,10 @@ function collectionsSection(index, modules, workspaceModule) {
418
419
  }
419
420
  const sys = index.filter((c) => c.systemGroup);
420
421
  const system = sys.filter((c) => c.generated).map((c) => c.name);
421
- const kept = sys.filter((c) => !c.generated).map((c) => c.name);
422
+ const kept = sys.filter((c) => !c.generated && !c.driver).map((c) => c.name);
423
+ // A DRIVER collection is neither build output nor files: its verbs are answered by a driver over
424
+ // something that runs (Docker), so it gets its own clause rather than being called either.
425
+ const driven = sys.filter((c) => c.driver).map((c) => `${c.name} (${c.driver})`);
422
426
  // ⚠ THIS LINE IS THE FIRST THING A SESSION READS about the system collections, and until 0.19.0
423
427
  // it said "schema-ops only", which named an internal module and a grammar that no longer exists.
424
428
  // It now names the VERBS and the one policy difference, because an agent that knows the verbs
@@ -428,7 +432,7 @@ function collectionsSection(index, modules, workspaceModule) {
428
432
  // told "it is build output" — a sentence that was false of it and is false of the next data-backed
429
433
  // system collection too, since the split is derived rather than naming one.
430
434
  if (sys.length) {
431
- lines.push('', `- system collections — the SAME verbs (add · set · rm · rename · list · get), plus \`dt add-field\`/\`set-field\`/\`rm-field\`/\`rename-field <collection>\`. A system write COMMITS ITSELF, in the repo holding the source; a record write does not (\`dt commit\` publishes).${system.length ? ` Never hand-edit \`.dreamteamer/\` — it is build output: ${system.join(' · ')}.` : ''}${kept.length ? ` Machinery whose records are real files you edit like any other: ${kept.join(' · ')}` : ''}`);
435
+ lines.push('', `- system collections — the SAME verbs (add · set · rm · rename · list · get), plus \`dt add-field\`/\`set-field\`/\`rm-field\`/\`rename-field <collection>\`. A system write COMMITS ITSELF, in the repo holding the source; a record write does not (\`dt commit\` publishes).${system.length ? ` Never hand-edit \`.dreamteamer/\` — it is build output: ${system.join(' · ')}.` : ''}${kept.length ? ` Machinery whose records are real files you edit like any other: ${kept.join(' · ')}.` : ''}${driven.length ? ` Answered by a DRIVER, not files — nothing under data/, nothing to commit, the same verbs plus start · stop · open: ${driven.join(' · ')}` : ''}`);
432
436
  }
433
437
  return lines;
434
438
  }
package/src/schema-ops.js CHANGED
@@ -754,6 +754,7 @@ const COLLECTION_SETTABLE = {
754
754
  use_when: (v) => String(v),
755
755
  title: (v) => String(v),
756
756
  title_template: (v) => String(v),
757
+ singular: (v) => String(v), // the word the CLI accepts beside the name; compile refuses a collision
757
758
  icon: (v) => String(v),
758
759
  // The collection's partition. `group=system` is the reserved value: it moves the collection out
759
760
  // of the block's domain listing and onto a surface's schema surface, and changes nothing about
package/src/server.js CHANGED
@@ -22,6 +22,7 @@ import { sortRows } from './temporal.js';
22
22
  import { placementKey } from './fractional-index.js';
23
23
  import { commandsFor, recordResolver } from './record-commands.js';
24
24
  import { distinctValues } from './field-values.js';
25
+ import { listContainers, listImages, inspectContainer, inspectImage, containerDetail } from './containers.js';
25
26
 
26
27
 
27
28
  export function startServer(ws, { port = 8080, host = '127.0.0.1' } = {}) {
@@ -77,6 +78,33 @@ export function startServer(ws, { port = 8080, host = '127.0.0.1' } = {}) {
77
78
  // wildcard, a literal and a second wildcard in one pattern, so `/collections/a/b/records/c` has
78
79
  // several readings and the router picks one. Encoding keeps the boundary explicit at the caller,
79
80
  // which is the same reason references declare their namespace instead of having it inferred.
81
+ // A DRIVER collection (`storage.driver: docker`) has no records on disk: list and get are answered
82
+ // by the driver, and every write is refused with the verb that does it — the same interception the
83
+ // CLI performs, at the same surface, so the extension's tree and record views work without either
84
+ // side learning Docker. Wrapped in `driven` so an unreachable daemon is a 502 with its sentence,
85
+ // not a 500 from the store looking for a folder that never exists.
86
+ const driverOf = (name) => store.descriptors.get(name)?.storage?.driver;
87
+ const driven = (fn) => (req, res, next) => {
88
+ if (!driverOf(req.params.name)) return next();
89
+ fn(req, res).catch((e) => res.status(502).json({ error: e.message }));
90
+ };
91
+ api.get('/collections/:name/records', driven(async (req, res) => {
92
+ const rows = req.params.name === 'images' ? await listImages() : await listContainers();
93
+ res.json({ records: rows.map((r) => ({ ...r, id: r.name ?? r.image })), total: rows.length });
94
+ }));
95
+ api.get('/collections/:name/records/*id', driven(async (req, res) => {
96
+ const id = idParam(req);
97
+ const fields = req.params.name === 'images' ? await inspectImage(id) : (await inspectContainer(id).then((c) => c && containerDetail(c)));
98
+ if (!fields) return res.status(404).json({ error: `no ${req.params.name === 'images' ? 'image' : 'container'} "${id}"` });
99
+ res.json({ id, fields, path: null });
100
+ }));
101
+ for (const [method, route] of [['post', '/collections/:name/records'], ['patch', '/collections/:name/records/*id'], ['delete', '/collections/:name/records/*id'], ['patch', '/collections/:name/position/*id'], ['post', '/collections/:name/rename']]) {
102
+ api[method](route, (req, res, next) => {
103
+ if (!driverOf(req.params.name)) return next();
104
+ const noun = req.params.name === 'images' ? 'image' : 'container';
105
+ res.status(405).json({ error: `"${req.params.name}" is a ${driverOf(req.params.name)} driver collection — it is written with the CLI, not as a record: dt start ${noun} <name> --template <t> · dt stop ${noun} <name> · dt rm ${noun} <name>` });
106
+ });
107
+ }
80
108
  api.get('/collections/:name/records', (req, res) => {
81
109
  const d = store.descriptor(req.params.name);
82
110
  const bf = bodyField(d);