@generativereality/cctabs 0.1.3 → 0.2.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cctabs",
3
3
  "description": "Claude Code tab manager. Terminal tabs as the UI, no tmux. Claude can orchestrate its own sibling sessions.",
4
- "version": "0.1.3",
4
+ "version": "0.2.0",
5
5
  "author": {
6
6
  "name": "generativereality",
7
7
  "url": "https://cctabs.com"
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@ import { consola } from "consola";
10
10
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
11
11
  //#region package.json
12
12
  var name = "@generativereality/cctabs";
13
- var version = "0.1.3";
13
+ var version = "0.2.0";
14
14
  var description = "Claude Code tab manager. Terminal tabs as the UI, no tmux.";
15
15
  var package_default = {
16
16
  name,
@@ -202,14 +202,17 @@ var WaveAdapter = class {
202
202
  this.jwt = process.env.WAVETERM_JWT ?? "";
203
203
  }
204
204
  blocksList() {
205
+ const wsId = process.env.WAVETERM_WORKSPACEID ?? "";
206
+ const args = [
207
+ "blocks",
208
+ "list",
209
+ "--json",
210
+ "--timeout",
211
+ "15000"
212
+ ];
213
+ if (wsId) args.push("--workspace", wsId);
205
214
  try {
206
- const out = execFileSync("wsh", [
207
- "blocks",
208
- "list",
209
- "--json",
210
- "--timeout",
211
- "15000"
212
- ], { encoding: "utf-8" });
215
+ const out = execFileSync("wsh", args, { encoding: "utf-8" });
213
216
  return JSON.parse(out);
214
217
  } catch {
215
218
  return [];
@@ -325,27 +328,37 @@ var WaveAdapter = class {
325
328
  tabsById.set(b.tabid, arr);
326
329
  }
327
330
  const tabNames = /* @__PURE__ */ new Map();
328
- let workspaces = [];
331
+ let rawWorkspaces = [];
329
332
  try {
330
333
  for (const tabId of tabsById.keys()) {
331
334
  const td = await this.getTab(tabId);
332
335
  tabNames.set(tabId, td.name ?? tabId.slice(0, 8));
333
336
  }
334
- workspaces = await this.workspaceList();
337
+ rawWorkspaces = await this.workspaceList();
335
338
  } catch {} finally {
336
339
  this.closeSocket();
337
340
  }
338
- if (!workspaces.length) {
339
- const wsId = process.env.WAVETERM_WORKSPACEID ?? "";
340
- workspaces = [{
341
- workspacedata: {
342
- oid: wsId,
343
- name: wsId.slice(0, 8) || "default",
344
- tabids: [...tabsById.keys()]
345
- },
346
- windowid: ""
347
- }];
348
- }
341
+ const currentWsId = process.env.WAVETERM_WORKSPACEID ?? "";
342
+ const tabIdsHere = [...tabsById.keys()];
343
+ const existing = rawWorkspaces.find((w) => w.workspacedata.oid === currentWsId);
344
+ const workspaces = [];
345
+ if (currentWsId) workspaces.push({
346
+ workspacedata: {
347
+ oid: currentWsId,
348
+ name: existing?.workspacedata.name ?? (currentWsId.slice(0, 8) || "current"),
349
+ tabids: tabIdsHere
350
+ },
351
+ windowid: existing?.windowid ?? ""
352
+ });
353
+ for (const ws of rawWorkspaces) if (ws.workspacedata.oid !== currentWsId) workspaces.push(ws);
354
+ if (!workspaces.length) workspaces.push({
355
+ workspacedata: {
356
+ oid: "",
357
+ name: "default",
358
+ tabids: tabIdsHere
359
+ },
360
+ windowid: ""
361
+ });
349
362
  return {
350
363
  blocks,
351
364
  tabsById,
@@ -527,6 +540,11 @@ function ensureConfigExists() {
527
540
  }
528
541
  //#endregion
529
542
  //#region src/core/open-session.ts
543
+ function shellQuoteEnv$1(env) {
544
+ const entries = Object.entries(env);
545
+ if (!entries.length) return "";
546
+ return entries.map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(" ") + " ";
547
+ }
530
548
  /** Poll scrollback until a pattern is visible, then return. Rejects on timeout. */
531
549
  async function waitForScrollbackMatch(adapter, blockId, pattern, label, timeoutMs, pollInterval = 1e3) {
532
550
  const deadline = Date.now() + timeoutMs;
@@ -541,7 +559,7 @@ async function waitForScrollbackMatch(adapter, blockId, pattern, label, timeoutM
541
559
  throw new Error(`Timed out waiting for ${label}`);
542
560
  }
543
561
  async function openSession(opts) {
544
- const { tabName, claudeCmd, workspaceQuery, initialPromptFile } = opts;
562
+ const { tabName, claudeCmd, workspaceQuery, initialPromptFile, envVars, modelOverride } = opts;
545
563
  const dir = resolve(opts.dir.replace(/^~/, homedir()));
546
564
  if (!existsSync(dir)) {
547
565
  consola.error(`Directory does not exist: ${dir}`);
@@ -582,7 +600,9 @@ async function openSession(opts) {
582
600
  }
583
601
  const extraFlags = config.claude.flags.join(" ");
584
602
  const namePart = claudeCmd.includes("--resume") ? "" : ` --name ${JSON.stringify(tabName)}`;
585
- const cmd = `cd ${JSON.stringify(dir)} && claude${extraFlags ? " " + extraFlags : ""} ${claudeCmd.replace(/^claude\s*/, "")}${namePart}\r`;
603
+ const modelPart = modelOverride ? ` --model ${JSON.stringify(modelOverride)}` : "";
604
+ const envPrefix = envVars ? shellQuoteEnv$1(envVars) : "";
605
+ const cmd = `cd ${JSON.stringify(dir)} && ${envPrefix}claude${extraFlags ? " " + extraFlags : ""} ${claudeCmd.replace(/^claude\s*/, "")}${namePart}${modelPart}\r`;
586
606
  await adapter.sendInput(blockId, cmd);
587
607
  if (initialPromptFile) {
588
608
  try {
@@ -602,6 +622,179 @@ async function openSession(opts) {
602
622
  return tabId;
603
623
  }
604
624
  //#endregion
625
+ //#region src/core/backends.ts
626
+ /**
627
+ * Backend presets. Each preset resolves to a set of env vars (prepended to the
628
+ * shell command in the new tab) plus a Claude --model name.
629
+ *
630
+ * The default `anthropic` preset is a no-op: no env vars, no --model override —
631
+ * Claude Code uses its built-in API connection.
632
+ *
633
+ * Ollama-backed presets point ANTHROPIC_BASE_URL at Ollama's
634
+ * Anthropic-compatible /v1/messages endpoint (Ollama ≥ 0.14):
635
+ * https://docs.ollama.com/openai
636
+ *
637
+ * The `*-tee` variants route through the local logging proxy on :11500
638
+ * (`npm run ollama-tee` in the motin-scripts repo) for wire-level inspection.
639
+ */
640
+ const OLLAMA_LOCAL = "http://localhost:11434";
641
+ const OLLAMA_TEE = "http://localhost:11500";
642
+ /**
643
+ * For Ollama-backed Claude Code sessions we pin the small/fast/haiku model to
644
+ * the same model. Otherwise Claude Code's background "haiku" calls 404 against
645
+ * Ollama because the haiku tag doesn't exist there.
646
+ */
647
+ function ollamaEnv(baseUrl, model) {
648
+ return {
649
+ ANTHROPIC_BASE_URL: baseUrl,
650
+ ANTHROPIC_AUTH_TOKEN: "ollama",
651
+ ANTHROPIC_API_KEY: "",
652
+ ANTHROPIC_SMALL_FAST_MODEL: model,
653
+ ANTHROPIC_DEFAULT_HAIKU_MODEL: model
654
+ };
655
+ }
656
+ const BUILTIN_BACKENDS = {
657
+ anthropic: {
658
+ env: {},
659
+ model: "",
660
+ description: "Default Anthropic API (no override)"
661
+ },
662
+ kimi: {
663
+ env: ollamaEnv(OLLAMA_LOCAL, "kimi-k2.6:cloud"),
664
+ model: "kimi-k2.6:cloud",
665
+ description: "Kimi K2.6 via Ollama Cloud (Pro)"
666
+ },
667
+ "qwen-cloud": {
668
+ env: ollamaEnv(OLLAMA_LOCAL, "qwen3-coder-next:cloud"),
669
+ model: "qwen3-coder-next:cloud",
670
+ description: "Qwen3 Coder Next via Ollama Cloud"
671
+ },
672
+ "gemma-cloud": {
673
+ env: ollamaEnv(OLLAMA_LOCAL, "gemma4:31b-cloud"),
674
+ model: "gemma4:31b-cloud",
675
+ description: "Gemma4 31B via Ollama Cloud"
676
+ },
677
+ "qwen-local": {
678
+ env: ollamaEnv(OLLAMA_LOCAL, "qwen3-coder:30b"),
679
+ model: "qwen3-coder:30b",
680
+ description: "Qwen3 Coder 30B local (18GB)"
681
+ },
682
+ "qwen-next-local": {
683
+ env: ollamaEnv(OLLAMA_LOCAL, "qwen3-coder-next:q3_K_M"),
684
+ model: "qwen3-coder-next:q3_K_M",
685
+ description: "Qwen3 Coder Next Q3_K_M local (38GB) — needs `ollama create` import"
686
+ },
687
+ "gpt-oss": {
688
+ env: ollamaEnv(OLLAMA_LOCAL, "gpt-oss:20b"),
689
+ model: "gpt-oss:20b",
690
+ description: "gpt-oss 20B local (13GB)"
691
+ },
692
+ llama: {
693
+ env: ollamaEnv(OLLAMA_LOCAL, "llama3.1:8b"),
694
+ model: "llama3.1:8b",
695
+ description: "Llama 3.1 8B local (5GB) — note: garbles on Claude Code's 50k system prompt"
696
+ },
697
+ "gemma-local": {
698
+ env: ollamaEnv(OLLAMA_LOCAL, "gemma4:26b"),
699
+ model: "gemma4:26b",
700
+ description: "Gemma4 26B local (17GB)"
701
+ },
702
+ "kimi-tee": {
703
+ env: ollamaEnv(OLLAMA_TEE, "kimi-k2.6:cloud"),
704
+ model: "kimi-k2.6:cloud",
705
+ description: "Kimi via tee proxy (logs to /tmp/ollama-tee.log)"
706
+ },
707
+ "qwen-cloud-tee": {
708
+ env: ollamaEnv(OLLAMA_TEE, "qwen3-coder-next:cloud"),
709
+ model: "qwen3-coder-next:cloud",
710
+ description: "Qwen Cloud via tee proxy"
711
+ },
712
+ "qwen-next-local-tee": {
713
+ env: ollamaEnv(OLLAMA_TEE, "qwen3-coder-next:q3_K_M"),
714
+ model: "qwen3-coder-next:q3_K_M",
715
+ description: "Qwen Next local Q3 via tee proxy"
716
+ }
717
+ };
718
+ /**
719
+ * Parse a `[backends.<name>]` section from the config TOML. Each section can
720
+ * override env vars and/or model. Format:
721
+ *
722
+ * [backends.my-preset]
723
+ * model = "qwen3-coder-next:cloud"
724
+ * base_url = "http://localhost:11434"
725
+ * auth_token = "ollama" # optional, defaults to "ollama" if base_url is set
726
+ *
727
+ * Or for full control:
728
+ *
729
+ * [backends.my-preset]
730
+ * model = "..."
731
+ * env_ANTHROPIC_BASE_URL = "..."
732
+ * env_ANTHROPIC_AUTH_TOKEN = "..."
733
+ */
734
+ function loadCustomBackends() {
735
+ if (!existsSync(CONFIG_PATH)) return {};
736
+ const text = readFileSync(CONFIG_PATH, "utf-8");
737
+ const sections = {};
738
+ let section = null;
739
+ for (const raw of text.split("\n")) {
740
+ const line = raw.trim();
741
+ if (!line || line.startsWith("#")) continue;
742
+ if (line.startsWith("[") && line.endsWith("]")) {
743
+ section = line.slice(1, -1).trim();
744
+ sections[section] ??= {};
745
+ continue;
746
+ }
747
+ if (section?.startsWith("backends.") && line.includes("=")) {
748
+ const [rawKey, ...rest] = line.split("=");
749
+ const key = rawKey.trim();
750
+ const val = rest.join("=").trim();
751
+ if (val.startsWith("\"") && val.endsWith("\"")) sections[section][key] = val.slice(1, -1);
752
+ }
753
+ }
754
+ const result = {};
755
+ for (const [section, kv] of Object.entries(sections)) {
756
+ if (!section.startsWith("backends.")) continue;
757
+ const name = section.slice(9);
758
+ const model = kv.model ?? "";
759
+ const env = {};
760
+ if (kv.base_url) {
761
+ const baseUrl = kv.base_url;
762
+ const token = kv.auth_token ?? "ollama";
763
+ Object.assign(env, {
764
+ ANTHROPIC_BASE_URL: baseUrl,
765
+ ANTHROPIC_AUTH_TOKEN: token,
766
+ ANTHROPIC_API_KEY: ""
767
+ });
768
+ if (model) {
769
+ env.ANTHROPIC_SMALL_FAST_MODEL = model;
770
+ env.ANTHROPIC_DEFAULT_HAIKU_MODEL = model;
771
+ }
772
+ }
773
+ for (const [k, v] of Object.entries(kv)) if (k.startsWith("env_")) env[k.slice(4)] = v;
774
+ result[name] = {
775
+ env,
776
+ model,
777
+ description: kv.description ?? `User-defined preset (${CONFIG_PATH})`
778
+ };
779
+ }
780
+ return result;
781
+ }
782
+ function resolveBackend(name) {
783
+ if (!name) return null;
784
+ return loadCustomBackends()[name] ?? BUILTIN_BACKENDS[name] ?? null;
785
+ }
786
+ function listBackends() {
787
+ const custom = loadCustomBackends();
788
+ const merged = {
789
+ ...BUILTIN_BACKENDS,
790
+ ...custom
791
+ };
792
+ return Object.entries(merged).map(([name, spec]) => ({
793
+ name,
794
+ description: spec.description ?? ""
795
+ }));
796
+ }
797
+ //#endregion
605
798
  //#region src/commands/new.ts
606
799
  const newCommand = define({
607
800
  name: "new",
@@ -634,6 +827,16 @@ const newCommand = define({
634
827
  type: "string",
635
828
  short: "p",
636
829
  description: "Send initial prompt text once Claude is ready"
830
+ },
831
+ backend: {
832
+ type: "string",
833
+ short: "b",
834
+ description: "Backend preset (e.g. kimi, qwen-cloud, qwen-next-local, gpt-oss). Run `cctabs backends` to list."
835
+ },
836
+ model: {
837
+ type: "string",
838
+ short: "m",
839
+ description: "Override the model name (passed as --model to claude). Useful with --backend ollama-local."
637
840
  }
638
841
  },
639
842
  async run(ctx) {
@@ -643,10 +846,24 @@ const newCommand = define({
643
846
  const useWorktree = ctx.values.worktree ?? false;
644
847
  const promptFile = ctx.values.file;
645
848
  const promptText = ctx.values.prompt;
849
+ const backendName = ctx.values.backend;
850
+ const modelOverride = ctx.values.model;
646
851
  if (!name) {
647
852
  consola.error("Tab name is required");
648
853
  process.exit(1);
649
854
  }
855
+ let envVars;
856
+ let resolvedModel = modelOverride;
857
+ if (backendName) {
858
+ const backend = resolveBackend(backendName);
859
+ if (!backend) {
860
+ consola.error(`Unknown backend "${backendName}". Available:`);
861
+ for (const b of listBackends()) consola.log(` ${b.name.padEnd(22)} ${b.description}`);
862
+ process.exit(1);
863
+ }
864
+ envVars = backend.env;
865
+ resolvedModel ??= backend.model || void 0;
866
+ }
650
867
  let initialPromptFile;
651
868
  if (promptText) {
652
869
  initialPromptFile = join(tmpdir(), `cctabs-prompt-${Date.now()}.txt`);
@@ -657,10 +874,13 @@ const newCommand = define({
657
874
  dir,
658
875
  claudeCmd: useWorktree ? `claude --worktree ${JSON.stringify(name)}` : "claude",
659
876
  workspaceQuery: workspace,
660
- initialPromptFile
877
+ initialPromptFile,
878
+ envVars,
879
+ modelOverride: resolvedModel
661
880
  });
662
- const suffix = useWorktree ? ` (worktree: .claude/worktrees/${name})` : "";
663
- consola.success(`Tab "${name}" [${tabId.slice(0, 8)}]claude at ${dir}${suffix}`);
881
+ const wt = useWorktree ? ` (worktree: .claude/worktrees/${name})` : "";
882
+ const be = backendName ? ` [backend: ${backendName}${resolvedModel ? ` → ${resolvedModel}` : ""}]` : "";
883
+ consola.success(`Tab "${name}" [${tabId.slice(0, 8)}] → claude at ${dir}${wt}${be}`);
664
884
  }
665
885
  });
666
886
  //#endregion
@@ -856,8 +1076,46 @@ function listSessionNames(dir) {
856
1076
  }
857
1077
  return results.sort((a, b) => b.mtime - a.mtime);
858
1078
  }
1079
+ /**
1080
+ * Resolve a session ID prefix (e.g. "19aae7b4") to the full UUID by scanning
1081
+ * `~/.claude/projects/`. Returns the input unchanged if it already looks like
1082
+ * a full UUID, or null if no unique match exists. Pass `dir` to scope the
1083
+ * search to one project; otherwise every project is checked.
1084
+ *
1085
+ * `claude --resume <prefix>` does NOT accept truncated IDs — it treats them
1086
+ * as a search query and shows the picker. So callers must expand prefixes
1087
+ * before forwarding to claude.
1088
+ */
1089
+ function expandSessionId(input, dir) {
1090
+ if (!input) return null;
1091
+ if (/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(input)) return input;
1092
+ const projectsRoot = join(homedir(), ".claude", "projects");
1093
+ if (!existsSync(projectsRoot)) return null;
1094
+ const projectDirs = dir ? [join(projectsRoot, pathToProjectSlug(dir))] : readdirSync(projectsRoot).map((d) => join(projectsRoot, d)).filter((p) => {
1095
+ try {
1096
+ return statSync(p).isDirectory();
1097
+ } catch {
1098
+ return false;
1099
+ }
1100
+ });
1101
+ const matches = [];
1102
+ for (const pd of projectDirs) {
1103
+ if (!existsSync(pd)) continue;
1104
+ for (const f of readdirSync(pd)) {
1105
+ if (extname(f) !== ".jsonl") continue;
1106
+ const id = basename(f, ".jsonl");
1107
+ if (id.startsWith(input) && !matches.includes(id)) matches.push(id);
1108
+ }
1109
+ }
1110
+ return matches.length === 1 ? matches[0] : null;
1111
+ }
859
1112
  //#endregion
860
1113
  //#region src/commands/resume.ts
1114
+ function shellQuoteEnv(env) {
1115
+ const entries = Object.entries(env);
1116
+ if (!entries.length) return "";
1117
+ return entries.map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(" ") + " ";
1118
+ }
861
1119
  function formatAge(mtimeMs) {
862
1120
  const mins = Math.round((Date.now() - mtimeMs) / 6e4);
863
1121
  if (mins < 60) return `${mins}m ago`;
@@ -886,6 +1144,16 @@ const resumeCommand = define({
886
1144
  type: "string",
887
1145
  short: "s",
888
1146
  description: "Session ID to resume (use when multiple sessions share the same name)"
1147
+ },
1148
+ backend: {
1149
+ type: "string",
1150
+ short: "b",
1151
+ description: "Backend preset (e.g. kimi, qwen-cloud, qwen-next-local). Run `cctabs backends` to list."
1152
+ },
1153
+ model: {
1154
+ type: "string",
1155
+ short: "m",
1156
+ description: "Override the model name (passed as --model to claude)."
889
1157
  }
890
1158
  },
891
1159
  async run(ctx) {
@@ -896,9 +1164,29 @@ const resumeCommand = define({
896
1164
  process.exit(1);
897
1165
  }
898
1166
  const explicitSession = ctx.values.session;
1167
+ const backendName = ctx.values.backend;
1168
+ const modelOverride = ctx.values.model;
1169
+ let envVars;
1170
+ let resolvedModel = modelOverride;
1171
+ if (backendName) {
1172
+ const backend = resolveBackend(backendName);
1173
+ if (!backend) {
1174
+ consola.error(`Unknown backend "${backendName}". Available:`);
1175
+ for (const b of listBackends()) consola.log(` ${b.name.padEnd(22)} ${b.description}`);
1176
+ process.exit(1);
1177
+ }
1178
+ envVars = backend.env;
1179
+ resolvedModel ??= backend.model || void 0;
1180
+ }
899
1181
  let sessionId;
900
- if (explicitSession) sessionId = explicitSession;
901
- else {
1182
+ if (explicitSession) {
1183
+ const expanded = expandSessionId(explicitSession, dir) ?? expandSessionId(explicitSession);
1184
+ if (!expanded) {
1185
+ consola.error(`Session '${explicitSession}' not found (or matches multiple sessions). Pass the full UUID.`);
1186
+ process.exit(1);
1187
+ }
1188
+ sessionId = expanded;
1189
+ } else {
902
1190
  const sessions = findSessionsByName(dir, name);
903
1191
  if (sessions.length === 0) {
904
1192
  consola.error(`No session named "${name}" in ${dir}`);
@@ -953,14 +1241,18 @@ const resumeCommand = define({
953
1241
  const newTabId = await openSession({
954
1242
  tabName: name,
955
1243
  dir,
956
- claudeCmd: `claude --resume ${sessionId} --name ${JSON.stringify(name)}`
1244
+ claudeCmd: `claude --resume ${sessionId} --name ${JSON.stringify(name)}`,
1245
+ envVars,
1246
+ modelOverride: resolvedModel
957
1247
  });
958
1248
  consola.success(`Tab "${name}" [${newTabId.slice(0, 8)}] → claude --resume ${sessionId.slice(0, 8)}… at ${dir} (recreated)`);
959
1249
  return;
960
1250
  }
961
1251
  }
962
1252
  const extraFlags = loadConfig().claude.flags.join(" ");
963
- const cmd = `cd ${JSON.stringify(dir)} && claude${extraFlags ? " " + extraFlags : ""} --resume ${sessionId} --name ${JSON.stringify(name)}\r`;
1253
+ const envPrefix = envVars ? shellQuoteEnv(envVars) : "";
1254
+ const modelPart = resolvedModel ? ` --model ${JSON.stringify(resolvedModel)}` : "";
1255
+ const cmd = `cd ${JSON.stringify(dir)} && ${envPrefix}claude${extraFlags ? " " + extraFlags : ""} --resume ${sessionId} --name ${JSON.stringify(name)}${modelPart}\r`;
964
1256
  await adapter.sendInput(termBlock.blockid, cmd);
965
1257
  let verified = false;
966
1258
  const deadline = Date.now() + 15e3;
@@ -980,7 +1272,9 @@ const resumeCommand = define({
980
1272
  const tabId = await openSession({
981
1273
  tabName: name,
982
1274
  dir,
983
- claudeCmd: `claude --resume ${sessionId} --name ${JSON.stringify(name)}`
1275
+ claudeCmd: `claude --resume ${sessionId} --name ${JSON.stringify(name)}`,
1276
+ envVars,
1277
+ modelOverride: resolvedModel
984
1278
  });
985
1279
  consola.success(`Tab "${name}" [${tabId.slice(0, 8)}] → claude --resume ${sessionId.slice(0, 8)}… at ${dir} (new tab)`);
986
1280
  } else {
@@ -988,7 +1282,9 @@ const resumeCommand = define({
988
1282
  const tabId = await openSession({
989
1283
  tabName: name,
990
1284
  dir,
991
- claudeCmd: "claude"
1285
+ claudeCmd: "claude",
1286
+ envVars,
1287
+ modelOverride: resolvedModel
992
1288
  });
993
1289
  consola.success(`Tab "${name}" [${tabId.slice(0, 8)}] → claude at ${dir} (new tab, no prior session found)`);
994
1290
  }
@@ -1445,6 +1741,19 @@ const restoreCommand = define({
1445
1741
  }
1446
1742
  });
1447
1743
  //#endregion
1744
+ //#region src/commands/backends.ts
1745
+ const backendsCommand = define({
1746
+ name: "backends",
1747
+ description: "List available Claude Code backend presets (Anthropic, Ollama Cloud, local Ollama)",
1748
+ args: {},
1749
+ async run() {
1750
+ consola.log("Available backends:\n");
1751
+ for (const b of listBackends()) consola.log(` ${b.name.padEnd(22)} ${b.description}`);
1752
+ consola.log("\nUsage: cctabs new <tab> <dir> --backend <name>");
1753
+ consola.log("Add custom presets in ~/.config/cctabs/config.toml under [backends.<name>].");
1754
+ }
1755
+ });
1756
+ //#endregion
1448
1757
  //#region src/commands/index.ts
1449
1758
  const defaultCommand = define({
1450
1759
  name: "cctabs",
@@ -1466,7 +1775,8 @@ const subCommands = new Map([
1466
1775
  ["scrollback", scrollbackCommand],
1467
1776
  ["send", sendCommand],
1468
1777
  ["config", configCommand],
1469
- ["restore", restoreCommand]
1778
+ ["restore", restoreCommand],
1779
+ ["backends", backendsCommand]
1470
1780
  ]);
1471
1781
  async function run() {
1472
1782
  await cli(process.argv.slice(2), defaultCommand, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@generativereality/cctabs",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "Claude Code tab manager. Terminal tabs as the UI, no tmux.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -70,6 +70,7 @@ cctabs new fix-api ~/Dev/myapp --worktree --prompt "checkout PR #102 and fix tes
70
70
  cctabs sessions # list all tabs with session status
71
71
  cctabs list # list all workspaces, tabs, and blocks
72
72
  cctabs new <name> [dir] [-w workspace] [-p "prompt"] [-f file] # new tab + claude
73
+ cctabs new <name> [dir] -b <preset> # new tab on a non-Anthropic backend (Ollama)
73
74
  cctabs resume <name> [dir] # resume last session (reuses tab or creates one)
74
75
  cctabs restore [dir] [--dry] # resume every dead tab (e.g. after a reboot)
75
76
  cctabs fork <tab-name> [-n new-name] # fork session into new tab (--resume <id> --fork-session)
@@ -77,9 +78,74 @@ cctabs close <name-or-id> # close a tab
77
78
  cctabs rename <name-or-id> <new-name> # rename a tab
78
79
  cctabs scrollback <tab-or-block> [n] # read terminal output (default: 50 lines)
79
80
  cctabs send <tab-or-block> [text] # send input — arg, --file, or stdin pipe
81
+ cctabs backends # list available backend presets
80
82
  cctabs config # show config and path
81
83
  ```
82
84
 
85
+ ## Backends: running Claude Code on Ollama / Kimi / Qwen / local models
86
+
87
+ By default, `cctabs new` runs `claude` against the Anthropic API. Pass `--backend <preset>` (or `-b`) to launch the tab against a different model provider — useful for cheap/free scratch sessions, privacy-sensitive work, or experimenting with frontier open-weight models.
88
+
89
+ `cctabs` does this by prepending the right env vars (`ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_DEFAULT_HAIKU_MODEL`, etc.) and `--model <name>` to the `claude` command in the new tab.
90
+
91
+ ### Built-in presets
92
+
93
+ Run `cctabs backends` for the live list. Common ones:
94
+
95
+ | Preset | What it is | When to use |
96
+ |---|---|---|
97
+ | `anthropic` (default) | Anthropic API | Production / coding work where capability matters |
98
+ | `kimi` | Kimi K2.6 via Ollama Cloud (Pro tier) | Cheap frontier alternative; ~5s/turn |
99
+ | `qwen-cloud` | Qwen3 Coder Next via Ollama Cloud | Fastest Pro option (~3.8s/turn) |
100
+ | `gemma-cloud` | Gemma4 31B via Ollama Cloud | Cheap general-purpose |
101
+ | `qwen-local` | Qwen3 Coder 30B local (18GB) | Offline / private; slow on M1 |
102
+ | `qwen-next-local` | Qwen3 Coder Next Q3_K_M local (38GB) | Private + most capable local; needs `ollama create` import |
103
+ | `gpt-oss` | gpt-oss 20B local (13GB) | Private; slow; ~100s/turn for 50k system prompt |
104
+ | `llama` | Llama 3.1 8B local | Fast but garbles inside Claude Code's 50k system prompt — capability gate |
105
+ | `*-tee` | Same as above but routed through `:11500` proxy | Wire-level inspection (`ollama-tee` proxy must be running) |
106
+
107
+ ### Cost × privacy framing
108
+
109
+ Two axes matter:
110
+
111
+ 1. **Cost** — Anthropic Pro $20/mo or Max ($100/$200/mo); Ollama Cloud Pro $20/mo (3 concurrent, includes Kimi/Qwen Cloud); local = free but hardware-bound
112
+ 2. **Privacy** — Anthropic API: Anthropic sees prompts. Ollama Cloud: Ollama sees prompts. Local: nothing leaves the laptop
113
+
114
+ Match the tier to the task:
115
+ - Sensitive prompts (client code, customer data) → `qwen-next-local` or `gpt-oss`
116
+ - Routine exploration / orchestration → `anthropic` (default)
117
+ - Cost-sensitive bulk work → `kimi` or `qwen-cloud`
118
+
119
+ ### Examples
120
+
121
+ ```bash
122
+ # Spin up a tab on Kimi for a side experiment
123
+ cctabs new explore-kimi ~/Dev/myapp -b kimi -p "explore alternative API designs"
124
+
125
+ # Local privacy session, slower but no data leaves the laptop
126
+ cctabs new private-refactor ~/Dev/clientwork -b qwen-next-local -W
127
+
128
+ # Compare two models on the same task in parallel
129
+ cctabs new task-anthropic ~/Dev/myapp -p "implement spec X"
130
+ cctabs new task-kimi ~/Dev/myapp -b kimi -p "implement spec X"
131
+
132
+ # Custom local Ollama tag not in built-in presets:
133
+ cctabs new x ~/Dev/myapp -b qwen-local -m my-custom-tag:latest
134
+ ```
135
+
136
+ ### Caveats
137
+
138
+ - **Local backends are slow on M1.** A Claude Code turn against the local 50k-token system prompt takes ~100s prefill + generation on M1 Max. Only worth it for non-time-sensitive private work.
139
+ - **Llama 3.1 8B garbles tool calls** under Claude Code's system prompt. Capability gate, not a bug.
140
+ - **Ollama Cloud Pro requires `ollama signin`** (one-time). Free tier denies cloud-tagged models.
141
+ - **Custom presets** can be added in `~/.config/cctabs/config.toml`:
142
+ ```toml
143
+ [backends.my-preset]
144
+ model = "qwen3-coder-next:cloud"
145
+ base_url = "http://localhost:11434"
146
+ description = "My custom preset"
147
+ ```
148
+
83
149
  ## Workflow: Checking What's Running
84
150
 
85
151
  Before starting new sessions, always check what's already active: