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
13 changes: 12 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,18 @@ panels**, and a few responsibilities are deliberately split across the AppKit/Sw
image window a double-click opens (AppKit `ZoomScrollView` zoom + Open in Preview), dressed like its
note (paper/glass background, note-style header, hints) — one reusable `ImageWindow` owned by
`NoteWindowManager`; far quicker than launching Preview.
- **App shell** — `TicApp` (`@main`) provides a `MenuBarExtra`; `AppDelegate`
- **MCP server (`MCP/`)** — lets AI agents drive Tic. `MCPService` runs an `NWListener` on a Unix
socket beside the DB (`~/Library/Application Support/Tic/mcp.sock`, 0600) — one official-SDK
`Server` session per connection (per agent). `MCPTools` is the tool surface (`create_note`,
`add_tasks`, `update_task`, `move_task`, …): pure `AppDatabase` writes plus three window pokes
(`WindowActions`), reusing `TaskOutline` for every structural rule so tools and UI can't diverge.
Clients speak **stdio** to `Tic --mcp` (`MCPProxy`), which pipes to the socket — one config works
for every client, and the server lives in the app next to the observers. Off by default
(`AppModel.mcpEnabled`). **Live updates are free:** tools write the DB, the existing
`ValueObservation` streams push into open panels; the one addition is `observeNote(id:)` so the
controller streams its own note row too (an agent's title/colour/flag write shows live).
- **App shell** — `TicApp` provides a `MenuBarExtra`; `main.swift` (not `@main`) routes `--mcp` to
the proxy, else `TicApp.main()`; `AppDelegate`
(`NSApplicationDelegateAdaptor`) builds the shared `AppDatabase` + `NoteWindowManager` and calls
`restoreAll()` on launch. The app is a **hybrid**: Dock icon **and** menu bar item.

Expand Down
177 changes: 177 additions & 0 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,183 @@ deferred to the distribution step.
6. Menu bar shows correct count of today's open tasks.
- Optionally drive the built app with the `run` / `verify` skills once it compiles.

## MCP: AI agents drive Tic (planned, next)

Agents (Claude Desktop/Code, Cursor, VS Code, Codex, Gemini, Windsurf, Zed, …) get **full
parity with a human** over notes and tasks through an MCP server, and the UI reflects every
agent write instantly. Off by default; a menu-bar item **AI Agents (MCP)…** opens the setup
window that holds the toggle and per-client install instructions.

### The one decision that keeps this small: the server runs *inside* the running Tic

- **Live updates cost nothing.** Every tool writes through the existing `AppDatabase`, and the
existing `ValueObservation` streams (`observeTasks`, `observeTaskImageCrops`, `observeNotes`)
already push those writes into the views. A standalone `tic-mcp` binary writing the SQLite file
would need a hand-built cross-process notification channel: GRDB does not observe other
processes' writes.
- **One gap to close:** an open note's `NoteController` streams its tasks but holds its `note`
row as a one-time snapshot, and `NoteView` seeds the title field once. Add
`AppDatabase.observeNote(id:)` (per-row twin of `observeNotes()`), subscribe in
`NoteController.start()`, diff each emission against the current `note` and fire the existing
closures when their fields changed (`onApplyBehavior` for float/all-Spaces, `onSetCollapsed`
for roll-up). Diffing against the optimistic in-memory value makes the controller's own writes
echo back as no-ops. `NoteView` syncs `titleText` from `controller.note.title` while the field
isn't focused. Colour/material/list options already render from `controller.note`.
- **Window actions go direct, not via reconcile.** Open/close/bring-to-front/move hop to the
main actor and call `NoteWindowManager` (`openNote`, new `closeNote(id)`, `setFrame(id:)`).
Reconciling panels from the notes observer was considered and dropped: `openNote` writes
`isOpen` asynchronously, so an emission in that gap looks like a closed note with a panel.
A programmatic `setFrame` fires `windowDidMove`/`windowDidResize`, so the existing debounced
save persists an agent move with no new code.

### Transport: stdio to the client, a Unix socket inside

```
Claude / Cursor / … ──stdio──▶ Tic --mcp (proxy) ──unix socket──▶ Tic.app: MCPServer ─▶ AppDatabase
one connection per client one serialised writer
```

- **Clients see stdio only**, the one transport every MCP client supports. Every client config
is identical: `command: <Tic.app>/Contents/MacOS/Tic`, `args: ["--mcp"]`.
- **The proxy is the same binary.** `@main` on `TicApp` becomes a `main.swift`: `--mcp` runs
`MCPProxy` (pipe stdin → socket, socket → stdout, exit on EOF) instead of the GUI. No second
target, no packaging change. If the socket is missing, launch Tic via
`NSWorkspace.openApplication(at:)` without activating and retry for a few seconds; if it's
still missing (MCP off) print a one-line hint to stderr and exit 1, which clients surface in
their logs. Under `swift run` the path is `.build/debug/Tic`, so dev testing works the same.
- **The inner hop is a Unix domain socket** at `~/Library/Application Support/Tic/mcp.sock`
(Network.framework: `NWListener` with `requiredLocalEndpoint = .unix(path:)`, available since
macOS 10.15). Chosen over localhost TCP because it needs no port, triggers no macOS firewall
prompt, and is a 0600 file in the user's Library, the same exposure as the SQLite file, not a
port open to every local process and browser page. Unlink a stale socket before binding.
(`sun_path` max is 104 bytes; the App Support path is ~50.)
- **Many agents at once** just work: the listener accepts one connection per client, each is its
own MCP session (own `initialize`, own request ids, handled in order per connection), and all
writes serialise through the `DatabaseQueue` (`MAX(sortIndex)+1` is computed inside the write).
Two agents editing one task is last-write-wins, the same rule as user-vs-agent today.
- Skipped: Streamable HTTP (needs an HTTP+SSE server or a dependency, Origin/auth per spec, the
firewall prompt, and Claude Desktop's config file still only spawns stdio). Add if a client
that can't spawn a process shows up.

### Protocol: hand-rolled, five methods

Newline-delimited JSON-RPC 2.0, exactly MCP's stdio framing, so the proxy pipes bytes
untouched. `MCPServer` handles `initialize` (echo the client's `protocolVersion` when known,
else ours; `capabilities.tools`; a short `instructions` string describing notes, the
three-level outline and the completion cascade), `notifications/initialized` (ignored),
`ping`, `tools/list`, `tools/call`; anything else → `-32601`. The dispatcher is a pure
`handle(line) async -> String?` over an `AppDatabase`, so tests drive it with strings on an
in-memory DB. The official swift-sdk is skipped (three transitive dependencies for five
methods); switch if resources/prompts/sampling are ever wanted.

### Tools: everything a human can do to notes and tasks

| Tool | Covers | Path |
|---|---|---|
| `list_notes`, `get_note` | Lists palette; reading a note (tasks with id/text/level/done/has_image) | reads |
| `create_note` | ⌘N, with title/color/material/tasks/frame; cascaded placement like `newNote` | insert + `openNote` |
| `update_note` | title, color, material, float, all-Spaces, collapsed, hide-completed, sort-to-bottom, `open` (X / bring to front) | targeted column writes → note observer; `open` → manager |
| `move_note` | drag/resize; global coordinates spanning displays, as stored | manager `setFrame` |
| `delete_note` | palette delete | `destructiveHint` |
| `add_tasks` | quick-add, add subtask, add sibling (`after_task_id` + `level`) | `insertTask` / `insertTask(reordering:)` |
| `update_task` | edit text; tick/untick with the bidirectional cascade | `update` / `applyingToggle` + `updateTaskCompletion` |
| `move_task` | drag reorder, indent, outdent | `movingSubtree` + `normalizedLevels` + `applyStructuralUpdate` |
| `delete_tasks`, `clear_completed` | delete, clear completed | `destructiveHint` |
| `set_task_image`, `crop_task_image`, `remove_task_image` | paste, crop (0…1 top-left rect), remove; base64 PNG/JPEG | `TaskImage` helpers + existing writes |

- **Trust boundary validation:** unknown ids → tool error (`isError: true`), text trimmed and
capped, blank text rejected (deletion is explicit), level 0…`TaskItem.maxIndentLevel`, batch
and image size capped, colors/materials by raw value.
- **Conventions carry over:** completion changes only via the toggle cascade; structural edits
never touch `isDone`; level diffs by id (`indentLevelChanges`); targeted column writes only.
`NoteController.completionChanges` moves to `TaskOutline` so both callers share it.
- **Annotations** (`readOnlyHint`, `destructiveHint`, `idempotentHint`) ride along so clients ask
the user before an agent deletes anything.
- **Defaults:** a write to a closed note reopens it so the user sees the work.
- **Excluded on purpose:** launch at login, the MCP toggle itself, quit, update check, the
transient image viewer window. App controls, not note content.

### Setup window (signed-off mockup)

```
┌──────────────────────────────────────────────────────────────┐
│ AI Agents (MCP) ✕ │
│ Let Claude, Cursor and friends draft and tick your lists. │
│ │
│ (● ) Enable MCP server ● Running · 1 connected │
│ │
│ Claude Desktop ▸ │ Add this to claude_desktop_config.json │
│ Claude Code │ ┌────────────────────────────────────┐ │
│ Cursor │ │ { "mcpServers": { "tic": { │ │
│ VS Code │ │ "command": "/Applications/…/Tic",│ │
│ Codex CLI │ │ "args": ["--mcp"] } } } │ │
│ Gemini CLI │ └────────────────────────────────────┘ │
│ Windsurf │ [ Copy ] [ Open config file ] │
│ Zed │ │
│ Other (JSON) │ CLIs show a one-line `… mcp add` command │
│ │ instead; Cursor gets an "Add to Cursor" │
│ │ deeplink button. │
│ │
│ ⚠ Tic isn't in /Applications, the path changes if you move it│
└──────────────────────────────────────────────────────────────┘
```

- Built like the Lists palette: an `NSPanel` hosting SwiftUI, owned by `AppModel`
(`openMCPSetup()`), `.regularMaterial`, ~640×420.
- `MCPClients` is one static table: name, config path, snippet shape (`mcpServers` JSON /
`servers` JSON / TOML / CLI command), optional deeplink. Adding a client is one array entry.
The executable path is read live from `Bundle.main.executableURL`, so it's right for dev too.
- Preference `mcpEnabled` (UserDefaults, default off); `AppModel.setMCPEnabled` starts/stops the
listener; `bootstrap()` starts it when on. Status text is the listener's live connection count.
- No activity feed and no highlight animation on agent edits in v1 (the live update already
shows them). Add the feed when debugging agents gets annoying.

### Files (~1,000 lines incl. tests)

```
Sources/Tic/
main.swift --mcp → MCPProxy, else TicApp.main() (@main removed)
MCP/MCPServer.swift NWListener, line framing, JSON-RPC dispatch, sessions
MCP/MCPTools.swift the tool table + handlers over AppDatabase (+ main-actor window calls)
MCP/MCPProxy.swift stdio ↔ socket bridge, auto-launch, off-hint
MCP/MCPClients.swift client instruction table
Views/MCPSetupView.swift the window
AppModel.swift mcpEnabled, start/stop, openMCPSetup()
TicApp.swift menu item
Database/AppDatabase.swift observeNote(id:)
Controllers/NoteController.swift note-row observation + diff → closures
Windows/NoteWindowManager.swift closeNote(id:), setFrame(id:_:)
Views/NoteView.swift titleText sync
Models/TaskOutline.swift completionChanges (moved from the controller)
Tests/TicTests/
MCPServerTests.swift handshake, tools/list, unknown method, framing split/joined lines
MCPToolsTests.swift create→get round trip, level clamping, done cascade, move re-nest,
update_note/move_note round trip, bad ids, image set/crop/remove
NoteControllerTests.swift note observer fires closures on change, quiet on echo; rename lands
```

### Build order

1. **Spike** (`main.swift`, `MCPProxy`, an echo listener). Verify: the Unix-socket listener
accepts connections; `swift test` still works with `main.swift` instead of `@main`; a client
spawning the GUI binary with `--mcp` shows no Dock icon; `claude mcp add tic --
$PWD/.build/debug/Tic --mcp` then `claude mcp list` reports it connected.
2. **Protocol + tools + tests**: `MCPServer`, `MCPTools`, `observeNote`, controller observer,
`closeNote`/`setFrame`, `titleText` sync.
3. **Toggle + menu item**, then the **setup window** per the mockup.
4. **Docs**: CLAUDE.md conventions (in-process server, stdio-via-`--mcp`, note observer, direct
window calls), README and site "Works with AI agents" mention.

### Verification

- `swift build`, `swift test` (the MCP suites drive the dispatcher with JSON-RPC lines over an
in-memory database).
- Manual, user-driven: enable MCP, add Tic to Claude Code with the command above, then ask it to
draft a list. Watch: the note appears cascaded; asking it to rename, recolour, roll up, float,
move, tick a parent (subtree ticks), add subtasks, and delete a task each show instantly in the
open panel. Toggle MCP off → the agent's next call fails with the stderr hint. Connect a second
client and confirm "2 connected" and that both see each other's writes via `get_note`.

## Open follow-ups (post-MVP, not built now)

Daily-vs-planned rollover (the signature feature — a Today note that carries unfinished items to
Expand Down
65 changes: 64 additions & 1 deletion Package.resolved

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

11 changes: 8 additions & 3 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,26 @@ let package = Package(
.macOS(.v14)
],
dependencies: [
.package(url: "https://github.com/groue/GRDB.swift", from: "7.0.0")
.package(url: "https://github.com/groue/GRDB.swift", from: "7.0.0"),
.package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", .upToNextMinor(from: "0.12.1"))
],
targets: [
.executableTarget(
name: "Tic",
dependencies: [
.product(name: "GRDB", package: "GRDB.swift")
.product(name: "GRDB", package: "GRDB.swift"),
.product(name: "MCP", package: "swift-sdk")
],
resources: [
.process("Resources")
]
),
.testTarget(
name: "TicTests",
dependencies: ["Tic"]
dependencies: [
"Tic",
.product(name: "MCP", package: "swift-sdk")
]
)
]
)
Loading