codedeck 0.2.0 → 0.4.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.
Files changed (125) hide show
  1. package/README.md +45 -16
  2. package/dist/cli/charts/bar-chart.js +104 -0
  3. package/dist/cli/charts/bar-chart.js.map +1 -0
  4. package/dist/cli/charts/sparkline.js +18 -0
  5. package/dist/cli/charts/sparkline.js.map +1 -0
  6. package/dist/cli/charts/stacked-bar.js +59 -0
  7. package/dist/cli/charts/stacked-bar.js.map +1 -0
  8. package/dist/cli/cli-name.js +20 -0
  9. package/dist/cli/cli-name.js.map +1 -0
  10. package/dist/cli/commands/claims.js +170 -0
  11. package/dist/cli/commands/claims.js.map +1 -0
  12. package/dist/cli/commands/doctor.js +22 -1
  13. package/dist/cli/commands/doctor.js.map +1 -1
  14. package/dist/cli/commands/models.js +5 -5
  15. package/dist/cli/commands/models.js.map +1 -1
  16. package/dist/cli/commands/open.js +309 -315
  17. package/dist/cli/commands/open.js.map +1 -1
  18. package/dist/cli/commands/ps.js +226 -23
  19. package/dist/cli/commands/ps.js.map +1 -1
  20. package/dist/cli/commands/rename.js +29 -0
  21. package/dist/cli/commands/rename.js.map +1 -0
  22. package/dist/cli/commands/run.js +50 -18
  23. package/dist/cli/commands/run.js.map +1 -1
  24. package/dist/cli/commands/setup.js +989 -72
  25. package/dist/cli/commands/setup.js.map +1 -1
  26. package/dist/cli/commands/usage.js +192 -0
  27. package/dist/cli/commands/usage.js.map +1 -0
  28. package/dist/cli/commands/wait.js +3 -2
  29. package/dist/cli/commands/wait.js.map +1 -1
  30. package/dist/cli/commands/web.js +224 -0
  31. package/dist/cli/commands/web.js.map +1 -0
  32. package/dist/cli/index.js +36 -21
  33. package/dist/cli/index.js.map +1 -1
  34. package/dist/cli/picker-state.js +75 -8
  35. package/dist/cli/picker-state.js.map +1 -1
  36. package/dist/cli/picker.js +13 -3
  37. package/dist/cli/picker.js.map +1 -1
  38. package/dist/cli/ui.js +54 -4
  39. package/dist/cli/ui.js.map +1 -1
  40. package/dist/cli/usage/dashboard.js +330 -0
  41. package/dist/cli/usage/dashboard.js.map +1 -0
  42. package/dist/cli/usage/snapshot.js +112 -0
  43. package/dist/cli/usage/snapshot.js.map +1 -0
  44. package/dist/config/config.js +326 -5
  45. package/dist/config/config.js.map +1 -1
  46. package/dist/config/orchestrator-mode.js +72 -0
  47. package/dist/config/orchestrator-mode.js.map +1 -0
  48. package/dist/config/paths.js +28 -9
  49. package/dist/config/paths.js.map +1 -1
  50. package/dist/core/autocompact.js +77 -0
  51. package/dist/core/autocompact.js.map +1 -0
  52. package/dist/core/driver.js.map +1 -1
  53. package/dist/core/models.js +166 -3
  54. package/dist/core/models.js.map +1 -1
  55. package/dist/core/pricing.js +83 -0
  56. package/dist/core/pricing.js.map +1 -0
  57. package/dist/core/roles.js +14 -3
  58. package/dist/core/roles.js.map +1 -1
  59. package/dist/core/run-usage.js +46 -0
  60. package/dist/core/run-usage.js.map +1 -0
  61. package/dist/core/session.js +18 -0
  62. package/dist/core/session.js.map +1 -1
  63. package/dist/daemon/daemon.js +286 -8
  64. package/dist/daemon/daemon.js.map +1 -1
  65. package/dist/daemon/ipc.js +10 -3
  66. package/dist/daemon/ipc.js.map +1 -1
  67. package/dist/drivers/antigravity/driver.js +158 -0
  68. package/dist/drivers/antigravity/driver.js.map +1 -0
  69. package/dist/drivers/antigravity/parser.js +168 -0
  70. package/dist/drivers/antigravity/parser.js.map +1 -0
  71. package/dist/drivers/claude/driver.js +13 -3
  72. package/dist/drivers/claude/driver.js.map +1 -1
  73. package/dist/drivers/opencode/driver.js +1 -114
  74. package/dist/drivers/opencode/driver.js.map +1 -1
  75. package/dist/drivers/opencode/parser.js +86 -0
  76. package/dist/drivers/opencode/parser.js.map +1 -0
  77. package/dist/drivers/registry.js +2 -0
  78. package/dist/drivers/registry.js.map +1 -1
  79. package/dist/drivers/session-driver.js +6 -4
  80. package/dist/drivers/session-driver.js.map +1 -1
  81. package/dist/drivers/session-runtime.js +1 -0
  82. package/dist/drivers/session-runtime.js.map +1 -1
  83. package/dist/git/worktree.js +1 -1
  84. package/dist/git/worktree.js.map +1 -1
  85. package/dist/open/contract.js +116 -0
  86. package/dist/open/contract.js.map +1 -0
  87. package/dist/open/injection.js +60 -0
  88. package/dist/open/injection.js.map +1 -0
  89. package/dist/open/launchers/claude.js +215 -0
  90. package/dist/open/launchers/claude.js.map +1 -0
  91. package/dist/open/launchers/opencode.js +213 -0
  92. package/dist/open/launchers/opencode.js.map +1 -0
  93. package/dist/open/orchestrator-prose.js +12 -0
  94. package/dist/open/orchestrator-prose.js.map +1 -0
  95. package/dist/open/pty.js +281 -0
  96. package/dist/open/pty.js.map +1 -0
  97. package/dist/open/runtime.js +632 -0
  98. package/dist/open/runtime.js.map +1 -0
  99. package/dist/plugin/agents/auditor.md +7 -1
  100. package/dist/plugin/agents/general.md +12 -1
  101. package/dist/plugin/agents/orchestrator-edit.md +67 -0
  102. package/dist/plugin/agents/orchestrator-read.md +67 -0
  103. package/dist/plugin/agents/orchestrator.md +74 -17
  104. package/dist/plugin/agents/reviewer.md +3 -0
  105. package/dist/plugin/commands/autonomous.md +58 -0
  106. package/dist/plugin/hooks/hooks.json +24 -0
  107. package/dist/plugin/hooks/session-id.sh +23 -0
  108. package/dist/plugin/hooks/session-name-lib.mjs +40 -0
  109. package/dist/plugin/hooks/session-name-worker.mjs +124 -0
  110. package/dist/plugin/hooks/session-name.mjs +66 -0
  111. package/dist/plugin/hooks/session-name.sh +7 -0
  112. package/dist/plugin/pty-shim.mjs +114 -0
  113. package/dist/plugin/statusline.sh +214 -22
  114. package/dist/plugin/themes/codedeck-rage.json +74 -0
  115. package/dist/plugin/themes/codedeck-ultra.json +52 -44
  116. package/dist/store/claims.js +152 -0
  117. package/dist/store/claims.js.map +1 -0
  118. package/dist/store/database.js +50 -15
  119. package/dist/store/database.js.map +1 -1
  120. package/dist/store/sessions.js +234 -3
  121. package/dist/store/sessions.js.map +1 -1
  122. package/dist/web/canvas-page.js +921 -0
  123. package/dist/web/canvas-page.js.map +1 -0
  124. package/package.json +3 -2
  125. package/dist/plugin/settings.json +0 -7
@@ -1,106 +1,142 @@
1
- import { execFile, spawn } from "node:child_process";
1
+ import { execFileSync, spawn } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
- import { promisify } from "node:util";
5
4
  import * as readline from "node:readline";
6
- import { constants } from "node:os";
7
5
  import { IpcClient } from "../../daemon/ipc.js";
8
- import { loadConfig, resolveModel } from "../../config/config.js";
6
+ import { loadConfig, resolveRoleBinding, resolveOrchestratorMode, } from "../../config/config.js";
7
+ import { parseAutocompact } from "../../core/autocompact.js";
8
+ import { harnessInjection } from "../../open/injection.js";
9
+ import { getGitInfo } from "../../git/repository.js";
10
+ import { createWorktree } from "../../git/worktree.js";
11
+ import { ptyShimPath } from "../../open/pty.js";
9
12
  import { isInteractiveTerminal } from "./setup.js";
10
- import { renderLogo } from "../ui.js";
11
- import { getRegistry } from "../../drivers/registry.js";
12
- import { detectBinary } from "../../drivers/helpers.js";
13
- import { findClosestModel, getCachedOrDiscoverModels, modelNames, } from "../../core/models.js";
13
+ import { sessionsDir } from "../../open/pty.js";
14
14
  import { ROLES, parseRole, resolvePluginDir } from "../../core/roles.js";
15
+ import { getCliName } from "../cli-name.js";
16
+ import { effectiveModel, resolveOpenModel } from "../../open/contract.js";
17
+ import { buildArgs as buildOpencodeArgs, buildInlineConfig, createEphemeralTuiDir, ensureOpencodeTheme, OPENCODE_NOT_FOUND, preflight as preflightOpencode, removeEphemeralTuiDir, resolveBinary as resolveOpencodeBinary, } from "../../open/launchers/opencode.js";
18
+ import { assertSupport, buildOpenArgs, CLAUDE_NOT_FOUND, DEFAULT_EFFORT, DEFAULT_MODEL, entitlementError, preflightModel, resolveBinary, } from "../../open/launchers/claude.js";
19
+ import { SESSION_ID_PATTERN, assertPluginDirectory, currentWorkingDirectory, finishOpenSession, playBoot, renderBanner, spawnHarness, } from "../../open/runtime.js";
15
20
  export { ROLES, parseRole, resolvePluginDir };
16
- const DEFAULT_MODEL = "claude-opus-4-8";
17
- const DEFAULT_EFFORT = "xhigh";
18
- const PLUGIN_NAME = "codedeck";
19
- const CLAUDE_NOT_FOUND = "Claude Code was not found on PATH. Install Claude Code and ensure `claude` is available.";
20
- const execFileAsync = promisify(execFile);
21
- /**
22
- * statusLine.command is handed to a shell, so an install directory carrying a
23
- * space, a `$`, a backtick or a quote would break the command or inject into
24
- * it. Single quotes take every one of those literally, and the only character
25
- * that can end them is a quote, which is why that one is spliced.
26
- *
27
- * plugin/settings.json quotes the same script with double quotes on purpose and
28
- * must keep doing so: it names the path through ${CLAUDE_PLUGIN_ROOT}, and
29
- * single quotes would stop the expansion instead of protecting it.
30
- */
31
- function shellQuote(value) {
32
- return `'${value.replaceAll("'", `'\\''`)}'`;
33
- }
34
- function settingsArgument(pluginDir, flags) {
35
- if (flags.theme !== false)
36
- return path.join(pluginDir, "settings.json");
37
- // Claude accepts an inline settings JSON value. Keep the status line while
38
- // removing only the theme, without mutating the plugin's shared settings file.
39
- return JSON.stringify({
40
- statusLine: {
41
- type: "command",
42
- command: `bash ${shellQuote(path.join(pluginDir, "statusline.sh"))}`,
43
- },
21
+ export { effectiveModel };
22
+ export { buildOpenArgs, buildSettings, entitlementError, judgeModel, sessionName } from "../../open/launchers/claude.js";
23
+ export { CLAUDE_RESUME_ERASE, SESSION_ID_PATTERN, bootFrame, ensureCodedeckShim, exitCodeFor, finishOpenSession, installSigintGuard, renderBanner, renderExit, resumeHint, sanitizeEnv, spinnerTips, withCodedeckOnPath, writeStdoutSync, } from "../../open/runtime.js";
24
+ const DEFAULT_ROLE = "orchestrator";
25
+ export function launchClaude(claudeBin, model, args, cwd, sessionFile, onClose, spawnChild = spawn, signalHost = process, envExtra, pty, onSpawn) {
26
+ return spawnHarness(claudeBin, args, {
27
+ cwd,
28
+ sessionFile,
29
+ envExtra,
30
+ model,
31
+ notFoundMessage: CLAUDE_NOT_FOUND,
32
+ entitlementError,
33
+ onClose,
34
+ spawnChild,
35
+ signalHost,
36
+ pty,
37
+ onSpawn,
44
38
  });
45
39
  }
46
- export function buildOpenArgs(role, flags, pluginDir, passthrough) {
47
- const args = [
48
- "--model",
49
- flags.model ?? DEFAULT_MODEL,
50
- "--effort",
51
- flags.effort ?? DEFAULT_EFFORT,
52
- ...(flags.bypass !== false ? ["--dangerously-skip-permissions"] : []),
53
- "--plugin-dir",
54
- pluginDir,
55
- "--append-system-prompt-file",
56
- path.join(pluginDir, "ultra.md"),
57
- "--settings",
58
- settingsArgument(pluginDir, flags),
59
- // `--agent` layers on top of Claude's own system prompt rather than
60
- // replacing it, and an agent file with no `tools:` key inherits the whole
61
- // toolset. So `general` carries its contract the same way the others do,
62
- // with nothing taken away.
63
- "--agent",
64
- `${PLUGIN_NAME}:${role}`,
65
- "-n",
66
- `CodeDeck · ${role}`,
67
- ...(flags.resume ? ["--resume", flags.resume] : []),
68
- ...(flags.worktree ? ["-w"] : []),
69
- ...passthrough,
70
- ];
71
- return args;
40
+ /**
41
+ * What CodeDeck may type into this harness, or nothing when the harness has no
42
+ * command worth typing or the caller turned the pty off. Interactive only: a
43
+ * `--print` launch has no TUI to type into.
44
+ */
45
+ export function ptyLaunchForHarness(harness, pluginDir, sessionFile, flags, config, interactive) {
46
+ if (!interactive)
47
+ return undefined;
48
+ if (flags.pty === false || config.pty === false)
49
+ return undefined;
50
+ const rename = harnessInjection(harness).rename;
51
+ if (!rename)
52
+ return undefined;
53
+ return { shim: ptyShimPath(pluginDir), sessionFile, keystrokesForName: rename };
72
54
  }
73
- const MODEL_PREFIX = "--model=";
74
55
  /**
75
- * Claude honours the last --model on the line and the passthrough is appended
76
- * last, so `open --model bad -- --model good` really launches "good". Checking
77
- * anything but the last one grounds a launch that would have worked.
78
- *
79
- * Only the passthrough is scanned, never the built vector. That vector always
80
- * opens with a --model pair, so scanning it could never answer "the passthrough
81
- * overrode nothing", and a bare "--model" swallowed as another flag's value (as
82
- * in `open --resume --model`) would be read as a model of its own.
56
+ * Width of each replacement verb in terminal columns. Keeping this fixed
57
+ * leaves Claude's elapsed-time and token-count fields aligned.
58
+ */
59
+ export const SPINNER_VERB_WIDTH = 12;
60
+ /**
61
+ * Why an agent bound to a harness without a launcher cannot be opened, or
62
+ * nothing. `open` launches claude and opencode sessions; anything else has no
63
+ * launcher, so refusing beats opening a session under a name whose
64
+ * configuration it does not follow.
83
65
  *
84
- * Known limit: a literal "--model" passed as the value of one of Claude's own
85
- * flags still reads as an override. Telling that apart needs Claude's option
86
- * arity, which CodeDeck does not have.
66
+ * The model is deliberately not part of the check. An explicit --model changes
67
+ * which binary runs, never whether it is the right harness.
68
+ */
69
+ export function harnessMismatch(role, binding) {
70
+ if (!binding || binding.harness === "claude" || binding.harness === "opencode") {
71
+ return undefined;
72
+ }
73
+ return (`Agent "${role}" runs on ${binding.harness}, and ${getCliName()} open only launches claude and opencode sessions. ` +
74
+ `Use \`${getCliName()} run --role ${role} "<prompt>"\`, or move it with \`${getCliName()} setup\`.`);
75
+ }
76
+ /**
77
+ * Best-effort snapshot of `opencode session list`, or undefined. Capture
78
+ * is allowed to fail: no snapshot means no resume hint (OP-09), never a
79
+ * failed launch. No daemon, no config writes (OP-10, OP-19).
87
80
  */
88
- export function effectiveModel(passthrough) {
89
- for (let i = passthrough.length - 1; i >= 0; i--) {
90
- const token = passthrough[i];
91
- if (token.startsWith(MODEL_PREFIX))
92
- return token.slice(MODEL_PREFIX.length);
93
- if (i > 0 && passthrough[i - 1] === "--model")
94
- return token;
81
+ export function readOpencodeSessions(bin, cwd) {
82
+ try {
83
+ const parsed = JSON.parse(execFileSync(bin, ["session", "list", "--format", "json", "-n", "50"], {
84
+ encoding: "utf8",
85
+ timeout: 10000,
86
+ maxBuffer: 1024 * 1024,
87
+ ...(cwd !== undefined ? { cwd } : {}),
88
+ }));
89
+ return Array.isArray(parsed) ? parsed : undefined;
90
+ }
91
+ catch {
92
+ return undefined;
95
93
  }
96
94
  }
97
- export function sanitizeEnv(env) {
98
- const sanitized = { ...env };
99
- delete sanitized.CLAUDE_CODE_CHILD_SESSION;
100
- return sanitized;
95
+ /**
96
+ * The id of the session created or continued between snapshots, or
97
+ * undefined. A newly created session takes precedence; if no session was
98
+ * created, a single session whose updated timestamp moved forward is
99
+ * identified. Zero or ambiguous sessions mean "cannot tell", and a
100
+ * missing hint beats a wrong one.
101
+ */
102
+ export function diffOpencodeSession(before, after) {
103
+ const known = new Set(before.map((row) => (typeof row.id === "string" ? row.id : "")));
104
+ const fresh = after.filter((row) => typeof row.id === "string" && !known.has(row.id));
105
+ if (fresh.length === 1)
106
+ return fresh[0].id;
107
+ if (fresh.length > 1)
108
+ return undefined;
109
+ const beforeUpdated = new Map();
110
+ for (const row of before) {
111
+ if (typeof row.id === "string" && typeof row.updated === "number") {
112
+ beforeUpdated.set(row.id, row.updated);
113
+ }
114
+ }
115
+ const touched = after.filter((row) => typeof row.id === "string" &&
116
+ typeof row.updated === "number" &&
117
+ beforeUpdated.has(row.id) &&
118
+ row.updated > (beforeUpdated.get(row.id) ?? 0));
119
+ if (touched.length === 1)
120
+ return touched[0].id;
121
+ if (touched.length > 1) {
122
+ touched.sort((a, b) => b.updated - a.updated);
123
+ if (touched[0].updated > touched[1].updated) {
124
+ return touched[0].id;
125
+ }
126
+ }
127
+ return undefined;
101
128
  }
102
- export function renderBanner(role, model, effort) {
103
- return `${renderLogo(`${role} · ${model} · ${effort}`)}\n`;
129
+ /**
130
+ * The one dispatch decision: the binding owns the harness, and an unbound
131
+ * role opens claude like it always did. Anything else throws instead of
132
+ * rounding down to the wrong session.
133
+ */
134
+ export function launcherFor(role, binding) {
135
+ const harness = binding?.harness ?? "claude";
136
+ if (harness === "claude" || harness === "opencode")
137
+ return harness;
138
+ const mismatch = harnessMismatch(role, binding);
139
+ throw new Error(mismatch ?? `Agent "${role}" runs on ${harness}, which ${getCliName()} open does not launch.`);
104
140
  }
105
141
  function selectRole() {
106
142
  const rl = readline.createInterface({
@@ -128,8 +164,8 @@ function selectRole() {
128
164
  reject(new Error("Role selection was interrupted; nothing was launched."));
129
165
  });
130
166
  const ask = () => {
131
- rl.question(`Role [general] (${ROLES.join("/")}): `, (answer) => {
132
- const role = parseRole(answer || "general");
167
+ rl.question(`Role [${DEFAULT_ROLE}] (${ROLES.join("/")}): `, (answer) => {
168
+ const role = parseRole(answer || DEFAULT_ROLE);
133
169
  if (role) {
134
170
  finish(role);
135
171
  return;
@@ -149,7 +185,7 @@ function selectRole() {
149
185
  export function isNonInteractiveLaunch(passthrough) {
150
186
  return passthrough.some((arg) => arg === "-p" || arg === "--print");
151
187
  }
152
- function resolveRole(input, interactive) {
188
+ export function resolveRole(input, interactive) {
153
189
  if (input !== undefined) {
154
190
  const role = parseRole(input);
155
191
  if (!role) {
@@ -158,7 +194,7 @@ function resolveRole(input, interactive) {
158
194
  return Promise.resolve(role);
159
195
  }
160
196
  if (!interactive || !isInteractiveTerminal())
161
- return Promise.resolve("general");
197
+ return Promise.resolve(DEFAULT_ROLE);
162
198
  return selectRole();
163
199
  }
164
200
  /**
@@ -204,13 +240,13 @@ export function scanOptions(tokens, command) {
204
240
  const name = separator >= 0 ? token.slice(0, separator) : token;
205
241
  const wantsValue = takesValue.get(name);
206
242
  if (wantsValue === undefined) {
207
- throw new Error(`Unknown option "${name}" for codedeck open. Options for Claude go after "--".`);
243
+ throw new Error(`Unknown option "${name}" for ${getCliName()} open. Options for Claude go after "--".`);
208
244
  }
209
245
  // Commander honours "=" only on options declared with a value and drops the
210
246
  // whole token otherwise, so `--no-bypass=false` would read as accepted and
211
247
  // launch with the bypass still on.
212
248
  if (separator >= 0 && !wantsValue) {
213
- throw new Error(`Option "${name}" for codedeck open takes no value.`);
249
+ throw new Error(`Option "${name}" for ${getCliName()} open takes no value.`);
214
250
  }
215
251
  // The next token belongs to this option even when it looks like a flag,
216
252
  // which is what keeps `--model -weird` from reading as an unknown option.
@@ -250,225 +286,23 @@ function getInvocation(command, roleArg) {
250
286
  }
251
287
  return { roleInput: roleArg, passthrough: [] };
252
288
  }
253
- function assertPluginDirectory(pluginDir) {
254
- try {
255
- if (fs.statSync(pluginDir).isDirectory())
256
- return;
257
- }
258
- catch { }
259
- throw new Error(`CodeDeck plugin directory not found at ${pluginDir}. Rebuild or reinstall CodeDeck before running open.`);
260
- }
261
- function currentWorkingDirectory() {
262
- try {
263
- const cwd = process.cwd();
264
- if (fs.statSync(cwd).isDirectory())
265
- return cwd;
266
- }
267
- catch { }
268
- throw new Error("Cannot launch Claude Code: the current working directory no longer exists.");
269
- }
270
- function errorDetails(error) {
271
- if (typeof error !== "object" || error === null)
272
- return { text: String(error) };
273
- const value = error;
274
- const text = [value.stderr, value.stdout, value.message]
275
- .filter((part) => typeof part === "string" && part.length > 0)
276
- .join("\n");
277
- return {
278
- code: typeof value.code === "string" ? value.code : undefined,
279
- text: text || String(error),
280
- };
281
- }
282
- /**
283
- * What the catalog can say about a model. Reachable is not the same as allowed:
284
- * the catalog lists what Claude Code knows about, never what this account may
285
- * use, so an entitlement problem only surfaces at launch (see launchClaude).
286
- */
287
- const catalogWarning = (model, state) => `Warning: Claude model catalog is ${state}; continuing with "${model}".`;
288
- /**
289
- * Pure so all three outcomes are testable without touching the disk cache or
290
- * spawning a harness.
291
- */
292
- export function judgeModel(model, catalogs, fromConfig) {
293
- const keepGoing = (reason) => ({
294
- kind: "unknown-catalog",
295
- warning: catalogWarning(model, `unavailable${reason}`),
296
- });
297
- const catalog = catalogs?.find((item) => item.agent === "claude");
298
- if (!catalog || !catalog.available || catalog.error) {
299
- return keepGoing(catalog?.error ? ` (${catalog.error})` : "");
300
- }
301
- const candidates = modelNames(catalog);
302
- if (candidates.length === 0) {
303
- return { kind: "unknown-catalog", warning: catalogWarning(model, "empty") };
304
- }
305
- if (candidates.includes(model))
306
- return { kind: "ok" };
307
- const suggestion = findClosestModel(model, candidates);
308
- const hint = suggestion ? ` Did you mean "${suggestion}"?` : " No close model was found.";
309
- // A model can leave the catalog on its own, with nobody having typed it
310
- // wrong, and `needsModelSetup` never asks again, so the way out has to be
311
- // spelled out.
312
- const recovery = fromConfig ? " Run `codedeck setup` to pick another." : "";
313
- return { kind: "rejected", error: `Model "${model}" is not in the Claude catalog.${hint}${recovery}` };
314
- }
315
- async function preflightModel(model, fromConfig) {
316
- let catalogs;
317
- try {
318
- catalogs = await getCachedOrDiscoverModels(getRegistry(), { agent: "claude" });
319
- }
320
- catch (error) {
321
- // A catalog that cannot be reached is not evidence against the model, so
322
- // this warns and lets the launch decide.
323
- const details = errorDetails(error);
324
- console.warn(catalogWarning(model, `unavailable (${details.text})`));
325
- return;
326
- }
327
- const verdict = judgeModel(model, catalogs, fromConfig);
328
- if (verdict.kind === "ok")
329
- return;
330
- if (verdict.kind === "unknown-catalog") {
331
- console.warn(verdict.warning);
332
- return;
333
- }
334
- throw new Error(verdict.error);
335
- }
336
- /**
337
- * Returns the resolved path rather than a boolean so everything downstream
338
- * launches the exact binary that was checked, instead of asking PATH again and
339
- * hoping it answers the same way.
340
- */
341
- async function resolveClaudeBinary() {
342
- // detectBinary reports failure in its result and never rejects, so there is
343
- // nothing here to catch.
344
- const installation = await detectBinary("claude");
345
- if (!installation.installed || !installation.path) {
346
- throw new Error(CLAUDE_NOT_FOUND);
347
- }
348
- return installation.path;
349
- }
350
- async function assertSystemPromptFlagSupported(claudeBin, cwd) {
351
- try {
352
- await execFileAsync(claudeBin, ["--append-system-prompt-file"], {
353
- cwd,
354
- env: sanitizeEnv(process.env),
355
- timeout: 5000,
356
- maxBuffer: 1024 * 1024,
357
- });
358
- }
359
- catch (error) {
360
- const details = errorDetails(error);
361
- if (details.code === "ENOENT") {
362
- throw new Error(CLAUDE_NOT_FOUND);
363
- }
364
- if (/unknown option|unknown argument|unrecognized option|invalid option/i.test(details.text)) {
365
- throw new Error("This Claude Code version does not support --append-system-prompt-file. Upgrade Claude Code and retry.");
366
- }
367
- // A supported Commander option reports a missing argument for this probe.
368
- // Other probe failures are left to the real launch, which can provide the
369
- // harness-specific diagnostic without blocking a valid installation.
370
- }
371
- }
372
- /**
373
- * Only stderr is piped, and only because Claude Code reports an unusable model
374
- * there as a tagged line rather than a distinct exit code. stdout stays
375
- * inherited so the TUI paints straight to the terminal, which also means
376
- * `child.stdout` is null and there is nothing to relay.
377
- */
378
- function relayStderr(child, output) {
379
- const stream = child.stderr;
380
- if (!stream)
381
- return;
382
- stream.setEncoding?.("utf8");
383
- stream.on("data", (chunk) => {
384
- output.value += typeof chunk === "string" ? chunk : chunk.toString();
385
- process.stderr.write(chunk);
386
- });
387
- }
388
- /**
389
- * A signalled death carries no exit code, and collapsing it to 1 tells a caller
390
- * the session failed rather than that it was killed. Shells report this as
391
- * 128 plus the signal number, so Ctrl+C stays 130 and a SIGKILL stays 137.
392
- */
393
- export function exitCodeFor(code, signal) {
394
- if (code !== null)
395
- return code;
396
- if (!signal)
397
- return 1;
398
- const number = constants.signals[signal];
399
- return typeof number === "number" ? 128 + number : 1;
400
- }
401
- /**
402
- * The catalog knows which models exist, not which ones this account may use, so
403
- * an entitlement problem can only be read off the launch itself. Claude Code
404
- * reports it as a tagged error rather than a distinct exit code.
405
- */
406
- export function entitlementError(model, output) {
407
- const tag = /\[claude-code:unrecognized_model\]\s*(\{.*\})?/i.exec(output);
408
- if (!tag)
409
- return undefined;
410
- // The passthrough can carry its own --model, which wins over the one CodeDeck
411
- // resolved, so the name in the payload is the one that was actually rejected.
412
- let rejected = model;
413
- if (tag[1]) {
414
- try {
415
- const payload = JSON.parse(tag[1]);
416
- if (typeof payload.model === "string" && payload.model)
417
- rejected = payload.model;
418
- }
419
- catch { }
420
- }
421
- return `Claude Code rejected model "${rejected}" because this account is not entitled to it. Check the Claude plan or model access for the account.`;
422
- }
423
- function launchClaude(claudeBin, model, args, cwd) {
424
- return new Promise((resolve, reject) => {
425
- let settled = false;
426
- const output = { value: "" };
427
- const child = spawn(claudeBin, args, {
428
- cwd,
429
- env: sanitizeEnv(process.env),
430
- stdio: ["inherit", "inherit", "pipe"],
431
- });
432
- relayStderr(child, output);
433
- child.once("error", (error) => {
434
- if (settled)
435
- return;
436
- settled = true;
437
- const details = errorDetails(error);
438
- if (details.code === "ENOENT") {
439
- reject(new Error(CLAUDE_NOT_FOUND));
440
- }
441
- else {
442
- reject(error);
443
- }
444
- });
445
- child.once("close", (code, signal) => {
446
- if (settled)
447
- return;
448
- settled = true;
449
- const entitlement = entitlementError(model, output.value);
450
- if (entitlement) {
451
- reject(new Error(entitlement));
452
- return;
453
- }
454
- process.exitCode = exitCodeFor(code, signal);
455
- resolve();
456
- });
457
- });
458
- }
459
289
  export function registerOpenCommand(program) {
460
290
  program
461
291
  .command("open [role]")
462
- .description("Open a configured Claude Code session")
292
+ .description(`Open a configured Claude Code session (roles: ${ROLES.join(" | ")}, default: ${DEFAULT_ROLE}, 3-letter prefixes accepted)`)
463
293
  .option("--model <model>", `model to use (default: ${DEFAULT_MODEL})`)
464
294
  .option("--effort <level>", `reasoning effort (default: ${DEFAULT_EFFORT})`)
465
- .option("--resume <session>", "resume a Claude Code session")
295
+ .option("--autocompact [value]", "native auto-compact: Claude window size is auto or 100000-1000000 tokens; OpenCode toggles its native setting")
296
+ .option("--no-autocompact", "disable native auto-compaction in Claude and OpenCode")
297
+ .option("--resume <session>", "resume an interactive session")
466
298
  .option("--worktree", "ask Claude Code to create an isolated worktree")
467
299
  .option("--no-bypass", "do not skip Claude Code permission prompts")
468
- .option("--no-theme", "keep the CodeDeck status line without applying its theme")
300
+ .option("--no-theme", "keep only the CodeDeck status line, without the theme or the renderer")
301
+ .option("--no-pty", "do not run the session under a pty, which also drops the automatic rename")
469
302
  .allowUnknownOption()
470
303
  .action(async (roleArg, opts, command) => {
471
304
  const invocation = getInvocation(command, roleArg);
305
+ const autocompact = parseAutocompact(opts.autocompact);
472
306
  const interactive = !isNonInteractiveLaunch(invocation.passthrough);
473
307
  const role = await resolveRole(invocation.roleInput, interactive);
474
308
  const pluginDir = resolvePluginDir();
@@ -480,19 +314,179 @@ export function registerOpenCommand(program) {
480
314
  // wrong question to greet someone with, and `codedeck setup` is the place
481
315
  // to answer it deliberately.
482
316
  const config = loadConfig();
483
- const resolved = resolveModel("claude", opts.model, config) ?? DEFAULT_MODEL;
484
- const args = buildOpenArgs(role, { ...opts, model: resolved }, pluginDir, invocation.passthrough);
485
- const model = effectiveModel(invocation.passthrough) ?? resolved;
486
- // It came from config only when nobody typed a model just now, neither by
487
- // flag nor by passthrough, and the config actually had one.
488
- const fromConfig = opts.model === undefined &&
489
- effectiveModel(invocation.passthrough) === undefined &&
490
- resolveModel("claude", undefined, config) !== undefined;
491
- await preflightModel(model, fromConfig);
492
- const claudeBin = await resolveClaudeBinary();
493
- await assertSystemPromptFlagSupported(claudeBin, cwd);
494
- process.stdout.write(renderBanner(role, model, opts.effort ?? DEFAULT_EFFORT));
495
- await launchClaude(claudeBin, model, args, cwd);
317
+ const orchestratorMode = resolveOrchestratorMode(config);
318
+ const binding = resolveRoleBinding(role, config);
319
+ const launcher = launcherFor(role, binding);
320
+ const passthroughModel = effectiveModel(invocation.passthrough);
321
+ const { model: boundModel, fromConfig } = resolveOpenModel(role, { model: opts.model, passthroughModel }, config);
322
+ // Not the shared temp directory: `open` types the name it finds beside
323
+ // this file into a live session, and on Linux anyone on the box can put
324
+ // a file in /tmp under a name that is only a pid.
325
+ const sessionFile = path.join(sessionsDir(), `codedeck-session-${process.pid}`);
326
+ if (launcher === "opencode" && boundModel === undefined) {
327
+ throw new Error(`Agent "${role}" has no model bound. Run \`${getCliName()} setup\` to bind one.`);
328
+ }
329
+ const initialModel = passthroughModel ?? boundModel ?? (launcher === "opencode" ? undefined : DEFAULT_MODEL);
330
+ const adoptRes = await client.request("session.adopt", {
331
+ agent: launcher,
332
+ model: initialModel,
333
+ cwd,
334
+ name: role,
335
+ });
336
+ const runId = adoptRes.session.id;
337
+ let patchPromise;
338
+ try {
339
+ if (launcher === "opencode") {
340
+ let openCwd = cwd;
341
+ let wtInfo;
342
+ if (opts.worktree) {
343
+ const gitInfo = await getGitInfo(cwd);
344
+ if (!gitInfo) {
345
+ throw new Error(`--worktree needs a git repository, and the current directory is outside one.`);
346
+ }
347
+ // A creation failure propagates before the spawn: OP-14 would
348
+ // rather fail than open in the wrong cwd.
349
+ const wt = await createWorktree({
350
+ repoRoot: gitInfo.root,
351
+ sessionId: runId,
352
+ prompt: role,
353
+ name: role,
354
+ });
355
+ wtInfo = wt;
356
+ openCwd = wt.path;
357
+ }
358
+ const model = passthroughModel ?? boundModel;
359
+ await preflightOpencode(model, fromConfig);
360
+ const opencodeBin = await resolveOpencodeBinary();
361
+ // No opencode effort reader (probe-effort-2026-09-07): an explicit
362
+ // flag warns instead of dying silently, and the banner keeps
363
+ // naming "default" (OP-16, OP-23).
364
+ const effort = "default";
365
+ if (opts.effort !== undefined) {
366
+ console.error('Warning: --effort has no effect on opencode (no mapped reader); continuing with "default".');
367
+ }
368
+ // --no-theme asks for no CodeDeck styling, and an animation is styling.
369
+ if (opts.theme === false) {
370
+ process.stdout.write(renderBanner(role, model, effort));
371
+ }
372
+ else {
373
+ await playBoot(role, model, effort);
374
+ }
375
+ // --no-theme leaves the user's own opencode theme alone. Otherwise the
376
+ // managed rage theme is ensured once and selected through an ephemeral
377
+ // config dir, so the user's tui.json is never rewritten.
378
+ const tuiDir = opts.theme === false || !ensureOpencodeTheme(pluginDir)
379
+ ? undefined
380
+ : createEphemeralTuiDir();
381
+ // Snapshot before the spawn so the close path can tell which
382
+ // session this launch created or continued (probe-session-id-2026-09-07).
383
+ const sessionsBefore = readOpencodeSessions(opencodeBin, openCwd);
384
+ const closeOpencode = async () => {
385
+ if (tuiDir !== undefined)
386
+ removeEphemeralTuiDir(tuiDir);
387
+ const after = readOpencodeSessions(opencodeBin, openCwd);
388
+ const diffedId = after === undefined || sessionsBefore === undefined
389
+ ? undefined
390
+ : diffOpencodeSession(sessionsBefore, after);
391
+ const id = diffedId ?? (opts.resume && SESSION_ID_PATTERN.test(opts.resume) ? opts.resume : undefined);
392
+ if (id !== undefined) {
393
+ try {
394
+ fs.writeFileSync(sessionFile, id);
395
+ }
396
+ catch { }
397
+ }
398
+ const nativeSessionId = finishOpenSession(role, sessionFile);
399
+ try {
400
+ if (patchPromise)
401
+ await patchPromise;
402
+ }
403
+ catch { }
404
+ try {
405
+ await client.request("session.release", {
406
+ id: runId,
407
+ nativeSessionId,
408
+ });
409
+ }
410
+ catch { }
411
+ };
412
+ await spawnHarness(opencodeBin, buildOpencodeArgs(role, { ...opts, model: boundModel }, invocation.passthrough), {
413
+ cwd: openCwd,
414
+ envExtra: {
415
+ CODEDECK_RUN_ID: runId,
416
+ OPENCODE_CONFIG_CONTENT: buildInlineConfig(pluginDir, role, orchestratorMode, { config, explicit: autocompact }),
417
+ ...(tuiDir !== undefined ? { OPENCODE_CONFIG_DIR: tuiDir } : {}),
418
+ },
419
+ sessionFile,
420
+ model,
421
+ notFoundMessage: OPENCODE_NOT_FOUND,
422
+ onClose: closeOpencode,
423
+ onSpawn: (child) => {
424
+ if (child.pid) {
425
+ patchPromise = client.request("session.patch", {
426
+ id: runId,
427
+ pid: child.pid,
428
+ ...(wtInfo ? { worktree: wtInfo.path, branch: wtInfo.branch, baseCommit: wtInfo.baseCommit ?? undefined, cwd: wtInfo.path } : {}),
429
+ });
430
+ }
431
+ },
432
+ });
433
+ return;
434
+ }
435
+ const resolved = boundModel ?? DEFAULT_MODEL;
436
+ const args = buildOpenArgs(role, { ...opts, model: resolved, remoteControl: config.remoteControl, autocompact }, pluginDir, invocation.passthrough, cwd, orchestratorMode, config);
437
+ const model = passthroughModel ?? resolved;
438
+ await preflightModel(model, fromConfig);
439
+ const claudeBin = await resolveBinary();
440
+ await assertSupport(claudeBin, cwd);
441
+ const effort = opts.effort ?? DEFAULT_EFFORT;
442
+ // --no-theme asks for no CodeDeck styling, and an animation is styling.
443
+ if (opts.theme === false) {
444
+ process.stdout.write(renderBanner(role, model, effort));
445
+ }
446
+ else {
447
+ await playBoot(role, model, effort);
448
+ }
449
+ const closeClaude = async () => {
450
+ if (!fs.existsSync(sessionFile) && opts.resume && SESSION_ID_PATTERN.test(opts.resume)) {
451
+ try {
452
+ fs.writeFileSync(sessionFile, opts.resume);
453
+ }
454
+ catch { }
455
+ }
456
+ const nativeSessionId = finishOpenSession(role, sessionFile);
457
+ try {
458
+ if (patchPromise)
459
+ await patchPromise;
460
+ }
461
+ catch { }
462
+ try {
463
+ await client.request("session.release", {
464
+ id: runId,
465
+ nativeSessionId,
466
+ });
467
+ }
468
+ catch { }
469
+ };
470
+ await launchClaude(claudeBin, model, args, cwd, sessionFile, closeClaude, undefined, undefined, { CODEDECK_RUN_ID: runId }, ptyLaunchForHarness("claude", pluginDir, sessionFile, opts, config, interactive), (child) => {
471
+ if (child.pid) {
472
+ patchPromise = client.request("session.patch", {
473
+ id: runId,
474
+ pid: child.pid,
475
+ });
476
+ }
477
+ });
478
+ }
479
+ catch (err) {
480
+ try {
481
+ await client.request("session.release", {
482
+ id: runId,
483
+ status: "failed",
484
+ error: err instanceof Error ? err.message : String(err),
485
+ });
486
+ }
487
+ catch { }
488
+ throw err;
489
+ }
496
490
  });
497
491
  }
498
492
  //# sourceMappingURL=open.js.map