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/src/acp-client.ts CHANGED
@@ -1,7 +1,16 @@
1
- // Spawn an ACP agent (opencode or claude) as a child process and connect
2
- // as a client via stdio. Uses @agentclientprotocol/sdk for the protocol.
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
 
4
- import { spawn } from 'node:child_process'
11
+ import { spawn, type ChildProcess } from 'node:child_process'
12
+ import { createRequire } from 'node:module'
13
+ import path from 'node:path'
5
14
  import { Writable, Readable } from 'node:stream'
6
15
  import {
7
16
  ClientSideConnection,
@@ -10,6 +19,102 @@ import {
10
19
  type Client,
11
20
  type SessionInfo,
12
21
  } from '@agentclientprotocol/sdk'
22
+ import { createOpencodeClient } from '@opencode-ai/sdk/v2'
23
+ import type { AcpClient } from './config.ts'
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Shared types
27
+ // ---------------------------------------------------------------------------
28
+
29
+ export type AgentConnection = {
30
+ client: AcpClient
31
+ listSessions: () => Promise<SessionInfo[]>
32
+ kill: () => void
33
+ }
34
+
35
+ // Keep the old type as an alias for backwards compat in sync.ts
36
+ export type AcpConnection = AgentConnection
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // opencode — HTTP server with /experimental/session (global, all projects)
40
+ // ---------------------------------------------------------------------------
41
+
42
+ async function connectOpencode(): Promise<AgentConnection> {
43
+ const PORT = 18_923
44
+ const baseUrl = `http://127.0.0.1:${PORT}`
45
+
46
+ // Spawn `opencode serve` on a known port. cwd doesn't matter since
47
+ // we use the global endpoint.
48
+ const child = spawn('opencode', ['serve', '--port', String(PORT)], {
49
+ stdio: ['pipe', 'pipe', 'pipe'],
50
+ cwd: '/',
51
+ })
52
+
53
+ const sdk = createOpencodeClient({ baseUrl })
54
+
55
+ // Wait for the server to be ready (poll until it responds)
56
+ const deadline = Date.now() + 15_000
57
+ while (Date.now() < deadline) {
58
+ try {
59
+ const res = await fetch(`${baseUrl}/session?limit=1`)
60
+ if (res.ok) break
61
+ } catch {
62
+ // server not ready yet
63
+ }
64
+ await new Promise((r) => setTimeout(r, 200))
65
+ }
66
+
67
+ // Verify it's actually up
68
+ const check = await fetch(`${baseUrl}/session?limit=1`).catch(() => null)
69
+ if (!check?.ok) {
70
+ child.kill()
71
+ throw new Error('opencode serve failed to start')
72
+ }
73
+
74
+ return {
75
+ client: 'opencode',
76
+ listSessions: async () => {
77
+ const sessions: SessionInfo[] = []
78
+ let cursor: number | undefined
79
+
80
+ // Paginate through /experimental/session which uses Session.listGlobal()
81
+ // (returns sessions across ALL projects, not scoped to one)
82
+ while (true) {
83
+ const result = await sdk.experimental.session.list({
84
+ roots: true,
85
+ ...(cursor !== undefined && { cursor }),
86
+ })
87
+
88
+ if (result.error || !result.data) {
89
+ throw new Error(`opencode API error: ${result.error}`)
90
+ }
91
+
92
+ for (const s of result.data) {
93
+ sessions.push({
94
+ sessionId: s.id,
95
+ cwd: s.directory,
96
+ title: s.title,
97
+ updatedAt: new Date(s.time.updated).toISOString(),
98
+ })
99
+ }
100
+
101
+ // Pagination cursor is in the x-next-cursor response header
102
+ const nextCursor = result.response.headers.get('x-next-cursor')
103
+ if (!nextCursor) break
104
+ cursor = Number(nextCursor)
105
+ }
106
+
107
+ return sessions
108
+ },
109
+ kill: () => {
110
+ child.kill()
111
+ },
112
+ }
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // claude / codex — ACP over stdio
117
+ // ---------------------------------------------------------------------------
13
118
 
14
119
  function nodeToWebWritable(nodeStream: Writable): WritableStream<Uint8Array> {
15
120
  return new WritableStream<Uint8Array>({
@@ -43,9 +148,6 @@ function nodeToWebReadable(nodeStream: Readable): ReadableStream<Uint8Array> {
43
148
  })
44
149
  }
45
150
 
46
- // Minimal Client implementation — we only need session listing,
47
- // not file ops or permissions. requestPermission and sessionUpdate
48
- // are required by the Client interface.
49
151
  class MinimalClient implements Client {
50
152
  async requestPermission() {
51
153
  return { outcome: { outcome: 'cancelled' as const } }
@@ -59,22 +161,28 @@ class MinimalClient implements Client {
59
161
  }
60
162
  }
61
163
 
62
- export type AcpConnection = {
63
- connection: ClientSideConnection
64
- client: 'opencode' | 'claude'
65
- kill: () => void
164
+ function resolveAcpBinary(client: 'claude' | 'codex'): { cmd: string; args: string[] } {
165
+ const require = createRequire(import.meta.url)
166
+ const packageName = client === 'claude'
167
+ ? '@zed-industries/claude-agent-acp'
168
+ : '@zed-industries/codex-acp'
169
+ const binName = client === 'claude' ? 'claude-agent-acp' : 'codex-acp'
170
+
171
+ const pkgJsonPath = require.resolve(`${packageName}/package.json`)
172
+ const pkgDir = path.dirname(pkgJsonPath)
173
+ const pkg = require(pkgJsonPath) as { bin: string | Record<string, string> }
174
+ const binRelative = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin[binName]
175
+ const binPath = path.resolve(pkgDir, binRelative)
176
+
177
+ return { cmd: process.execPath, args: [binPath] }
66
178
  }
67
179
 
68
- export async function connectAcp({
69
- client,
70
- }: {
71
- client: 'opencode' | 'claude'
72
- }): Promise<AcpConnection> {
73
- const cmd = client === 'opencode' ? 'opencode' : 'claude'
74
- const args = ['acp']
180
+ async function connectAcpAgent(client: 'claude' | 'codex'): Promise<AgentConnection> {
181
+ const { cmd, args } = resolveAcpBinary(client)
75
182
 
76
183
  const child = spawn(cmd, args, {
77
184
  stdio: ['pipe', 'pipe', 'inherit'],
185
+ cwd: '/',
78
186
  })
79
187
 
80
188
  const stream = ndJsonStream(
@@ -92,33 +200,48 @@ export async function connectAcp({
92
200
  })
93
201
 
94
202
  return {
95
- connection,
96
203
  client,
204
+ listSessions: async () => {
205
+ const sessions: SessionInfo[] = []
206
+ let cursor: string | undefined
207
+
208
+ while (true) {
209
+ const response = await connection.listSessions({
210
+ ...(cursor ? { cursor } : {}),
211
+ })
212
+ sessions.push(...response.sessions)
213
+ if (!response.nextCursor) break
214
+ cursor = response.nextCursor
215
+ }
216
+
217
+ return sessions
218
+ },
97
219
  kill: () => {
98
220
  child.kill()
99
221
  },
100
222
  }
101
223
  }
102
224
 
225
+ // ---------------------------------------------------------------------------
226
+ // Public API
227
+ // ---------------------------------------------------------------------------
228
+
229
+ export async function connectAgent({ client }: { client: AcpClient }): Promise<AgentConnection> {
230
+ if (client === 'opencode') {
231
+ return connectOpencode()
232
+ }
233
+ return connectAcpAgent(client)
234
+ }
235
+
236
+ // Legacy exports for backwards compat
237
+ export async function connectAcp({ client }: { client: AcpClient }): Promise<AgentConnection> {
238
+ return connectAgent({ client })
239
+ }
240
+
103
241
  export async function listAllSessions({
104
242
  connection,
105
243
  }: {
106
- connection: ClientSideConnection
244
+ connection: AgentConnection
107
245
  }): Promise<SessionInfo[]> {
108
- const sessions: SessionInfo[] = []
109
- let cursor: string | undefined
110
-
111
- // Paginate through all sessions
112
- while (true) {
113
- const response = await connection.listSessions({
114
- ...(cursor ? { cursor } : {}),
115
- })
116
- sessions.push(...response.sessions)
117
- if (!response.nextCursor) {
118
- break
119
- }
120
- cursor = response.nextCursor
121
- }
122
-
123
- return sessions
246
+ return connection.listSessions()
124
247
  }
package/src/cli.ts CHANGED
@@ -21,9 +21,9 @@ import crypto from 'node:crypto'
21
21
  import path from 'node:path'
22
22
  import { exec } from 'node:child_process'
23
23
  import { readConfig, writeConfig, type OpenplexerConfig, type OpenplexerBoard, type AcpClient } from './config.ts'
24
- import { connectAcp, listAllSessions, type AcpConnection } from './acp-client.ts'
24
+ import { connectAgent, type AgentConnection } from './acp-client.ts'
25
25
  import { getRepoInfo } from './git.ts'
26
- import { createNotionClient, createBoardDatabase, getRootPages } from './notion.ts'
26
+ import { createNotionClient, createBoardDatabase, createExamplePage, getRootPages } from './notion.ts'
27
27
  import { evictExistingInstance, getLockPort, startLockServer } from './lock.ts'
28
28
  import { startSyncLoop } from './sync.ts'
29
29
  import {
@@ -149,6 +149,7 @@ async function connectFlow(): Promise<void> {
149
149
  options: [
150
150
  { value: 'opencode' as const, label: 'OpenCode' },
151
151
  { value: 'claude' as const, label: 'Claude Code' },
152
+ { value: 'codex' as const, label: 'Codex' },
152
153
  ],
153
154
  required: true,
154
155
  })
@@ -169,8 +170,8 @@ async function connectFlow(): Promise<void> {
169
170
 
170
171
  for (const client of config.clients) {
171
172
  try {
172
- const acp = await connectAcp({ client })
173
- const sessions = await listAllSessions({ connection: acp.connection })
173
+ const acp = await connectAgent({ client })
174
+ const sessions = await acp.listSessions()
174
175
 
175
176
  // Extract unique repos from session cwds
176
177
  const cwds = [...new Set(sessions.map((sess) => sess.cwd).filter(Boolean))] as string[]
@@ -240,6 +241,7 @@ async function connectFlow(): Promise<void> {
240
241
  workspaceName: string
241
242
  notionUserId?: string
242
243
  notionUserName?: string
244
+ duplicatedTemplateId?: string | null
243
245
  }
244
246
  let authResult: AuthResult | undefined
245
247
  const maxAttempts = 150 // 5 minutes at 2s intervals
@@ -305,12 +307,15 @@ async function connectFlow(): Promise<void> {
305
307
  return pageChoice
306
308
  })()
307
309
 
308
- // Step 6: Create database
310
+ // Step 6: Create database and seed with an example page
309
311
  s.start('Creating board database...')
310
312
  const { databaseId } = await createBoardDatabase({ notion, pageId })
313
+ await createExamplePage({ notion, databaseId })
311
314
  s.stop('Board database created')
312
315
 
313
- log.success('Open the database in Notion and click "+ Add a view" → Board, grouped by Status.')
316
+ // Show link to the Notion page so user can open it directly
317
+ const notionPageUrl = `https://notion.so/${pageId.replace(/-/g, '')}`
318
+ log.success(`Board created: ${notionPageUrl}`)
314
319
 
315
320
  // Step 7: Save to config
316
321
  const board: OpenplexerBoard = {
@@ -347,15 +352,10 @@ async function connectFlow(): Promise<void> {
347
352
  log.info(`Already registered at ${getServiceLocationDescription()}`)
348
353
  }
349
354
 
350
- // Step 9: Spawn daemon in background so syncing starts immediately
351
- const { spawn: spawnProcess } = await import('node:child_process')
352
- const child = spawnProcess(process.execPath, [openplexerBin], {
353
- detached: true,
354
- stdio: 'ignore',
355
- })
356
- child.unref()
355
+ outro('Board connected! Starting sync, keep this process running.')
357
356
 
358
- outro('Board connected! Sync daemon started in background.')
357
+ // Transition directly into the sync daemon instead of spawning a child
358
+ await startDaemon(config)
359
359
  }
360
360
 
361
361
  // --- Daemon ---
@@ -367,10 +367,10 @@ async function startDaemon(config: OpenplexerConfig): Promise<void> {
367
367
 
368
368
  console.log(`openplexer daemon started (PID ${process.pid}, port ${port})`)
369
369
 
370
- const connections: AcpConnection[] = []
370
+ const connections: AgentConnection[] = []
371
371
  for (const client of config.clients) {
372
372
  try {
373
- const acp = await connectAcp({ client })
373
+ const acp = await connectAgent({ client })
374
374
  connections.push(acp)
375
375
  console.log(`Connected to ${client} via ACP`)
376
376
  } catch {
package/src/config.ts CHANGED
@@ -30,7 +30,7 @@ export type OpenplexerBoard = {
30
30
  connectedAt: string
31
31
  }
32
32
 
33
- export type AcpClient = 'opencode' | 'claude'
33
+ export type AcpClient = 'opencode' | 'claude' | 'codex'
34
34
 
35
35
  export type OpenplexerConfig = {
36
36
  /** ACP clients to connect to (user may use both opencode and claude) */
package/src/notion.ts CHANGED
@@ -7,8 +7,6 @@ export const STATUS_OPTIONS = [
7
7
  { name: 'Not Started', color: 'default' as const },
8
8
  { name: 'In Progress', color: 'blue' as const },
9
9
  { name: 'Done', color: 'green' as const },
10
- { name: 'Needs Attention', color: 'red' as const },
11
- { name: 'Ignored', color: 'gray' as const },
12
10
  ]
13
11
 
14
12
  export type CreateDatabaseResult = {
@@ -26,10 +24,10 @@ export type RootPage = {
26
24
  icon: string
27
25
  }
28
26
 
29
- // Get all accessible pages using notion.search. With OAuth integrations,
30
- // only pages the user explicitly shared during consent are returned.
31
- // We don't filter by parent.type === 'workspace' because shared pages
32
- // can be nested under other pages.
27
+ // Get root-level pages (parent.type === 'workspace') using notion.search.
28
+ // Only pages are returned (not databases). With OAuth integrations,
29
+ // only pages the user explicitly shared during consent are searchable,
30
+ // so users must share root-level pages for them to appear here.
33
31
  export async function getRootPages({ notion }: { notion: Client }): Promise<RootPage[]> {
34
32
  const pages: RootPage[] = []
35
33
  let startCursor: string | undefined
@@ -46,6 +44,10 @@ export async function getRootPages({ notion }: { notion: Client }): Promise<Root
46
44
  if (!('parent' in result) || !('properties' in result)) {
47
45
  continue
48
46
  }
47
+ // Only show root pages (direct children of workspace), skip databases
48
+ if (result.object !== 'page' || result.parent.type !== 'workspace') {
49
+ continue
50
+ }
49
51
 
50
52
  const titleProp = Object.values(result.properties).find((p) => p.type === 'title')
51
53
  const title = (() => {
@@ -86,6 +88,7 @@ export async function createBoardDatabase({
86
88
  }): Promise<CreateDatabaseResult> {
87
89
  const database = await notion.databases.create({
88
90
  parent: { type: 'page_id', page_id: pageId },
91
+ is_inline: true,
89
92
  title: [{ text: { content: 'openplexer - Coding Sessions' } }],
90
93
  initial_data_source: {
91
94
  properties: {
@@ -108,22 +111,172 @@ export async function createBoardDatabase({
108
111
  })
109
112
 
110
113
  // Database is created with a default Table view. Create a Board view
111
- // grouped by Status so sessions show as a kanban board.
114
+ // grouped by Status so sessions show as a kanban board, then delete the
115
+ // default Table view so Board becomes the default.
112
116
  const dataSourceId = 'data_sources' in database
113
117
  ? database.data_sources?.[0]?.id
114
118
  : undefined
115
119
  if (dataSourceId) {
120
+ // Retrieve the data source to get the Status property ID for group_by
121
+ const dataSource = await notion.dataSources.retrieve({ data_source_id: dataSourceId })
122
+ const statusPropertyId = 'properties' in dataSource
123
+ ? Object.entries(dataSource.properties as Record<string, { id: string; type: string }>)
124
+ .find(([name]) => name === 'Status')?.[1]?.id
125
+ : undefined
126
+
127
+ // List existing views (should contain the auto-created Table view)
128
+ const existingViews = await notion.views.list({ database_id: database.id })
129
+ const tableViewIds = existingViews.results.map((v) => v.id)
130
+
131
+ // Create the Board view, grouped by Status
116
132
  await notion.views.create({
117
133
  database_id: database.id,
118
134
  data_source_id: dataSourceId,
119
135
  name: 'Board',
120
136
  type: 'board',
137
+ ...(statusPropertyId && {
138
+ configuration: {
139
+ type: 'board' as const,
140
+ group_by: {
141
+ type: 'select' as const,
142
+ property_id: statusPropertyId,
143
+ sort: { type: 'manual' as const },
144
+ hide_empty_groups: false,
145
+ },
146
+ },
147
+ }),
121
148
  })
149
+
150
+ // Delete the default Table view(s) so Board is the only (and default) view
151
+ for (const viewId of tableViewIds) {
152
+ await notion.views.delete({ view_id: viewId }).catch(() => {
153
+ // Ignore errors — can't delete the last view, but we just created Board
154
+ })
155
+ }
122
156
  }
123
157
 
124
158
  return { databaseId: database.id }
125
159
  }
126
160
 
161
+ // Create an example page in the database explaining how sessions appear.
162
+ export async function createExamplePage({
163
+ notion,
164
+ databaseId,
165
+ }: {
166
+ notion: Client
167
+ databaseId: string
168
+ }): Promise<string> {
169
+ const page = await notion.pages.create({
170
+ parent: { database_id: databaseId },
171
+ properties: {
172
+ Name: { title: [{ text: { content: 'Sessions will appear here automatically' } }] },
173
+ Status: { select: { name: 'In Progress' } },
174
+ 'Session ID': { rich_text: [{ text: { content: 'example' } }] },
175
+ Repo: { select: { name: 'owner/repo' } },
176
+ Resume: { rich_text: [{ text: { content: 'opencode --session <id>' } }] },
177
+ Folder: { rich_text: [{ text: { content: '/path/to/project' } }] },
178
+ } as Parameters<Client['pages']['create']>[0]['properties'],
179
+ children: [
180
+ {
181
+ type: 'paragraph',
182
+ paragraph: {
183
+ rich_text: [
184
+ {
185
+ type: 'text',
186
+ text: { content: 'Each card on this board represents a coding session from OpenCode, Claude Code, or Codex. openplexer syncs them automatically every few seconds.' },
187
+ },
188
+ ],
189
+ },
190
+ },
191
+ { type: 'divider', divider: {} },
192
+ {
193
+ type: 'heading_3',
194
+ heading_3: {
195
+ rich_text: [{ type: 'text', text: { content: 'What each field means' } }],
196
+ },
197
+ },
198
+ {
199
+ type: 'bulleted_list_item',
200
+ bulleted_list_item: {
201
+ rich_text: [
202
+ { type: 'text', text: { content: 'Status' }, annotations: { bold: true } },
203
+ { type: 'text', text: { content: ' — In Progress while the session is active, Done when finished. You can set Needs Attention or Ignored manually.' } },
204
+ ],
205
+ },
206
+ },
207
+ {
208
+ type: 'bulleted_list_item',
209
+ bulleted_list_item: {
210
+ rich_text: [
211
+ { type: 'text', text: { content: 'Repo' }, annotations: { bold: true } },
212
+ { type: 'text', text: { content: ' — The GitHub repository the session is working in (owner/repo).' } },
213
+ ],
214
+ },
215
+ },
216
+ {
217
+ type: 'bulleted_list_item',
218
+ bulleted_list_item: {
219
+ rich_text: [
220
+ { type: 'text', text: { content: 'Branch' }, annotations: { bold: true } },
221
+ { type: 'text', text: { content: ' — Link to the git branch on GitHub.' } },
222
+ ],
223
+ },
224
+ },
225
+ {
226
+ type: 'bulleted_list_item',
227
+ bulleted_list_item: {
228
+ rich_text: [
229
+ { type: 'text', text: { content: 'Resume' }, annotations: { bold: true } },
230
+ { type: 'text', text: { content: ' — Command to resume the session in your terminal.' } },
231
+ ],
232
+ },
233
+ },
234
+ {
235
+ type: 'bulleted_list_item',
236
+ bulleted_list_item: {
237
+ rich_text: [
238
+ { type: 'text', text: { content: 'Share URL' }, annotations: { bold: true } },
239
+ { type: 'text', text: { content: ' — Public share link for the session (if available).' } },
240
+ ],
241
+ },
242
+ },
243
+ {
244
+ type: 'bulleted_list_item',
245
+ bulleted_list_item: {
246
+ rich_text: [
247
+ { type: 'text', text: { content: 'Discord' }, annotations: { bold: true } },
248
+ { type: 'text', text: { content: ' — Link to the Discord thread (if using kimaki).' } },
249
+ ],
250
+ },
251
+ },
252
+ {
253
+ type: 'bulleted_list_item',
254
+ bulleted_list_item: {
255
+ rich_text: [
256
+ { type: 'text', text: { content: 'Assignee' }, annotations: { bold: true } },
257
+ { type: 'text', text: { content: ' — The Notion user who authorized the integration.' } },
258
+ ],
259
+ },
260
+ },
261
+ { type: 'divider', divider: {} },
262
+ {
263
+ type: 'paragraph',
264
+ paragraph: {
265
+ rich_text: [
266
+ {
267
+ type: 'text',
268
+ text: { content: 'You can archive this card once real sessions start appearing.' },
269
+ annotations: { italic: true, color: 'gray' },
270
+ },
271
+ ],
272
+ },
273
+ },
274
+ ] as Parameters<Client['blocks']['children']['append']>[0]['children'],
275
+ })
276
+
277
+ return page.id
278
+ }
279
+
127
280
  export async function createSessionPage({
128
281
  notion,
129
282
  databaseId,