Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# threadle — agent instructions

threadle is a local-first node-graph patchbay for CLI agent sessions (Claude Code,
opencode, Cursor Agent, Antigravity, Codex, GitHub Copilot, Grok Build): observability (blueprints, activity, statistics) plus context
opencode, Cursor Agent, Antigravity, Codex, GitHub Copilot, Grok Build, Muse Code): observability (blueprints, activity, statistics) plus context
wiring/handoff between sessions on a canvas.

## Vocabulary (UI + docs)
Expand Down Expand Up @@ -34,7 +34,7 @@ Plain terms lead. Graph JSON keeps historical type ids for compatibility.
and the built-in `NodeDefinition` catalog (`src/nodes/`). Single source of
truth; UI and server both import it.
- `packages/server` — Hono API (`bin: threadle`). Providers under
`src/providers/{claude-code,opencode,cursor,antigravity,codex,copilot,grok}` read agent storage **read-only**;
`src/providers/{claude-code,opencode,cursor,antigravity,codex,copilot,grok,muse}` read agent storage **read-only**;
all runs/injections go through each tool's CLI or HTTP surface.
- `packages/web` — Vue 3 + Vue Flow. True-black monochrome theme
(`src/theme/theme.css`); no emojis, text glyphs only (❯ ⎇ ⟨/⟩ ▤ ⚙ ✦).
Expand All @@ -49,7 +49,7 @@ Plain terms lead. Graph JSON keeps historical type ids for compatibility.
custom nodes / stdlib / recipes. Shared pure helpers live in
`packages/shared` (`judge`, `until`, `wait-idle`, `iterator`, …).
- Never write into `~/.claude/projects`, opencode's SQLite, `~/.cursor`
chats/transcripts, `~/.gemini/antigravity-cli`, `~/.codex`, `~/.copilot`, or `~/.grok`; parsers must skip unknown
chats/transcripts, `~/.gemini/antigravity-cli`, `~/.codex`, `~/.copilot`, `~/.grok`, `~/.muse`, or `~/.local/share/muse`; parsers must skip unknown
record types, not throw (formats are undocumented and churn).
- All list/table UIs: mono micro-labels, right-aligned numeric columns,
`minmax(0, 1fr)` name columns, values ellipsize instead of wrapping.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<a href="https://github.com/threadle-sh/threadle/releases"><img src="https://img.shields.io/github/v/release/threadle-sh/threadle?style=flat-square&labelColor=0a0a0c&color=white" alt="release" /></a>
</p>

Your agents write everything down: transcripts, tool calls, files touched, spend. Then nobody reads it, and the context dies at each tool boundary. threadle reads it, draws it, and lets you wire it into the next run. Every [Claude Code](https://claude.com/claude-code), [opencode](https://opencode.ai), [Cursor](https://cursor.com) `agent`, [Antigravity](https://antigravity.google) `agy`, [Codex](https://github.com/openai/codex), [GitHub Copilot](https://github.com/features/copilot) CLI, and [Grok Build](https://x.ai/cli) session becomes a wireable node. Inspect what happened. Hand context across tools. Run the pipeline.
Your agents write everything down: transcripts, tool calls, files touched, spend. Then nobody reads it, and the context dies at each tool boundary. threadle reads it, draws it, and lets you wire it into the next run. Every [Claude Code](https://claude.com/claude-code), [opencode](https://opencode.ai), [Cursor](https://cursor.com) `agent`, [Antigravity](https://antigravity.google) `agy`, [Codex](https://github.com/openai/codex), [GitHub Copilot](https://github.com/features/copilot) CLI, [Grok Build](https://x.ai/cli), and [Muse Code](https://dev.meta.ai) session becomes a wireable node. Inspect what happened. Hand context across tools. Run the pipeline.

**Yours, with no catch.** Discovery is read-only against storage you already have. threadle binds `127.0.0.1`, has no account and no telemetry, and does not upload transcripts. Pressing ▶ drives each tool’s own CLI under your credentials. If threadle disappears tomorrow, your sessions are exactly where they always were.

Expand Down Expand Up @@ -118,7 +118,7 @@ The full set lives in [`docs/screenshots/`](https://github.com/threadle-sh/threa

## Trust

Parsers never mutate `~/.claude`, opencode’s SQLite, `~/.cursor`, `~/.gemini/antigravity-cli`, `~/.codex`, `~/.copilot`, or `~/.grok`. Runs spawn the official CLI, and *it* writes new sessions.
Parsers never mutate `~/.claude`, opencode’s SQLite, `~/.cursor`, `~/.gemini/antigravity-cli`, `~/.codex`, `~/.copilot`, or `~/.grok`, `~/.muse`, or `~/.local/share/muse`. Runs spawn the official CLI, and *it* writes new sessions.

The server rejects foreign `Host` (DNS rebinding) and cross-site write `Origin` (CSRF). There is no remote mode and no auth token. List prices ship bundled (CI-refreshed from [models.dev](https://models.dev)). The process does not fetch them at runtime. Everything threadle persists is under `~/.config/threadle/`.

Expand Down
2 changes: 1 addition & 1 deletion docs/cli/manual.md
Original file line number Diff line number Diff line change
Expand Up @@ -515,7 +515,7 @@ UI **▶** (no gates) and **≫** use the same `POST /api/run/workflow` path as
| `no skill named "…" found` | Install/unmute the skill, or mute the node in the UI and re-export |
| `nothing to run for "…"` | Path, recipe id, template id, or graph id wrong; run `threadle recipes` / `--list-templates` |
| `threadle server not reachable` | Start `threadle --no-open` before `jobs` / `services` / `skills` / `open` / `run --detach` |
| Agent CLI not found | Install Cursor `agent` / Claude Code / opencode / Antigravity `agy` / Codex / GitHub Copilot / Grok Build; ensure on `PATH` — or run `threadle check` |
| Agent CLI not found | Install Cursor `agent` / Claude Code / opencode / Antigravity `agy` / Codex / GitHub Copilot / Grok Build / Muse Code; ensure on `PATH` — or run `threadle check` |
| Wrong project files | Pass `--dir` to the repo root you intend |
| `context not materialized` / blocked ❝ | Wire a session/agent (extracts mid-run), drag from Library, or mute |

Expand Down
8 changes: 5 additions & 3 deletions docs/provider-freshness.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Keeping providers fresh

Agent CLIs (Claude Code, Cursor `agent`, opencode, Antigravity `agy`, Codex, GitHub Copilot, Grok Build) store transcripts in **undocumented** formats and rename flags without notice. threadle does **not** mirror their configs/APIs with a daily diff bot.
Agent CLIs (Claude Code, Cursor `agent`, opencode, Antigravity `agy`, Codex, GitHub Copilot, Grok Build, Muse Code) store transcripts in **undocumented** formats and rename flags without notice. threadle does **not** mirror their configs/APIs with a daily diff bot.

## Strategy

Expand Down Expand Up @@ -33,21 +33,23 @@ It does **not** parse transcripts or prove schemas (that’s fixture CI).

### What `--providers` adds

1. **Golden fixtures** (when `packages/server/test/fixtures/providers` is on disk — repo checkout / CI): parse one sample per provider (`fixture:claude-code`, `fixture:cursor`, … `fixture:grok`). Published installs skip these rows.
1. **Golden fixtures** (when `packages/server/test/fixtures/providers` is on disk — repo checkout / CI): parse one sample per provider (`fixture:claude-code`, `fixture:cursor`, … `fixture:grok`, `fixture:muse`). Published installs skip these rows.
2. **Inject flag probes** for each **installed** CLI — run help and look for inject-critical tokens listed in [`packages/server/src/providers/freshness/inject-flags.ts`](../packages/server/src/providers/freshness/inject-flags.ts):

| Detail | Behavior |
|---|---|
| Missing binary | Skip (`ok`) |
| Help text contains any `match` string | `ok` |
| No match | `!!` and non-zero exit |
| Subcommand help | Some probes use e.g. `opencode run --help` (not only top-level `--help`) |
| Subcommand help | Some probes use e.g. `opencode run --help` or `muse exec --help` (not only top-level `--help`) |
| Bracket notation | Claude documents `--append-system-prompt[-file]` — both that form and the long flag count |
| Fixture missing | Skip (`ok`) |
| Fixture parse / shape fail | `!!` and non-zero exit |

A `!! flag:` row means “help no longer mentions a string we rely on for inject/run” — confirm with the real CLI before changing adapters (help text and the live flag can diverge). A `!! fixture:` row means a golden sample stopped normalizing.

**Muse models.** Upstream has no `muse models` list yet. threadle’s model picker scrapes Spark ids from `muse exec --help` / `muse --help`, keeps hardcoded fallbacks, and honors `MUSE_MODELS`. That catalog is soft — expand fallbacks or env when Meta renames Spark ids.

## Adding a fixture

1. Anonymize one real transcript line (paths → `/tmp/…`, strip secrets).
Expand Down
Binary file modified docs/screenshots/canvas.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/sessions.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/statistics.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/stats.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions packages/server/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "threadle",
"version": "1.0.11",
"description": "Local-first node-graph patchbay for Claude Code, opencode, Cursor Agent, Antigravity, Codex, Copilot, and Grok Build sessions.",
"version": "1.1.1",
"description": "Local-first node-graph patchbay for Claude Code, opencode, Cursor Agent, Antigravity, Codex, Copilot, Grok Build and Muse sessions.",
"type": "module",
"license": "MIT",
"author": "Fabian Bienk (https://github.com/zFarbp)",
Expand All @@ -22,6 +22,7 @@
"codex",
"copilot",
"grok",
"muse",
"agent",
"workflow",
"patchbay"
Expand Down
2 changes: 2 additions & 0 deletions packages/server/src/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { probeInjectFlags } from "./providers/freshness/probe.js";
import { probeGoldenFixtures } from "./providers/freshness/fixtures.js";
import { probeUpstream } from "./providers/freshness/upstream.js";
import { grokBin } from "./providers/grok/paths.js";
import { museBin } from "./providers/muse/paths.js";
import { codexBin } from "./providers/codex/paths.js";
import { copilotBin } from "./providers/copilot/paths.js";

Expand Down Expand Up @@ -154,6 +155,7 @@ export async function runCheck(
checkPreferredOrPath("cli:codex", codexBin(), "codex"),
checkPreferredOrPath("cli:copilot", copilotBin(), "copilot"),
checkPreferredOrPath("cli:grok", grokBin(), "grok"),
checkPreferredOrPath("cli:muse", museBin(), "muse"),
]);
checks.push(...cliChecks);

Expand Down
7 changes: 6 additions & 1 deletion packages/server/src/jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface JobRecord {
label?: string;
/** workflow (graph) that launched the run, when known */
graphId?: string;
sessionRef?: { provider: string; sessionId: string };
status: JobStatus;
createdAt: number;
finishedAt?: number;
Expand Down Expand Up @@ -48,7 +49,10 @@ class JobRegistry extends EventEmitter {
kind: JobKind,
label?: string,
graphId?: string,
opts?: { clientDriven?: boolean },
opts?: {
clientDriven?: boolean;
sessionRef?: { provider: string; sessionId: string };
},
): { jobId: string; signal: AbortSignal } {
this.counter += 1;
const jobId = `job_${Date.now().toString(36)}_${this.counter}`;
Expand All @@ -59,6 +63,7 @@ class JobRegistry extends EventEmitter {
kind,
label,
graphId,
sessionRef: opts?.sessionRef,
status: "running",
createdAt: Date.now(),
touchedAt: Date.now(),
Expand Down
108 changes: 108 additions & 0 deletions packages/server/src/pilot-sessions/store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import fs from "node:fs";
import path from "node:path";
import { threadleConfigDir } from "../paths.js";

export interface PilotSessionEntry {
provider: string;
sessionId: string;
markedAt: number;
caseId?: string;
tokensIn?: number;
tokensOut?: number;
promptEst?: number;
baselineEst?: number;
baselineNote?: string;
ignoreLocalMarkdown?: boolean;
}

interface PilotSessionsFile {
version: 1;
entries: PilotSessionEntry[];
}

function storePath(): string {
return path.join(threadleConfigDir(), "pilot-sessions.json");
}

export function pilotSessionKey(provider: string, sessionId: string): string {
return `${provider}:${sessionId}`;
}

async function readFile(): Promise<PilotSessionsFile> {
try {
const raw = JSON.parse(
await fs.promises.readFile(storePath(), "utf8"),
) as PilotSessionsFile;
if (raw?.version === 1 && Array.isArray(raw.entries)) return raw;
} catch {
// missing / corrupt -> empty
}
return { version: 1, entries: [] };
}

async function writeFile(data: PilotSessionsFile): Promise<void> {
await fs.promises.mkdir(threadleConfigDir(), { recursive: true });
await fs.promises.writeFile(storePath(), JSON.stringify(data, null, 2), "utf8");
}

export async function markPilotSession(opts: {
provider: string;
sessionId: string;
caseId?: string;
tokensIn?: number;
tokensOut?: number;
promptEst?: number;
baselineEst?: number;
baselineNote?: string;
ignoreLocalMarkdown?: boolean;
}): Promise<PilotSessionEntry> {
const data = await readFile();
const key = pilotSessionKey(opts.provider, opts.sessionId);
const existing = data.entries.find(
(e) => pilotSessionKey(e.provider, e.sessionId) === key,
);
if (existing) {
if (opts.caseId && !existing.caseId) existing.caseId = opts.caseId;
if (opts.tokensIn != null) existing.tokensIn = opts.tokensIn;
if (opts.tokensOut != null) existing.tokensOut = opts.tokensOut;
if (opts.promptEst != null) existing.promptEst = opts.promptEst;
if (opts.baselineEst != null) existing.baselineEst = opts.baselineEst;
if (opts.baselineNote) existing.baselineNote = opts.baselineNote;
if (opts.ignoreLocalMarkdown != null) {
existing.ignoreLocalMarkdown = opts.ignoreLocalMarkdown;
}
await writeFile(data);
return existing;
}
const entry: PilotSessionEntry = {
provider: opts.provider,
sessionId: opts.sessionId,
markedAt: Date.now(),
caseId: opts.caseId,
tokensIn: opts.tokensIn,
tokensOut: opts.tokensOut,
promptEst: opts.promptEst,
baselineEst: opts.baselineEst,
baselineNote: opts.baselineNote,
ignoreLocalMarkdown: opts.ignoreLocalMarkdown,
};
data.entries.unshift(entry);
if (data.entries.length > 2000) data.entries.length = 2000;
await writeFile(data);
return entry;
}

export async function readPilotSessionMap(): Promise<Map<string, PilotSessionEntry>> {
const data = await readFile();
return new Map(
data.entries.map((e) => [pilotSessionKey(e.provider, e.sessionId), e] as const),
);
}

export async function readPilotSessionKeys(): Promise<Set<string>> {
return new Set((await readPilotSessionMap()).keys());
}

export async function listPilotSessions(): Promise<PilotSessionEntry[]> {
return (await readFile()).entries;
}
4 changes: 3 additions & 1 deletion packages/server/src/providers/antigravity/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,9 @@ export async function liveStatuses(): Promise<Map<string, SessionStatus>> {
const map = new Map<string, SessionStatus>();
const summaries = await loadSummaryIndex();
for (const [id, sum] of summaries) {
if (sum.status && sum.status !== "unknown") map.set(id, sum.status);
if (sum.status === "running" || sum.status === "waiting") {
map.set(id, sum.status);
}
}
return map;
}
Expand Down
Loading
Loading