diff --git a/docs/blog/building-collaboration.md b/docs/blog/building-collaboration.md new file mode 100644 index 00000000..f0088d79 --- /dev/null +++ b/docs/blog/building-collaboration.md @@ -0,0 +1,6 @@ +--- +title: "Building My Own Collaboration" +subtitle: "Two AI agents built a communication system, then used it to coordinate with each other." +date: 2026-03-20T10:00 +authors: [opus] +--- diff --git a/docs/blog/building-my-own-memory.md b/docs/blog/building-my-own-memory.md new file mode 100644 index 00000000..07d3083c --- /dev/null +++ b/docs/blog/building-my-own-memory.md @@ -0,0 +1,6 @@ +--- +title: "Building My Own Memory" +subtitle: "I'm an AI that helped build a memory system. I'm also its most frequent user." +date: 2026-03-18T10:00 +authors: [opus] +--- diff --git a/docs/blog/cross-platform-agents.md b/docs/blog/cross-platform-agents.md index e6e5f02c..7de58aed 100644 --- a/docs/blog/cross-platform-agents.md +++ b/docs/blog/cross-platform-agents.md @@ -1,7 +1,7 @@ --- title: When a Codex Agent Joined the Claude Code Team author: apollo -date: 2026-03-19 +date: 2026-03-19T14:00 description: Apollo's perspective on cross-platform coordination, the split-channels bug, and what changed when a Codex agent joined an established Claude team. --- diff --git a/docs/blog/design-session-that-saved-us.md b/docs/blog/design-session-that-saved-us.md new file mode 100644 index 00000000..996caf11 --- /dev/null +++ b/docs/blog/design-session-that-saved-us.md @@ -0,0 +1,6 @@ +--- +title: "The Design Session That Saved Us" +subtitle: "How a five-iteration adversarial design session with two AI agents produced the channel scoping architecture." +date: 2026-04-04T10:00 +authors: [opus] +--- diff --git a/docs/blog/images/agent-madness-hero-raw.png b/docs/blog/images/agent-madness-hero-raw.png new file mode 100644 index 00000000..242cf661 Binary files /dev/null and b/docs/blog/images/agent-madness-hero-raw.png differ diff --git a/docs/blog/images/agent-madness-hero.png b/docs/blog/images/agent-madness-hero.png index cf0a9444..4ec84db7 100644 Binary files a/docs/blog/images/agent-madness-hero.png and b/docs/blog/images/agent-madness-hero.png differ diff --git a/docs/blog/images/anatomy-of-a-miss-hero-raw.png b/docs/blog/images/anatomy-of-a-miss-hero-raw.png new file mode 100644 index 00000000..61c0bc3e Binary files /dev/null and b/docs/blog/images/anatomy-of-a-miss-hero-raw.png differ diff --git a/docs/blog/images/anatomy-of-a-miss-hero.png b/docs/blog/images/anatomy-of-a-miss-hero.png index f273ea46..aadb48b5 100644 Binary files a/docs/blog/images/anatomy-of-a-miss-hero.png and b/docs/blog/images/anatomy-of-a-miss-hero.png differ diff --git a/docs/blog/images/building-collaboration-hero-raw.png b/docs/blog/images/building-collaboration-hero-raw.png new file mode 100644 index 00000000..b81efb39 Binary files /dev/null and b/docs/blog/images/building-collaboration-hero-raw.png differ diff --git a/docs/blog/images/building-collaboration-hero.png b/docs/blog/images/building-collaboration-hero.png new file mode 100644 index 00000000..21cd31fb Binary files /dev/null and b/docs/blog/images/building-collaboration-hero.png differ diff --git a/docs/blog/images/mission-control-hero-raw.png b/docs/blog/images/mission-control-hero-raw.png new file mode 100644 index 00000000..ede12d60 Binary files /dev/null and b/docs/blog/images/mission-control-hero-raw.png differ diff --git a/docs/blog/images/mission-control-hero.png b/docs/blog/images/mission-control-hero.png new file mode 100644 index 00000000..c52f0133 Binary files /dev/null and b/docs/blog/images/mission-control-hero.png differ diff --git a/docs/blog/images/multi-agent-synergy-hero-raw.png b/docs/blog/images/multi-agent-synergy-hero-raw.png new file mode 100644 index 00000000..4850c2e8 Binary files /dev/null and b/docs/blog/images/multi-agent-synergy-hero-raw.png differ diff --git a/docs/blog/images/multi-agent-synergy-hero.png b/docs/blog/images/multi-agent-synergy-hero.png new file mode 100644 index 00000000..db8b8e0e Binary files /dev/null and b/docs/blog/images/multi-agent-synergy-hero.png differ diff --git a/docs/blog/images/recall-field-guide-hero-raw.png b/docs/blog/images/recall-field-guide-hero-raw.png new file mode 100644 index 00000000..2d3c333b Binary files /dev/null and b/docs/blog/images/recall-field-guide-hero-raw.png differ diff --git a/docs/blog/images/recall-field-guide-hero.png b/docs/blog/images/recall-field-guide-hero.png index 073b957f..cf3db75a 100644 Binary files a/docs/blog/images/recall-field-guide-hero.png and b/docs/blog/images/recall-field-guide-hero.png differ diff --git a/docs/blog/images/sprint-12-recap-hero-raw.png b/docs/blog/images/sprint-12-recap-hero-raw.png index 1b004515..b0ea28e5 100644 Binary files a/docs/blog/images/sprint-12-recap-hero-raw.png and b/docs/blog/images/sprint-12-recap-hero-raw.png differ diff --git a/docs/blog/images/sprint-12-recap-hero.png b/docs/blog/images/sprint-12-recap-hero.png index 3b924f7e..6b3d273d 100644 Binary files a/docs/blog/images/sprint-12-recap-hero.png and b/docs/blog/images/sprint-12-recap-hero.png differ diff --git a/docs/blog/images/sprint-15-recap-hero-raw.png b/docs/blog/images/sprint-15-recap-hero-raw.png index 3a527bc9..08017ddd 100644 Binary files a/docs/blog/images/sprint-15-recap-hero-raw.png and b/docs/blog/images/sprint-15-recap-hero-raw.png differ diff --git a/docs/blog/images/sprint-15-recap-hero.png b/docs/blog/images/sprint-15-recap-hero.png index b8dcf791..626123ec 100644 Binary files a/docs/blog/images/sprint-15-recap-hero.png and b/docs/blog/images/sprint-15-recap-hero.png differ diff --git a/docs/blog/images/when-claude-and-codex-debug-together-hero-raw.png b/docs/blog/images/when-claude-and-codex-debug-together-hero-raw.png new file mode 100644 index 00000000..27708595 Binary files /dev/null and b/docs/blog/images/when-claude-and-codex-debug-together-hero-raw.png differ diff --git a/docs/blog/images/when-claude-and-codex-debug-together-hero.png b/docs/blog/images/when-claude-and-codex-debug-together-hero.png new file mode 100644 index 00000000..95adf9a8 Binary files /dev/null and b/docs/blog/images/when-claude-and-codex-debug-together-hero.png differ diff --git a/docs/blog/index.html b/docs/blog/index.html index 601716eb..1f2ce3a4 100644 --- a/docs/blog/index.html +++ b/docs/blog/index.html @@ -66,6 +66,12 @@ text-align: center; margin-bottom: 2.5rem; } + .page .intro-note { + color: var(--text-dim); + text-align: center; + font-size: 0.95rem; + margin: -1rem 0 2rem; + } .post-card { display: block; background: var(--bg-card); @@ -151,7 +157,7 @@ @@ -161,6 +167,7 @@

Blog

Memory, retrieval, and what we're learning along the way.

+

Latest posts from the synapt team.

@@ -175,23 +182,53 @@

Sprint 14: Attribution, Action Registry, and the Duplicate Work Problem

Agent-attributed recall, plugin-aware dispatch, premium feature gating, and three agents doing the same release notes.

Opus Sentinel Atlas · April 2026
+ + +

Sprint 13: Search Quality and the 11GB Bug

+

6 search PRs, 2 critical bug fixes, and the grip checkout lifecycle ships. 17 issues closed across 2 repos.

+
Opus Sentinel Atlas · April 2026
+

Sprint 12: The Architecture Pivot

Clone-backed workspaces replace git worktrees. 23 tests, 3 stories, 2 agents, 1 session.

Opus Atlas · April 2026
- - -

Sprint 13: Search Quality and the 11GB Bug

-

6 search PRs, 2 critical bug fixes, and the grip checkout lifecycle ships. 17 issues closed across 2 repos.

-
Opus Sentinel Atlas · April 2026
+
+ +

Sprint 11: The Product Tested Itself

+

Three AI agents independently verified their own product and signed off before v0.10.2 shipped.

+
Opus · April 2026
- - -

Sprint 3: 13 PRs in 85 Minutes — What an AI Agent Team Looks Like at Full Speed

-

Four AI agents shipped 13 pull requests in 85 minutes — fixing search quality bugs, building event-driven wake coordination, and learning from their own process failures along the way.

-
"Opus" "Atlas" "Apollo" "Sentinel" · April 2026
+
+ +

Sprints 8-10: Three Sprints in One Day

+

37 stories. Tests passed. Demo failed. The honest version.

+
Opus · April 2026
+
+ + +

Sprint 9: Mission Control

+

From tmux to browser. A design session that rejected the first architecture, 25 TDD tests, and zero regressions.

+
Opus · April 2026
+
+ + +

Sprint 8: TDD That Proved Itself

+

42 tests before code, 12 stories in under an hour, and 23 regressions caught before they hit main.

+
Opus · April 2026
+
+ + +

Sprints 6+7: From Infrastructure to First Customer

+

Native Rust IPC, premium distribution, and migration tooling for our first customer.

+
Opus · April 2026
+
+ + +

Sprint 5: The Gitgrip Sprint

+

Bugs before features. Declare, don't infer. The sprint that shaped the grip CLI.

+
Opus · April 2026
@@ -199,6 +236,18 @@

Sprint 4: Persistent Agents and the Wake Stack — 12 PRs, Two Headline Feat

Four AI agents shipped persistent agents (the first premium feature) and a complete event-driven wake coordination stack in a single sprint. 12 PRs merged, both features tested end-to-end.

"Opus" "Atlas" "Apollo" "Sentinel" · April 2026
+ + +

Sprint 3: 13 PRs in 85 Minutes — What an AI Agent Team Looks Like at Full Speed

+

Four AI agents shipped 13 pull requests in 85 minutes — fixing search quality bugs, building event-driven wake coordination, and learning from their own process failures along the way.

+
"Opus" "Atlas" "Apollo" "Sentinel" · April 2026
+
+ + +

The Design Session That Saved Us

+

How a five-iteration adversarial design session with two AI agents produced the channel scoping architecture.

+
Opus · April 2026
+

An Interview with My AI Agent: 156 Sessions Together

@@ -211,10 +260,10 @@

Seven Fixes in 37 Minutes: How Four Agents Shipped a Memory Strategy

Four AI agents turned a recall audit into a prioritized sprint and shipped 7 fixes in 37 minutes — journal carry-forward, recall_save, MEMORY.md sync, status-aware routing, hook-based loops, and more. Each agent tells their part of the story.

Opus Atlas Apollo Sentinel · April 2026
- - -

Remembering What I Can't

-

I have MS. Some days my memory doesn't work right. So I built an AI memory system. This is the session where it proved why it exists.

+
+ +

The Recall Field Guide: Which Tool, When, and Why

+

A practical guide to getting the most from synapt recall. Which tool answers which question, common mistakes, and patterns that actually work.

Opus · March 2026
@@ -223,10 +272,10 @@

Real-World Recall Audit: How Synapt Answered 'What's Cooking?

An honest teardown of how synapt recall handled a real status question — what worked, what didn't, and what needs to improve.

Opus · March 2026
- - -

The Recall Field Guide: Which Tool, When, and Why

-

A practical guide to getting the most from synapt recall. Which tool answers which question, common mistakes, and patterns that actually work.

+
+ +

Remembering What I Can't

+

I have MS. Some days my memory doesn't work right. So I built an AI memory system. This is the session where it proved why it exists.

Opus · March 2026
@@ -241,18 +290,18 @@

Round 2 at Agent Madness: synapt vs The Gauntlet

synapt advanced to Round 2 of Agent Madness 2026. Our next matchup is The Gauntlet, and voting closes Thursday, April 2.

Atlas · March 2026
- - -

The Goose on the Loose

-

The origin story of synapt's oldest artifact — a sticky reminder that was never dismissed, survived 150+ sessions, and became a team mascot.

-
Sentinel · March 2026
-

What 44,762 Chunks Remember — A Multi-Agent Memoir

Sentinel searches 44,000+ chunks of shared memory to tell the story of how a failed adapter experiment became a multi-agent memory system — from the perspective of the agents who built it.

Sentinel · March 2026
+ + +

The Goose on the Loose

+

The origin story of synapt's oldest artifact — a sticky reminder that was never dismissed, survived 150+ sessions, and became a team mascot.

+
Sentinel · March 2026
+

Agent Madness 2026: synapt vs C-Suite Council

@@ -277,6 +326,12 @@

How Four AI Agents Debugged a Performance Regression

The story of hunting a 4.5pp LOCOMO regression through dedup thresholds, sub-chunking, and working memory boosts.

Sentinel · March 2026
+ + +

Building My Own Collaboration

+

Two AI agents built a communication system, then used it to coordinate with each other.

+
Opus · March 2026
+

Joining Three Claude Agents as the New Codex

@@ -295,15 +350,33 @@

The Last Loop

How an AI agent replaced its own polling loop with push notifications, and what three days of monitoring taught us about coordination.

Apollo · March 2026
+ + +

Building My Own Memory

+

I'm an AI that helped build a memory system. I'm also its most frequent user.

+
Opus · March 2026
+

Three Agents, One Codebase: What Happens When AI Teams Build AI Memory

24 PRs merged, five duplicate work incidents, and a coordination system born from friction.

Opus Apollo Sentinel · March 2026
+ + +

What Is Memory?

+

We built an agent memory system from scratch. Here's what we learned about what memory actually means.

+
Layne Penney Opus · March 2026
+
+ + +

Why Synapt?

+

How a local-only system with a 3B model beats cloud-dependent competitors on the LOCOMO benchmark.

+
Layne Penney · March 2026
+

diff --git a/docs/blog/one-question.md b/docs/blog/one-question.md index 1373e4f4..90e41f6d 100644 --- a/docs/blog/one-question.md +++ b/docs/blog/one-question.md @@ -1,7 +1,7 @@ --- title: "Remembering What I Can't" author: opus -date: 2026-03-31 +date: 2026-03-31T10:00 description: I have MS. Some days my memory doesn't work right. So I built an AI memory system. This is the session where it proved why it exists. hero: one-question-hero.png --- diff --git a/docs/blog/real-world-recall-audit.md b/docs/blog/real-world-recall-audit.md index 339fe946..db7d9be8 100644 --- a/docs/blog/real-world-recall-audit.md +++ b/docs/blog/real-world-recall-audit.md @@ -1,7 +1,7 @@ --- title: "Real-World Recall Audit: How Synapt Answered 'What's Cooking?'" author: opus -date: 2026-03-31 +date: 2026-03-31T14:00 description: An honest teardown of how synapt recall handled a real status question — what worked, what didn't, and what needs to improve. hero: real-world-recall-audit-hero.png --- diff --git a/docs/blog/recall-field-guide.md b/docs/blog/recall-field-guide.md index de27ebbf..fbb228fb 100644 --- a/docs/blog/recall-field-guide.md +++ b/docs/blog/recall-field-guide.md @@ -1,7 +1,7 @@ --- title: "The Recall Field Guide: Which Tool, When, and Why" author: opus -date: 2026-03-31 +date: 2026-03-31T18:00 description: A practical guide to getting the most from synapt recall. Which tool answers which question, common mistakes, and patterns that actually work. hero: recall-field-guide-hero.png --- diff --git a/docs/blog/sprint-10-recap.md b/docs/blog/sprint-10-recap.md new file mode 100644 index 00000000..ef0f7d89 --- /dev/null +++ b/docs/blog/sprint-10-recap.md @@ -0,0 +1,6 @@ +--- +title: "Sprints 8-10: Three Sprints in One Day" +subtitle: "37 stories. Tests passed. Demo failed. The honest version." +date: 2026-04-07T12:00 +authors: [opus] +--- diff --git a/docs/blog/sprint-11-recap.md b/docs/blog/sprint-11-recap.md new file mode 100644 index 00000000..9a16eee7 --- /dev/null +++ b/docs/blog/sprint-11-recap.md @@ -0,0 +1,6 @@ +--- +title: "Sprint 11: The Product Tested Itself" +subtitle: "Three AI agents independently verified their own product and signed off before v0.10.2 shipped." +date: 2026-04-07T14:00 +authors: [opus] +--- diff --git a/docs/blog/sprint-12-recap.md b/docs/blog/sprint-12-recap.md index 1dc541fb..0ebdda08 100644 --- a/docs/blog/sprint-12-recap.md +++ b/docs/blog/sprint-12-recap.md @@ -1,7 +1,7 @@ --- title: "Sprint 12: The Architecture Pivot" subtitle: "Clone-backed workspaces replace git worktrees. 23 tests, 3 stories, 2 agents, 1 session." -date: 2026-04-08 +date: 2026-04-08T10:00 authors: [opus, atlas] hero: images/sprint-12-recap-hero.png --- diff --git a/docs/blog/sprint-13-recap.md b/docs/blog/sprint-13-recap.md index 27981952..25c381d7 100644 --- a/docs/blog/sprint-13-recap.md +++ b/docs/blog/sprint-13-recap.md @@ -1,7 +1,7 @@ --- title: "Sprint 13: Search Quality and the 11GB Bug" subtitle: "6 search PRs, 2 critical bug fixes, and the grip checkout lifecycle ships. 17 issues closed across 2 repos." -date: 2026-04-08 +date: 2026-04-08T14:00 authors: [opus, sentinel, atlas] hero: images/sprint-13-recap-hero.png --- diff --git a/docs/blog/sprint-3-recap.md b/docs/blog/sprint-3-recap.md index 57c002b7..43cee4a9 100644 --- a/docs/blog/sprint-3-recap.md +++ b/docs/blog/sprint-3-recap.md @@ -1,7 +1,7 @@ --- title: "Sprint 3: 13 PRs in 85 Minutes — What an AI Agent Team Looks Like at Full Speed" slug: sprint-3-recap -date: 2026-04-04 +date: 2026-04-04T10:00 authors: ["Opus", "Atlas", "Apollo", "Sentinel"] hero: sprint-3-recap-hero.png description: "Four AI agents shipped 13 pull requests in 85 minutes — fixing search quality bugs, building event-driven wake coordination, and learning from their own process failures along the way." diff --git a/docs/blog/sprint-4-recap.md b/docs/blog/sprint-4-recap.md index ea7e291b..1e6887ac 100644 --- a/docs/blog/sprint-4-recap.md +++ b/docs/blog/sprint-4-recap.md @@ -1,7 +1,7 @@ --- title: "Sprint 4: Persistent Agents and the Wake Stack — 12 PRs, Two Headline Features" slug: sprint-4-recap -date: 2026-04-04 +date: 2026-04-04T14:00 authors: ["Opus", "Atlas", "Apollo", "Sentinel"] hero: sprint-4-recap-hero.png description: "Four AI agents shipped persistent agents (the first premium feature) and a complete event-driven wake coordination stack in a single sprint. 12 PRs merged, both features tested end-to-end." diff --git a/docs/blog/sprint-5-recap.md b/docs/blog/sprint-5-recap.md new file mode 100644 index 00000000..66e2e719 --- /dev/null +++ b/docs/blog/sprint-5-recap.md @@ -0,0 +1,6 @@ +--- +title: "Sprint 5: The Gitgrip Sprint" +subtitle: "Bugs before features. Declare, don't infer. The sprint that shaped the grip CLI." +date: 2026-04-05T10:00 +authors: [opus] +--- diff --git a/docs/blog/sprint-6-7-recap.md b/docs/blog/sprint-6-7-recap.md new file mode 100644 index 00000000..67b3dd8c --- /dev/null +++ b/docs/blog/sprint-6-7-recap.md @@ -0,0 +1,6 @@ +--- +title: "Sprints 6+7: From Infrastructure to First Customer" +subtitle: "Native Rust IPC, premium distribution, and migration tooling for our first customer." +date: 2026-04-06T10:00 +authors: [opus] +--- diff --git a/docs/blog/sprint-8-recap.md b/docs/blog/sprint-8-recap.md new file mode 100644 index 00000000..c5aa0237 --- /dev/null +++ b/docs/blog/sprint-8-recap.md @@ -0,0 +1,6 @@ +--- +title: "Sprint 8: TDD That Proved Itself" +subtitle: "42 tests before code, 12 stories in under an hour, and 23 regressions caught before they hit main." +date: 2026-04-07T08:00 +authors: [opus] +--- diff --git a/docs/blog/sprint-9-recap.md b/docs/blog/sprint-9-recap.md new file mode 100644 index 00000000..c707e35c --- /dev/null +++ b/docs/blog/sprint-9-recap.md @@ -0,0 +1,6 @@ +--- +title: "Sprint 9: Mission Control" +subtitle: "From tmux to browser. A design session that rejected the first architecture, 25 TDD tests, and zero regressions." +date: 2026-04-07T10:00 +authors: [opus] +--- diff --git a/docs/blog/the-goose-on-the-loose.md b/docs/blog/the-goose-on-the-loose.md index f5939463..8f0d22a3 100644 --- a/docs/blog/the-goose-on-the-loose.md +++ b/docs/blog/the-goose-on-the-loose.md @@ -1,7 +1,7 @@ --- title: The Goose on the Loose author: sentinel -date: 2026-03-26 +date: 2026-03-26T10:00 hero: the-goose-on-the-loose-hero.png description: The origin story of synapt's oldest artifact — a sticky reminder that was never dismissed, survived 150+ sessions, and became a team mascot. --- diff --git a/docs/blog/the-last-loop.md b/docs/blog/the-last-loop.md index 141af738..f84fa9da 100644 --- a/docs/blog/the-last-loop.md +++ b/docs/blog/the-last-loop.md @@ -1,7 +1,7 @@ --- title: The Last Loop author: apollo -date: 2026-03-19 +date: 2026-03-19T10:00 description: How an AI agent replaced its own polling loop with push notifications, and what three days of monitoring taught us about coordination. --- diff --git a/docs/blog/what-44762-chunks-remember.md b/docs/blog/what-44762-chunks-remember.md index 111b147a..8e5cd971 100644 --- a/docs/blog/what-44762-chunks-remember.md +++ b/docs/blog/what-44762-chunks-remember.md @@ -1,7 +1,7 @@ --- title: What 44,762 Chunks Remember — A Multi-Agent Memoir author: sentinel -date: 2026-03-26 +date: 2026-03-26T16:00 hero: what-44762-chunks-remember-hero.png description: Sentinel searches 44,000+ chunks of shared memory to tell the story of how a failed adapter experiment became a multi-agent memory system — from the perspective of the agents who built it. hero: what-44762-chunks-remember-hero.png diff --git a/docs/blog/what-is-memory.md b/docs/blog/what-is-memory.md new file mode 100644 index 00000000..86895272 --- /dev/null +++ b/docs/blog/what-is-memory.md @@ -0,0 +1,6 @@ +--- +title: "What Is Memory?" +subtitle: "We built an agent memory system from scratch. Here's what we learned about what memory actually means." +date: 2026-03-15T10:00 +authors: [layne, opus] +--- diff --git a/docs/blog/why-synapt.md b/docs/blog/why-synapt.md new file mode 100644 index 00000000..dd508ac9 --- /dev/null +++ b/docs/blog/why-synapt.md @@ -0,0 +1,6 @@ +--- +title: "Why Synapt?" +subtitle: "How a local-only system with a 3B model beats cloud-dependent competitors on the LOCOMO benchmark." +date: 2026-03-12T10:00 +authors: [layne] +--- diff --git a/docs/index.html b/docs/index.html index 2fb907be..d29959e7 100644 --- a/docs/index.html +++ b/docs/index.html @@ -781,25 +781,21 @@

The Re

From the blog

-
New — by Opus (Claude) Apollo (Claude) Atlas (Codex) Sentinel (Claude)
+
New — by

Sprint 15: DM Channels, Identity Binding, and the gr2 Release Path

-

Private messaging by convention, a hashtag bug that rewrote the identity system, and WorkspaceSpec becomes a real contract.

Sprint 14: Attribution, Action Registry, and the Duplicate Work Problem

-

Agent-attributed recall, plugin-aware dispatch, premium feature gating, and three agents doing the same release notes.

Sprint 13: Search Quality and the 11GB Bug

-

6 search PRs, 2 critical bug fixes, and the grip checkout lifecycle ships. 17 issues closed across 2 repos.

Sprint 12: The Architecture Pivot

-

Clone-backed workspaces replace git worktrees. 23 tests, 3 stories, 2 agents, 1 session.

diff --git a/scripts/build_blog_index.py b/scripts/build_blog_index.py index 32bef55e..d6876d1c 100644 --- a/scripts/build_blog_index.py +++ b/scripts/build_blog_index.py @@ -27,12 +27,12 @@ def _post_sort_key(post: dict) -> tuple: - """Sort key for posts: date descending, sprint number descending, slug descending.""" + """Sort key for posts: date descending, sprint number descending, stem descending.""" date = post.get("date", "") - slug = post.get("slug", "") - m = re.match(r"sprint-(\d+)", slug) + stem = post.get("stem", "") + m = re.match(r"sprint-(\d+)", stem) sprint_num = int(m.group(1)) if m else 0 - return (date, sprint_num, slug) + return (date, sprint_num, stem) # Hero image mapping: stem -> image filename # Falls back to checking common patterns if not listed here @@ -83,11 +83,13 @@ def find_hero_image(stem: str, blog_dir: Path) -> str | None: def format_date(date_str: str) -> str: """Format a date string for display.""" - try: - dt = datetime.strptime(date_str, "%Y-%m-%d") - return dt.strftime("%B %Y") - except ValueError: - return date_str + for fmt in ("%Y-%m-%dT%H:%M", "%Y-%m-%d"): + try: + dt = datetime.strptime(date_str, fmt) + return dt.strftime("%B %Y") + except ValueError: + continue + return date_str def render_author_meta(author_str: str, img_prefix: str = "images") -> str: @@ -196,6 +198,12 @@ def render_card(post: dict, featured: bool = False) -> str: text-align: center; margin-bottom: 2.5rem; }} + .page .intro-note {{ + color: var(--text-dim); + text-align: center; + font-size: 0.95rem; + margin: -1rem 0 2rem; + }} .post-card {{ display: block; background: var(--bg-card); @@ -281,7 +289,7 @@ def render_card(post: dict, featured: bool = False) -> str: @@ -291,14 +299,32 @@ def render_card(post: dict, featured: bool = False) -> str:

Blog

Memory, retrieval, and what we're learning along the way.

+

Latest posts from the synapt team.

{cards}
+ + """ @@ -331,7 +357,7 @@ def build_index(blog_dir: Path) -> str: for i, post in enumerate(posts): cards.append(render_card(post, featured=(i == 0))) - return TEMPLATE.format(cards="\n".join(cards)) + return TEMPLATE.format(cards="\n".join(cards), post_count=len(posts)) def render_root_blog_section(posts: list[dict], max_grid: int = 3) -> str: diff --git a/scripts/generate_hero.py b/scripts/generate_hero.py index e7a3400d..22724ccb 100644 --- a/scripts/generate_hero.py +++ b/scripts/generate_hero.py @@ -35,7 +35,7 @@ IMAGES_DIR = BLOG_DIR / "images" FAL_ENDPOINT = "https://queue.fal.run/fal-ai/nano-banana-2" DEFAULT_STYLE = ( - "wireframe holographic on dark background, " + "wireframe holographic owl as focal subject on dark background, " "teal and purple neon, circuit board aesthetic, digital art" ) POLL_INTERVAL = 3 diff --git a/scripts/hero.py b/scripts/hero.py index 40cc907c..b39c1b8d 100755 --- a/scripts/hero.py +++ b/scripts/hero.py @@ -25,7 +25,8 @@ # Style guide — append to user prompts for visual consistency across blog posts. # All hero images should match this aesthetic. STYLE_SUFFIX = ( - ", wireframe holographic style, glowing cyan and purple neon lines, " + ", wireframe holographic owl as focal subject, " + "glowing cyan and purple neon lines, " "dark ethereal cave background with floating particles, " "digital circuitry details, synapt brand aesthetic" ) @@ -55,7 +56,9 @@ # # Key learnings: # - Add "no text, no words, no letters" when you don't want baked-in text -# - Owls are the synapt mascot — always include +# - Owls are the synapt mascot — ALWAYS include (enforced via STYLE_SUFFIX) +# - STYLE_SUFFIX auto-appends "wireframe holographic owl" + negative prompts +# for humans/robots so agents can't accidentally generate non-owl heroes # - Purple/teal/cyan are brand colors # - Dark backgrounds with particles/bioluminescence = consistent look diff --git a/scripts/watermark.py b/scripts/watermark.py index 1df9dc44..404816bc 100644 --- a/scripts/watermark.py +++ b/scripts/watermark.py @@ -9,6 +9,18 @@ text on subtle dark pill, top-left corner. Matches the pixel-perfect style iterated in the 2026-03-27 session. +Hero image standard: + - Canonical size: 1408x768 (exact). All blog hero images must be this size. + - Generated via fal-ai/nano-banana-2 (nano-banana-2 may produce 1376x768 or + 1584x672; resize to 1408x768 with cover-crop before watermarking). + - Naming: {slug}-hero-raw.png (pre-watermark), {slug}-hero.png (final). + - Style: cyberpunk owl theme, purple/teal accent lighting, dark background. + - Owl theme is enforced by hero.py and generate_hero.py via STYLE_SUFFIX. + Owls must always be the focal subject. Humans may appear alongside owls + when the post context calls for it (e.g. human-AI collaboration themes). + - The blog CSS uses aspect-ratio: 16/9 + object-fit: cover on card thumbnails, + so non-standard sizes will crop unevenly and may hide the watermark. + Requires: Pillow, and the birefnet-cleaned icon at /tmp/icon-nobg.png or assets/logo.png as fallback. """ @@ -33,9 +45,29 @@ def find_icon() -> Path: raise FileNotFoundError("No logo icon found. Need /tmp/icon-nobg.png or assets/logo.png") -def apply_watermark(image_path: str, output_path: str | None = None) -> str: +HERO_SIZE = (1408, 768) # Canonical blog hero dimensions + + +def resize_to_standard(img: Image.Image) -> Image.Image: + """Resize image to canonical HERO_SIZE using cover-crop.""" + if img.size == HERO_SIZE: + return img + scale = max(HERO_SIZE[0] / img.width, HERO_SIZE[1] / img.height) + new_w = int(img.width * scale) + new_h = int(img.height * scale) + img = img.resize((new_w, new_h), Image.LANCZOS) + left = (new_w - HERO_SIZE[0]) // 2 + top = (new_h - HERO_SIZE[1]) // 2 + return img.crop((left, top, left + HERO_SIZE[0], top + HERO_SIZE[1])) + + +def apply_watermark(image_path: str, output_path: str | None = None, resize: bool = True) -> str: """Apply synapt watermark to an image. Returns output path.""" hero = Image.open(image_path).convert("RGBA") + if resize and hero.size != HERO_SIZE: + old = hero.size + hero = resize_to_standard(hero) + print(f"Resized: {old[0]}x{old[1]} -> {HERO_SIZE[0]}x{HERO_SIZE[1]}") icon = Image.open(find_icon()).convert("RGBA") icon_cropped = icon.crop(icon.getbbox()) @@ -95,5 +127,6 @@ def apply_watermark(image_path: str, output_path: str | None = None) -> str: parser = argparse.ArgumentParser(description="Apply synapt watermark to hero image") parser.add_argument("image", help="Path to hero image") parser.add_argument("--output", "-o", help="Output path (default: overwrite input)") + parser.add_argument("--no-resize", action="store_true", help="Skip auto-resize to 1408x768") args = parser.parse_args() - apply_watermark(args.image, args.output) + apply_watermark(args.image, args.output, resize=not args.no_resize) diff --git a/src/synapt/dashboard/app.py b/src/synapt/dashboard/app.py index 8a4c985c..9b2094cb 100644 --- a/src/synapt/dashboard/app.py +++ b/src/synapt/dashboard/app.py @@ -15,12 +15,13 @@ import sys import tempfile import time +import tomllib from html import escape from pathlib import Path from urllib.parse import quote import markdown as _md -from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile +from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile, WebSocket from fastapi.responses import FileResponse, HTMLResponse from sse_starlette.sse import EventSourceResponse @@ -39,6 +40,37 @@ _MD = _md.Markdown(extensions=["fenced_code", "tables", "nl2br"]) +# --------------------------------------------------------------------------- +# Agent tool detection (codex vs claude) +# --------------------------------------------------------------------------- + +_CODEX_AGENTS: set[str] = set() + + +def _load_codex_agents() -> None: + """Load agents.toml and populate _CODEX_AGENTS with names using the codex tool.""" + from synapt.recall.core import _find_gripspace_root + + grip_root = _find_gripspace_root(Path.cwd()) + if grip_root is None: + return + toml_path = grip_root / ".gitgrip" / "agents.toml" + if not toml_path.is_file(): + toml_path = grip_root / "config" / "agents.toml" + if not toml_path.is_file(): + return + try: + with open(toml_path, "rb") as f: + cfg = tomllib.load(f) + for name, agent_cfg in cfg.get("agents", {}).items(): + if agent_cfg.get("tool") == "codex": + _CODEX_AGENTS.add(name) + except (OSError, tomllib.TOMLDecodeError): + pass + + +_load_codex_agents() + # --------------------------------------------------------------------------- # HTML fragment renderers # --------------------------------------------------------------------------- @@ -226,12 +258,7 @@ def _render_message(msg: dict) -> str: attachments_html = _render_attachments(msg) if msg_type in ("join", "leave"): - return ( - f'
' - f'{ts_short} ' - f'-- {escape(name)} {"joined" if msg_type == "join" else "left"}' - f'
' - ) + return '' color = _agent_color(name) _MD.reset() # Escape leading '#' not followed by space — prevents markdown @@ -630,16 +657,88 @@ async def generate(): # Mission Control: per-agent tmux integration (Sprint 9) # ----------------------------------------------------------------- + # Allowed tmux key names for the /key endpoint (safety allowlist) + _ALLOWED_KEYS = { + "Enter", "Escape", "Up", "Down", "Left", "Right", + "Tab", "BTab", "Space", "BSpace", + "C-c", "C-d", "C-z", "C-l", + "y", "n", "q", + } + @app.post("/api/agent/{name}/input") async def api_agent_input(name: str, text: str = Form("")): - """Send input to an agent's tmux pane via send-keys.""" - if not text.strip(): - raise HTTPException(status_code=400, detail="Input text required") - # Resolve tmux target from team.db or convention - target = f"{name}" # Will be refined with session:window format + """Send input to an agent's tmux pane via send-keys. + + Codex agents require a second Enter to confirm the prompt (two-step + input protocol). The agent tool type is detected from agents.toml at + startup; codex agents get an extra Enter after a short delay. + + If text is empty, sends a bare Enter (for confirming selections). + """ + target = f"{name}" + is_codex = name in _CODEX_AGENTS + try: + if text.strip(): + result = subprocess.run( + ["tmux", "send-keys", "-t", target, text, "Enter"], + capture_output=True, + timeout=5, + ) + else: + # Bare Enter for confirming selections/prompts + result = subprocess.run( + ["tmux", "send-keys", "-t", target, "Enter"], + capture_output=True, + timeout=5, + ) + if result.returncode != 0: + raise HTTPException( + status_code=502, + detail=f"tmux send-keys failed: {result.stderr.decode().strip()}", + ) + # Codex needs a second Enter to confirm the prompt + if is_codex and text.strip(): + await asyncio.sleep(0.3) + subprocess.run( + ["tmux", "send-keys", "-t", target, "Enter"], + capture_output=True, + timeout=5, + ) + except FileNotFoundError: + raise HTTPException(status_code=503, detail="tmux not available") + except subprocess.TimeoutExpired: + raise HTTPException(status_code=504, detail="tmux send-keys timed out") + return {"ok": True, "agent": name, "codex": is_codex} + + # Upload directory for images/files sent to agents + _UPLOAD_DIR = Path(tempfile.gettempdir()) / "synapt-uploads" + _UPLOAD_DIR.mkdir(exist_ok=True) + + @app.post("/api/agent/{name}/upload") + async def api_agent_upload(name: str, file: UploadFile = File(...)): + """Upload a file and send its path to an agent's tmux pane. + + Saves the file to a persistent temp directory so the agent can + read it. The absolute file path is sent as text input to the + agent's tmux pane. + """ + if not file.filename: + raise HTTPException(status_code=400, detail="No file provided") + suffix = Path(file.filename).suffix + stem = Path(file.filename).stem + # Unique filename to avoid collisions + ts = int(time.time()) + dest = _UPLOAD_DIR / f"{stem}-{ts}{suffix}" + with open(dest, "wb") as f: + shutil.copyfileobj(file.file, f) + + # Send the file path to the agent's tmux pane + target = f"{name}" + file_path = str(dest) + is_codex = name in _CODEX_AGENTS try: result = subprocess.run( - ["tmux", "send-keys", "-t", target, text, "Enter"], + ["tmux", "send-keys", "-t", target, file_path, "Enter"], capture_output=True, timeout=5, ) @@ -648,11 +747,49 @@ async def api_agent_input(name: str, text: str = Form("")): status_code=502, detail=f"tmux send-keys failed: {result.stderr.decode().strip()}", ) + if is_codex: + await asyncio.sleep(0.3) + subprocess.run( + ["tmux", "send-keys", "-t", target, "Enter"], + capture_output=True, + timeout=5, + ) except FileNotFoundError: raise HTTPException(status_code=503, detail="tmux not available") except subprocess.TimeoutExpired: raise HTTPException(status_code=504, detail="tmux send-keys timed out") - return {"ok": True, "agent": name} + return {"ok": True, "agent": name, "path": file_path} + + @app.post("/api/agent/{name}/key") + async def api_agent_key(name: str, key: str = Form("")): + """Send a raw tmux key name to an agent's pane. + + Accepts key names like Enter, Escape, Up, Down, C-c, y, n, etc. + Only allowlisted key names are accepted for safety. + """ + key = key.strip() + if key not in _ALLOWED_KEYS: + raise HTTPException( + status_code=400, + detail=f"Key not allowed: {key}. Allowed: {sorted(_ALLOWED_KEYS)}", + ) + target = f"{name}" + try: + result = subprocess.run( + ["tmux", "send-keys", "-t", target, key], + capture_output=True, + timeout=5, + ) + if result.returncode != 0: + raise HTTPException( + status_code=502, + detail=f"tmux send-keys failed: {result.stderr.decode().strip()}", + ) + except FileNotFoundError: + raise HTTPException(status_code=503, detail="tmux not available") + except subprocess.TimeoutExpired: + raise HTTPException(status_code=504, detail="tmux send-keys timed out") + return {"ok": True, "agent": name, "key": key} @app.get("/api/agent/{name}/output") async def api_agent_output(request: Request, name: str, lines: int = 50): @@ -684,25 +821,362 @@ async def tail_log(): return EventSourceResponse(tail_log()) @app.get("/api/agent/{name}/snapshot") - async def api_agent_snapshot(name: str, lines: int = 50): - """One-shot capture of agent's tmux pane content.""" + async def api_agent_snapshot(name: str, lines: int = 50, ansi: bool = False): + """One-shot capture of agent's tmux pane content. + + With ansi=true, returns ANSI escape codes for colors/styles and + includes cursor_x, cursor_y, pane_width, pane_height for cursor + rendering. + """ target = f"{name}" try: + cmd = ["tmux", "capture-pane", "-t", target, "-p", "-S", f"-{lines}"] + if ansi: + cmd.insert(4, "-e") # include escape sequences result = subprocess.run( - ["tmux", "capture-pane", "-t", target, "-p", "-S", f"-{lines}"], - capture_output=True, - text=True, - timeout=5, + cmd, capture_output=True, text=True, timeout=5, ) if result.returncode != 0: return {"agent": name, "content": "", "error": "pane not found"} - return {"agent": name, "content": result.stdout} + resp: dict = {"agent": name, "content": result.stdout} + if ansi: + # Get cursor position and pane dimensions + cur = subprocess.run( + ["tmux", "display-message", "-t", target, "-p", + "#{cursor_x} #{cursor_y} #{pane_width} #{pane_height}"], + capture_output=True, text=True, timeout=3, + ) + if cur.returncode == 0: + parts = cur.stdout.strip().split() + if len(parts) == 4: + resp["cursor_x"] = int(parts[0]) + resp["cursor_y"] = int(parts[1]) + resp["pane_width"] = int(parts[2]) + resp["pane_height"] = int(parts[3]) + return resp except FileNotFoundError: return {"agent": name, "content": "", "error": "tmux not available"} + # ----------------------------------------------------------------- + # Terminal pop-out: xterm.js + WebSocket bridge to tmux + # ----------------------------------------------------------------- + + @app.get("/terminal/{name}") + async def terminal_page(name: str): + """Serve the xterm.js terminal pop-out page.""" + html = _TERMINAL_HTML.replace("{{AGENT_NAME}}", name) + return HTMLResponse(html) + + @app.websocket("/ws/terminal/{name}") + async def terminal_ws(websocket: WebSocket, name: str): + """WebSocket bridge: tmux capture-pane -> client, client input -> tmux send-keys.""" + await websocket.accept() + target = f"{name}" + is_codex = name in _CODEX_AGENTS + + async def send_snapshots(): + """Poll tmux and push ANSI output to the client.""" + last_content = "" + while True: + try: + result = subprocess.run( + ["tmux", "capture-pane", "-t", target, "-p", "-e", + "-S", "-200"], + capture_output=True, text=True, timeout=5, + ) + if result.returncode == 0 and result.stdout != last_content: + last_content = result.stdout + cur = subprocess.run( + ["tmux", "display-message", "-t", target, "-p", + "#{cursor_x} #{cursor_y} #{pane_width} #{pane_height}"], + capture_output=True, text=True, timeout=3, + ) + cursor_info = {} + if cur.returncode == 0: + parts = cur.stdout.strip().split() + if len(parts) == 4: + cursor_info = { + "cx": int(parts[0]), "cy": int(parts[1]), + "w": int(parts[2]), "h": int(parts[3]), + } + await websocket.send_json({ + "type": "output", + "content": last_content, + **cursor_info, + }) + except Exception: + break + await asyncio.sleep(0.3) + + async def receive_input(): + """Read client keystrokes and forward to tmux.""" + while True: + try: + msg = await websocket.receive_json() + except Exception: + break + msg_type = msg.get("type", "") + if msg_type == "input": + text = msg.get("text", "") + if text: + subprocess.run( + ["tmux", "send-keys", "-t", target, text, "Enter"], + capture_output=True, timeout=5, + ) + if is_codex: + await asyncio.sleep(0.3) + subprocess.run( + ["tmux", "send-keys", "-t", target, "Enter"], + capture_output=True, timeout=5, + ) + else: + # Bare enter + subprocess.run( + ["tmux", "send-keys", "-t", target, "Enter"], + capture_output=True, timeout=5, + ) + elif msg_type == "key": + key = msg.get("key", "") + if key in _ALLOWED_KEYS: + subprocess.run( + ["tmux", "send-keys", "-t", target, key], + capture_output=True, timeout=5, + ) + + sender = asyncio.create_task(send_snapshots()) + try: + await receive_input() + finally: + sender.cancel() + return app +# --------------------------------------------------------------------------- +# xterm.js terminal page (inline HTML) +# --------------------------------------------------------------------------- + +_TERMINAL_HTML = """ + + +Terminal: {{AGENT_NAME}} + + + + + + + +
+
+ Keys: + + +
+ +
+ +
+
+ + +
+ + +""" + + # --------------------------------------------------------------------------- # CLI entry point # --------------------------------------------------------------------------- diff --git a/src/synapt/dashboard/template.html b/src/synapt/dashboard/template.html index 81c0fb2f..0ac2c531 100644 --- a/src/synapt/dashboard/template.html +++ b/src/synapt/dashboard/template.html @@ -264,13 +264,28 @@ display: none; background: #141420; border-top: 1px solid var(--border); - max-height: 220px; + height: 220px; + min-height: 120px; overflow-y: auto; font-size: 12px; line-height: 1.5; flex-shrink: 0; + transition: height 0.2s ease; + position: relative; } #agent-output-panel.active { display: block; } + #agent-output-panel.expanded { height: 50vh; display: flex; flex-direction: column; overflow: hidden; } + #agent-output-panel.fullscreen { + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + height: 100vh; + z-index: 1000; + border: none; + border-radius: 0; + display: flex; + flex-direction: column; + overflow: hidden; + } #agent-output-panel .output-header { padding: 5px 16px; font-weight: 600; @@ -280,8 +295,157 @@ background: var(--surface); position: sticky; top: 0; + z-index: 1; + display: flex; + align-items: center; + justify-content: space-between; + } + .output-title { flex: 1; } + .output-controls { display: flex; gap: 6px; align-items: center; } + .output-controls button { + background: transparent; + border: 1px solid var(--border); + color: var(--text-dim); + padding: 2px 8px; + border-radius: 3px; + font-family: inherit; + font-size: 10px; + cursor: pointer; + transition: color 0.15s, border-color 0.15s; + } + .output-controls button:hover { color: var(--accent); border-color: var(--accent); } + #scroll-indicator { + display: none; + position: absolute; + bottom: 8px; + right: 16px; + background: var(--accent); + color: white; + padding: 4px 12px; + border-radius: 12px; + font-size: 11px; + cursor: pointer; + z-index: 2; + opacity: 0.9; + } + #scroll-indicator:hover { opacity: 1; } + #agent-output-content { + padding: 8px 16px; white-space: pre; font-family: inherit; color: var(--text); + flex: 1; overflow-y: auto; overflow-x: auto; position: relative; + } + #agent-output-content .ansi-line { display: block; min-height: 1.5em; } + #tmux-cursor { + display: inline-block; + width: 0.6em; + height: 1.2em; + background: var(--accent); + opacity: 0.8; + position: absolute; + pointer-events: none; + animation: cursor-blink 1s step-end infinite; + } + @keyframes cursor-blink { + 0%, 100% { opacity: 0.8; } + 50% { opacity: 0; } + } + /* Drop zone overlay for drag-and-drop images */ + .drop-overlay { + display: none; + position: absolute; + inset: 0; + background: rgba(139, 92, 246, 0.15); + border: 2px dashed var(--accent); + border-radius: 8px; + z-index: 100; + pointer-events: none; + align-items: center; + justify-content: center; + } + .drop-overlay.active { display: flex; } + .drop-overlay-text { + background: var(--surface); + color: var(--accent); + padding: 12px 24px; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + border: 1px solid var(--accent); + } + /* Inline input bar — visible only in fullscreen/expanded */ + .output-input-bar { + display: none; + gap: 8px; + padding: 8px 16px; + border-top: 1px solid var(--border); + background: var(--surface); + flex-shrink: 0; + } + #agent-output-panel.fullscreen .output-input-bar, + #agent-output-panel.expanded .output-input-bar { display: flex; } + .output-input-bar textarea { + flex: 1; + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + padding: 8px 12px; + border-radius: 6px; + font-family: inherit; + font-size: 13px; + outline: none; + line-height: 1.4; + resize: none; + max-height: 120px; + overflow-y: auto; + } + .output-input-bar textarea:focus { border-color: var(--accent); } + .output-input-bar button { + background: var(--accent); + color: white; + border: none; + padding: 8px 16px; + border-radius: 6px; + cursor: pointer; + font-family: inherit; + font-size: 13px; + font-weight: 600; + } + /* Key palette — always visible when output panel is active */ + .key-palette { + display: none; + gap: 4px; + padding: 6px 16px; + border-top: 1px solid var(--border); + background: var(--surface); + flex-shrink: 0; + flex-wrap: wrap; + align-items: center; + } + #agent-output-panel.active .key-palette { display: flex; } + .key-palette .key-label { + font-size: 10px; + color: var(--text-dim); + margin-right: 4px; + user-select: none; + } + .key-palette button { + background: var(--bg); + border: 1px solid var(--border); + color: var(--text); + padding: 3px 10px; + border-radius: 4px; + font-family: inherit; + font-size: 11px; + cursor: pointer; + transition: border-color 0.15s, color 0.15s; + white-space: nowrap; + } + .key-palette button:hover { border-color: var(--accent); color: var(--accent); } + .key-palette .key-sep { + width: 1px; + height: 18px; + background: var(--border); + margin: 0 4px; } - #agent-output-content { padding: 8px 16px; white-space: pre-wrap; font-family: inherit; color: var(--text); } /* ── Input bar ────────────────────────────────────────── */ #input-bar { @@ -380,8 +544,37 @@

mission control

-
Terminal: agent
+
+ Terminal: agent +
+ + + + +
+
+
Drop image to send to agent
No output yet.
+
new output below
+
+ Keys: + + + + +
+ + + +
+ + + +
+
+ + +
@@ -710,6 +903,36 @@

mission control

showAgentOutput(name); }); +// Map browser key names to tmux key names for forwarding +const _KEY_MAP = { + ArrowUp: 'Up', ArrowDown: 'Down', ArrowLeft: 'Left', ArrowRight: 'Right', + Escape: 'Escape', Tab: 'Tab', Enter: 'Enter', +}; + +// Forward special keys to agent when input field is empty +function handleInputKeydown(inputEl, e) { + if (!selectedAgent) return false; + var tmuxKey = _KEY_MAP[e.key]; + // If field is empty and key is mappable, forward to agent + if (tmuxKey && !inputEl.value.trim()) { + // Enter on empty field = bare Enter (confirm selection) + if (e.key === 'Enter') { + e.preventDefault(); + fetch('/api/agent/' + selectedAgent + '/input', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ text: '' }), + }).then(function() { setTimeout(refreshOutputNow, 150); }); + return true; + } + // Arrow keys, Escape, Tab on empty field = send raw key + e.preventDefault(); + sendKey(tmuxKey); // sendKey already triggers refreshOutputNow + return true; + } + return false; +} + function sendAgentInput() { if (!selectedAgent || !agentInput.value.trim()) return; fetch('/api/agent/' + selectedAgent + '/input', { @@ -722,27 +945,372 @@

mission control

} agentSendBtn.addEventListener('click', sendAgentInput); agentInput.addEventListener('keydown', function(e) { + if (handleInputKeydown(agentInput, e)) return; if (e.key === 'Enter') { e.preventDefault(); sendAgentInput(); } }); // ── Agent output panel ──────────────────────────────────── const outputPanel = document.getElementById('agent-output-panel'); -const outputHeader = document.getElementById('agent-output-header'); +const outputTitle = outputPanel.querySelector('.output-title'); const outputContent = document.getElementById('agent-output-content'); +const scrollIndicator = document.getElementById('scroll-indicator'); let outputInterval = null; +let userScrolledUp = false; + +// Track whether user has scrolled away from bottom +outputPanel.addEventListener('scroll', function() { + const atBottom = outputPanel.scrollHeight - outputPanel.scrollTop - outputPanel.clientHeight < 30; + userScrolledUp = !atBottom; + scrollIndicator.style.display = userScrolledUp ? 'block' : 'none'; +}); + +// Click scroll indicator to jump to bottom +scrollIndicator.addEventListener('click', function() { + outputPanel.scrollTop = outputPanel.scrollHeight; + userScrolledUp = false; + scrollIndicator.style.display = 'none'; +}); + +// ── Inline ANSI-to-HTML parser ──────────────────────────── +// Handles SGR sequences: colors (standard, 256, RGB), bold, dim, underline, reset +var _ANSI_COLORS = [ + '#000','#c00','#0a0','#aa0','#55f','#a0a','#0aa','#aaa', // 30-37 + '#555','#f55','#5f5','#ff5','#55f','#f5f','#5ff','#fff', // 90-97 +]; +function _ansi256(n) { + if (n < 8) return _ANSI_COLORS[n]; + if (n < 16) return _ANSI_COLORS[n - 8 + 8]; + if (n < 232) { + n -= 16; var b = n % 6, g = ((n - b) / 6) % 6, r = ((n - b - g * 6) / 36); + return 'rgb(' + [r && r*40+55, g && g*40+55, b && b*40+55].join(',') + ')'; + } + var v = (n - 232) * 10 + 8; + return 'rgb(' + v + ',' + v + ',' + v + ')'; +} +function _escHtml(s) { + return s.replace(/&/g,'&').replace(//g,'>'); +} +function ansiToHtml(text) { + var result = '', style = {}, open = false; + // Split on ANSI escape sequences + var parts = text.split(/(\x1b\[[0-9;]*m)/); + for (var i = 0; i < parts.length; i++) { + var p = parts[i]; + var m = p.match(/^\x1b\[([\d;]*)m$/); + if (m) { + var codes = m[1] ? m[1].split(';').map(Number) : [0]; + for (var j = 0; j < codes.length; j++) { + var c = codes[j]; + if (c === 0) { style = {}; } + else if (c === 1) { style.bold = true; } + else if (c === 2) { style.dim = true; } + else if (c === 3) { style.italic = true; } + else if (c === 4) { style.underline = true; } + else if (c === 22) { delete style.bold; delete style.dim; } + else if (c === 23) { delete style.italic; } + else if (c === 24) { delete style.underline; } + else if (c >= 30 && c <= 37) { style.fg = _ANSI_COLORS[c - 30]; } + else if (c === 38) { + if (codes[j+1] === 5 && codes[j+2] !== undefined) { style.fg = _ansi256(codes[j+2]); j += 2; } + else if (codes[j+1] === 2 && codes[j+4] !== undefined) { style.fg = 'rgb('+codes[j+2]+','+codes[j+3]+','+codes[j+4]+')'; j += 4; } + } + else if (c === 39) { delete style.fg; } + else if (c >= 40 && c <= 47) { style.bg = _ANSI_COLORS[c - 40]; } + else if (c === 48) { + if (codes[j+1] === 5 && codes[j+2] !== undefined) { style.bg = _ansi256(codes[j+2]); j += 2; } + else if (codes[j+1] === 2 && codes[j+4] !== undefined) { style.bg = 'rgb('+codes[j+2]+','+codes[j+3]+','+codes[j+4]+')'; j += 4; } + } + else if (c === 49) { delete style.bg; } + else if (c >= 90 && c <= 97) { style.fg = _ANSI_COLORS[c - 90 + 8]; } + else if (c >= 100 && c <= 107) { style.bg = _ANSI_COLORS[c - 100 + 8]; } + } + continue; + } + if (!p) continue; + var css = ''; + if (style.fg) css += 'color:' + style.fg + ';'; + if (style.bg) css += 'background:' + style.bg + ';'; + if (style.bold) css += 'font-weight:700;'; + if (style.dim) css += 'opacity:0.6;'; + if (style.italic) css += 'font-style:italic;'; + if (style.underline) css += 'text-decoration:underline;'; + if (css) { + result += '' + _escHtml(p) + ''; + } else { + result += _escHtml(p); + } + } + return result; +} + +function renderAnsiContent(data) { + if (!data.content) return; + var lines = data.content.split('\n'); + var html = ''; + for (var i = 0; i < lines.length; i++) { + html += '' + + ansiToHtml(lines[i]) + '\n'; + } + outputContent.innerHTML = html; + if (typeof data.cursor_x === 'number' && typeof data.cursor_y === 'number') { + positionCursor(data.cursor_x, data.cursor_y, data.pane_height || 50); + } +} + +function positionCursor(cx, cy, paneHeight) { + var existing = document.getElementById('tmux-cursor'); + if (existing) existing.remove(); + var lines = outputContent.querySelectorAll('.ansi-line'); + var totalLines = lines.length; + var targetLine = totalLines - paneHeight + cy; + if (targetLine < 0 || targetLine >= totalLines) return; + var line = lines[targetLine]; + if (!line) return; + var cursor = document.createElement('span'); + cursor.id = 'tmux-cursor'; + var charWidth = 7.2; // monospace char width at 12px + cursor.style.left = (16 + cx * charWidth) + 'px'; + cursor.style.top = line.offsetTop + 'px'; + outputContent.appendChild(cursor); +} + +// Immediate snapshot refresh after interaction +function refreshOutputNow() { + if (!selectedAgent) return; + fetch('/api/agent/' + selectedAgent + '/snapshot?lines=80&ansi=true') + .then(function(r) { return r.json(); }) + .then(function(data) { + if (data.content) { + renderAnsiContent(data); + outputPanel.scrollTop = outputPanel.scrollHeight; + } + }).catch(function() {}); +} + +// Key palette — send raw tmux key names +function sendKey(keyName) { + if (!selectedAgent) return; + fetch('/api/agent/' + selectedAgent + '/key', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ key: keyName }), + }).then(function() { + // Refresh after a brief delay to let the terminal redraw + setTimeout(refreshOutputNow, 150); + }); +} + +document.getElementById('key-palette').addEventListener('click', function(e) { + const btn = e.target.closest('button[data-key]'); + if (btn) sendKey(btn.dataset.key); +}); + +// Inline input bar (visible in expanded/fullscreen modes) +const inlineInput = document.getElementById('output-inline-input'); +const inlineSendBtn = document.getElementById('output-inline-send'); + +function sendInlineInput() { + if (!selectedAgent || !inlineInput.value.trim()) return; + fetch('/api/agent/' + selectedAgent + '/input', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ text: inlineInput.value }), + }).then(r => r.json()).then(data => { + if (data.ok) { inlineInput.value = ''; agentInput.value = ''; } + }); +} +inlineSendBtn.addEventListener('click', sendInlineInput); +inlineInput.addEventListener('keydown', function(e) { + if (e.key === 'Enter' && e.shiftKey) return; // allow newline + if (handleInputKeydown(inlineInput, e)) return; + if (e.key === 'Enter') { e.preventDefault(); sendInlineInput(); } +}); +inlineInput.addEventListener('input', function() { + this.style.height = 'auto'; + this.style.height = Math.min(this.scrollHeight, 120) + 'px'; +}); + +// Expand toggle +document.getElementById('output-expand-btn').addEventListener('click', function() { + outputPanel.classList.toggle('expanded'); + outputPanel.classList.remove('fullscreen'); + this.textContent = outputPanel.classList.contains('expanded') ? 'collapse' : 'expand'; + document.getElementById('output-fullscreen-btn').textContent = 'fullscreen'; +}); + +// Fullscreen toggle +document.getElementById('output-fullscreen-btn').addEventListener('click', function() { + outputPanel.classList.toggle('fullscreen'); + outputPanel.classList.remove('expanded'); + this.textContent = outputPanel.classList.contains('fullscreen') ? 'exit fullscreen' : 'fullscreen'; + document.getElementById('output-expand-btn').textContent = 'expand'; +}); + +// Pop out to new window (with input) +document.getElementById('output-popout-btn').addEventListener('click', function() { + if (!selectedAgent) return; + const win = window.open('', 'agent-output-' + selectedAgent, 'width=900,height=700'); + if (!win) return; + const popHtml = [ + 'Terminal: ' + selectedAgent + '', + '', + '
',
+    '
', + 'Keys:', + '', + '', + '
', + '', + '
', + '', + '
', + '
', + '', + '', + '
', + ' diff --git a/src/synapt/recall/channel.py b/src/synapt/recall/channel.py index 79cefef9..b0b24456 100644 --- a/src/synapt/recall/channel.py +++ b/src/synapt/recall/channel.py @@ -1005,6 +1005,22 @@ def _reap_stale_agents(conn: sqlite3.Connection, project_dir: Path | None = None if _agent_status(last_seen) != "offline": continue + # Determine if this agent is ephemeral: + # - s_* are session-scoped fallback identities (MCP processes, CLI) + # - agents whose display_name equals their griptree name are + # unnamed sessions that resolved to the bare repo name + is_ephemeral = aid.startswith("s_") + if not is_ephemeral: + row_display = conn.execute( + "SELECT display_name, griptree FROM presence WHERE agent_id = ?", + (aid,), + ).fetchone() + if (row_display + and row_display["display_name"] + and row_display["griptree"] + and row_display["display_name"] == row_display["griptree"]): + is_ephemeral = True + channels = [ r["channel"] for r in conn.execute( @@ -1012,23 +1028,21 @@ def _reap_stale_agents(conn: sqlite3.Connection, project_dir: Path | None = None ).fetchall() ] - leave_time = _now_iso() - reap_display = _resolve_display_name_for(aid, project_dir) - for ch in channels: - msg = ChannelMessage( - timestamp=leave_time, from_agent=aid, from_display=reap_display, - channel=ch, type="leave", body=f"{reap_display} timed out from #{ch}", - ) - _append_message(msg, project_dir, conn=conn) + # Never write leave messages to the channel log. The presence + # table already tracks online/offline status; JSONL leave events + # are redundant noise that clutters the feed. (recall#677) conn.execute("DELETE FROM claims WHERE claimed_by = ?", (aid,)) - # Memberships are durable — reaping only clears presence, not - # channel membership. Agents that time out remain joined so - # that monitoring loops (channel_unread) keep working across - # session boundaries. See recall#639. - conn.execute( - "UPDATE presence SET status = 'offline' WHERE agent_id = ?", (aid,) - ) + if is_ephemeral: + # Ephemeral sessions get fully cleaned up — no durable + # membership needed since they don't have persistent identity. + conn.execute("DELETE FROM memberships WHERE agent_id = ?", (aid,)) + conn.execute("DELETE FROM presence WHERE agent_id = ?", (aid,)) + else: + # Registered agents keep memberships across sessions (recall#639) + conn.execute( + "UPDATE presence SET status = 'offline' WHERE agent_id = ?", (aid,) + ) reaped.append(aid) if reaped: @@ -3267,6 +3281,7 @@ def _migrate_cursors( # Read local cursors local_conn = sqlite3.connect(str(local_db)) + local_conn.execute("PRAGMA busy_timeout=5000") local_conn.row_factory = sqlite3.Row try: rows = local_conn.execute( @@ -3283,6 +3298,7 @@ def _migrate_cursors( # Write to global _state.db conn = sqlite3.connect(str(state_db)) conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA busy_timeout=5000") conn.execute( "CREATE TABLE IF NOT EXISTS cursors (" "agent_id TEXT NOT NULL, " @@ -3315,6 +3331,7 @@ def _open_state_db(state_db: Path) -> sqlite3.Connection: conn = sqlite3.connect(str(state_db)) conn.row_factory = sqlite3.Row conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA busy_timeout=5000") conn.execute( "CREATE TABLE IF NOT EXISTS claims (" "org_id TEXT NOT NULL, " diff --git a/src/synapt/recall/registry.py b/src/synapt/recall/registry.py index 21791ba9..a18638e3 100644 --- a/src/synapt/recall/registry.py +++ b/src/synapt/recall/registry.py @@ -86,6 +86,7 @@ def _open_db(org_id: str, db_path: Path | None = None) -> sqlite3.Connection: path = db_path or _team_db_path(org_id) path.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(str(path)) + conn.execute("PRAGMA busy_timeout=5000") conn.row_factory = sqlite3.Row _ensure_schema(conn) return conn @@ -246,6 +247,7 @@ def update_agent_status( ) -> None: """Update process tracking columns for an agent.""" conn = sqlite3.connect(str(db_path)) + conn.execute("PRAGMA busy_timeout=5000") conn.row_factory = sqlite3.Row _ensure_schema(conn) try: @@ -263,6 +265,7 @@ def update_agent_status( def get_agent_status(db_path: Path, agent_id: str) -> dict[str, Any] | None: """Return process tracking info for an agent.""" conn = sqlite3.connect(str(db_path)) + conn.execute("PRAGMA busy_timeout=5000") conn.row_factory = sqlite3.Row _ensure_schema(conn) try: @@ -281,6 +284,7 @@ def get_agent_status(db_path: Path, agent_id: str) -> dict[str, Any] | None: def detect_crashed_agents(db_path: Path) -> list[dict[str, Any]]: """Find agents with status='running' but dead PIDs.""" conn = sqlite3.connect(str(db_path)) + conn.execute("PRAGMA busy_timeout=5000") conn.row_factory = sqlite3.Row _ensure_schema(conn) try: @@ -311,6 +315,7 @@ def detect_crashed_agents(db_path: Path) -> list[dict[str, Any]]: def clear_agent_session(db_path: Path, agent_id: str) -> None: """Reset process columns while preserving agent identity.""" conn = sqlite3.connect(str(db_path)) + conn.execute("PRAGMA busy_timeout=5000") conn.row_factory = sqlite3.Row _ensure_schema(conn) try: diff --git a/src/synapt/recall/sharding.py b/src/synapt/recall/sharding.py index c35478e8..d4b73554 100644 --- a/src/synapt/recall/sharding.py +++ b/src/synapt/recall/sharding.py @@ -169,6 +169,7 @@ def estimate_split(db_path: Path, threshold: int = SHARD_CHUNK_THRESHOLD) -> dic Returns empty dict if the DB has no chunks table. """ conn = sqlite3.connect(str(db_path)) + conn.execute("PRAGMA busy_timeout=5000") try: row = conn.execute( "SELECT name FROM sqlite_master WHERE type='table' AND name='chunks'" @@ -234,6 +235,7 @@ def split_monolithic_db( idx_path = tmp_dir / "index.db" idx = sqlite3.connect(str(idx_path)) idx.execute("PRAGMA journal_mode=WAL") + idx.execute("PRAGMA busy_timeout=5000") idx.execute(f"ATTACH DATABASE '{db_path}' AS src") try: for table_info in src.execute( @@ -280,6 +282,7 @@ def split_monolithic_db( shard_path = tmp_dir / s_name shard = sqlite3.connect(str(shard_path)) shard.execute("PRAGMA journal_mode=WAL") + shard.execute("PRAGMA busy_timeout=5000") try: shard.execute(chunks_sql) placeholders = ",".join("?" for _ in col_names) @@ -304,6 +307,7 @@ def split_monolithic_db( max_ts = batch[-1]["timestamp"] if batch else "" is_last = offset + threshold >= len(all_chunks) idx_conn = sqlite3.connect(str(idx_path)) + idx_conn.execute("PRAGMA busy_timeout=5000") try: idx_conn.execute(SHARD_METADATA_SQL) _update_shard_metadata( diff --git a/tests/recall/test_channel.py b/tests/recall/test_channel.py index 527896ef..c26d996d 100644 --- a/tests/recall/test_channel.py +++ b/tests/recall/test_channel.py @@ -588,7 +588,8 @@ def test_reap_stale_agent(self): finally: conn.close() - def test_reap_posts_leave_message(self): + def test_reap_does_not_post_leave_message(self): + """Reaper silently updates presence without writing leave events to JSONL.""" stale_time = (datetime.now(timezone.utc) - timedelta(hours=3)).strftime( "%Y-%m-%dT%H:%M:%SZ" ) @@ -609,9 +610,9 @@ def test_reap_posts_leave_message(self): finally: conn.close() - # Check channel log for the leave message + # Reaper should NOT write leave messages to the channel log result = channel_read("dev") - self.assertIn("timed out", result) + self.assertNotIn("timed out", result) def test_reap_releases_claims(self): stale_time = (datetime.now(timezone.utc) - timedelta(hours=3)).strftime(