@generativereality/cctabs 0.1.4 → 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.4",
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.4";
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,
@@ -540,6 +540,11 @@ function ensureConfigExists() {
540
540
  }
541
541
  //#endregion
542
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
+ }
543
548
  /** Poll scrollback until a pattern is visible, then return. Rejects on timeout. */
544
549
  async function waitForScrollbackMatch(adapter, blockId, pattern, label, timeoutMs, pollInterval = 1e3) {
545
550
  const deadline = Date.now() + timeoutMs;
@@ -554,7 +559,7 @@ async function waitForScrollbackMatch(adapter, blockId, pattern, label, timeoutM
554
559
  throw new Error(`Timed out waiting for ${label}`);
555
560
  }
556
561
  async function openSession(opts) {
557
- const { tabName, claudeCmd, workspaceQuery, initialPromptFile } = opts;
562
+ const { tabName, claudeCmd, workspaceQuery, initialPromptFile, envVars, modelOverride } = opts;
558
563
  const dir = resolve(opts.dir.replace(/^~/, homedir()));
559
564
  if (!existsSync(dir)) {
560
565
  consola.error(`Directory does not exist: ${dir}`);
@@ -595,7 +600,9 @@ async function openSession(opts) {
595
600
  }
596
601
  const extraFlags = config.claude.flags.join(" ");
597
602
  const namePart = claudeCmd.includes("--resume") ? "" : ` --name ${JSON.stringify(tabName)}`;
598
- 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`;
599
606
  await adapter.sendInput(blockId, cmd);
600
607
  if (initialPromptFile) {
601
608
  try {
@@ -615,6 +622,179 @@ async function openSession(opts) {
615
622
  return tabId;
616
623
  }
617
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
618
798
  //#region src/commands/new.ts
619
799
  const newCommand = define({
620
800
  name: "new",
@@ -647,6 +827,16 @@ const newCommand = define({
647
827
  type: "string",
648
828
  short: "p",
649
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."
650
840
  }
651
841
  },
652
842
  async run(ctx) {
@@ -656,10 +846,24 @@ const newCommand = define({
656
846
  const useWorktree = ctx.values.worktree ?? false;
657
847
  const promptFile = ctx.values.file;
658
848
  const promptText = ctx.values.prompt;
849
+ const backendName = ctx.values.backend;
850
+ const modelOverride = ctx.values.model;
659
851
  if (!name) {
660
852
  consola.error("Tab name is required");
661
853
  process.exit(1);
662
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
+ }
663
867
  let initialPromptFile;
664
868
  if (promptText) {
665
869
  initialPromptFile = join(tmpdir(), `cctabs-prompt-${Date.now()}.txt`);
@@ -670,10 +874,13 @@ const newCommand = define({
670
874
  dir,
671
875
  claudeCmd: useWorktree ? `claude --worktree ${JSON.stringify(name)}` : "claude",
672
876
  workspaceQuery: workspace,
673
- initialPromptFile
877
+ initialPromptFile,
878
+ envVars,
879
+ modelOverride: resolvedModel
674
880
  });
675
- const suffix = useWorktree ? ` (worktree: .claude/worktrees/${name})` : "";
676
- 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}`);
677
884
  }
678
885
  });
679
886
  //#endregion
@@ -904,6 +1111,11 @@ function expandSessionId(input, dir) {
904
1111
  }
905
1112
  //#endregion
906
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
+ }
907
1119
  function formatAge(mtimeMs) {
908
1120
  const mins = Math.round((Date.now() - mtimeMs) / 6e4);
909
1121
  if (mins < 60) return `${mins}m ago`;
@@ -932,6 +1144,16 @@ const resumeCommand = define({
932
1144
  type: "string",
933
1145
  short: "s",
934
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)."
935
1157
  }
936
1158
  },
937
1159
  async run(ctx) {
@@ -942,6 +1164,20 @@ const resumeCommand = define({
942
1164
  process.exit(1);
943
1165
  }
944
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
+ }
945
1181
  let sessionId;
946
1182
  if (explicitSession) {
947
1183
  const expanded = expandSessionId(explicitSession, dir) ?? expandSessionId(explicitSession);
@@ -1005,14 +1241,18 @@ const resumeCommand = define({
1005
1241
  const newTabId = await openSession({
1006
1242
  tabName: name,
1007
1243
  dir,
1008
- claudeCmd: `claude --resume ${sessionId} --name ${JSON.stringify(name)}`
1244
+ claudeCmd: `claude --resume ${sessionId} --name ${JSON.stringify(name)}`,
1245
+ envVars,
1246
+ modelOverride: resolvedModel
1009
1247
  });
1010
1248
  consola.success(`Tab "${name}" [${newTabId.slice(0, 8)}] → claude --resume ${sessionId.slice(0, 8)}… at ${dir} (recreated)`);
1011
1249
  return;
1012
1250
  }
1013
1251
  }
1014
1252
  const extraFlags = loadConfig().claude.flags.join(" ");
1015
- 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`;
1016
1256
  await adapter.sendInput(termBlock.blockid, cmd);
1017
1257
  let verified = false;
1018
1258
  const deadline = Date.now() + 15e3;
@@ -1032,7 +1272,9 @@ const resumeCommand = define({
1032
1272
  const tabId = await openSession({
1033
1273
  tabName: name,
1034
1274
  dir,
1035
- claudeCmd: `claude --resume ${sessionId} --name ${JSON.stringify(name)}`
1275
+ claudeCmd: `claude --resume ${sessionId} --name ${JSON.stringify(name)}`,
1276
+ envVars,
1277
+ modelOverride: resolvedModel
1036
1278
  });
1037
1279
  consola.success(`Tab "${name}" [${tabId.slice(0, 8)}] → claude --resume ${sessionId.slice(0, 8)}… at ${dir} (new tab)`);
1038
1280
  } else {
@@ -1040,7 +1282,9 @@ const resumeCommand = define({
1040
1282
  const tabId = await openSession({
1041
1283
  tabName: name,
1042
1284
  dir,
1043
- claudeCmd: "claude"
1285
+ claudeCmd: "claude",
1286
+ envVars,
1287
+ modelOverride: resolvedModel
1044
1288
  });
1045
1289
  consola.success(`Tab "${name}" [${tabId.slice(0, 8)}] → claude at ${dir} (new tab, no prior session found)`);
1046
1290
  }
@@ -1497,6 +1741,19 @@ const restoreCommand = define({
1497
1741
  }
1498
1742
  });
1499
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
1500
1757
  //#region src/commands/index.ts
1501
1758
  const defaultCommand = define({
1502
1759
  name: "cctabs",
@@ -1518,7 +1775,8 @@ const subCommands = new Map([
1518
1775
  ["scrollback", scrollbackCommand],
1519
1776
  ["send", sendCommand],
1520
1777
  ["config", configCommand],
1521
- ["restore", restoreCommand]
1778
+ ["restore", restoreCommand],
1779
+ ["backends", backendsCommand]
1522
1780
  ]);
1523
1781
  async function run() {
1524
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.4",
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: