openplexer 0.1.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/notion.ts ADDED
@@ -0,0 +1,233 @@
1
+ // Notion API wrapper for openplexer.
2
+ // Creates board databases, creates/updates session pages.
3
+
4
+ import { Client } from '@notionhq/client'
5
+
6
+ export const STATUS_OPTIONS = [
7
+ { name: 'Not Started', color: 'default' as const },
8
+ { name: 'In Progress', color: 'blue' as const },
9
+ { name: 'Done', color: 'green' as const },
10
+ { name: 'Needs Attention', color: 'red' as const },
11
+ { name: 'Ignored', color: 'gray' as const },
12
+ ]
13
+
14
+ export type CreateDatabaseResult = {
15
+ databaseId: string
16
+ }
17
+
18
+ export function createNotionClient({ token }: { token: string }): Client {
19
+ return new Client({ auth: token })
20
+ }
21
+
22
+ export type RootPage = {
23
+ id: string
24
+ title: string
25
+ url: string
26
+ icon: string
27
+ }
28
+
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.
33
+ export async function getRootPages({ notion }: { notion: Client }): Promise<RootPage[]> {
34
+ const pages: RootPage[] = []
35
+ let startCursor: string | undefined
36
+
37
+ for (let page = 0; page < 3; page++) {
38
+ const res = await notion.search({
39
+ filter: { property: 'object', value: 'page' },
40
+ page_size: 100,
41
+ sort: { direction: 'descending', timestamp: 'last_edited_time' },
42
+ ...(startCursor ? { start_cursor: startCursor } : {}),
43
+ })
44
+
45
+ for (const result of res.results) {
46
+ if (!('parent' in result) || !('properties' in result)) {
47
+ continue
48
+ }
49
+
50
+ const titleProp = Object.values(result.properties).find((p) => p.type === 'title')
51
+ const title = (() => {
52
+ if (!titleProp || titleProp.type !== 'title') {
53
+ return ''
54
+ }
55
+ return titleProp.title.map((t: { plain_text: string }) => t.plain_text).join('')
56
+ })()
57
+
58
+ const icon = (() => {
59
+ if (!result.icon) {
60
+ return ''
61
+ }
62
+ if (result.icon.type === 'emoji') {
63
+ return result.icon.emoji
64
+ }
65
+ return ''
66
+ })()
67
+
68
+ pages.push({ id: result.id, title: title || result.url, url: result.url, icon })
69
+ }
70
+
71
+ if (!res.has_more || !res.next_cursor) {
72
+ break
73
+ }
74
+ startCursor = res.next_cursor
75
+ }
76
+
77
+ return pages
78
+ }
79
+
80
+ export async function createBoardDatabase({
81
+ notion,
82
+ pageId,
83
+ }: {
84
+ notion: Client
85
+ pageId: string
86
+ }): Promise<CreateDatabaseResult> {
87
+ const database = await notion.databases.create({
88
+ parent: { type: 'page_id', page_id: pageId },
89
+ title: [{ text: { content: 'openplexer - Coding Sessions' } }],
90
+ initial_data_source: {
91
+ properties: {
92
+ Name: { type: 'title', title: {} },
93
+ Status: {
94
+ type: 'select',
95
+ select: { options: STATUS_OPTIONS },
96
+ },
97
+ Repo: { type: 'select', select: { options: [] } },
98
+ Branch: { type: 'url', url: {} },
99
+ 'Share URL': { type: 'url', url: {} },
100
+ Resume: { type: 'rich_text', rich_text: {} },
101
+ 'Session ID': { type: 'rich_text', rich_text: {} },
102
+ Assignee: { type: 'people', people: {} },
103
+ Folder: { type: 'rich_text', rich_text: {} },
104
+ Discord: { type: 'url', url: {} },
105
+ Updated: { type: 'date', date: {} },
106
+ },
107
+ },
108
+ })
109
+
110
+ // Database is created with a default Table view. Create a Board view
111
+ // grouped by Status so sessions show as a kanban board.
112
+ const dataSourceId = 'data_sources' in database
113
+ ? database.data_sources?.[0]?.id
114
+ : undefined
115
+ if (dataSourceId) {
116
+ await notion.views.create({
117
+ database_id: database.id,
118
+ data_source_id: dataSourceId,
119
+ name: 'Board',
120
+ type: 'board',
121
+ })
122
+ }
123
+
124
+ return { databaseId: database.id }
125
+ }
126
+
127
+ export async function createSessionPage({
128
+ notion,
129
+ databaseId,
130
+ title,
131
+ sessionId,
132
+ status,
133
+ repoSlug,
134
+ branchUrl,
135
+ shareUrl,
136
+ resumeCommand,
137
+ assigneeId,
138
+ folder,
139
+ discordUrl,
140
+ updatedAt,
141
+ }: {
142
+ notion: Client
143
+ databaseId: string
144
+ title: string
145
+ sessionId: string
146
+ status: string
147
+ repoSlug: string
148
+ branchUrl?: string
149
+ shareUrl?: string
150
+ resumeCommand: string
151
+ assigneeId?: string
152
+ folder: string
153
+ discordUrl?: string
154
+ updatedAt?: string
155
+ }): Promise<string> {
156
+ const properties: Record<string, unknown> = {
157
+ Name: { title: [{ text: { content: title } }] },
158
+ Status: { select: { name: status } },
159
+ 'Session ID': { rich_text: [{ text: { content: sessionId } }] },
160
+ Repo: { select: { name: repoSlug } },
161
+ Resume: { rich_text: [{ text: { content: resumeCommand } }] },
162
+ Folder: { rich_text: [{ text: { content: folder } }] },
163
+ }
164
+
165
+ if (branchUrl) {
166
+ properties['Branch'] = { url: branchUrl }
167
+ }
168
+ if (shareUrl) {
169
+ properties['Share URL'] = { url: shareUrl }
170
+ }
171
+ if (assigneeId) {
172
+ properties['Assignee'] = { people: [{ id: assigneeId }] }
173
+ }
174
+ if (discordUrl) {
175
+ properties['Discord'] = { url: discordUrl }
176
+ }
177
+ if (updatedAt) {
178
+ properties['Updated'] = { date: { start: updatedAt } }
179
+ }
180
+
181
+ const page = await notion.pages.create({
182
+ parent: { database_id: databaseId },
183
+ properties: properties as Parameters<Client['pages']['create']>[0]['properties'],
184
+ })
185
+
186
+ return page.id
187
+ }
188
+
189
+ export async function updateSessionPage({
190
+ notion,
191
+ pageId,
192
+ title,
193
+ updatedAt,
194
+ }: {
195
+ notion: Client
196
+ pageId: string
197
+ title?: string
198
+ updatedAt?: string
199
+ }): Promise<void> {
200
+ const properties: Record<string, unknown> = {}
201
+
202
+ if (title) {
203
+ properties['Name'] = { title: [{ text: { content: title } }] }
204
+ }
205
+ if (updatedAt) {
206
+ properties['Updated'] = { date: { start: updatedAt } }
207
+ }
208
+
209
+ if (Object.keys(properties).length === 0) {
210
+ return
211
+ }
212
+
213
+ await notion.pages.update({
214
+ page_id: pageId,
215
+ properties: properties as Parameters<Client['pages']['update']>[0]['properties'],
216
+ })
217
+ }
218
+
219
+ // Rate-limited queue for Notion API calls (max 3/sec)
220
+ const RATE_LIMIT_MS = 350
221
+ let lastCallTime = 0
222
+
223
+ export async function rateLimitedCall<T>(fn: () => Promise<T>): Promise<T> {
224
+ const now = Date.now()
225
+ const elapsed = now - lastCallTime
226
+ if (elapsed < RATE_LIMIT_MS) {
227
+ await new Promise((resolve) => {
228
+ setTimeout(resolve, RATE_LIMIT_MS - elapsed)
229
+ })
230
+ }
231
+ lastCallTime = Date.now()
232
+ return fn()
233
+ }
@@ -0,0 +1,175 @@
1
+ // Cross-platform startup service registration for openplexer daemon.
2
+ // Adapted from kimaki's startup-service.ts (vendored from startup-run, MIT).
3
+ //
4
+ // macOS: ~/Library/LaunchAgents/com.openplexer.plist (launchd)
5
+ // Linux: ~/.config/autostart/openplexer.desktop (XDG autostart)
6
+ // Windows: HKCU\Software\Microsoft\Windows\CurrentVersion\Run (registry)
7
+
8
+ import fs from 'node:fs'
9
+ import os from 'node:os'
10
+ import path from 'node:path'
11
+ import { exec as _exec, execFile as _execFile } from 'node:child_process'
12
+
13
+ const SERVICE_NAME = 'com.openplexer'
14
+
15
+ function execAsync(command: string): Promise<{ stdout: string; stderr: string }> {
16
+ return new Promise((resolve, reject) => {
17
+ _exec(command, { timeout: 5000 }, (error, stdout, stderr) => {
18
+ if (error) {
19
+ reject(error)
20
+ return
21
+ }
22
+ resolve({ stdout, stderr })
23
+ })
24
+ })
25
+ }
26
+
27
+ function getServiceFilePath(): string {
28
+ switch (process.platform) {
29
+ case 'darwin':
30
+ return path.join(os.homedir(), 'Library', 'LaunchAgents', `${SERVICE_NAME}.plist`)
31
+ case 'linux':
32
+ return path.join(os.homedir(), '.config', 'autostart', 'openplexer.desktop')
33
+ case 'win32':
34
+ return 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\\openplexer'
35
+ default:
36
+ throw new Error(`Unsupported platform: ${process.platform}`)
37
+ }
38
+ }
39
+
40
+ function escapeXml(value: string): string {
41
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
42
+ }
43
+
44
+ function shellEscape(value: string): string {
45
+ if (/^[a-zA-Z0-9._/=-]+$/.test(value)) {
46
+ return value
47
+ }
48
+ return `"${value.replace(/"/g, '\\"')}"`
49
+ }
50
+
51
+ function buildMacOSPlist({ command, args }: { command: string; args: string[] }): string {
52
+ const segments = [command, ...args]
53
+ return `<?xml version="1.0" encoding="UTF-8"?>
54
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
55
+ <plist version="1.0">
56
+ <dict>
57
+ <key>Label</key>
58
+ <string>${SERVICE_NAME}</string>
59
+ <key>ProgramArguments</key>
60
+ <array>
61
+ ${segments.map((s) => ` <string>${escapeXml(s)}</string>`).join('\n')}
62
+ </array>
63
+ <key>RunAtLoad</key>
64
+ <true/>
65
+ <key>KeepAlive</key>
66
+ <false/>
67
+ </dict>
68
+ </plist>
69
+ `
70
+ }
71
+
72
+ function buildLinuxDesktop({ command, args }: { command: string; args: string[] }): string {
73
+ const execLine = [command, ...args].map(shellEscape).join(' ')
74
+ return `[Desktop Entry]
75
+ Type=Application
76
+ Version=1.0
77
+ Name=openplexer
78
+ Comment=openplexer session sync daemon
79
+ Exec=${execLine}
80
+ StartupNotify=false
81
+ Terminal=false
82
+ `
83
+ }
84
+
85
+ export type StartupServiceOptions = {
86
+ command: string
87
+ args: string[]
88
+ }
89
+
90
+ export async function enableStartupService({ command, args }: StartupServiceOptions): Promise<void> {
91
+ const platform = process.platform
92
+
93
+ if (platform === 'darwin') {
94
+ const filePath = getServiceFilePath()
95
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
96
+ fs.writeFileSync(filePath, buildMacOSPlist({ command, args }))
97
+ } else if (platform === 'linux') {
98
+ const filePath = getServiceFilePath()
99
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
100
+ fs.writeFileSync(filePath, buildLinuxDesktop({ command, args }))
101
+ } else if (platform === 'win32') {
102
+ const execLine = [command, ...args]
103
+ .map((s) => {
104
+ return s.includes(' ') ? `"${s}"` : s
105
+ })
106
+ .join(' ')
107
+ // Use execFile with args array to avoid shell-quoting issues
108
+ await new Promise<void>((resolve, reject) => {
109
+ _execFile(
110
+ 'reg',
111
+ ['add', 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run', '/v', 'openplexer', '/t', 'REG_SZ', '/d', execLine, '/f'],
112
+ { timeout: 5000 },
113
+ (error) => {
114
+ if (error) {
115
+ reject(error)
116
+ } else {
117
+ resolve()
118
+ }
119
+ },
120
+ )
121
+ })
122
+ } else {
123
+ throw new Error(`Unsupported platform: ${platform}`)
124
+ }
125
+ }
126
+
127
+ export async function disableStartupService(): Promise<void> {
128
+ const platform = process.platform
129
+
130
+ if (platform === 'darwin' || platform === 'linux') {
131
+ const filePath = getServiceFilePath()
132
+ if (fs.existsSync(filePath)) {
133
+ fs.unlinkSync(filePath)
134
+ }
135
+ } else if (platform === 'win32') {
136
+ await execAsync(
137
+ `reg delete "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" /v openplexer /f`,
138
+ ).catch(() => {})
139
+ } else {
140
+ throw new Error(`Unsupported platform: ${platform}`)
141
+ }
142
+ }
143
+
144
+ export async function isStartupServiceEnabled(): Promise<boolean> {
145
+ const platform = process.platform
146
+
147
+ if (platform === 'darwin' || platform === 'linux') {
148
+ return fs.existsSync(getServiceFilePath())
149
+ }
150
+
151
+ if (platform === 'win32') {
152
+ const result = await execAsync(
153
+ `reg query "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" /v openplexer`,
154
+ ).catch(() => {
155
+ return null
156
+ })
157
+ return result !== null
158
+ }
159
+
160
+ return false
161
+ }
162
+
163
+ export function getServiceLocationDescription(): string {
164
+ const platform = process.platform
165
+ if (platform === 'darwin') {
166
+ return `launchd: ${getServiceFilePath()}`
167
+ }
168
+ if (platform === 'linux') {
169
+ return `XDG autostart: ${getServiceFilePath()}`
170
+ }
171
+ if (platform === 'win32') {
172
+ return `registry: ${getServiceFilePath()}`
173
+ }
174
+ return `unsupported platform: ${platform}`
175
+ }
package/src/sync.ts ADDED
@@ -0,0 +1,193 @@
1
+ // Core sync loop: polls ACP sessions and syncs them to Notion pages.
2
+ // Runs every 5 seconds, creates new pages for untracked sessions,
3
+ // updates existing ones when title/updatedAt changes.
4
+
5
+ import type { SessionInfo } from '@agentclientprotocol/sdk'
6
+ import type { OpenplexerBoard, OpenplexerConfig, AcpClient } from './config.ts'
7
+ import { writeConfig } from './config.ts'
8
+ import { listAllSessions, type AcpConnection } from './acp-client.ts'
9
+ import { getRepoInfo } from './git.ts'
10
+ import {
11
+ createNotionClient,
12
+ createSessionPage,
13
+ updateSessionPage,
14
+ rateLimitedCall,
15
+ } from './notion.ts'
16
+ import { execFile } from 'node:child_process'
17
+
18
+ const SYNC_INTERVAL_MS = 5000
19
+
20
+ type TaggedSession = SessionInfo & { source: AcpClient }
21
+
22
+ export async function startSyncLoop({
23
+ config,
24
+ acpConnections,
25
+ }: {
26
+ config: OpenplexerConfig
27
+ acpConnections: AcpConnection[]
28
+ }): Promise<void> {
29
+ console.log(`Syncing ${config.boards.length} board(s) every ${SYNC_INTERVAL_MS / 1000}s`)
30
+
31
+ const tick = async () => {
32
+ try {
33
+ await syncOnce({ config, acpConnections })
34
+ } catch (err) {
35
+ console.error('Sync error:', err)
36
+ }
37
+ }
38
+
39
+ // Initial sync
40
+ await tick()
41
+
42
+ // Then every 5 seconds
43
+ setInterval(tick, SYNC_INTERVAL_MS)
44
+ }
45
+
46
+ async function syncOnce({
47
+ config,
48
+ acpConnections,
49
+ }: {
50
+ config: OpenplexerConfig
51
+ acpConnections: AcpConnection[]
52
+ }): Promise<void> {
53
+ // Collect sessions from all ACP connections, tagged with their source
54
+ const sessions: TaggedSession[] = []
55
+ const seenIds = new Set<string>()
56
+
57
+ for (const acp of acpConnections) {
58
+ const clientSessions = await listAllSessions({ connection: acp.connection })
59
+ for (const session of clientSessions) {
60
+ if (!seenIds.has(session.sessionId)) {
61
+ seenIds.add(session.sessionId)
62
+ sessions.push({ ...session, source: acp.client })
63
+ }
64
+ }
65
+ }
66
+
67
+ for (const board of config.boards) {
68
+ await syncBoard({ board, sessions })
69
+ }
70
+
71
+ // Persist updated syncedSessions
72
+ writeConfig(config)
73
+ }
74
+
75
+ async function syncBoard({
76
+ board,
77
+ sessions,
78
+ }: {
79
+ board: OpenplexerBoard
80
+ sessions: TaggedSession[]
81
+ }): Promise<void> {
82
+ const notion = createNotionClient({ token: board.notionToken })
83
+
84
+ // Filter sessions to tracked repos
85
+ const filteredSessions: Array<{
86
+ session: TaggedSession
87
+ repoSlug: string
88
+ repoUrl: string
89
+ branch: string
90
+ }> = []
91
+
92
+ const connectedAtMs = new Date(board.connectedAt).getTime()
93
+
94
+ for (const session of sessions) {
95
+ if (!session.cwd) {
96
+ continue
97
+ }
98
+ // Skip sessions that predate board creation (unless already synced)
99
+ if (!board.syncedSessions[session.sessionId]) {
100
+ const updatedAtMs = session.updatedAt ? new Date(session.updatedAt).getTime() : 0
101
+ if (updatedAtMs < connectedAtMs) {
102
+ continue
103
+ }
104
+ }
105
+ const repo = await getRepoInfo({ cwd: session.cwd })
106
+ if (!repo) {
107
+ continue
108
+ }
109
+ // If trackedRepos is empty, track all repos
110
+ if (board.trackedRepos.length > 0 && !board.trackedRepos.includes(repo.slug)) {
111
+ continue
112
+ }
113
+ filteredSessions.push({
114
+ session,
115
+ repoSlug: repo.slug,
116
+ repoUrl: repo.url,
117
+ branch: repo.branch,
118
+ })
119
+ }
120
+
121
+ // Sync each session
122
+ for (const { session, repoSlug, repoUrl, branch } of filteredSessions) {
123
+ const existingPageId = board.syncedSessions[session.sessionId]
124
+
125
+ if (existingPageId) {
126
+ // Update existing page
127
+ await rateLimitedCall(() => {
128
+ return updateSessionPage({
129
+ notion,
130
+ pageId: existingPageId,
131
+ title: session.title || undefined,
132
+ updatedAt: session.updatedAt || undefined,
133
+ })
134
+ })
135
+ } else {
136
+ // Create new page
137
+ const title = session.title || `Session ${session.sessionId.slice(0, 8)}`
138
+ const branchUrl = `${repoUrl}/tree/${branch}`
139
+ const resumeCommand = (() => {
140
+ if (session.source === 'opencode') {
141
+ return `opencode --session ${session.sessionId}`
142
+ }
143
+ return `claude --resume ${session.sessionId}`
144
+ })()
145
+
146
+ // Try to get Discord URL if kimaki is available
147
+ const discordUrl = await getKimakiDiscordUrl(session.sessionId)
148
+
149
+ const pageId = await rateLimitedCall(() => {
150
+ return createSessionPage({
151
+ notion,
152
+ databaseId: board.notionDatabaseId,
153
+ title,
154
+ sessionId: session.sessionId,
155
+ status: 'In Progress',
156
+ repoSlug,
157
+ branchUrl,
158
+ resumeCommand,
159
+ assigneeId: board.notionUserId,
160
+ folder: session.cwd || '',
161
+ discordUrl: discordUrl || undefined,
162
+ updatedAt: session.updatedAt || undefined,
163
+ })
164
+ })
165
+
166
+ board.syncedSessions[session.sessionId] = pageId
167
+ console.log(` + ${title} (${repoSlug})`)
168
+ }
169
+ }
170
+ }
171
+
172
+ // Try to get Discord URL for a session via kimaki CLI
173
+ async function getKimakiDiscordUrl(sessionId: string): Promise<string | undefined> {
174
+ return new Promise((resolve) => {
175
+ execFile(
176
+ 'kimaki',
177
+ ['session', 'discord-url', '--json', sessionId],
178
+ { timeout: 3000 },
179
+ (error, stdout) => {
180
+ if (error) {
181
+ resolve(undefined)
182
+ return
183
+ }
184
+ try {
185
+ const data = JSON.parse(stdout.trim()) as { url?: string }
186
+ resolve(data.url)
187
+ } catch {
188
+ resolve(undefined)
189
+ }
190
+ },
191
+ )
192
+ })
193
+ }