gdkbox 0.1.19 → 0.1.21

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 17efb5fee81f15043aed3c8dbabe3e9259c70a8d8a03dd400b8103231b36a9d6
4
- data.tar.gz: d926bbae45cbe226fec0530f43e11e12f1ef346450e5801909c251e6f159bf66
3
+ metadata.gz: ed8db8e33eb456eab81c33ec420fccbb86294d1569373da95d4eab9aebf1ee79
4
+ data.tar.gz: 23812d9d52938ddbdbd5613f412bdb5e35ed588ec177c5f1ad8acfbfa76fe214
5
5
  SHA512:
6
- metadata.gz: 179859b546c7b76825d22d6e100b39446d96205adaab696982f960409f14041a8135daf67600d3755545613537a011131302c238de44fefd64df5a7980834227
7
- data.tar.gz: 59f9e916539ca282cc16ec86e8fde2ab8437541aad4c433d4c5a2895f49017cf747880f75e3777e80c39b88e767591c9a8b18d115badfae9fcabf4525bc22f2d
6
+ metadata.gz: 588e9c4a5beaa9f7ddcfd0cdfe3c897de80b230646a24b1c580bdb1cf008ec9aa86531d5f5a03d2234bf128918dd821f02dba7b41a79470b172d5607b2abd8c2
7
+ data.tar.gz: d1369eb4dadb712a8e2cacef481c55475e4239f447daad5ef952bdb956d5651ca13ac7fe80a29e8390ac609228514ed3bbcac3cf659f1c050bcc22ce8d679ca5
data/README.md CHANGED
@@ -122,6 +122,8 @@ Or run it straight from the checkout without installing:
122
122
  | `--ttl SECS` | no expiry | Lease duration for `--owner`'s claim. |
123
123
  | `--docker` | off | Mount the host Docker socket + per-box network so docker-executor CI jobs work. **Grants the box root-equivalent host access** — see "Docker inside a box". |
124
124
  | `--publish PORT [PORT...]` | (none) | Publish extra container ports on `127.0.0.1`, same number on both sides (e.g. AI Gateway `5052`, Duo Workflow `50052`). |
125
+ | `--key-env NAME` | the harness's | Env var this box's API key is seeded under (e.g. `GITLAB_TOKEN`); see "opencode with GitLab Duo". |
126
+ | `--model ID` | the harness's own | Model the box's agent runs with on `dispatch` (claude, codex, opencode; e.g. `gitlab/duo-chat-sonnet-4-5`). |
125
127
 
126
128
  ## Typical workflow
127
129
 
@@ -373,8 +375,8 @@ harness the box was created with.
373
375
  | --- | --- | --- | --- | --- |
374
376
  | `claude` | Claude Code | `claude -p` | `ANTHROPIC_API_KEY` | `~/.claude/skills` |
375
377
  | `codex` | OpenAI Codex | `codex exec` | `OPENAI_API_KEY` | `~/.codex/skills` |
376
- | `opencode` | opencode (sst) | `opencode run` | `OPENAI_API_KEY` | `~/.config/opencode/skills` |
377
- | `pi` | pi (earendil-works) | `pi -p` | `ANTHROPIC_API_KEY` | `~/.pi/skills` |
378
+ | `opencode` | opencode (sst) | `opencode run` | `GITLAB_TOKEN` | `~/.config/opencode/skills` |
379
+ | `pi` | pi (earendil-works) | `pi -p` | `GITLAB_TOKEN` | `~/.pi/skills` |
378
380
 
379
381
  ```sh
380
382
  gdkbox up reviewer --harness codex # a Codex box
@@ -385,8 +387,39 @@ gdkbox harnesses # list available harnesses
385
387
  ```
386
388
 
387
389
  Skills use the same `SKILL.md` format across all four, so a skill installed by
388
- `add-skill`/`--skill` works regardless of harness. The key env / provider for
389
- `opencode` and `pi` are configurable defaults — adjust per your provider.
390
+ `add-skill`/`--skill` works regardless of harness. The key env is a per-harness
391
+ default (claude → `ANTHROPIC_API_KEY`, codex → `OPENAI_API_KEY`, opencode and
392
+ pi → `GITLAB_TOKEN`): point a box at another provider with
393
+ `gdkbox up --key-env NAME`.
394
+ `gdkbox up --model ID` pins the model a box's agent is dispatched with
395
+ (claude, codex, and opencode; not pi yet). Both are fixed when the box is
396
+ created.
397
+
398
+ ### opencode with GitLab Duo
399
+
400
+ The `opencode` harness defaults to opencode's GitLab Duo provider, which reads
401
+ a personal access token from `GITLAB_TOKEN`. Pin a Duo model with `--model`:
402
+
403
+ ```sh
404
+ GITLAB_TOKEN=<token> gdkbox up duo --harness opencode --model gitlab/duo-chat-sonnet-4-5
405
+ gdkbox dispatch duo --task "review the diff"
406
+ ```
407
+
408
+ - The `duo-chat-*` models are GitLab Duo Agentic Chat and work with the token
409
+ alone. The `duo-workflow-*` models need a login stored with opencode's
410
+ `/connect`, so they are not available this way.
411
+ - GitLab Duo needs a Premium or Ultimate subscription with the Duo Agent
412
+ Platform turned on.
413
+ - Without `--model`, opencode picks its own default model.
414
+ - For another provider, seed its key instead, e.g.
415
+ `gdkbox up NAME --harness opencode --key-env OPENAI_API_KEY`.
416
+
417
+ > **The token acts as you.** opencode needs the `api` scope, which lets the
418
+ > agent read and write anything you can on GitLab. That is what lets it post
419
+ > MR review comments, but it also means a prompt injection in an issue, MR, or
420
+ > file the agent reads can act with your permissions. Use a dedicated,
421
+ > short-lived token. `gdkbox up --harness opencode` picks up `$GITLAB_TOKEN`
422
+ > from your shell, so check what that variable holds first.
390
423
 
391
424
  ## Shell completion
392
425
 
@@ -439,15 +472,17 @@ skills:
439
472
  # - ./path/to/a/local/skill
440
473
 
441
474
  # API keys seeded into boxes for unattended `gdkbox dispatch`, per provider.
442
- # anthropic_api_key: sk-ant-... # used by the claude / pi harnesses
443
- # openai_api_key: sk-... # used by the codex / opencode harnesses
475
+ # anthropic_api_key: sk-ant-... # used by the claude harness
476
+ # openai_api_key: sk-... # used by the codex harness
477
+ # gitlab_token: <personal access token, api scope> # used by the opencode / pi harnesses
444
478
  ```
445
479
 
446
480
  `gdkbox up` installs the skills on top of any `--skill` you pass; use
447
481
  `--no-default-skills` to skip them for one run. Precedence for the image is
448
482
  `--image` > `$GDKBOX_IMAGE` > `config.yml` > built-in default; for the API key
449
483
  it is `--api-key` > the harness's provider env var (e.g. `$ANTHROPIC_API_KEY`,
450
- `$OPENAI_API_KEY`) > `config.yml`.
484
+ `$GITLAB_TOKEN`, or the `--key-env` name) > `config.yml` (the env var's name
485
+ lowercased, e.g. `gitlab_token:`).
451
486
 
452
487
  > **API keys in `config.yml` are plaintext secrets on disk.** Prefer the
453
488
  > provider env var; if you do put a key in `config.yml`, keep the file mode
data/lib/gdkbox/box.rb CHANGED
@@ -73,9 +73,16 @@ module GDKBox
73
73
  end
74
74
 
75
75
  # The agent harness this box runs (defaults to Claude for boxes created
76
- # before harness support existed).
76
+ # before harness support existed), with the box's own key env override
77
+ # (`up --key-env`) applied.
77
78
  def harness
78
- Harness[(data && data["harness"]) || Harness.default]
79
+ Harness[(data && data["harness"]) || Harness.default].with_key_env(data && data["key_env"])
80
+ end
81
+
82
+ # The model the box's agent is pinned to (`up --model`), or nil to let
83
+ # the harness pick its own default.
84
+ def model
85
+ data && data["model"]
79
86
  end
80
87
 
81
88
  def state
@@ -91,10 +98,12 @@ module GDKBox
91
98
  # box this one is still provisioning.
92
99
  def create!(image: nil, ssh_port: nil, web_port: nil, vite_port: nil,
93
100
  harness: Harness.default, install_agent: true, api_key: nil,
94
- claim_owner: nil, claim_ttl: nil, docker_access: false, extra_ports: [])
101
+ claim_owner: nil, claim_ttl: nil, docker_access: false, extra_ports: [],
102
+ key_env: nil, model: nil)
95
103
  raise Error, "Box '#{name}' already exists" if exists?
96
104
 
97
- harness = Harness[harness]
105
+ harness = Harness[harness].with_key_env(key_env)
106
+ model = harness.check_model!(model)
98
107
  image ||= @config.default_image
99
108
  public_key = @ssh_key.ensure!
100
109
  cname = @config.container_name(name)
@@ -168,6 +177,8 @@ module GDKBox
168
177
  "harness" => harness.id,
169
178
  "created_at" => Time.now.utc.iso8601
170
179
  )
180
+ @data["key_env"] = harness.key_env if key_env
181
+ @data["model"] = model if model
171
182
  if docker_access
172
183
  @data["docker_access"] = true
173
184
  @data["docker_network"] = net
@@ -493,12 +504,12 @@ module GDKBox
493
504
 
494
505
  # Run this box's agent harness non-interactively and capture its output.
495
506
  # This is the primitive an orchestrator uses to dispatch a task to a box.
496
- # The task text is passed through the environment so arbitrary prompts
497
- # cannot break out of the shell command.
507
+ # The task text (and the pinned model, if any) is passed through the
508
+ # environment so arbitrary values cannot break out of the shell command.
498
509
  def run_agent(task:, json: false, yolo: true, timeout: nil)
499
510
  raise Error, "Box '#{name}' does not exist" unless exists?
500
511
 
501
- agent = harness.agent_command(json: json, yolo: yolo)
512
+ agent = harness.agent_command(json: json, yolo: yolo, model: model)
502
513
  agent = "timeout #{Integer(timeout)} #{agent}" if timeout
503
514
  # Source the seeded API key (if any) so the agent authenticates unattended.
504
515
  command = %([ -f "$HOME/.gdkbox/env" ] && . "$HOME/.gdkbox/env"; #{agent})
@@ -507,7 +518,7 @@ module GDKBox
507
518
  container_name, command,
508
519
  user: @config.ssh_user,
509
520
  workdir: remote_path,
510
- env: { "GDKBOX_TASK" => task },
521
+ env: { "GDKBOX_TASK" => task, "GDKBOX_MODEL" => model }.compact,
511
522
  check: false
512
523
  )
513
524
  AgentResult.new(result.stdout, result.stderr, result.status)
@@ -515,6 +526,7 @@ module GDKBox
515
526
 
516
527
  # A machine-readable summary for orchestrators (`gdkbox ls --json`).
517
528
  def summary(state: nil)
529
+ harness = self.harness
518
530
  {
519
531
  "name" => name,
520
532
  "state" => (state || self.state).to_s,
@@ -529,6 +541,8 @@ module GDKBox
529
541
  # Back-compat: orchestrators predating multi-harness check this field.
530
542
  "claude_installed" => (harness.id == "claude" && (data && data["agent_installed"])) || false,
531
543
  "api_key_set" => (data && data["api_key_set"]) || false,
544
+ "key_env" => harness.key_env,
545
+ "model" => model,
532
546
  # Vite assets: the published port (nil on boxes created before vite
533
547
  # support) and whether the in-box alignment step succeeded.
534
548
  "vite_port" => vite_port,
data/lib/gdkbox/cli.rb CHANGED
@@ -54,6 +54,12 @@ module GDKBox
54
54
  option :publish, type: :array, default: [],
55
55
  desc: "Extra container port(s) to publish on 127.0.0.1, same number on both sides " \
56
56
  "(e.g. --publish 5052 50052)"
57
+ option :key_env, type: :string,
58
+ desc: "Env var to seed the harness API key under for this box " \
59
+ "(e.g. GITLAB_TOKEN for opencode + GitLab Duo; default: the harness's own)"
60
+ option :model, type: :string,
61
+ desc: "Model this box's agent runs with on dispatch " \
62
+ "(e.g. gitlab/duo-chat-sonnet-4-5 for opencode; default: the harness's own)"
57
63
  def up(name)
58
64
  if options[:ttl] && options[:owner].to_s.strip.empty?
59
65
  raise Error, "--ttl only makes sense with --owner (it is the claim's lease)."
@@ -61,7 +67,8 @@ module GDKBox
61
67
  ensure_docker!
62
68
  box = build_box(name)
63
69
  raise Error, "Box '#{name}' already exists. Use `gdkbox rm #{name}` first." if box.exists?
64
- harness = Harness[options[:harness] || config.default_harness]
70
+ harness = Harness[options[:harness] || config.default_harness].with_key_env(options[:key_env])
71
+ model = harness.check_model!(options[:model])
65
72
  api_key = resolve_api_key(harness)
66
73
 
67
74
  say "Spinning up GDK box '#{name}' (#{harness.summary}; first run pulls a large image)...", :green unless options[:json]
@@ -76,7 +83,9 @@ module GDKBox
76
83
  claim_owner: options[:owner],
77
84
  claim_ttl: options[:ttl],
78
85
  docker_access: options[:docker],
79
- extra_ports: parse_extra_ports(options[:publish])
86
+ extra_ports: parse_extra_ports(options[:publish]),
87
+ key_env: options[:key_env],
88
+ model: model
80
89
  )
81
90
  rewrite_ssh_config
82
91
  seed_skills(box)
@@ -754,6 +763,7 @@ module GDKBox
754
763
  say " Web UI: #{box.web_url} (http://#{HostsFile::HOSTNAME}:#{box.web_port})", :cyan
755
764
  say " Assets: vite on port #{box.vite_port}", :cyan if box.vite_port
756
765
  say " Agent: ssh #{box.ssh_host_alias} -t #{box.harness.bin}", :cyan
766
+ say " Model: #{box.model} (on dispatch)", :cyan if box.model
757
767
  end
758
768
  end
759
769
  end
@@ -10,6 +10,10 @@ module GDKBox
10
10
  # /usr/local/bin). A harness declares the env var its provider reads for
11
11
  # credentials and the config.yml key gdkbox looks under for a default.
12
12
  class Harness
13
+ # Env var names end up in an `export NAME=...` line inside the box, so
14
+ # only plain shell identifiers are accepted.
15
+ ENV_VAR_NAME = /\A[A-Z_][A-Z0-9_]*\z/
16
+
13
17
  @registry = {}
14
18
 
15
19
  class << self
@@ -43,8 +47,9 @@ module GDKBox
43
47
  :summary, :skills_subdir
44
48
 
45
49
  def initialize(id:, bin:, key_env:, config_key:, summary:, run:, skills_subdir:,
46
- npm_package: nil, npm_flags: nil)
50
+ npm_package: nil, npm_flags: nil, model_flag: nil)
47
51
  @id = id
52
+ @model_flag = model_flag
48
53
  @npm_package = npm_package
49
54
  @npm_flags = npm_flags
50
55
  @bin = bin
@@ -55,15 +60,45 @@ module GDKBox
55
60
  @skills_subdir = skills_subdir
56
61
  end
57
62
 
63
+ # A copy of this harness that seeds its key under `key_env` instead
64
+ # (`up --key-env`), or self when blank. The config.yml fallback key
65
+ # follows the env var (GITLAB_TOKEN -> gitlab_token) so the override stays
66
+ # self-consistent.
67
+ def with_key_env(key_env)
68
+ key_env = key_env.to_s.strip
69
+ return self if key_env.empty?
70
+ unless ENV_VAR_NAME.match?(key_env)
71
+ raise Error, "Invalid --key-env '#{key_env}': not an env var name (use e.g. GITLAB_TOKEN)."
72
+ end
73
+
74
+ dup.tap do |copy|
75
+ copy.key_env = key_env
76
+ copy.config_key = key_env.downcase
77
+ end
78
+ end
79
+
58
80
  # True when gdkbox knows how to install this harness automatically.
59
81
  def installable?
60
82
  !@npm_package.nil?
61
83
  end
62
84
 
85
+ # The model id (`up --model`) stripped, or nil when blank. Raises when
86
+ # this harness cannot pin a model. The id reaches the agent only through
87
+ # the environment, so it needs no further validation.
88
+ def check_model!(model)
89
+ model = model.to_s.strip
90
+ return nil if model.empty?
91
+ raise Error, "The #{id} harness does not support --model yet." unless @model_flag
92
+
93
+ model
94
+ end
95
+
63
96
  # The in-box shell snippet that runs the agent against $GDKBOX_TASK. The
64
- # caller wraps it with a timeout and sources the seeded API key.
65
- def agent_command(json:, yolo:)
66
- @run.call(json: json, yolo: yolo)
97
+ # caller wraps it with a timeout and sources the seeded API key. With a
98
+ # model, the snippet reads it from $GDKBOX_MODEL, which the caller sets.
99
+ def agent_command(json:, yolo:, model: nil)
100
+ cmd = @run.call(json: json, yolo: yolo)
101
+ check_model!(model) ? %(#{cmd} #{@model_flag} "$GDKBOX_MODEL") : cmd
67
102
  end
68
103
 
69
104
  register(
@@ -74,6 +109,7 @@ module GDKBox
74
109
  config_key: "anthropic_api_key",
75
110
  summary: "Anthropic Claude Code",
76
111
  skills_subdir: ".claude/skills",
112
+ model_flag: "--model",
77
113
  run: lambda do |json:, yolo:|
78
114
  cmd = +%(claude -p "$GDKBOX_TASK")
79
115
  cmd << " --output-format json" if json
@@ -90,6 +126,7 @@ module GDKBox
90
126
  config_key: "openai_api_key",
91
127
  summary: "OpenAI Codex CLI",
92
128
  skills_subdir: ".codex/skills",
129
+ model_flag: "-m",
93
130
  run: lambda do |json:, yolo:|
94
131
  cmd = +%(codex exec "$GDKBOX_TASK")
95
132
  cmd << " --json" if json
@@ -103,11 +140,15 @@ module GDKBox
103
140
  id: "opencode",
104
141
  npm_package: "opencode-ai",
105
142
  bin: "opencode",
106
- # opencode reads provider keys from the environment; default to OpenAI.
107
- key_env: "OPENAI_API_KEY",
108
- config_key: "openai_api_key",
143
+ # opencode reads provider keys from the environment; default to its
144
+ # GitLab Duo provider (a personal access token with the `api` scope).
145
+ # Point a box at another provider with `up --key-env` (e.g.
146
+ # OPENAI_API_KEY).
147
+ key_env: "GITLAB_TOKEN",
148
+ config_key: "gitlab_token",
109
149
  summary: "opencode (sst)",
110
150
  skills_subdir: ".config/opencode/skills",
151
+ model_flag: "-m",
111
152
  run: lambda do |json:, yolo:|
112
153
  # `opencode run` is non-interactive and has no skip-permissions flag.
113
154
  +%(opencode run "$GDKBOX_TASK")
@@ -121,9 +162,10 @@ module GDKBox
121
162
  # docs recommend installing with --ignore-scripts.
122
163
  npm_flags: "--ignore-scripts",
123
164
  bin: "pi",
124
- # pi is multi-provider; default the seeded key to Anthropic.
125
- key_env: "ANTHROPIC_API_KEY",
126
- config_key: "anthropic_api_key",
165
+ # pi is multi-provider; default the seeded key to GitLab Duo
166
+ # (GITLAB_TOKEN). Point a box at another provider with `up --key-env`.
167
+ key_env: "GITLAB_TOKEN",
168
+ config_key: "gitlab_token",
127
169
  summary: "pi (earendil-works)",
128
170
  skills_subdir: ".pi/skills",
129
171
  run: lambda do |json:, yolo:|
@@ -132,5 +174,9 @@ module GDKBox
132
174
  cmd
133
175
  end
134
176
  )
177
+
178
+ protected
179
+
180
+ attr_writer :key_env, :config_key
135
181
  end
136
182
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GDKBox
4
- VERSION = "0.1.19"
4
+ VERSION = "0.1.21"
5
5
  end
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  name: gdkbox-fleet
3
+ version: 1.0.0
3
4
  description: >-
4
5
  Orchestrate a fleet of GDK-in-a-box environments with the `gdkbox` CLI:
5
6
  provision a reusable pool of GitLab Development Kit boxes, then dispatch
@@ -66,6 +67,30 @@ Violating these is how "another agent stole my box" happens:
66
67
  user) and never in host-side metadata. Check readiness with the
67
68
  `api_key_set` field from `gdkbox ls --json` / `status --json`.
68
69
 
70
+ ### Other harnesses (e.g. opencode with GitLab Duo)
71
+
72
+ The steps above assume the default Claude Code harness. If you are told to use
73
+ another agent, or GitLab Duo, the harness, key env var and model are fixed
74
+ **when the box is created**, so pass them to every `up`:
75
+
76
+ ```sh
77
+ export GITLAB_TOKEN=<token> # personal access token, api scope
78
+ gdkbox up "pool-$i" --owner "$OWNER" --ttl 7200 --json \
79
+ --harness opencode --model gitlab/duo-chat-sonnet-4-5
80
+ ```
81
+
82
+ - The opencode harness seeds `$GITLAB_TOKEN` (GitLab Duo) by default. For
83
+ another provider, pass `--key-env <VAR>` (e.g. `OPENAI_API_KEY`) and export
84
+ that variable instead.
85
+ - `--model` pins the model `dispatch` uses. Use a `gitlab/duo-chat-*` model;
86
+ `duo-workflow-*` models do not work with a token.
87
+ - An existing box cannot switch harness, key env or model. `gdkbox rm` it and
88
+ `up` it again.
89
+ - Verify with `gdkbox ls --json`: `"harness"`, `"key_env"` and `"model"` must
90
+ match what you asked for, and `"api_key_set"` must be `true`.
91
+ - `dispatch --json` output is harness-specific. For opencode it is plain text,
92
+ so rely on the exit status rather than parsing it.
93
+
69
94
  In the examples below, `gdkbox` means "the gdkbox CLI, however it is invoked".
70
95
 
71
96
  ## Command reference (what the orchestrator uses)
@@ -73,7 +98,8 @@ In the examples below, `gdkbox` means "the gdkbox CLI, however it is invoked".
73
98
  | Goal | Command |
74
99
  | --- | --- |
75
100
  | Create/start a box (claimed from birth) | `gdkbox up <name> --owner <id> --ttl <secs> [--json]` |
76
- | Seed/rotate API key | `gdkbox set-key <name>` (uses `$ANTHROPIC_API_KEY`) |
101
+ | Create a non-Claude box | add `--harness <id> [--key-env <VAR>] [--model <id>]` to `up` (see above) |
102
+ | Seed/rotate API key | `gdkbox set-key <name>` (uses the box's `key_env`, `$ANTHROPIC_API_KEY` by default) |
77
103
  | List the fleet (parseable) | `gdkbox ls --json` |
78
104
  | Inspect one box | `gdkbox status <name> --json` |
79
105
  | **Claim a free box** | `gdkbox claim --owner <id> --ttl <secs> --json` (or `gdkbox claim <name> ...`) |
@@ -97,8 +123,11 @@ In the examples below, `gdkbox` means "the gdkbox CLI, however it is invoked".
97
123
  "web_port": 3000,
98
124
  "web_url": "http://127.0.0.1:3000",
99
125
  "remote_path": "/home/gdk/gdk",
126
+ "harness": "claude",
100
127
  "claude_installed": true,
101
128
  "api_key_set": true,
129
+ "key_env": "ANTHROPIC_API_KEY",
130
+ "model": null,
102
131
  "claimed_by": "agent-7",
103
132
  "claim_expires_at": "2026-08-12T13:00:00Z"
104
133
  }
@@ -108,7 +137,8 @@ In the examples below, `gdkbox` means "the gdkbox CLI, however it is invoked".
108
137
  `claimed_by` is `null` for a free box (an expired lease counts as free).
109
138
 
110
139
  `dispatch` exits with the agent's own exit status (0 = success). With `--json`
111
- its stdout is Claude's structured result, which you can parse per task.
140
+ its stdout is the agent's structured result (Claude's by default), which you
141
+ can parse per task.
112
142
 
113
143
  ## Orchestration workflow
114
144
 
@@ -161,8 +191,10 @@ its stdout is Claude's structured result, which you can parse per task.
161
191
  ```
162
192
 
163
193
  When a dispatch returns, that box is idle again — assign it the next task
164
- from the queue. Never run two dispatches against the same box at once,
165
- and never dispatch into a box whose `claimed_by` is not your owner id.
194
+ from the queue, or **stop it immediately if nothing is queued for it**
195
+ (`gdkbox stop <box>`; see the "never leave a box running idle" guardrail).
196
+ Never run two dispatches against the same box at once, and never dispatch
197
+ into a box whose `claimed_by` is not your owner id.
166
198
 
167
199
  **Delegating to subagents:** the renewal in `run_task` above is for when
168
200
  *you* run the loop. If you hand a task to a subagent instead, do the
@@ -185,14 +217,20 @@ its stdout is Claude's structured result, which you can parse per task.
185
217
  and summarize per task: success/failure (exit status), what the agent did,
186
218
  and any follow-ups. Surface failures explicitly.
187
219
 
188
- 6. **Wind down — release every box you claimed.** When the batch is done,
189
- release **all** your boxes so the pool is usable by others (a lease would
190
- expire eventually, but do not rely on it for normal completion). Then keep
191
- them warm (`gdkbox stop`, later `gdkbox start`) or `gdkbox rm --force` to
192
- discard:
220
+ 6. **Stop idle boxes as you go; release everything at wind-down.** A box with
221
+ no task running right now must not be left running — `gdkbox stop <box>`
222
+ it the moment it goes idle (stopping keeps the claim, license and seeded
223
+ data; `gdkbox start <box>` brings it straight back). Do this throughout the
224
+ batch, not only at the end — especially whenever you pause to wait on the
225
+ user, on a review, or on another box. See the guardrail below.
226
+
227
+ When the **whole batch is done**, stop every box, then release **all** of
228
+ them so the pool is usable by others (a lease would expire eventually, but
229
+ do not rely on it for normal completion), or `gdkbox rm --force` to discard:
193
230
 
194
231
  ```sh
195
232
  for b in $(gdkbox ls --json | jq -r ".[] | select(.claimed_by==\"$OWNER\") | .name"); do
233
+ gdkbox stop "$b" # no box left running once its work is done
196
234
  gdkbox release "$b" --owner "$OWNER"
197
235
  done
198
236
  ```
@@ -216,7 +254,8 @@ for i in 1 2 3; do
216
254
  gdkbox dispatch "pool-$i" --task-file "tasks/$i.md" --json > "out/$i.json" 2>&1 &
217
255
  done
218
256
  wait
219
- for i in 1 2 3; do gdkbox release "pool-$i" --owner "$OWNER"; done
257
+ # batch done: stop each box so none is left running, then release the pool
258
+ for i in 1 2 3; do gdkbox stop "pool-$i"; gdkbox release "pool-$i" --owner "$OWNER"; done
220
259
  ```
221
260
 
222
261
  Adopt one more existing free box when the queue outgrows the pool (each call
@@ -274,6 +313,15 @@ rest of the pool accordingly, and give the claim a generous `--ttl`.
274
313
  heavy. Prefer reusing the pool over creating more boxes.
275
314
  - **One dispatch per box at a time.** Serialize tasks on a box; parallelize
276
315
  *across* boxes.
316
+ - **Never leave a box running idle.** A box is expensive (a full GDK
317
+ container); it must be running *only* while a dispatch is actually using it.
318
+ The moment a box has no task queued for it — including whenever the
319
+ orchestration pauses to wait on the user, on a review, or on another box —
320
+ `gdkbox stop <box>` it. Stopping frees the host's memory and CPU but **keeps
321
+ the claim, license and seeded data**, so `gdkbox start <box>` resumes it in
322
+ seconds. Keep the claim while the box is yours and only *release* at session
323
+ wind-down; stopping is for idleness, releasing is for "done with it". If you
324
+ stopped boxes mid-session, `gdkbox start` them again before dispatching.
277
325
  - **Own every box you touch, for the whole batch.** Provision with
278
326
  `up --owner`, adopt with `claim`, and hold the claims until wind-down —
279
327
  then release them all. Claims are advisory: `dispatch` will not stop you
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gdkbox
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.19
4
+ version: 0.1.21
5
5
  platform: ruby
6
6
  authors:
7
7
  - jotolo