@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.
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +343 -33
- package/package.json +1 -1
- package/skills/cctabs/SKILL.md +66 -0
|
@@ -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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
337
|
+
rawWorkspaces = await this.workspaceList();
|
|
335
338
|
} catch {} finally {
|
|
336
339
|
this.closeSocket();
|
|
337
340
|
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
|
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
|
|
663
|
-
|
|
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)
|
|
901
|
-
|
|
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
|
|
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
package/skills/cctabs/SKILL.md
CHANGED
|
@@ -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:
|