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/LICENSE +21 -0
- package/dist/acp-client.d.ts +13 -0
- package/dist/acp-client.d.ts.map +1 -0
- package/dist/acp-client.js +88 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +308 -0
- package/dist/config.d.ts +34 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +26 -0
- package/dist/env.d.ts +6 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +4 -0
- package/dist/git.d.ts +14 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +60 -0
- package/dist/lock.d.ts +9 -0
- package/dist/lock.d.ts.map +1 -0
- package/dist/lock.js +55 -0
- package/dist/notion.d.ts +59 -0
- package/dist/notion.d.ts.map +1 -0
- package/dist/notion.js +154 -0
- package/dist/startup-service.d.ts +9 -0
- package/dist/startup-service.d.ts.map +1 -0
- package/dist/startup-service.js +150 -0
- package/dist/sync.d.ts +7 -0
- package/dist/sync.d.ts.map +1 -0
- package/dist/sync.js +139 -0
- package/dist/worker.d.ts +6 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +151 -0
- package/package.json +66 -0
- package/src/acp-client.ts +124 -0
- package/src/cli.ts +387 -0
- package/src/config.ts +63 -0
- package/src/env.ts +9 -0
- package/src/git.ts +84 -0
- package/src/lock.ts +65 -0
- package/src/notion.ts +233 -0
- package/src/startup-service.ts +175 -0
- package/src/sync.ts +193 -0
- package/src/worker.ts +187 -0
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
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
|
+
}
|