openplexer 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +195 -0
- package/dist/acp-client.d.ts +13 -8
- package/dist/acp-client.d.ts.map +1 -1
- package/dist/acp-client.js +127 -23
- package/dist/acp-client.test.d.ts +2 -0
- package/dist/acp-client.test.d.ts.map +1 -0
- package/dist/acp-client.test.js +91 -0
- package/dist/cli.js +14 -15
- package/dist/config.d.ts +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/notion.d.ts +4 -6
- package/dist/notion.d.ts.map +1 -1
- package/dist/notion.js +149 -7
- package/dist/sync.d.ts +2 -2
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.js +51 -33
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +68 -11
- package/package.json +11 -7
- package/src/acp-client.test.ts +95 -0
- package/src/acp-client.ts +158 -35
- package/src/cli.ts +16 -16
- package/src/config.ts +1 -1
- package/src/notion.ts +160 -7
- package/src/sync.ts +52 -35
- package/src/worker.ts +71 -11
- package/LICENSE +0 -21
package/README.md
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# openplexer
|
|
2
|
+
|
|
3
|
+
Track every coding session across your team in a Notion board. Automatically.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
npm install -g openplexer
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## The problem
|
|
10
|
+
|
|
11
|
+
AI coding agents are everywhere now. OpenCode, Claude Code, Codex — you run them in worktrees, in different repos, on different branches. You start a session to fix a bug, another to refactor auth, another to explore an idea. Some finish, some don't. Some need your attention, some are fine.
|
|
12
|
+
|
|
13
|
+
After a week you have 40+ sessions scattered across your machine with no way to tell which ones matter.
|
|
14
|
+
|
|
15
|
+
**For solo developers**, it's hard to keep track. You forget about sessions. You don't know which worktree has unfinished work. You resume the wrong one. You lose context.
|
|
16
|
+
|
|
17
|
+
**For teams**, it's worse. You have no idea what your teammates are working on right now. You can't see if someone already started a session on the bug you're about to fix. There's no shared view of who's doing what, on which branch, in which repo. No way to flag a session as "needs review" or "blocked" or "done — merge it."
|
|
18
|
+
|
|
19
|
+
## The solution
|
|
20
|
+
|
|
21
|
+
openplexer runs as a background daemon on your machine. It connects to your coding agents via ACP (Agent Client Protocol), discovers all your sessions, and syncs them to a Notion kanban board — automatically, every 5 seconds.
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
┌──────────────┐ ┌──────────────────┐
|
|
25
|
+
│ OpenCode │◄── ACP (stdio) ────►│ │
|
|
26
|
+
│ Claude Code │◄── ACP (stdio) ────►│ openplexer │
|
|
27
|
+
│ Codex │◄── ACP (stdio) ────►│ (background) │
|
|
28
|
+
└──────────────┘ │ │
|
|
29
|
+
│ syncs every 5s │
|
|
30
|
+
└────────┬─────────┘
|
|
31
|
+
│
|
|
32
|
+
│ Notion API
|
|
33
|
+
▼
|
|
34
|
+
┌──────────────────┐
|
|
35
|
+
│ Notion Board │
|
|
36
|
+
│ (shared kanban) │
|
|
37
|
+
└──────────────────┘
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Each session becomes a card on the board. You can see at a glance:
|
|
41
|
+
|
|
42
|
+
- **What's in progress** — sessions that are still running or were never finished
|
|
43
|
+
- **What's done** — completed sessions you can archive or review
|
|
44
|
+
- **What needs attention** — sessions you manually flag for follow-up
|
|
45
|
+
- **Who's working on what** — every card is assigned to the person who started it
|
|
46
|
+
- **Which repo and branch** — direct links to the GitHub branch
|
|
47
|
+
- **How to resume** — a ready-to-paste CLI command to pick up where you left off
|
|
48
|
+
|
|
49
|
+
## Collaborative by default
|
|
50
|
+
|
|
51
|
+
The board is a shared Notion page. Multiple team members can connect their machines to the same board. Each person's sessions show up automatically, assigned to them.
|
|
52
|
+
|
|
53
|
+
This means your team gets a single view of all active coding work:
|
|
54
|
+
|
|
55
|
+
- Alice is refactoring the auth module on `feature/auth-v2` in `acme/backend` — **In Progress**
|
|
56
|
+
- Bob finished the migration script on `fix/db-migrate` in `acme/infra` — **Done**
|
|
57
|
+
- Charlie's session on `acme/frontend` needs review — **Needs Attention**
|
|
58
|
+
|
|
59
|
+
No standups needed to know what's happening. No Slack messages asking "are you still working on that?" The board is always current because every machine syncs continuously.
|
|
60
|
+
|
|
61
|
+
Each user controls which repos they sync to the shared board. If you're also hacking on personal side projects, those sessions stay off the shared board — only repos you explicitly select are tracked. This keeps the shared view clean and focused on team work.
|
|
62
|
+
|
|
63
|
+
## Getting started
|
|
64
|
+
|
|
65
|
+
Run `openplexer` for the first time and the setup wizard walks you through everything:
|
|
66
|
+
|
|
67
|
+
**1. Pick your agents**
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
◆ Which coding agents do you use?
|
|
71
|
+
│ ◼ OpenCode
|
|
72
|
+
│ ◼ Claude Code
|
|
73
|
+
│ ◻ Codex
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Select one or more. openplexer connects to each via ACP and merges all sessions into a single board.
|
|
77
|
+
|
|
78
|
+
**2. Auto-discover repos**
|
|
79
|
+
|
|
80
|
+
openplexer spawns the ACP server, lists all your sessions, and extracts the git repos from their working directories. No manual configuration needed.
|
|
81
|
+
|
|
82
|
+
**3. Select repos to track**
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
◆ Which repos to track?
|
|
86
|
+
│ ◼ * All repos
|
|
87
|
+
│ ◻ acme/backend
|
|
88
|
+
│ ◻ acme/frontend
|
|
89
|
+
│ ◻ acme/infra
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Pick specific repos for shared boards (recommended — keeps personal projects off the team board). Or select all if you want everything tracked.
|
|
93
|
+
|
|
94
|
+
**4. Connect Notion**
|
|
95
|
+
|
|
96
|
+
Your browser opens to authorize the Notion integration. Select the page where the board should live. No manual API tokens, no copying secrets — just click authorize and you're done.
|
|
97
|
+
|
|
98
|
+
**5. Board created**
|
|
99
|
+
|
|
100
|
+
openplexer creates a database inside your selected Notion page with a kanban Board view grouped by status. An example card explains what each field means. Real sessions start appearing within seconds.
|
|
101
|
+
|
|
102
|
+
**6. Run on login (optional)**
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
◆ Register openplexer to run on login?
|
|
106
|
+
│ Yes
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
openplexer registers itself as a startup service so it runs in the background every time you log in. The board stays current without you having to think about it.
|
|
110
|
+
|
|
111
|
+
## Board properties
|
|
112
|
+
|
|
113
|
+
Every session card in Notion has these fields:
|
|
114
|
+
|
|
115
|
+
| Property | Type | Description |
|
|
116
|
+
|---|---|---|
|
|
117
|
+
| **Name** | Title | Session title from the agent |
|
|
118
|
+
| **Status** | Select | `In Progress`, `Done`, `Needs Attention`, `Ignored`, `Not Started` |
|
|
119
|
+
| **Repo** | Select | GitHub repo as `owner/repo` |
|
|
120
|
+
| **Branch** | URL | Link to the branch on GitHub |
|
|
121
|
+
| **Share URL** | URL | Public share link (OpenCode `/share`) |
|
|
122
|
+
| **Resume** | Text | CLI command to resume the session |
|
|
123
|
+
| **Assignee** | People | Notion user who authorized the integration |
|
|
124
|
+
| **Folder** | Text | Local filesystem path |
|
|
125
|
+
| **Discord** | URL | Discord thread link (if using kimaki) |
|
|
126
|
+
| **Updated** | Date | Last update timestamp from the agent |
|
|
127
|
+
| **Session ID** | Text | Internal ACP session identifier |
|
|
128
|
+
|
|
129
|
+
**Status** is the only field you manage manually. Everything else is synced automatically. Move cards between columns as you triage — mark sessions as done when you're finished, flag ones that need attention, ignore ones you don't care about.
|
|
130
|
+
|
|
131
|
+
**Resume** gives you the exact command to pick up a session:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
# OpenCode sessions
|
|
135
|
+
opencode --session ses_abc123
|
|
136
|
+
|
|
137
|
+
# Claude Code sessions
|
|
138
|
+
claude --resume ses_abc123
|
|
139
|
+
|
|
140
|
+
# Codex sessions
|
|
141
|
+
codex resume ses_abc123
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## CLI commands
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
openplexer # Start daemon (first run triggers setup wizard)
|
|
148
|
+
openplexer connect # Add another board
|
|
149
|
+
openplexer status # Show sync state and session counts
|
|
150
|
+
openplexer boards # List all configured boards with URLs
|
|
151
|
+
openplexer stop # Kill the running daemon
|
|
152
|
+
openplexer startup # Show startup registration status
|
|
153
|
+
openplexer startup enable # Register to run on login
|
|
154
|
+
openplexer startup disable # Unregister from login
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Multiple boards
|
|
158
|
+
|
|
159
|
+
You can connect as many boards as you want. Each board is a separate Notion database with its own repo filter and assignee.
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
openplexer connect
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Use cases:
|
|
166
|
+
- **Team board** — shared page, filtered to company repos, everyone connects to it
|
|
167
|
+
- **Personal board** — private page, all repos, just for you
|
|
168
|
+
- **Project board** — scoped to a single repo for focused tracking
|
|
169
|
+
|
|
170
|
+
## How it works
|
|
171
|
+
|
|
172
|
+
**ACP protocol** — openplexer spawns each agent's ACP server as a child process and communicates over stdio using the Agent Client Protocol. It lists all sessions with pagination and extracts git repo info from each session's working directory.
|
|
173
|
+
|
|
174
|
+
**Sync loop** — every 5 seconds, openplexer polls all connected agents for sessions. New sessions get a Notion page created. Existing sessions get their title and timestamp updated. Only sessions created or updated after the board was connected are synced (no backfilling old sessions).
|
|
175
|
+
|
|
176
|
+
**Single instance** — a lock port (default `29990`) ensures only one daemon runs at a time. Starting a new instance cleanly terminates the old one via SIGTERM, then SIGKILL if needed.
|
|
177
|
+
|
|
178
|
+
**Notion OAuth** — authentication goes through `openplexer.com` (a Cloudflare Worker). The CLI opens your browser, you authorize, the worker exchanges the code for tokens and stores them in KV with a 5-minute TTL. The CLI polls for the result. No secrets to manage.
|
|
179
|
+
|
|
180
|
+
**Startup service** — cross-platform registration so openplexer starts automatically:
|
|
181
|
+
- **macOS**: launchd plist at `~/Library/LaunchAgents/com.openplexer.plist`
|
|
182
|
+
- **Linux**: XDG autostart at `~/.config/autostart/openplexer.desktop`
|
|
183
|
+
- **Windows**: registry key at `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`
|
|
184
|
+
|
|
185
|
+
**Config** — stored at `~/.openplexer/config.json`. Contains the list of agents, board configurations (Notion tokens, database IDs, tracked repos), and a map of synced session IDs to Notion page IDs.
|
|
186
|
+
|
|
187
|
+
**Rate limiting** — Notion API calls are throttled to ~3/second to stay within rate limits.
|
|
188
|
+
|
|
189
|
+
## Discord integration
|
|
190
|
+
|
|
191
|
+
If you use [kimaki](https://kimaki.xyz) to run coding sessions from Discord, openplexer automatically detects the kimaki CLI and adds a Discord thread URL to each session card. This links the Notion board directly to the Discord conversation where the work is happening.
|
|
192
|
+
|
|
193
|
+
## License
|
|
194
|
+
|
|
195
|
+
MIT
|
package/dist/acp-client.d.ts
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
client:
|
|
1
|
+
import { type SessionInfo } from '@agentclientprotocol/sdk';
|
|
2
|
+
import type { AcpClient } from './config.ts';
|
|
3
|
+
export type AgentConnection = {
|
|
4
|
+
client: AcpClient;
|
|
5
|
+
listSessions: () => Promise<SessionInfo[]>;
|
|
5
6
|
kill: () => void;
|
|
6
7
|
};
|
|
7
|
-
export
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
export type AcpConnection = AgentConnection;
|
|
9
|
+
export declare function connectAgent({ client }: {
|
|
10
|
+
client: AcpClient;
|
|
11
|
+
}): Promise<AgentConnection>;
|
|
12
|
+
export declare function connectAcp({ client }: {
|
|
13
|
+
client: AcpClient;
|
|
14
|
+
}): Promise<AgentConnection>;
|
|
10
15
|
export declare function listAllSessions({ connection, }: {
|
|
11
|
-
connection:
|
|
16
|
+
connection: AgentConnection;
|
|
12
17
|
}): Promise<SessionInfo[]>;
|
|
13
18
|
//# sourceMappingURL=acp-client.d.ts.map
|
package/dist/acp-client.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"acp-client.d.ts","sourceRoot":"","sources":["../src/acp-client.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"acp-client.d.ts","sourceRoot":"","sources":["../src/acp-client.ts"],"names":[],"mappings":"AAcA,OAAO,EAKL,KAAK,WAAW,EACjB,MAAM,0BAA0B,CAAA;AAEjC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAM5C,MAAM,MAAM,eAAe,GAAG;IAC5B,MAAM,EAAE,SAAS,CAAA;IACjB,YAAY,EAAE,MAAM,OAAO,CAAC,WAAW,EAAE,CAAC,CAAA;IAC1C,IAAI,EAAE,MAAM,IAAI,CAAA;CACjB,CAAA;AAGD,MAAM,MAAM,aAAa,GAAG,eAAe,CAAA;AAiM3C,wBAAsB,YAAY,CAAC,EAAE,MAAM,EAAE,EAAE;IAAE,MAAM,EAAE,SAAS,CAAA;CAAE,GAAG,OAAO,CAAC,eAAe,CAAC,CAK9F;AAGD,wBAAsB,UAAU,CAAC,EAAE,MAAM,EAAE,EAAE;IAAE,MAAM,EAAE,SAAS,CAAA;CAAE,GAAG,OAAO,CAAC,eAAe,CAAC,CAE5F;AAED,wBAAsB,eAAe,CAAC,EACpC,UAAU,GACX,EAAE;IACD,UAAU,EAAE,eAAe,CAAA;CAC5B,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CAEzB"}
|
package/dist/acp-client.js
CHANGED
|
@@ -1,7 +1,88 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// Connect to coding agents and list their sessions.
|
|
2
|
+
//
|
|
3
|
+
// opencode: spawns `opencode serve` and uses the HTTP API's
|
|
4
|
+
// /experimental/session endpoint which returns sessions across ALL
|
|
5
|
+
// projects (Session.listGlobal). The ACP protocol's listSessions
|
|
6
|
+
// calls Session.list which is scoped to a single project — that's
|
|
7
|
+
// why we bypass ACP for opencode.
|
|
8
|
+
//
|
|
9
|
+
// claude / codex: uses ACP over stdio (unchanged).
|
|
3
10
|
import { spawn } from 'node:child_process';
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
import path from 'node:path';
|
|
4
13
|
import { ClientSideConnection, ndJsonStream, } from '@agentclientprotocol/sdk';
|
|
14
|
+
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// opencode — HTTP server with /experimental/session (global, all projects)
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
async function connectOpencode() {
|
|
19
|
+
const PORT = 18_923;
|
|
20
|
+
const baseUrl = `http://127.0.0.1:${PORT}`;
|
|
21
|
+
// Spawn `opencode serve` on a known port. cwd doesn't matter since
|
|
22
|
+
// we use the global endpoint.
|
|
23
|
+
const child = spawn('opencode', ['serve', '--port', String(PORT)], {
|
|
24
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
25
|
+
cwd: '/',
|
|
26
|
+
});
|
|
27
|
+
const sdk = createOpencodeClient({ baseUrl });
|
|
28
|
+
// Wait for the server to be ready (poll until it responds)
|
|
29
|
+
const deadline = Date.now() + 15_000;
|
|
30
|
+
while (Date.now() < deadline) {
|
|
31
|
+
try {
|
|
32
|
+
const res = await fetch(`${baseUrl}/session?limit=1`);
|
|
33
|
+
if (res.ok)
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// server not ready yet
|
|
38
|
+
}
|
|
39
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
40
|
+
}
|
|
41
|
+
// Verify it's actually up
|
|
42
|
+
const check = await fetch(`${baseUrl}/session?limit=1`).catch(() => null);
|
|
43
|
+
if (!check?.ok) {
|
|
44
|
+
child.kill();
|
|
45
|
+
throw new Error('opencode serve failed to start');
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
client: 'opencode',
|
|
49
|
+
listSessions: async () => {
|
|
50
|
+
const sessions = [];
|
|
51
|
+
let cursor;
|
|
52
|
+
// Paginate through /experimental/session which uses Session.listGlobal()
|
|
53
|
+
// (returns sessions across ALL projects, not scoped to one)
|
|
54
|
+
while (true) {
|
|
55
|
+
const result = await sdk.experimental.session.list({
|
|
56
|
+
roots: true,
|
|
57
|
+
...(cursor !== undefined && { cursor }),
|
|
58
|
+
});
|
|
59
|
+
if (result.error || !result.data) {
|
|
60
|
+
throw new Error(`opencode API error: ${result.error}`);
|
|
61
|
+
}
|
|
62
|
+
for (const s of result.data) {
|
|
63
|
+
sessions.push({
|
|
64
|
+
sessionId: s.id,
|
|
65
|
+
cwd: s.directory,
|
|
66
|
+
title: s.title,
|
|
67
|
+
updatedAt: new Date(s.time.updated).toISOString(),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
// Pagination cursor is in the x-next-cursor response header
|
|
71
|
+
const nextCursor = result.response.headers.get('x-next-cursor');
|
|
72
|
+
if (!nextCursor)
|
|
73
|
+
break;
|
|
74
|
+
cursor = Number(nextCursor);
|
|
75
|
+
}
|
|
76
|
+
return sessions;
|
|
77
|
+
},
|
|
78
|
+
kill: () => {
|
|
79
|
+
child.kill();
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// claude / codex — ACP over stdio
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
5
86
|
function nodeToWebWritable(nodeStream) {
|
|
6
87
|
return new WritableStream({
|
|
7
88
|
write(chunk) {
|
|
@@ -33,9 +114,6 @@ function nodeToWebReadable(nodeStream) {
|
|
|
33
114
|
},
|
|
34
115
|
});
|
|
35
116
|
}
|
|
36
|
-
// Minimal Client implementation — we only need session listing,
|
|
37
|
-
// not file ops or permissions. requestPermission and sessionUpdate
|
|
38
|
-
// are required by the Client interface.
|
|
39
117
|
class MinimalClient {
|
|
40
118
|
async requestPermission() {
|
|
41
119
|
return { outcome: { outcome: 'cancelled' } };
|
|
@@ -48,11 +126,24 @@ class MinimalClient {
|
|
|
48
126
|
return {};
|
|
49
127
|
}
|
|
50
128
|
}
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
const
|
|
129
|
+
function resolveAcpBinary(client) {
|
|
130
|
+
const require = createRequire(import.meta.url);
|
|
131
|
+
const packageName = client === 'claude'
|
|
132
|
+
? '@zed-industries/claude-agent-acp'
|
|
133
|
+
: '@zed-industries/codex-acp';
|
|
134
|
+
const binName = client === 'claude' ? 'claude-agent-acp' : 'codex-acp';
|
|
135
|
+
const pkgJsonPath = require.resolve(`${packageName}/package.json`);
|
|
136
|
+
const pkgDir = path.dirname(pkgJsonPath);
|
|
137
|
+
const pkg = require(pkgJsonPath);
|
|
138
|
+
const binRelative = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin[binName];
|
|
139
|
+
const binPath = path.resolve(pkgDir, binRelative);
|
|
140
|
+
return { cmd: process.execPath, args: [binPath] };
|
|
141
|
+
}
|
|
142
|
+
async function connectAcpAgent(client) {
|
|
143
|
+
const { cmd, args } = resolveAcpBinary(client);
|
|
54
144
|
const child = spawn(cmd, args, {
|
|
55
145
|
stdio: ['pipe', 'pipe', 'inherit'],
|
|
146
|
+
cwd: '/',
|
|
56
147
|
});
|
|
57
148
|
const stream = ndJsonStream(nodeToWebWritable(child.stdin), nodeToWebReadable(child.stdout));
|
|
58
149
|
const connection = new ClientSideConnection((_agent) => {
|
|
@@ -63,26 +154,39 @@ export async function connectAcp({ client, }) {
|
|
|
63
154
|
clientCapabilities: {},
|
|
64
155
|
});
|
|
65
156
|
return {
|
|
66
|
-
connection,
|
|
67
157
|
client,
|
|
158
|
+
listSessions: async () => {
|
|
159
|
+
const sessions = [];
|
|
160
|
+
let cursor;
|
|
161
|
+
while (true) {
|
|
162
|
+
const response = await connection.listSessions({
|
|
163
|
+
...(cursor ? { cursor } : {}),
|
|
164
|
+
});
|
|
165
|
+
sessions.push(...response.sessions);
|
|
166
|
+
if (!response.nextCursor)
|
|
167
|
+
break;
|
|
168
|
+
cursor = response.nextCursor;
|
|
169
|
+
}
|
|
170
|
+
return sessions;
|
|
171
|
+
},
|
|
68
172
|
kill: () => {
|
|
69
173
|
child.kill();
|
|
70
174
|
},
|
|
71
175
|
};
|
|
72
176
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
...(cursor ? { cursor } : {}),
|
|
80
|
-
});
|
|
81
|
-
sessions.push(...response.sessions);
|
|
82
|
-
if (!response.nextCursor) {
|
|
83
|
-
break;
|
|
84
|
-
}
|
|
85
|
-
cursor = response.nextCursor;
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// Public API
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
export async function connectAgent({ client }) {
|
|
181
|
+
if (client === 'opencode') {
|
|
182
|
+
return connectOpencode();
|
|
86
183
|
}
|
|
87
|
-
return
|
|
184
|
+
return connectAcpAgent(client);
|
|
185
|
+
}
|
|
186
|
+
// Legacy exports for backwards compat
|
|
187
|
+
export async function connectAcp({ client }) {
|
|
188
|
+
return connectAgent({ client });
|
|
189
|
+
}
|
|
190
|
+
export async function listAllSessions({ connection, }) {
|
|
191
|
+
return connection.listSessions();
|
|
88
192
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"acp-client.test.d.ts","sourceRoot":"","sources":["../src/acp-client.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Integration test: verifies that opencode returns sessions from
|
|
2
|
+
// multiple different project directories, not just one.
|
|
3
|
+
// This test only runs on machines with opencode installed and real sessions.
|
|
4
|
+
import { describe, it, expect } from 'vitest';
|
|
5
|
+
import { connectAgent } from "./acp-client.js";
|
|
6
|
+
import { getRepoInfo } from "./git.js";
|
|
7
|
+
describe('agent-client', () => {
|
|
8
|
+
it('listSessions returns sessions from at least 2 different projects', async () => {
|
|
9
|
+
const agent = await connectAgent({ client: 'opencode' });
|
|
10
|
+
try {
|
|
11
|
+
const sessions = await agent.listSessions();
|
|
12
|
+
// Collect unique cwd values
|
|
13
|
+
const cwds = [...new Set(sessions.map((s) => s.cwd).filter(Boolean))];
|
|
14
|
+
console.log(`Found ${sessions.length} sessions across ${cwds.length} directories:`);
|
|
15
|
+
for (const cwd of cwds) {
|
|
16
|
+
const count = sessions.filter((s) => s.cwd === cwd).length;
|
|
17
|
+
console.log(` ${cwd}: ${count} sessions`);
|
|
18
|
+
}
|
|
19
|
+
expect(sessions.length).toBeGreaterThan(0);
|
|
20
|
+
expect(cwds.length).toBeGreaterThanOrEqual(2);
|
|
21
|
+
}
|
|
22
|
+
finally {
|
|
23
|
+
agent.kill();
|
|
24
|
+
}
|
|
25
|
+
}, 30_000);
|
|
26
|
+
it('debug: show which sessions would pass sync filters', async () => {
|
|
27
|
+
const connectedAt = '2026-03-21T23:03:40.127Z';
|
|
28
|
+
const connectedAtMs = new Date(connectedAt).getTime();
|
|
29
|
+
console.log(`connectedAt: ${connectedAt} (${connectedAtMs})`);
|
|
30
|
+
console.log(`now: ${new Date().toISOString()} (${Date.now()})`);
|
|
31
|
+
console.log();
|
|
32
|
+
const agent = await connectAgent({ client: 'opencode' });
|
|
33
|
+
try {
|
|
34
|
+
const sessions = await agent.listSessions();
|
|
35
|
+
// Sort by updatedAt descending (most recent first)
|
|
36
|
+
const sorted = [...sessions].sort((a, b) => {
|
|
37
|
+
const aMs = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
|
|
38
|
+
const bMs = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
|
|
39
|
+
return bMs - aMs;
|
|
40
|
+
});
|
|
41
|
+
// Show the 10 most recent sessions with all their timestamps and filter results
|
|
42
|
+
console.log('=== 10 most recent sessions ===');
|
|
43
|
+
for (const session of sorted.slice(0, 10)) {
|
|
44
|
+
const updatedAt = session.updatedAt || '(none)';
|
|
45
|
+
const updatedAtMs = session.updatedAt ? new Date(session.updatedAt).getTime() : 0;
|
|
46
|
+
const passesTimeFilter = updatedAtMs >= connectedAtMs;
|
|
47
|
+
const hasCwd = !!session.cwd;
|
|
48
|
+
const repo = hasCwd ? await getRepoInfo({ cwd: session.cwd }) : undefined;
|
|
49
|
+
console.log(` session: ${session.sessionId.slice(0, 12)}`);
|
|
50
|
+
console.log(` title: ${(session.title || '(none)').slice(0, 80)}`);
|
|
51
|
+
console.log(` cwd: ${session.cwd || '(none)'}`);
|
|
52
|
+
console.log(` updatedAt: ${updatedAt}`);
|
|
53
|
+
console.log(` passTime: ${passesTimeFilter}`);
|
|
54
|
+
console.log(` repo: ${repo ? repo.slug : '(no repo)'}`);
|
|
55
|
+
console.log();
|
|
56
|
+
}
|
|
57
|
+
// Summary: how many pass each filter
|
|
58
|
+
let noCwd = 0;
|
|
59
|
+
let tooOld = 0;
|
|
60
|
+
let noRepo = 0;
|
|
61
|
+
let wouldSync = 0;
|
|
62
|
+
for (const session of sessions) {
|
|
63
|
+
if (!session.cwd) {
|
|
64
|
+
noCwd++;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const updatedAtMs = session.updatedAt ? new Date(session.updatedAt).getTime() : 0;
|
|
68
|
+
if (updatedAtMs < connectedAtMs) {
|
|
69
|
+
tooOld++;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const repo = await getRepoInfo({ cwd: session.cwd });
|
|
73
|
+
if (!repo) {
|
|
74
|
+
noRepo++;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
wouldSync++;
|
|
78
|
+
}
|
|
79
|
+
console.log('=== Summary ===');
|
|
80
|
+
console.log(` Total sessions: ${sessions.length}`);
|
|
81
|
+
console.log(` No cwd: ${noCwd}`);
|
|
82
|
+
console.log(` Too old: ${tooOld}`);
|
|
83
|
+
console.log(` No git repo: ${noRepo}`);
|
|
84
|
+
console.log(` Would sync: ${wouldSync}`);
|
|
85
|
+
expect(sessions.length).toBeGreaterThan(0);
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
agent.kill();
|
|
89
|
+
}
|
|
90
|
+
}, 120_000);
|
|
91
|
+
});
|
package/dist/cli.js
CHANGED
|
@@ -8,9 +8,9 @@ import crypto from 'node:crypto';
|
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { exec } from 'node:child_process';
|
|
10
10
|
import { readConfig, writeConfig } from "./config.js";
|
|
11
|
-
import {
|
|
11
|
+
import { connectAgent } from "./acp-client.js";
|
|
12
12
|
import { getRepoInfo } from "./git.js";
|
|
13
|
-
import { createNotionClient, createBoardDatabase, getRootPages } from "./notion.js";
|
|
13
|
+
import { createNotionClient, createBoardDatabase, createExamplePage, getRootPages } from "./notion.js";
|
|
14
14
|
import { evictExistingInstance, getLockPort, startLockServer } from "./lock.js";
|
|
15
15
|
import { startSyncLoop } from "./sync.js";
|
|
16
16
|
import { enableStartupService, disableStartupService, isStartupServiceEnabled, getServiceLocationDescription, } from "./startup-service.js";
|
|
@@ -112,6 +112,7 @@ async function connectFlow() {
|
|
|
112
112
|
options: [
|
|
113
113
|
{ value: 'opencode', label: 'OpenCode' },
|
|
114
114
|
{ value: 'claude', label: 'Claude Code' },
|
|
115
|
+
{ value: 'codex', label: 'Codex' },
|
|
115
116
|
],
|
|
116
117
|
required: true,
|
|
117
118
|
});
|
|
@@ -129,8 +130,8 @@ async function connectFlow() {
|
|
|
129
130
|
const connectedClients = [];
|
|
130
131
|
for (const client of config.clients) {
|
|
131
132
|
try {
|
|
132
|
-
const acp = await
|
|
133
|
-
const sessions = await
|
|
133
|
+
const acp = await connectAgent({ client });
|
|
134
|
+
const sessions = await acp.listSessions();
|
|
134
135
|
// Extract unique repos from session cwds
|
|
135
136
|
const cwds = [...new Set(sessions.map((sess) => sess.cwd).filter(Boolean))];
|
|
136
137
|
const repoInfos = await Promise.all(cwds.map((cwd) => getRepoInfo({ cwd })));
|
|
@@ -237,11 +238,14 @@ async function connectFlow() {
|
|
|
237
238
|
}
|
|
238
239
|
return pageChoice;
|
|
239
240
|
})();
|
|
240
|
-
// Step 6: Create database
|
|
241
|
+
// Step 6: Create database and seed with an example page
|
|
241
242
|
s.start('Creating board database...');
|
|
242
243
|
const { databaseId } = await createBoardDatabase({ notion, pageId });
|
|
244
|
+
await createExamplePage({ notion, databaseId });
|
|
243
245
|
s.stop('Board database created');
|
|
244
|
-
|
|
246
|
+
// Show link to the Notion page so user can open it directly
|
|
247
|
+
const notionPageUrl = `https://notion.so/${pageId.replace(/-/g, '')}`;
|
|
248
|
+
log.success(`Board created: ${notionPageUrl}`);
|
|
245
249
|
// Step 7: Save to config
|
|
246
250
|
const board = {
|
|
247
251
|
notionToken: authResult.accessToken,
|
|
@@ -274,14 +278,9 @@ async function connectFlow() {
|
|
|
274
278
|
else {
|
|
275
279
|
log.info(`Already registered at ${getServiceLocationDescription()}`);
|
|
276
280
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
detached: true,
|
|
281
|
-
stdio: 'ignore',
|
|
282
|
-
});
|
|
283
|
-
child.unref();
|
|
284
|
-
outro('Board connected! Sync daemon started in background.');
|
|
281
|
+
outro('Board connected! Starting sync, keep this process running.');
|
|
282
|
+
// Transition directly into the sync daemon instead of spawning a child
|
|
283
|
+
await startDaemon(config);
|
|
285
284
|
}
|
|
286
285
|
// --- Daemon ---
|
|
287
286
|
async function startDaemon(config) {
|
|
@@ -292,7 +291,7 @@ async function startDaemon(config) {
|
|
|
292
291
|
const connections = [];
|
|
293
292
|
for (const client of config.clients) {
|
|
294
293
|
try {
|
|
295
|
-
const acp = await
|
|
294
|
+
const acp = await connectAgent({ client });
|
|
296
295
|
connections.push(acp);
|
|
297
296
|
console.log(`Connected to ${client} via ACP`);
|
|
298
297
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -21,7 +21,7 @@ export type OpenplexerBoard = {
|
|
|
21
21
|
* created or last updated after this time are synced. */
|
|
22
22
|
connectedAt: string;
|
|
23
23
|
};
|
|
24
|
-
export type AcpClient = 'opencode' | 'claude';
|
|
24
|
+
export type AcpClient = 'opencode' | 'claude' | 'codex';
|
|
25
25
|
export type OpenplexerConfig = {
|
|
26
26
|
/** ACP clients to connect to (user may use both opencode and claude) */
|
|
27
27
|
clients: AcpClient[];
|
package/dist/config.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAQA,MAAM,MAAM,eAAe,GAAG;IAC5B,gCAAgC;IAChC,WAAW,EAAE,MAAM,CAAA;IACnB,4CAA4C;IAC5C,YAAY,EAAE,MAAM,CAAA;IACpB,uBAAuB;IACvB,cAAc,EAAE,MAAM,CAAA;IACtB,0BAA0B;IAC1B,iBAAiB,EAAE,MAAM,CAAA;IACzB,4BAA4B;IAC5B,mBAAmB,EAAE,MAAM,CAAA;IAC3B,gDAAgD;IAChD,YAAY,EAAE,MAAM,CAAA;IACpB,0CAA0C;IAC1C,gBAAgB,EAAE,MAAM,CAAA;IACxB,mEAAmE;IACnE,YAAY,EAAE,MAAM,EAAE,CAAA;IACtB,8DAA8D;IAC9D,cAAc,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACtC;8DAC0D;IAC1D,WAAW,EAAE,MAAM,CAAA;CACpB,CAAA;AAED,MAAM,MAAM,SAAS,GAAG,UAAU,GAAG,QAAQ,CAAA;
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAQA,MAAM,MAAM,eAAe,GAAG;IAC5B,gCAAgC;IAChC,WAAW,EAAE,MAAM,CAAA;IACnB,4CAA4C;IAC5C,YAAY,EAAE,MAAM,CAAA;IACpB,uBAAuB;IACvB,cAAc,EAAE,MAAM,CAAA;IACtB,0BAA0B;IAC1B,iBAAiB,EAAE,MAAM,CAAA;IACzB,4BAA4B;IAC5B,mBAAmB,EAAE,MAAM,CAAA;IAC3B,gDAAgD;IAChD,YAAY,EAAE,MAAM,CAAA;IACpB,0CAA0C;IAC1C,gBAAgB,EAAE,MAAM,CAAA;IACxB,mEAAmE;IACnE,YAAY,EAAE,MAAM,EAAE,CAAA;IACtB,8DAA8D;IAC9D,cAAc,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACtC;8DAC0D;IAC1D,WAAW,EAAE,MAAM,CAAA;CACpB,CAAA;AAED,MAAM,MAAM,SAAS,GAAG,UAAU,GAAG,QAAQ,GAAG,OAAO,CAAA;AAEvD,MAAM,MAAM,gBAAgB,GAAG;IAC7B,wEAAwE;IACxE,OAAO,EAAE,SAAS,EAAE,CAAA;IACpB,wCAAwC;IACxC,MAAM,EAAE,eAAe,EAAE,CAAA;CAC1B,CAAA;AAKD,wBAAgB,YAAY,IAAI,MAAM,CAErC;AAED,wBAAgB,UAAU,IAAI,gBAAgB,GAAG,SAAS,CAOzD;AAED,wBAAgB,WAAW,CAAC,MAAM,EAAE,gBAAgB,GAAG,IAAI,CAK1D"}
|
package/dist/notion.d.ts
CHANGED
|
@@ -8,12 +8,6 @@ export declare const STATUS_OPTIONS: ({
|
|
|
8
8
|
} | {
|
|
9
9
|
name: string;
|
|
10
10
|
color: "green";
|
|
11
|
-
} | {
|
|
12
|
-
name: string;
|
|
13
|
-
color: "red";
|
|
14
|
-
} | {
|
|
15
|
-
name: string;
|
|
16
|
-
color: "gray";
|
|
17
11
|
})[];
|
|
18
12
|
export type CreateDatabaseResult = {
|
|
19
13
|
databaseId: string;
|
|
@@ -34,6 +28,10 @@ export declare function createBoardDatabase({ notion, pageId, }: {
|
|
|
34
28
|
notion: Client;
|
|
35
29
|
pageId: string;
|
|
36
30
|
}): Promise<CreateDatabaseResult>;
|
|
31
|
+
export declare function createExamplePage({ notion, databaseId, }: {
|
|
32
|
+
notion: Client;
|
|
33
|
+
databaseId: string;
|
|
34
|
+
}): Promise<string>;
|
|
37
35
|
export declare function createSessionPage({ notion, databaseId, title, sessionId, status, repoSlug, branchUrl, shareUrl, resumeCommand, assigneeId, folder, discordUrl, updatedAt, }: {
|
|
38
36
|
notion: Client;
|
|
39
37
|
databaseId: string;
|
package/dist/notion.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"notion.d.ts","sourceRoot":"","sources":["../src/notion.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAEzC,eAAO,MAAM,cAAc
|
|
1
|
+
{"version":3,"file":"notion.d.ts","sourceRoot":"","sources":["../src/notion.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAEzC,eAAO,MAAM,cAAc;;;;;;;;;IAI1B,CAAA;AAED,MAAM,MAAM,oBAAoB,GAAG;IACjC,UAAU,EAAE,MAAM,CAAA;CACnB,CAAA;AAED,wBAAgB,kBAAkB,CAAC,EAAE,KAAK,EAAE,EAAE;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,MAAM,CAEvE;AAED,MAAM,MAAM,QAAQ,GAAG;IACrB,EAAE,EAAE,MAAM,CAAA;IACV,KAAK,EAAE,MAAM,CAAA;IACb,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,EAAE,MAAM,CAAA;CACb,CAAA;AAMD,wBAAsB,YAAY,CAAC,EAAE,MAAM,EAAE,EAAE;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC,CAiDtF;AAED,wBAAsB,mBAAmB,CAAC,EACxC,MAAM,EACN,MAAM,GACP,EAAE;IACD,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,MAAM,CAAA;CACf,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAuEhC;AAGD,wBAAsB,iBAAiB,CAAC,EACtC,MAAM,EACN,UAAU,GACX,EAAE;IACD,MAAM,EAAE,MAAM,CAAA;IACd,UAAU,EAAE,MAAM,CAAA;CACnB,GAAG,OAAO,CAAC,MAAM,CAAC,CA8GlB;AAED,wBAAsB,iBAAiB,CAAC,EACtC,MAAM,EACN,UAAU,EACV,KAAK,EACL,SAAS,EACT,MAAM,EACN,QAAQ,EACR,SAAS,EACT,QAAQ,EACR,aAAa,EACb,UAAU,EACV,MAAM,EACN,UAAU,EACV,SAAS,GACV,EAAE;IACD,MAAM,EAAE,MAAM,CAAA;IACd,UAAU,EAAE,MAAM,CAAA;IAClB,KAAK,EAAE,MAAM,CAAA;IACb,SAAS,EAAE,MAAM,CAAA;IACjB,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,EAAE,MAAM,CAAA;IAChB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,aAAa,EAAE,MAAM,CAAA;IACrB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,MAAM,EAAE,MAAM,CAAA;IACd,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB,GAAG,OAAO,CAAC,MAAM,CAAC,CAgClB;AAED,wBAAsB,iBAAiB,CAAC,EACtC,MAAM,EACN,MAAM,EACN,KAAK,EACL,SAAS,GACV,EAAE;IACD,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB,GAAG,OAAO,CAAC,IAAI,CAAC,CAkBhB;AAMD,wBAAsB,eAAe,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAUzE"}
|