gemcatch 0.4.0 → 0.5.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,71 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-09-04
11
+
12
+ ### Added
13
+
14
+ - **Collaborative planning.** Until now the only agent flow was a blind one-shot
15
+ bet: gemcatch showed a cost band, asked y/N, and fired. The cost band was
16
+ everything you knew before spending, and there was no way to check the agent
17
+ had read the prompt the way you meant it. A 20-line `batch` against
18
+ `deep-research-max` is a $60–$140 command placed sight unseen. The Deep
19
+ Research docs have always documented a second mode for exactly this, and
20
+ gemcatch could not reach it. Now it can:
21
+
22
+ - `--plan` on `research` and `batch` submits with
23
+ `agent_config.collaborative_planning: true`, so **the agent returns a
24
+ research plan instead of a report**. Still `background: true`, still stored,
25
+ still collected by the daemon. The row is stored with `kind='plan'`.
26
+ - `gemcatch get <id>` on a plan prints the plan and then the literal next
27
+ command, `Approve with: gemcatch approve 8f3a1c04 · Refine with: gemcatch
28
+ refine 8f3a1c04 "..."`, on stderr, so `get <plan> > plan.md` still captures
29
+ just the plan.
30
+ - `gemcatch refine <id> "<instruction>"` sends the instruction back with
31
+ `previous_interaction_id` and planning still on, and stores the revised plan
32
+ linked to the one it came from, inheriting its agent and tag.
33
+ - `gemcatch approve <id>` is the turn that commits: `previous_interaction_id`
34
+ with `collaborative_planning: false`, stored as `kind='report'` linked to the
35
+ plan. It shows the cost band and asks, honours `--yes` when stdin is not a
36
+ TTY, and honours `--dry-run`.
37
+
38
+ **Planning is not cheaper.** The docs give one band per task and do not price a
39
+ planning turn separately, so a planning turn is quoted at the same band and the
40
+ line says so: *"the docs price per task and do not price a planning turn
41
+ separately"*. What you get for it is a look at the plan before you commit to
42
+ the research run, not a discount.
43
+ - `gemcatch list` renders a chain indented under its root, in submission order,
44
+ and grows a `KIND` column when a listing contains plans or reports (a store
45
+ with neither keeps the layout it had). `gemcatch export` follows a chain to its
46
+ report and leaves the intermediate plans out unless `--include-plans` is
47
+ passed; the Markdown sections and the JSON rows now name the turn.
48
+ - `gemcatch stats` now tallies plan and report turns, and totals the estimated
49
+ spend across the agent turns that were actually billed, from the same
50
+ published bands the guard quotes before each one. A chain bills per turn, so a
51
+ release that turns one submission into three owes the user a running total. It
52
+ is the documented bands applied to what was submitted, not a reading of your
53
+ bill, and it errs toward telling you rather than flattering you: only runs
54
+ that reached the server are priced (a submit that failed before it left the
55
+ machine is counted as an attempt but costs nothing), and an agent with no
56
+ published band totals to `unknown`, never to `$0.00`. A store with no agent
57
+ runs prints neither line.
58
+ - Additive schema migration: `collaborative_planning`, `previous_interaction_id`,
59
+ `kind` (defaulting to `'task'`) and `parent_id`. A 0.4.0 `tasks.db` upgrades in
60
+ place, keeps every row, and behaves exactly as it did.
61
+
62
+ ### Fixed
63
+
64
+ - `approve` and `refine` fail before sending anything when the target is not a
65
+ plan, or is a plan that has not completed. A plan whose interaction the free
66
+ tier has already dropped names the retention window (1 day free, 55 days paid)
67
+ instead of sending a `previous_interaction_id` the server would reject. A plan
68
+ that expires *after* it completed locally gets the same explanation rather
69
+ than a bare 404 for an id the user never typed.
70
+ - `--plan` without `--agent` (or with `--model`) is a clean error rather than a
71
+ flag that quietly does nothing: collaborative planning is an agent feature.
72
+ - Approving the same plan twice submits a second run rather than silently
73
+ reusing the first, and `list` shows both under their plan.
74
+
10
75
  ## [0.4.0] - 2026-08-08
11
76
 
12
77
  ### Added
@@ -204,7 +269,8 @@ seen a task complete, the text is cached locally and survives that expiry — bu
204
269
  something has to poll inside that window for it to be seen at all, which is what
205
270
  `gemcatch daemon` exists to do.
206
271
 
207
- [Unreleased]: https://github.com/Booyaka101/gemcatch/compare/v0.4.0...HEAD
272
+ [Unreleased]: https://github.com/Booyaka101/gemcatch/compare/v0.5.0...HEAD
273
+ [0.5.0]: https://github.com/Booyaka101/gemcatch/compare/v0.4.0...v0.5.0
208
274
  [0.4.0]: https://github.com/Booyaka101/gemcatch/compare/v0.3.0...v0.4.0
209
275
  [0.3.0]: https://github.com/Booyaka101/gemcatch/compare/v0.2.0...v0.3.0
210
276
  [0.2.0]: https://github.com/Booyaka101/gemcatch/compare/v0.1.1...v0.2.0
package/README.md CHANGED
@@ -81,8 +81,10 @@ This week in AI: ...
81
81
  | `gemcatch research "<prompt>"` | Submits with `background: true`, stores the interaction ID, exits immediately. |
82
82
  | `gemcatch batch <file>` | Submits many prompts from a file at once, tagged as one collectable batch. |
83
83
  | `gemcatch status <id>` | Polls the API and prints the current state. |
84
- | `gemcatch get <id>` | Prints the full response if complete, otherwise the current status. |
85
- | `gemcatch list` | All tasks, newest first: id, age, status, prompt. |
84
+ | `gemcatch get <id>` | Prints the full response if complete, otherwise the current status. On a [plan](#see-the-plan-before-you-pay-for-the-run), the plan plus the approve/refine commands. |
85
+ | `gemcatch refine <id> "<instruction>"` | Sends an instruction back to a plan and returns a revised plan. |
86
+ | `gemcatch approve <id>` | Approves a plan and submits the research run it describes. |
87
+ | `gemcatch list` | All tasks, newest first: id, age, status, prompt. Plan chains are indented under their root. |
86
88
  | `gemcatch export` | Concatenates finished results, each under its prompt, to stdout or a file (Markdown or JSON). |
87
89
  | `gemcatch digest` | Feeds a tag's completed results through one Gemini call into a single summary. |
88
90
  | `gemcatch watch <id>` | Polls until the task finishes, then prints the result. |
@@ -91,7 +93,7 @@ This week in AI: ...
91
93
  | `gemcatch cancel <id>` | Asks the API to stop an in-flight task. |
92
94
  | `gemcatch rm <ids...>` | Forgets tasks locally. `--remote` deletes them server-side too. |
93
95
  | `gemcatch prune` | Drops finished tasks older than `--days` (default 30). |
94
- | `gemcatch stats` | Where the store lives and what's in it. |
96
+ | `gemcatch stats` | Where the store lives, what's in it, and what the agent runs have plausibly cost. |
95
97
 
96
98
  Useful flags:
97
99
 
@@ -100,7 +102,8 @@ Useful flags:
100
102
  | `--json` | most commands | Machine-readable output. |
101
103
  | `-m, --model <id>` | `research`, `batch` | Override the model. |
102
104
  | `-a, --agent <id>` | `research`, `batch` | Submit to a [research agent](#research-agents) instead of a model. Mutually exclusive with `--model`. |
103
- | `--yes` | `research`, `batch` | Confirm the agent cost without asking. Required for `--agent` when stdin is not a TTY. |
105
+ | `--plan` | `research`, `batch` | Ask the agent for a [research plan](#see-the-plan-before-you-pay-for-the-run) first, to refine and approve. Needs `--agent`. |
106
+ | `--yes` | `research`, `batch`, `refine`, `approve` | Confirm the agent cost without asking. Required for `--agent` when stdin is not a TTY. |
104
107
  | `-s, --system <text>` | `research`, `batch` | Set a system instruction. |
105
108
  | `-f, --file <path>` | `research` | Read the prompt from a file. |
106
109
  | `-t, --tag <tag>` | `research`, `batch`, `list` | Label tasks and filter them. |
@@ -112,7 +115,8 @@ Useful flags:
112
115
  | `-n, --limit <n>` | `list` | Cap the rows (non-negative; `0` shows none). |
113
116
  | `--format <md\|json>` | `export` | Output format. Default `md`. |
114
117
  | `-o, --out <file>` | `export` | Write to a file instead of stdout. |
115
- | `--dry-run` | `research`, `batch`, `prune` | Show what would go including the projected agent spend; submit/delete nothing. |
118
+ | `--include-plans` | `export` | Also emit a chain's plan turns, not just its report. |
119
+ | `--dry-run` | `research`, `batch`, `refine`, `approve`, `prune` | Show what would go — including the projected agent spend; submit/delete nothing. |
116
120
  | `--raw` | `get` | Dump the raw interaction JSON. |
117
121
 
118
122
  IDs are the first 8 characters of a UUID. Any unique prefix works, so `gemcatch get 8f3a` is fine.
@@ -187,14 +191,97 @@ The report lands like any other result — final answer only, none of the agent'
187
191
 
188
192
  An agent run can also come back `incomplete` — that is what a `max_total_tokens` budget cap produces when the run "safely pauses" — which `gemcatch` treats as terminal, exactly like the API does: the daemon retires it and moves on.
189
193
 
194
+ ### See the plan before you pay for the run
195
+
196
+ A cost band tells you what a run will cost. It tells you nothing about whether the agent understood the question. Add `--plan` and it doesn't start researching: with `agent_config.collaborative_planning: true`, *"the agent returns a research plan instead of a full report"*. You read it, push back on it, and approve it when it's right.
197
+
198
+ ```console
199
+ $ gemcatch research "map the EU AI Act high-risk obligations against the UK approach" --agent deep-research --plan -t euuk
200
+ Agent deep-research-preview-04-2026 (planning turn) — estimated $1.00–$3.00 for this task (preview rates, subject to change; the docs price per task and do not price a planning turn separately).
201
+ Submit? [y/N] y
202
+ Plan task d014e21b submitted. Run: gemcatch get d014e21b when ready.
203
+
204
+ $ gemcatch get d014e21b
205
+ Research plan
206
+
207
+ 1. Scope the EU AI Act high-risk regime: Annex III use cases, Article 6 classification,
208
+ and the Chapter III obligations (risk management, data governance, logging, human
209
+ oversight, conformity assessment) with their August 2026 / August 2027 dates.
210
+ 2. Scope the UK approach: the five cross-sector principles, the regulator-led model
211
+ (ICO, FCA, MHRA, Ofcom), and what is guidance rather than statute.
212
+ 3. Build an obligation-by-obligation comparison table: EU requirement, nearest UK
213
+ equivalent, whether it is binding, and who enforces it.
214
+ 4. Flag the gaps in both directions and the compliance implications for a firm
215
+ operating in both.
216
+ Approve with: gemcatch approve d014e21b · Refine with: gemcatch refine d014e21b "..."
217
+
218
+ $ gemcatch refine d014e21b "cut the history, and add enforcement penalties on both sides"
219
+ Plan task 2140e699 submitted (refines d014e21b). Run: gemcatch get 2140e699 when ready.
220
+
221
+ $ gemcatch approve 2140e699
222
+ Agent deep-research-preview-04-2026 — estimated $1.00–$3.00 for this task (preview rates, subject to change).
223
+ Submit? [y/N] y
224
+ Task 96e8209b submitted (approves plan 2140e699).
225
+ ```
226
+
227
+ Every turn is an ordinary background task: stored, polled, and collected by the daemon before the 1-day expiry, same as everything else. `refine` chains as many times as you like; each revision inherits the agent and tag of the plan it came from.
228
+
229
+ **A planning turn is a task, and it is billed as one.** The docs publish one band per task and price no planning turn separately, so `gemcatch` quotes the same band for it and says exactly that on the line. What `--plan` buys you is a look at the plan before you commit to the research run, not a discount. Budget for the plan, each refinement, and the run.
230
+
231
+ Since a chain bills per turn, `gemcatch stats` keeps a running total across all of them:
232
+
233
+ ```console
234
+ $ gemcatch stats
235
+ Store: ~/.gemcatch/tasks.db
236
+ Tasks: 3
237
+ completed 3
238
+ Agent runs:
239
+ deep-research-preview-04-2026 3
240
+ Plan chains: 2 plan, 1 report
241
+ Estimated spend: $3.00–$9.00 across 3 billed task(s) (preview rates, subject to change).
242
+ ```
243
+
244
+ That's the chain above: one plan, one refinement, one run, three tasks at the same band. It's the published bands applied to what you actually submitted, not a reading of your bill, and it errs toward telling you rather than flattering you:
245
+
246
+ - Only runs that **reached the server** are priced. A submit that failed before it left the machine (bad key, rejected agent id) is still counted under "Agent runs" as an attempt, but it costs nothing and isn't billed.
247
+ - An agent with **no published band** totals to `unknown`, never to `$0.00`. Quoting zero for a run that costs real money is the one thing a spend guard must not do.
248
+
249
+ `list` shows the chain as one thing, and `export` follows it to the report:
250
+
251
+ ```console
252
+ $ gemcatch list
253
+ ID AGE STATUS KIND AGENT PROMPT
254
+ d014e21b 22s completed plan deep-research map the EU AI Act high-risk obligations against the UK ap...
255
+ 2140e699 12s completed plan deep-research └─ cut the history, and add enforcement penalties on both sides
256
+ 96e8209b 7s completed report deep-research └─ map the EU AI Act high-risk obligations against the UK ap...
257
+
258
+ $ gemcatch export --tag euuk -o report.md
259
+ Wrote 1 result(s) to report.md.
260
+ ```
261
+
262
+ The plans are working notes on the way to the report, so `export` leaves them out; pass `--include-plans` if you want the whole chain in the document. The report is filed under the question that started the chain rather than the one-line approval actually sent to the API, so an exported document reads as research.
263
+
264
+ A few things that will bite otherwise:
265
+
266
+ - `--plan` needs `--agent`. Collaborative planning is an agent feature, so `--plan --model ...` (or `--plan` on its own) is an error, not a no-op.
267
+ - `approve` only works on a plan that has completed. Anything else fails before a request goes out.
268
+ - On the free tier the plan's interaction is dropped after a day. Once that happens the chain cannot be continued. `approve` says so and names the window instead of sending a `previous_interaction_id` the server will reject. Run the daemon, or approve the same day.
269
+ - Approving twice submits twice. There is no dedupe, and both runs show up under the plan in `list`.
270
+ - `--plan` and `--watch` work together: `gemcatch research "..." --agent deep-research --plan -w` waits for the plan and then prints it.
271
+
190
272
  The agent recipe, end to end:
191
273
 
192
274
  ```bash
193
- $ gemcatch batch questions.txt --agent deep-research --yes # bands shown, N × total quoted
194
- $ gemcatch daemon --exit-when-idle # catch reports before the 1-day expiry
195
- $ gemcatch export --tag batch-1a2b3c -o reports.md # every report, with its sources
275
+ $ gemcatch batch questions.txt --agent deep-research --plan --yes # a plan per prompt, N × band quoted
276
+ $ gemcatch daemon --exit-when-idle # catch the plans
277
+ $ gemcatch list --tag batch-1a2b3c # read them, approve the good ones
278
+ $ gemcatch approve 8f3a1c04 --yes
279
+ $ gemcatch daemon --exit-when-idle # catch the reports before the 1-day expiry
280
+ $ gemcatch export --tag batch-1a2b3c -o reports.md # every report, with its sources
196
281
  ```
197
282
 
283
+ Drop `--plan` and the first two lines become the 0.4.0 one-shot flow, which still works exactly as it did.
284
+
198
285
  ## How it works
199
286
 
200
287
  Tasks live in SQLite at `~/.gemcatch/tasks.db` (override with `GEMCATCH_HOME`):
@@ -202,7 +289,8 @@ Tasks live in SQLite at `~/.gemcatch/tasks.db` (override with `GEMCATCH_HOME`):
202
289
  ```sql
203
290
  CREATE TABLE tasks (id TEXT PRIMARY KEY, prompt TEXT, interaction_id TEXT,
204
291
  status TEXT DEFAULT 'pending', result TEXT, created_at INTEGER);
205
- -- plus model, system_instruction, tag, error, usage, updated_at, agent, citations
292
+ -- plus model, system_instruction, tag, error, usage, updated_at, agent, citations,
293
+ -- collaborative_planning, previous_interaction_id, kind, parent_id
206
294
  ```
207
295
 
208
296
  `research` calls `interactions.create({model, input, background: true})` via [`@google/genai`](https://www.npmjs.com/package/@google/genai) and keeps the returned `id`. The polling commands call `interactions.get(id)` and write the status back. Once a task completes, the text is cached in the `result` column — `gemcatch get` then answers from disk without touching the network.
package/db.js CHANGED
@@ -31,6 +31,17 @@ const MIGRATIONS = [
31
31
  // the JSON array of sources an agent run returned alongside its report.
32
32
  ['agent', 'TEXT'],
33
33
  ['citations', 'TEXT'],
34
+ // 0.5.0: collaborative planning. `collaborative_planning` is the agent_config
35
+ // flag the row was submitted with (1 plan turn, 0 report turn, NULL for every
36
+ // run that sent no agent_config at all, including every pre-0.5.0 row);
37
+ // `previous_interaction_id` is the interaction this turn continues;
38
+ // `kind` is 'task' | 'plan' | 'report'; `parent_id` is the local task this
39
+ // turn continues. The DEFAULT backfills 'task' for older rows, so a 0.4.0
40
+ // store keeps behaving exactly as it did.
41
+ ['collaborative_planning', 'INTEGER'],
42
+ ['previous_interaction_id', 'TEXT'],
43
+ ['kind', "TEXT DEFAULT 'task'"],
44
+ ['parent_id', 'TEXT'],
34
45
  ];
35
46
 
36
47
  let _db = null;
@@ -64,8 +75,10 @@ function createTask(fields) {
64
75
  const now = Date.now();
65
76
  db()
66
77
  .prepare(
67
- 'INSERT INTO tasks (id, prompt, status, created_at, updated_at, model, system_instruction, tag, agent) ' +
68
- 'VALUES (@id, @prompt, @status, @now, @now, @model, @system_instruction, @tag, @agent)'
78
+ 'INSERT INTO tasks (id, prompt, status, created_at, updated_at, model, system_instruction, tag, agent, ' +
79
+ 'kind, parent_id, collaborative_planning, previous_interaction_id) ' +
80
+ 'VALUES (@id, @prompt, @status, @now, @now, @model, @system_instruction, @tag, @agent, ' +
81
+ '@kind, @parent_id, @collaborative_planning, @previous_interaction_id)'
69
82
  )
70
83
  .run({
71
84
  id,
@@ -76,6 +89,12 @@ function createTask(fields) {
76
89
  system_instruction: t.systemInstruction || null,
77
90
  tag: t.tag || null,
78
91
  agent: t.agent || null,
92
+ kind: t.kind || 'task',
93
+ parent_id: t.parentId || null,
94
+ // Presence, not truthiness: `false` is the report turn's real flag and
95
+ // must be stored as 0, while a run that sends no agent_config stores NULL.
96
+ collaborative_planning: t.collaborativePlanning === undefined ? null : Number(!!t.collaborativePlanning),
97
+ previous_interaction_id: t.previousInteractionId || null,
79
98
  });
80
99
  return id;
81
100
  }
@@ -181,6 +200,27 @@ function agentCounts() {
181
200
  .all();
182
201
  }
183
202
 
203
+ // Agent runs that actually reached the server, keyed by agent. A submit that
204
+ // never got an interaction_id (a bad key, a rejected agent id, a network
205
+ // failure) was never billed, so it must not appear in a spend total -- unlike
206
+ // agentCounts(), which tallies every attempt.
207
+ function billedAgentCounts() {
208
+ return db()
209
+ .prepare(
210
+ 'SELECT agent, COUNT(*) AS n FROM tasks WHERE agent IS NOT NULL AND interaction_id IS NOT NULL GROUP BY agent'
211
+ )
212
+ .all();
213
+ }
214
+
215
+ // Plan/report totals for `stats`. Ordinary tasks are not a row here; they are
216
+ // already accounted for in counts(), and a store that has never planned reports
217
+ // nothing at all.
218
+ function kindCounts() {
219
+ return db()
220
+ .prepare("SELECT kind, COUNT(*) AS n FROM tasks WHERE kind IS NOT NULL AND kind != 'task' GROUP BY kind")
221
+ .all();
222
+ }
223
+
184
224
  function close() {
185
225
  if (_db) _db.close();
186
226
  _db = null;
@@ -200,5 +240,7 @@ module.exports = {
200
240
  prunableTasks,
201
241
  counts,
202
242
  agentCounts,
243
+ billedAgentCounts,
244
+ kindCounts,
203
245
  close,
204
246
  };
package/gemini.js CHANGED
@@ -23,6 +23,12 @@ const AGENT_ALIASES = Object.freeze({
23
23
  'deep-research-max': 'deep-research-max-preview-04-2026',
24
24
  });
25
25
 
26
+ // `agent_config.type` for the config block a collaborative-planning turn sends.
27
+ // Both documented Deep Research agents use the same value, and an unknown
28
+ // pass-through id is assumed to be one too -- collaborative planning is a Deep
29
+ // Research feature, so there is nothing else it could be.
30
+ const AGENT_CONFIG_TYPE = 'deep-research';
31
+
26
32
  // Documented per-task price bands, in dollars, keyed by the RESOLVED id.
27
33
  // The docs' own hedge applies -- "These figures are estimates based on
28
34
  // preview rates and are subject to change" -- so the spend guard quotes
@@ -360,6 +366,21 @@ async function submit(prompt, opts) {
360
366
  ? { agent: o.agent, input: prompt, background: true }
361
367
  : { model: o.model || DEFAULT_MODEL, input: prompt, background: true };
362
368
  if (o.systemInstruction) body.system_instruction = o.systemInstruction;
369
+ // collaborative_planning is an `agent_config` field, NOT a top-level one, and
370
+ // the docs send the whole block (type + thinking_summaries) with it. Sent only
371
+ // when a plan turn is involved -- agent_config is optional otherwise, so an
372
+ // ordinary run keeps making exactly the request it always made. Presence, not
373
+ // truthiness: `false` is the approval turn's real value and must reach the API.
374
+ if (o.collaborativePlanning !== undefined) {
375
+ body.agent_config = {
376
+ type: AGENT_CONFIG_TYPE,
377
+ thinking_summaries: 'auto',
378
+ collaborative_planning: !!o.collaborativePlanning,
379
+ };
380
+ }
381
+ // Continues an earlier interaction server-side: the plan is already in that
382
+ // conversation, so this turn sends only what changed.
383
+ if (o.previousInteractionId) body.previous_interaction_id = o.previousInteractionId;
363
384
  const r = await call(() => {
364
385
  const api = sdkInteractions();
365
386
  return api
@@ -414,6 +435,7 @@ module.exports = {
414
435
  MAX_RETRIES,
415
436
  AGENT_ALIASES,
416
437
  AGENT_PRICE_BANDS,
438
+ AGENT_CONFIG_TYPE,
417
439
  resolveAgent,
418
440
  submit,
419
441
  poll,
package/index.js CHANGED
@@ -126,6 +126,12 @@ function parseCitations(raw) {
126
126
  // The bands are quoted with the docs' own hedge ("estimates based on preview
127
127
  // rates and subject to change"), never as authoritative.
128
128
 
129
+ // A planning turn is a task and is billed as one. The docs publish ONE band per
130
+ // task and price no planning turn separately, so it is quoted at the same band
131
+ // and the line says so outright. Planning buys you a look at the plan before you
132
+ // commit to the research run; it does not buy you a discount.
133
+ const PLAN_NOTE = 'the docs price per task and do not price a planning turn separately';
134
+
129
135
  function bandText(agentId, count) {
130
136
  const band = gemini.AGENT_PRICE_BANDS[agentId];
131
137
  if (!band) return 'no published price band for this agent';
@@ -135,9 +141,51 @@ function bandText(agentId, count) {
135
141
  : `estimated ${money(band[0])}–${money(band[1])} for this task`;
136
142
  }
137
143
 
138
- function spendLine(agentId, count) {
144
+ function spendLine(agentId, count, planning) {
139
145
  const head = count > 1 ? `${count} prompts × ${agentId}` : `Agent ${agentId}`;
140
- return `${head} — ${bandText(agentId, count)}`;
146
+ return `${head}${planning ? ' (planning turn)' : ''} — ${bandText(agentId, count)}`;
147
+ }
148
+
149
+ // The parenthetical after the band. `hedge` is the docs' "preview rates" caveat,
150
+ // which the confirmation carries; --dry-run already reads as a projection.
151
+ function spendNote(planning, hedge) {
152
+ const parts = [];
153
+ if (hedge) parts.push('preview rates, subject to change');
154
+ if (planning) parts.push(PLAN_NOTE);
155
+ return parts.length ? ` (${parts.join('; ')})` : '';
156
+ }
157
+
158
+ // What the agent runs in the store have plausibly cost, from the same per-task
159
+ // bands the guard quotes before each one. A plan chain bills per turn, so this
160
+ // counts plans and refinements alongside reports -- that is the number worth
161
+ // knowing. Agents with no published band are counted separately rather than
162
+ // silently priced at zero. Null when nothing has been billed at all.
163
+ function estimatedSpend(agentRows) {
164
+ let low = 0;
165
+ let high = 0;
166
+ let tasks = 0;
167
+ let unpriced = 0;
168
+ for (const a of agentRows) {
169
+ const band = gemini.AGENT_PRICE_BANDS[a.agent];
170
+ if (!band) {
171
+ unpriced += a.n;
172
+ continue;
173
+ }
174
+ low += band[0] * a.n;
175
+ high += band[1] * a.n;
176
+ tasks += a.n;
177
+ }
178
+ if (!tasks && !unpriced) return null;
179
+ // Nothing priced means the total is unknown, NOT zero. Reporting $0.00 for
180
+ // runs that cost real money is the exact dishonesty this guard exists to
181
+ // avoid, so low/high are null and the caller says "unknown" instead.
182
+ if (!tasks) return { low: null, high: null, tasks: 0, unpriced };
183
+ return { low, high, tasks, unpriced };
184
+ }
185
+
186
+ // One sentence for a --dry-run: the band, the honesty note, and that nothing went.
187
+ function dryRunSpend(agentId, count, planning) {
188
+ return `${spendLine(agentId, count, planning)}${spendNote(planning, false)}. Nothing submitted (--dry-run).`;
141
189
  }
142
190
 
143
191
  function askYesNo(question) {
@@ -154,8 +202,8 @@ function askYesNo(question) {
154
202
  // Returns only when the submission is confirmed; otherwise it exits (declined)
155
203
  // or throws (no way to ask). Runs BEFORE any row is written, so a declined or
156
204
  // refused submission leaves the tasks table untouched.
157
- async function confirmSpend(agentId, count, opts) {
158
- console.error(`${spendLine(agentId, count)} (preview rates, subject to change).`);
205
+ async function confirmSpend(agentId, count, opts, planning) {
206
+ console.error(`${spendLine(agentId, count, planning)}${spendNote(planning, true)}.`);
159
207
  if (opts.yes) return;
160
208
  // GEMCATCH_ASSUME_TTY lets the offline suite drive the interactive branch
161
209
  // through a pipe; real non-TTY callers (cron, CI, scripts) must say --yes.
@@ -177,7 +225,18 @@ async function confirmSpend(agentId, count, opts) {
177
225
  // only when the user actually typed it -- commander fills in the default
178
226
  // otherwise, and the default must not poison every agent run.
179
227
  function resolveAgentOpts(opts, cmd) {
180
- if (!opts.agent) return null;
228
+ if (!opts.agent) {
229
+ // Collaborative planning is an agent_config field on an agent run. A model
230
+ // run has no plan turn at all, so --plan without --agent is a mistake worth
231
+ // naming rather than a flag that quietly does nothing.
232
+ if (opts.plan) {
233
+ throw new Error(
234
+ '--plan is a research-agent feature: collaborative planning applies to an agent, not a model, ' +
235
+ 'and a model run has no plan turn.\n Try: --agent deep-research --plan'
236
+ );
237
+ }
238
+ return null;
239
+ }
181
240
  if (cmd.getOptionValueSource('model') === 'cli') {
182
241
  throw new Error(
183
242
  '--model and --agent are mutually exclusive: an agent run is submitted with `agent` ' +
@@ -187,6 +246,170 @@ function resolveAgentOpts(opts, cmd) {
187
246
  return gemini.resolveAgent(opts.agent);
188
247
  }
189
248
 
249
+ // --- plan chains ----------------------------------------------------------
250
+
251
+ // `collaborative_planning: true` makes the agent return a research plan instead
252
+ // of a report. That plan is a decision point, not a deliverable: you read it,
253
+ // optionally `refine` it, and `approve` it to spend on the research run itself.
254
+ // Each turn is its own task row, linked to the one it continues by parent_id
255
+ // locally and by previous_interaction_id on the wire.
256
+
257
+ // What the approval turn sends as `input`. The plan is already in the
258
+ // conversation via previous_interaction_id, so this turn only has to say yes --
259
+ // the docs' own example sends a one-line confirmation, not the question again.
260
+ const APPROVE_INPUT = 'Plan looks good, proceed with the research.';
261
+
262
+ // The reason refresh() records when a poll 404s. Named because `approve` reads
263
+ // it back to tell an expired plan apart from any other terminal one.
264
+ const EXPIRED_ERROR = 'interaction not found (expired or deleted)';
265
+
266
+ const RETENTION_NOTE =
267
+ 'The Interactions API retains interactions for 1 day on the free tier (55 days on paid).';
268
+
269
+ // A finished plan is a decision point, so the next command is spelled out under
270
+ // it. Guidance, so it goes to stderr like the rest -- `gemcatch get <plan> >
271
+ // plan.md` still captures only the plan.
272
+ function planFooter(task) {
273
+ return `Approve with: gemcatch approve ${task.id} · Refine with: gemcatch refine ${task.id} "..."`;
274
+ }
275
+
276
+ // The result payload for `get`/`watch`. The plan-chain fields ride along only on
277
+ // a plan row, so a model run's --json shape is exactly what it always was.
278
+ function resultPayload(task, status, result, citations) {
279
+ const p = { id: task.id, status, result, citations: citations || null };
280
+ if (task.kind === 'plan') {
281
+ p.kind = 'plan';
282
+ p.approve = `gemcatch approve ${task.id}`;
283
+ p.refine = `gemcatch refine ${task.id} "<instruction>"`;
284
+ }
285
+ return p;
286
+ }
287
+
288
+ // Both continuation commands need the same thing: a plan row that completed and
289
+ // whose interaction the server can still resolve. Anything else exits here,
290
+ // before a row is written or a request is sent.
291
+ function needPlan(id, verb) {
292
+ const task = needTask(id);
293
+ if (task.kind !== 'plan') {
294
+ die(
295
+ new Error(
296
+ `Task ${task.id} is not a plan (kind: ${task.kind || 'task'}), so there is nothing to ${verb}.\n` +
297
+ ' Plans come from: gemcatch research "<prompt>" --agent deep-research --plan'
298
+ )
299
+ );
300
+ }
301
+ if (task.status === 'incomplete' && task.error === EXPIRED_ERROR) {
302
+ die(
303
+ new Error(
304
+ `Plan ${task.id}'s interaction was dropped server-side, so it can no longer be continued.\n` +
305
+ ` ${RETENTION_NOTE}\n` +
306
+ ` Submit a fresh plan: gemcatch research "<prompt>" --agent ${task.agent || '<agent>'} --plan`
307
+ )
308
+ );
309
+ }
310
+ // A plan that finished any way other than `completed` is never coming back, so
311
+ // "wait for it" would be wrong advice. Only a plan still in flight gets
312
+ // pointed at `watch`.
313
+ if (isDone(task.status) && !isSuccess(task.status)) {
314
+ die(
315
+ new Error(
316
+ `Plan ${task.id} ended ${task.status}, so there is nothing to ${verb}.\n` +
317
+ ` Submit a fresh plan: gemcatch research "<prompt>" --agent ${task.agent || '<agent>'} --plan`
318
+ )
319
+ );
320
+ }
321
+ if (!isSuccess(task.status)) {
322
+ die(
323
+ new Error(
324
+ `Plan ${task.id} has not completed yet (status: ${task.status}), so there is nothing to ${verb}.\n` +
325
+ ` Wait for it: gemcatch watch ${task.id}`
326
+ )
327
+ );
328
+ }
329
+ if (!task.interaction_id) die(new Error(`Plan ${task.id} was never submitted.`));
330
+ return task;
331
+ }
332
+
333
+ // A plan can complete locally and still expire server-side before you approve
334
+ // it, in which case the continuation 404s on an id the user never typed. Name
335
+ // the retention window instead of passing that through raw.
336
+ function expiredHint(err, plan) {
337
+ if (!err || err.httpStatus !== 404) return err;
338
+ const e = new Error(
339
+ `the plan's interaction (${plan.interaction_id}) is gone server-side, so it cannot be continued.\n` +
340
+ ` ${RETENTION_NOTE}\n` +
341
+ ` Submit a fresh plan: gemcatch research "<prompt>" --agent ${plan.agent || '<agent>'} --plan`
342
+ );
343
+ e.code = 'API_ERROR';
344
+ e.httpStatus = 404;
345
+ return e;
346
+ }
347
+
348
+ // A report row is displayed under the question that started the chain rather
349
+ // than the "plan looks good" line actually sent -- that is what keeps `list` and
350
+ // `export` reading as research instead of as protocol chatter.
351
+ function rootPrompt(task) {
352
+ let cur = task;
353
+ const seen = new Set([task.id]);
354
+ while (cur.parent_id && !seen.has(cur.parent_id)) {
355
+ seen.add(cur.parent_id);
356
+ const parent = store.getTask(cur.parent_id);
357
+ if (!parent) break;
358
+ cur = parent;
359
+ }
360
+ return cur.prompt;
361
+ }
362
+
363
+ // `refine` (another plan) and `approve` (the report) are the same submission --
364
+ // same agent, same tag, linked to the plan by previous_interaction_id --
365
+ // differing only in the collaborative_planning flag they send, the kind they
366
+ // store and the line they print. So they share one path, and the spend guard is
367
+ // on it exactly once.
368
+ async function continuePlan(plan, opts, turn) {
369
+ let id;
370
+ try {
371
+ if (opts.dryRun) {
372
+ emit(
373
+ opts.json,
374
+ {
375
+ dry_run: true,
376
+ agent: plan.agent,
377
+ kind: turn.kind,
378
+ parent_id: plan.id,
379
+ previous_interaction_id: plan.interaction_id,
380
+ input: turn.input,
381
+ },
382
+ () => console.log(dryRunSpend(plan.agent, 1, turn.planning))
383
+ );
384
+ return;
385
+ }
386
+ await confirmSpend(plan.agent, 1, opts, turn.planning);
387
+ id = store.createTask({
388
+ prompt: turn.prompt,
389
+ agent: plan.agent,
390
+ tag: plan.tag,
391
+ kind: turn.kind,
392
+ parentId: plan.id,
393
+ collaborativePlanning: turn.planning,
394
+ previousInteractionId: plan.interaction_id,
395
+ });
396
+ const r = await gemini.submit(turn.input, {
397
+ agent: plan.agent,
398
+ collaborativePlanning: turn.planning,
399
+ previousInteractionId: plan.interaction_id,
400
+ });
401
+ store.setInteraction(id, r.interactionId, r.status);
402
+ emit(
403
+ opts.json,
404
+ { id, interaction_id: r.interactionId, status: r.status, kind: turn.kind, parent_id: plan.id },
405
+ () => console.log(turn.line(id))
406
+ );
407
+ } catch (err) {
408
+ markSubmitFailure(id, err);
409
+ die(expiredHint(err, plan));
410
+ }
411
+ }
412
+
190
413
  // --- input ----------------------------------------------------------------
191
414
 
192
415
  function readStdin() {
@@ -230,7 +453,7 @@ async function refresh(task) {
230
453
  // the end of time. Any other error (5xx, network) is transient and is
231
454
  // re-thrown for the caller to retry on its next pass.
232
455
  if (err && err.httpStatus === 404) {
233
- store.setStatus(task.id, 'incomplete', { error: 'interaction not found (expired or deleted)' });
456
+ store.setStatus(task.id, 'incomplete', { error: EXPIRED_ERROR });
234
457
  return { status: 'incomplete', text: null, usage: null, raw: null };
235
458
  }
236
459
  throw err;
@@ -249,6 +472,18 @@ async function refresh(task) {
249
472
  return r;
250
473
  }
251
474
 
475
+ // Only a failed *submit* should mark a task failed. Once it has an
476
+ // interaction_id it is live on the server, and a later watch/poll error must
477
+ // never overwrite it to failed -- that would drop it from the active set and the
478
+ // daemon would abandon a task whose result is still coming. Leave it active; the
479
+ // daemon (or a later `get`) collects it. Every command that submits calls this
480
+ // on its way out, so the rule cannot drift between them.
481
+ function markSubmitFailure(id, err) {
482
+ if (!id) return;
483
+ const t = store.getTask(id);
484
+ if (!t || !t.interaction_id) store.setStatus(id, 'failed', { error: err.message });
485
+ }
486
+
252
487
  // Bounds how many polls are open at once. The *rate* limit is enforced in
253
488
  // gemini.js (GEMCATCH_RPM), which is the part that keeps a wide fan-out inside the
254
489
  // free tier's requests-per-minute allowance.
@@ -280,6 +515,7 @@ program
280
515
  .option('-f, --file <path>', 'read the prompt from a file')
281
516
  .option('-m, --model <id>', 'model to use', gemini.DEFAULT_MODEL)
282
517
  .option('-a, --agent <id>', 'submit to a research agent instead of a model (e.g. deep-research)')
518
+ .option('--plan', 'ask the agent for a research plan first, to refine and approve (needs --agent)')
283
519
  .option('-s, --system <text>', 'system instruction')
284
520
  .option('-t, --tag <tag>', 'label for filtering with `gemcatch list --tag`')
285
521
  .option('-w, --watch', 'wait for the result instead of exiting')
@@ -292,44 +528,50 @@ program
292
528
  try {
293
529
  const agent = resolveAgentOpts(opts, cmd);
294
530
  const prompt = await resolvePrompt(promptArg, opts);
531
+ // undefined, not false: an ordinary run must keep sending no agent_config
532
+ // at all, exactly as it did before collaborative planning existed.
533
+ const planning = opts.plan ? true : undefined;
295
534
  if (opts.dryRun) {
296
- emit(opts.json, { dry_run: true, agent: agent || null, model: agent ? null : opts.model, prompt }, () => {
297
- if (agent) console.log(`${spendLine(agent, 1)}. Nothing submitted (--dry-run).`);
298
- else console.log(`Would submit to ${opts.model}: ${snippet(prompt)}. Nothing submitted (--dry-run).`);
299
- });
535
+ emit(
536
+ opts.json,
537
+ { dry_run: true, agent: agent || null, model: agent ? null : opts.model, plan: !!opts.plan, prompt },
538
+ () => {
539
+ if (agent) console.log(dryRunSpend(agent, 1, opts.plan));
540
+ else console.log(`Would submit to ${opts.model}: ${snippet(prompt)}. Nothing submitted (--dry-run).`);
541
+ }
542
+ );
300
543
  return;
301
544
  }
302
- if (agent) await confirmSpend(agent, 1, opts);
545
+ if (agent) await confirmSpend(agent, 1, opts, opts.plan);
303
546
  id = store.createTask({
304
547
  prompt,
305
548
  model: agent ? null : opts.model,
306
549
  agent,
307
550
  systemInstruction: opts.system,
308
551
  tag: opts.tag,
552
+ kind: opts.plan ? 'plan' : 'task',
553
+ collaborativePlanning: planning,
554
+ });
555
+ const r = await gemini.submit(prompt, {
556
+ model: opts.model,
557
+ agent,
558
+ systemInstruction: opts.system,
559
+ collaborativePlanning: planning,
309
560
  });
310
- const r = await gemini.submit(prompt, { model: opts.model, agent, systemInstruction: opts.system });
311
561
  store.setInteraction(id, r.interactionId, r.status);
312
562
  if (opts.watch) {
313
563
  // Under --watch the submit line is progress, not the answer, so it
314
564
  // goes to stderr -- `gemcatch research -w "..." > out.txt` then captures
315
565
  // only the result.
316
- if (!opts.json) console.error(edim(`Task ${id} submitted.`));
566
+ if (!opts.json) console.error(edim(`${opts.plan ? 'Plan task' : 'Task'} ${id} submitted.`));
317
567
  await watchTask(store.getTask(id), DEFAULT_POLL_MS, opts.json);
318
568
  return;
319
569
  }
320
- emit(opts.json, { id, interaction_id: r.interactionId, status: r.status }, () =>
321
- console.log(`Task ${id} submitted. Run: gemcatch get ${id} when ready.`)
570
+ emit(opts.json, { id, interaction_id: r.interactionId, status: r.status, kind: opts.plan ? 'plan' : 'task' }, () =>
571
+ console.log(`${opts.plan ? 'Plan task' : 'Task'} ${id} submitted. Run: gemcatch get ${id} when ready.`)
322
572
  );
323
573
  } catch (err) {
324
- // Only a failed *submit* should mark the task failed. Once it has an
325
- // interaction_id it is live on the server, and a later watch/poll error
326
- // must never overwrite it to failed -- that would drop it from the active
327
- // set and the daemon would abandon a task whose result is still coming.
328
- // Leave it active; the daemon (or a later `get`) collects it.
329
- if (id) {
330
- const t = store.getTask(id);
331
- if (!t || !t.interaction_id) store.setStatus(id, 'failed', { error: err.message });
332
- }
574
+ markSubmitFailure(id, err);
333
575
  die(err);
334
576
  }
335
577
  });
@@ -412,6 +654,7 @@ program
412
654
  .argument('<file>', 'prompts file — one per line, or "-" to read stdin')
413
655
  .option('-m, --model <id>', 'model to use', gemini.DEFAULT_MODEL)
414
656
  .option('-a, --agent <id>', 'submit every prompt to a research agent instead of a model')
657
+ .option('--plan', 'ask the agent for a research plan per prompt, to refine and approve (needs --agent)')
415
658
  .option('-s, --system <text>', 'system instruction')
416
659
  .option('-t, --tag <tag>', 'tag the whole batch (default: batch-<hex>)')
417
660
  .option('--separator <str>', 'split the file on this delimiter line for multi-line prompts')
@@ -434,11 +677,14 @@ program
434
677
  // Auto-tag so the batch is collectable as a unit; a user tag wins.
435
678
  const tag = opts.tag || `batch-${crypto.randomUUID().slice(0, 6)}`;
436
679
 
680
+ // undefined, not false: an ordinary run must keep sending no agent_config.
681
+ const planning = opts.plan ? true : undefined;
682
+
437
683
  if (opts.dryRun) {
438
- emit(opts.json, { tag, dry_run: true, agent: agent || null, prompts }, () => {
684
+ emit(opts.json, { tag, dry_run: true, agent: agent || null, plan: !!opts.plan, prompts }, () => {
439
685
  if (agent) {
440
686
  // The whole point of the guard: N × the per-task band, up front.
441
- console.log(`${spendLine(agent, prompts.length)}. Nothing submitted (--dry-run).`);
687
+ console.log(dryRunSpend(agent, prompts.length, opts.plan));
442
688
  } else {
443
689
  console.log(`Batch ${tag}: ${prompts.length} prompt(s) would be submitted:`);
444
690
  for (const p of prompts) console.log(` ${snippet(p)}`);
@@ -449,14 +695,27 @@ program
449
695
 
450
696
  // An agent batch multiplies a per-task dollar band by the whole file, so
451
697
  // it is confirmed as one total before a single row is written.
452
- if (agent) await confirmSpend(agent, prompts.length, opts);
698
+ if (agent) await confirmSpend(agent, prompts.length, opts, opts.plan);
453
699
 
454
700
  // One failed submit must not sink the batch: mark that task failed and
455
701
  // keep going. mapLimit preserves input order, so the report is stable.
456
702
  const results = await mapLimit(prompts, 4, async (prompt) => {
457
- const id = store.createTask({ prompt, model: agent ? null : opts.model, agent, systemInstruction: opts.system, tag });
703
+ const id = store.createTask({
704
+ prompt,
705
+ model: agent ? null : opts.model,
706
+ agent,
707
+ systemInstruction: opts.system,
708
+ tag,
709
+ kind: opts.plan ? 'plan' : 'task',
710
+ collaborativePlanning: planning,
711
+ });
458
712
  try {
459
- const r = await gemini.submit(prompt, { model: opts.model, agent, systemInstruction: opts.system });
713
+ const r = await gemini.submit(prompt, {
714
+ model: opts.model,
715
+ agent,
716
+ systemInstruction: opts.system,
717
+ collaborativePlanning: planning,
718
+ });
460
719
  store.setInteraction(id, r.interactionId, r.status);
461
720
  return { id, interaction_id: r.interactionId, status: r.status, prompt };
462
721
  } catch (err) {
@@ -484,9 +743,16 @@ program
484
743
  const pad = ' '.repeat(Math.max(0, 16 - status.length));
485
744
  console.log(`${r.id} ${colorStatus(status)}${pad} ${snippet(r.prompt)}`);
486
745
  }
487
- console.log(dim('\nCollect them:'));
488
- console.log(dim(' gemcatch daemon --exit-when-idle'));
489
- console.log(dim(` gemcatch list --tag ${tag} --status completed`));
746
+ if (opts.plan) {
747
+ console.log(dim('\nCollect the plans, then approve the ones worth running:'));
748
+ console.log(dim(' gemcatch daemon --exit-when-idle'));
749
+ console.log(dim(` gemcatch list --tag ${tag}`));
750
+ console.log(dim(' gemcatch get <id> # prints the plan and the approve command'));
751
+ } else {
752
+ console.log(dim('\nCollect them:'));
753
+ console.log(dim(' gemcatch daemon --exit-when-idle'));
754
+ console.log(dim(` gemcatch list --tag ${tag} --status completed`));
755
+ }
490
756
  });
491
757
  } catch (err) {
492
758
  die(err);
@@ -530,17 +796,19 @@ program
530
796
  // -- re-polling it would 404 after 24h, the very thing we cache to avoid.
531
797
  if (isSuccess(task.status) && task.result != null && !opts.raw) {
532
798
  const cits = parseCitations(task.citations);
533
- emit(opts.json, { id: task.id, status: task.status, result: task.result, citations: cits }, () =>
534
- console.log(withSources(task.result, cits))
535
- );
799
+ emit(opts.json, resultPayload(task, task.status, task.result, cits), () => {
800
+ console.log(withSources(task.result, cits));
801
+ if (task.kind === 'plan') console.error(planFooter(task));
802
+ });
536
803
  return;
537
804
  }
538
805
  const r = await refresh(task);
539
806
  if (opts.raw) return console.log(JSON.stringify(r.raw, null, 2));
540
807
  if (isSuccess(r.status)) {
541
- emit(opts.json, { id: task.id, status: r.status, result: r.text, citations: r.citations || null }, () =>
542
- console.log(withSources(r.text, r.citations))
543
- );
808
+ emit(opts.json, resultPayload(task, r.status, r.text, r.citations), () => {
809
+ console.log(withSources(r.text, r.citations));
810
+ if (task.kind === 'plan') console.error(planFooter(task));
811
+ });
544
812
  } else if (isDone(r.status)) {
545
813
  emit(opts.json, { id: task.id, status: r.status, error: r.text || null }, () =>
546
814
  console.log(`Task ${task.id}: ${colorStatus(r.status)}${r.text ? `\n${r.text}` : ''}`)
@@ -556,8 +824,80 @@ program
556
824
  }
557
825
  });
558
826
 
827
+ // --- refine / approve -----------------------------------------------------
828
+
829
+ program
830
+ .command('refine')
831
+ .argument('<id>', 'plan task id')
832
+ .argument('<instruction>', 'what the plan should do differently')
833
+ .option('--yes', 'confirm the agent cost without asking (required when stdin is not a TTY)')
834
+ .option('--dry-run', 'show what it would cost; submit nothing')
835
+ .option('--json', 'machine-readable output')
836
+ .description('send an instruction back to a plan and get a revised plan')
837
+ .action(async (id, instruction, opts) => {
838
+ const text = (instruction || '').trim();
839
+ if (!text) {
840
+ return die(new Error('provide an instruction, e.g. gemcatch refine 8f3a1c04 "focus on enforcement dates"'));
841
+ }
842
+ const plan = needPlan(id, 'refine');
843
+ await continuePlan(plan, opts, {
844
+ planning: true,
845
+ kind: 'plan',
846
+ input: text,
847
+ prompt: text,
848
+ line: (newId) => `Plan task ${newId} submitted (refines ${plan.id}). Run: gemcatch get ${newId} when ready.`,
849
+ });
850
+ });
851
+
852
+ program
853
+ .command('approve')
854
+ .argument('<id>', 'plan task id')
855
+ .option('--yes', 'confirm the agent cost without asking (required when stdin is not a TTY)')
856
+ .option('--dry-run', 'show what it would cost; submit nothing')
857
+ .option('--json', 'machine-readable output')
858
+ .description('approve a plan and submit the research run it describes')
859
+ .action(async (id, opts) => {
860
+ const plan = needPlan(id, 'approve');
861
+ await continuePlan(plan, opts, {
862
+ planning: false,
863
+ kind: 'report',
864
+ input: APPROVE_INPUT,
865
+ prompt: rootPrompt(plan),
866
+ line: (newId) => `Task ${newId} submitted (approves plan ${plan.id}).`,
867
+ });
868
+ });
869
+
559
870
  // --- list -----------------------------------------------------------------
560
871
 
872
+ // A plan chain is one piece of work, so it is listed as one: the root in its
873
+ // normal newest-first position, its continuations indented under it in the order
874
+ // they were submitted. A row whose parent is filtered out of this listing (by
875
+ // --status, --tag or -n) is rendered as its own root rather than dropped.
876
+ function chainOrder(tasks) {
877
+ const present = new Set(tasks.map((t) => t.id));
878
+ const kids = new Map();
879
+ for (const t of tasks) {
880
+ if (!t.parent_id || !present.has(t.parent_id)) continue;
881
+ if (!kids.has(t.parent_id)) kids.set(t.parent_id, []);
882
+ kids.get(t.parent_id).push(t);
883
+ }
884
+ for (const list of kids.values()) list.sort((a, b) => a.created_at - b.created_at);
885
+ const out = [];
886
+ const seen = new Set();
887
+ const walk = (t, depth) => {
888
+ if (seen.has(t.id)) return;
889
+ seen.add(t.id);
890
+ out.push({ task: t, depth });
891
+ for (const c of kids.get(t.id) || []) walk(c, depth + 1);
892
+ };
893
+ for (const t of tasks) if (!t.parent_id || !present.has(t.parent_id)) walk(t, 0);
894
+ // A listing must never lose a row. Nothing the CLI writes can put a cycle in
895
+ // parent_id, but a row that is unreachable from any root would otherwise
896
+ // vanish silently, so anything left over is rendered flat.
897
+ for (const t of tasks) walk(t, 0);
898
+ return out;
899
+ }
900
+
561
901
  program
562
902
  .command('list')
563
903
  .alias('ls')
@@ -578,21 +918,27 @@ program
578
918
  console.log('No tasks yet. Submit one: gemcatch research "your question"');
579
919
  return;
580
920
  }
581
- // The AGENT column only appears when something in the listing used one, so
582
- // a pure-model store keeps the compact four-column layout it always had.
583
- // Agent ids are shown compact -- the "-preview-MM-YYYY" suffix is version
584
- // noise in a table (the full id is in --json and in stats).
921
+ // The AGENT and KIND columns only appear when something in the listing uses
922
+ // them, so a pure-model store keeps the compact four-column layout it always
923
+ // had. Agent ids are shown compact -- the "-preview-MM-YYYY" suffix is
924
+ // version noise in a table (the full id is in --json and in stats).
585
925
  const showAgent = tasks.some((t) => t.agent);
926
+ const showKind = tasks.some((t) => t.kind && t.kind !== 'task');
586
927
  const shortAgent = (a) => (a ? a.replace(/-preview-\d{2}-\d{4}$/, '') : '-');
587
- console.log(dim(`ID AGE STATUS ${showAgent ? 'AGENT ' : ''}PROMPT`));
588
- for (const t of tasks) {
589
- const snip = snippet(t.prompt);
928
+ console.log(
929
+ dim(`ID AGE STATUS ${showKind ? 'KIND ' : ''}${showAgent ? 'AGENT ' : ''}PROMPT`)
930
+ );
931
+ for (const { task: t, depth } of chainOrder(tasks)) {
590
932
  const status = t.status || PENDING;
591
933
  // Pad before colouring: ANSI codes would break the column width.
592
934
  const pad = ' '.repeat(Math.max(0, 16 - status.length));
935
+ const kindCol = showKind ? `${(t.kind || 'task').padEnd(7)} ` : '';
593
936
  const agentCol = showAgent ? `${shortAgent(t.agent).padEnd(18)} ` : '';
937
+ // Indent the prompt, not the id: the fixed-width columns stay aligned and
938
+ // the chain still reads as one thing.
939
+ const branch = depth ? `${' '.repeat(depth - 1)}└─ ` : '';
594
940
  console.log(
595
- `${t.id} ${age(t.created_at).padEnd(4)} ${colorStatus(status)}${pad} ${agentCol}${snip}`
941
+ `${t.id} ${age(t.created_at).padEnd(4)} ${colorStatus(status)}${pad} ${kindCol}${agentCol}${branch}${snippet(t.prompt)}`
596
942
  );
597
943
  }
598
944
  });
@@ -609,6 +955,7 @@ program
609
955
  .addOption(new Option('--status <status>', 'only this status').choices(ALL_STATUSES).default('completed'))
610
956
  .addOption(new Option('--format <fmt>', 'output format').choices(['md', 'json']).default('md'))
611
957
  .option('-o, --out <file>', 'write to a file instead of stdout')
958
+ .option('--include-plans', 'also export the plan turns of a chain, not just its report')
612
959
  .description('concatenate finished results, each under its prompt, to stdout or a file')
613
960
  .action((opts) => {
614
961
  const tasks = store.listTasks({ tag: opts.tag, status: opts.status });
@@ -617,11 +964,19 @@ program
617
964
  tasks.reverse();
618
965
  // Only rows that actually carry a result are worth exporting: a status
619
966
  // filter other than `completed` can match tasks that never stored text.
620
- const rows = tasks.filter((t) => t.result != null);
967
+ // A chain's plan turns are working notes on the way to its report, so an
968
+ // export of a tag follows the chain to the report and leaves them out
969
+ // unless they were asked for.
970
+ const rows = tasks.filter((t) => t.result != null && (opts.includePlans || t.kind !== 'plan'));
621
971
  if (!rows.length) {
622
972
  // Nothing to write isn't an error, but say why so an empty -o file (or an
623
973
  // empty pipe) isn't a mystery. The note goes to stderr, never the output.
624
974
  console.error(`No ${opts.status} results to export${opts.tag ? ` for tag '${opts.tag}'` : ''}.`);
975
+ // A chain with no approved run yet has plans and nothing else, which would
976
+ // otherwise read as "there is nothing here".
977
+ if (!opts.includePlans && tasks.some((t) => t.result != null && t.kind === 'plan')) {
978
+ console.error(' Only plan turns matched. Approve one (gemcatch approve <id>), or pass --include-plans.');
979
+ }
625
980
  return;
626
981
  }
627
982
 
@@ -632,6 +987,7 @@ program
632
987
  id: t.id,
633
988
  tag: t.tag,
634
989
  status: t.status,
990
+ kind: t.kind || 'task',
635
991
  prompt: t.prompt,
636
992
  result: t.result,
637
993
  created_at: t.created_at,
@@ -645,7 +1001,8 @@ program
645
1001
  const when = new Date(t.created_at).toISOString().replace('T', ' ').slice(0, 16);
646
1002
  const head = (t.prompt || '(no prompt)').replace(/\s+/g, ' ').trim();
647
1003
  const body = t.result && t.result.trim() ? t.result : '_(empty result)_';
648
- return `## ${head}\n\n\`${t.id}\` · ${t.status} · ${when} UTC\n\n${body}`;
1004
+ const kind = t.kind && t.kind !== 'task' ? ` · ${t.kind}` : '';
1005
+ return `## ${head}\n\n\`${t.id}\` · ${t.status}${kind} · ${when} UTC\n\n${body}`;
649
1006
  })
650
1007
  .join('\n\n---\n\n');
651
1008
  }
@@ -699,11 +1056,7 @@ program
699
1056
  if (!opts.json) console.error(edim(`Digesting ${done.length} result(s) tagged ${opts.tag} -> task ${id}.`));
700
1057
  await watchTask(store.getTask(id), DEFAULT_POLL_MS, opts.json);
701
1058
  } catch (err) {
702
- // Same rule as `research`: only a failed *submit* marks the task failed.
703
- if (id) {
704
- const t = store.getTask(id);
705
- if (!t || !t.interaction_id) store.setStatus(id, 'failed', { error: err.message });
706
- }
1059
+ markSubmitFailure(id, err);
707
1060
  die(err);
708
1061
  }
709
1062
  });
@@ -861,9 +1214,10 @@ async function watchTask(task, intervalMs, json) {
861
1214
  last = r.status;
862
1215
  }
863
1216
  if (isSuccess(r.status)) {
864
- emit(json, { id: task.id, status: r.status, result: r.text, citations: r.citations || null }, () =>
865
- console.log(withSources(r.text, r.citations))
866
- );
1217
+ emit(json, resultPayload(task, r.status, r.text, r.citations), () => {
1218
+ console.log(withSources(r.text, r.citations));
1219
+ if (task.kind === 'plan') console.error(planFooter(task));
1220
+ });
867
1221
  return;
868
1222
  }
869
1223
  if (isDone(r.status)) {
@@ -891,9 +1245,10 @@ program
891
1245
  // empty-text completion is served instead of re-polled (and lost at 24h).
892
1246
  if (isSuccess(task.status) && task.result != null) {
893
1247
  const cits = parseCitations(task.citations);
894
- emit(opts.json, { id: task.id, status: task.status, result: task.result, citations: cits }, () =>
895
- console.log(withSources(task.result, cits))
896
- );
1248
+ emit(opts.json, resultPayload(task, task.status, task.result, cits), () => {
1249
+ console.log(withSources(task.result, cits));
1250
+ if (task.kind === 'plan') console.error(planFooter(task));
1251
+ });
897
1252
  return;
898
1253
  }
899
1254
  if (opts.interval != null && (!Number.isFinite(opts.interval) || opts.interval <= 0)) {
@@ -983,20 +1338,44 @@ program
983
1338
  program
984
1339
  .command('stats')
985
1340
  .option('--json', 'machine-readable output')
986
- .description('where the store lives and what is in it')
1341
+ .description('where the store lives, what is in it, and what the agent runs have plausibly cost')
987
1342
  .action((opts) => {
988
1343
  const rows = store.counts();
989
1344
  const agents = store.agentCounts();
1345
+ const kinds = store.kindCounts();
990
1346
  const total = rows.reduce((n, r) => n + r.n, 0);
991
- emit(opts.json, { db: store.DB_PATH, total, by_status: rows, by_agent: agents }, () => {
992
- console.log(`Store: ${store.DB_PATH}`);
993
- console.log(`Tasks: ${total}`);
994
- for (const r of rows) console.log(` ${colorStatus(r.status).padEnd(useColor ? 26 : 17)} ${r.n}`);
995
- if (agents.length) {
996
- console.log('Agent runs:');
997
- for (const a of agents) console.log(` ${a.agent.padEnd(34)} ${a.n}`);
1347
+ // Priced from the runs that actually reached the server, not from every
1348
+ // attempt: a submit that failed before it left the machine cost nothing.
1349
+ const spend = estimatedSpend(store.billedAgentCounts());
1350
+ emit(
1351
+ opts.json,
1352
+ { db: store.DB_PATH, total, by_status: rows, by_agent: agents, by_kind: kinds, estimated_spend: spend },
1353
+ () => {
1354
+ console.log(`Store: ${store.DB_PATH}`);
1355
+ console.log(`Tasks: ${total}`);
1356
+ for (const r of rows) console.log(` ${colorStatus(r.status).padEnd(useColor ? 26 : 17)} ${r.n}`);
1357
+ if (agents.length) {
1358
+ console.log('Agent runs:');
1359
+ for (const a of agents) console.log(` ${a.agent.padEnd(34)} ${a.n}`);
1360
+ }
1361
+ if (kinds.length) {
1362
+ console.log(`Plan chains: ${kinds.map((k) => `${k.n} ${k.kind}`).join(', ')}`);
1363
+ }
1364
+ if (spend && !spend.tasks) {
1365
+ console.log(
1366
+ `Estimated spend: unknown for ${spend.unpriced} agent task(s) on an agent with no published price band.`
1367
+ );
1368
+ } else if (spend) {
1369
+ const rest = spend.unpriced
1370
+ ? `, plus ${spend.unpriced} on an agent with no published band`
1371
+ : '';
1372
+ console.log(
1373
+ `Estimated spend: $${spend.low.toFixed(2)}–$${spend.high.toFixed(2)} across ${spend.tasks} billed task(s)` +
1374
+ ` (preview rates, subject to change)${rest}.`
1375
+ );
1376
+ }
998
1377
  }
999
- });
1378
+ );
1000
1379
  });
1001
1380
 
1002
1381
  // Close the store on the way out so a one-shot command doesn't leave the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gemcatch",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Fire-and-forget CLI for Gemini's Interactions API background execution. Submit long-running research prompts, close your laptop, collect results later.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -51,7 +51,7 @@
51
51
  "commander": "^15.0.0"
52
52
  },
53
53
  "allowScripts": {
54
- "@google/genai@2.15.0": true,
54
+ "@google/genai@2.16.0": true,
55
55
  "protobufjs@7.6.5": true
56
56
  }
57
57
  }