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/dist/lock.js ADDED
@@ -0,0 +1,55 @@
1
+ // Single-instance enforcement via lock port.
2
+ // Same pattern as kimaki's hrana-server.ts: probe /health, SIGTERM, SIGKILL.
3
+ import http from 'node:http';
4
+ const DEFAULT_LOCK_PORT = 29990;
5
+ export function getLockPort() {
6
+ const envPort = process.env['OPENPLEXER_LOCK_PORT'];
7
+ if (envPort) {
8
+ const parsed = Number.parseInt(envPort, 10);
9
+ if (Number.isInteger(parsed) && parsed >= 1 && parsed <= 65535) {
10
+ return parsed;
11
+ }
12
+ }
13
+ return DEFAULT_LOCK_PORT;
14
+ }
15
+ export async function evictExistingInstance({ port }) {
16
+ const url = `http://127.0.0.1:${port}/health`;
17
+ const probe = await fetch(url, { signal: AbortSignal.timeout(1000) }).catch(() => {
18
+ return undefined;
19
+ });
20
+ if (!probe) {
21
+ return;
22
+ }
23
+ const body = (await probe.json().catch(() => ({})));
24
+ const targetPid = body.pid;
25
+ if (!targetPid || targetPid === process.pid) {
26
+ return;
27
+ }
28
+ process.kill(targetPid, 'SIGTERM');
29
+ await new Promise((resolve) => {
30
+ setTimeout(resolve, 1000);
31
+ });
32
+ const secondProbe = await fetch(url, { signal: AbortSignal.timeout(500) }).catch(() => {
33
+ return undefined;
34
+ });
35
+ if (!secondProbe) {
36
+ return;
37
+ }
38
+ process.kill(targetPid, 'SIGKILL');
39
+ await new Promise((resolve) => {
40
+ setTimeout(resolve, 1000);
41
+ });
42
+ }
43
+ export function startLockServer({ port }) {
44
+ const server = http.createServer((req, res) => {
45
+ if (req.url === '/health') {
46
+ res.writeHead(200, { 'Content-Type': 'application/json' });
47
+ res.end(JSON.stringify({ pid: process.pid, status: 'ok' }));
48
+ return;
49
+ }
50
+ res.writeHead(404);
51
+ res.end();
52
+ });
53
+ server.listen(port, '127.0.0.1');
54
+ return server;
55
+ }
@@ -0,0 +1,59 @@
1
+ import { Client } from '@notionhq/client';
2
+ export declare const STATUS_OPTIONS: ({
3
+ name: string;
4
+ color: "default";
5
+ } | {
6
+ name: string;
7
+ color: "blue";
8
+ } | {
9
+ name: string;
10
+ color: "green";
11
+ } | {
12
+ name: string;
13
+ color: "red";
14
+ } | {
15
+ name: string;
16
+ color: "gray";
17
+ })[];
18
+ export type CreateDatabaseResult = {
19
+ databaseId: string;
20
+ };
21
+ export declare function createNotionClient({ token }: {
22
+ token: string;
23
+ }): Client;
24
+ export type RootPage = {
25
+ id: string;
26
+ title: string;
27
+ url: string;
28
+ icon: string;
29
+ };
30
+ export declare function getRootPages({ notion }: {
31
+ notion: Client;
32
+ }): Promise<RootPage[]>;
33
+ export declare function createBoardDatabase({ notion, pageId, }: {
34
+ notion: Client;
35
+ pageId: string;
36
+ }): Promise<CreateDatabaseResult>;
37
+ export declare function createSessionPage({ notion, databaseId, title, sessionId, status, repoSlug, branchUrl, shareUrl, resumeCommand, assigneeId, folder, discordUrl, updatedAt, }: {
38
+ notion: Client;
39
+ databaseId: string;
40
+ title: string;
41
+ sessionId: string;
42
+ status: string;
43
+ repoSlug: string;
44
+ branchUrl?: string;
45
+ shareUrl?: string;
46
+ resumeCommand: string;
47
+ assigneeId?: string;
48
+ folder: string;
49
+ discordUrl?: string;
50
+ updatedAt?: string;
51
+ }): Promise<string>;
52
+ export declare function updateSessionPage({ notion, pageId, title, updatedAt, }: {
53
+ notion: Client;
54
+ pageId: string;
55
+ title?: string;
56
+ updatedAt?: string;
57
+ }): Promise<void>;
58
+ export declare function rateLimitedCall<T>(fn: () => Promise<T>): Promise<T>;
59
+ //# sourceMappingURL=notion.d.ts.map
@@ -0,0 +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;;;;;;;;;;;;;;;IAM1B,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,CA6CtF;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,CAuChC;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"}
package/dist/notion.js ADDED
@@ -0,0 +1,154 @@
1
+ // Notion API wrapper for openplexer.
2
+ // Creates board databases, creates/updates session pages.
3
+ import { Client } from '@notionhq/client';
4
+ export const STATUS_OPTIONS = [
5
+ { name: 'Not Started', color: 'default' },
6
+ { name: 'In Progress', color: 'blue' },
7
+ { name: 'Done', color: 'green' },
8
+ { name: 'Needs Attention', color: 'red' },
9
+ { name: 'Ignored', color: 'gray' },
10
+ ];
11
+ export function createNotionClient({ token }) {
12
+ return new Client({ auth: token });
13
+ }
14
+ // Get all accessible pages using notion.search. With OAuth integrations,
15
+ // only pages the user explicitly shared during consent are returned.
16
+ // We don't filter by parent.type === 'workspace' because shared pages
17
+ // can be nested under other pages.
18
+ export async function getRootPages({ notion }) {
19
+ const pages = [];
20
+ let startCursor;
21
+ for (let page = 0; page < 3; page++) {
22
+ const res = await notion.search({
23
+ filter: { property: 'object', value: 'page' },
24
+ page_size: 100,
25
+ sort: { direction: 'descending', timestamp: 'last_edited_time' },
26
+ ...(startCursor ? { start_cursor: startCursor } : {}),
27
+ });
28
+ for (const result of res.results) {
29
+ if (!('parent' in result) || !('properties' in result)) {
30
+ continue;
31
+ }
32
+ const titleProp = Object.values(result.properties).find((p) => p.type === 'title');
33
+ const title = (() => {
34
+ if (!titleProp || titleProp.type !== 'title') {
35
+ return '';
36
+ }
37
+ return titleProp.title.map((t) => t.plain_text).join('');
38
+ })();
39
+ const icon = (() => {
40
+ if (!result.icon) {
41
+ return '';
42
+ }
43
+ if (result.icon.type === 'emoji') {
44
+ return result.icon.emoji;
45
+ }
46
+ return '';
47
+ })();
48
+ pages.push({ id: result.id, title: title || result.url, url: result.url, icon });
49
+ }
50
+ if (!res.has_more || !res.next_cursor) {
51
+ break;
52
+ }
53
+ startCursor = res.next_cursor;
54
+ }
55
+ return pages;
56
+ }
57
+ export async function createBoardDatabase({ notion, pageId, }) {
58
+ const database = await notion.databases.create({
59
+ parent: { type: 'page_id', page_id: pageId },
60
+ title: [{ text: { content: 'openplexer - Coding Sessions' } }],
61
+ initial_data_source: {
62
+ properties: {
63
+ Name: { type: 'title', title: {} },
64
+ Status: {
65
+ type: 'select',
66
+ select: { options: STATUS_OPTIONS },
67
+ },
68
+ Repo: { type: 'select', select: { options: [] } },
69
+ Branch: { type: 'url', url: {} },
70
+ 'Share URL': { type: 'url', url: {} },
71
+ Resume: { type: 'rich_text', rich_text: {} },
72
+ 'Session ID': { type: 'rich_text', rich_text: {} },
73
+ Assignee: { type: 'people', people: {} },
74
+ Folder: { type: 'rich_text', rich_text: {} },
75
+ Discord: { type: 'url', url: {} },
76
+ Updated: { type: 'date', date: {} },
77
+ },
78
+ },
79
+ });
80
+ // Database is created with a default Table view. Create a Board view
81
+ // grouped by Status so sessions show as a kanban board.
82
+ const dataSourceId = 'data_sources' in database
83
+ ? database.data_sources?.[0]?.id
84
+ : undefined;
85
+ if (dataSourceId) {
86
+ await notion.views.create({
87
+ database_id: database.id,
88
+ data_source_id: dataSourceId,
89
+ name: 'Board',
90
+ type: 'board',
91
+ });
92
+ }
93
+ return { databaseId: database.id };
94
+ }
95
+ export async function createSessionPage({ notion, databaseId, title, sessionId, status, repoSlug, branchUrl, shareUrl, resumeCommand, assigneeId, folder, discordUrl, updatedAt, }) {
96
+ const properties = {
97
+ Name: { title: [{ text: { content: title } }] },
98
+ Status: { select: { name: status } },
99
+ 'Session ID': { rich_text: [{ text: { content: sessionId } }] },
100
+ Repo: { select: { name: repoSlug } },
101
+ Resume: { rich_text: [{ text: { content: resumeCommand } }] },
102
+ Folder: { rich_text: [{ text: { content: folder } }] },
103
+ };
104
+ if (branchUrl) {
105
+ properties['Branch'] = { url: branchUrl };
106
+ }
107
+ if (shareUrl) {
108
+ properties['Share URL'] = { url: shareUrl };
109
+ }
110
+ if (assigneeId) {
111
+ properties['Assignee'] = { people: [{ id: assigneeId }] };
112
+ }
113
+ if (discordUrl) {
114
+ properties['Discord'] = { url: discordUrl };
115
+ }
116
+ if (updatedAt) {
117
+ properties['Updated'] = { date: { start: updatedAt } };
118
+ }
119
+ const page = await notion.pages.create({
120
+ parent: { database_id: databaseId },
121
+ properties: properties,
122
+ });
123
+ return page.id;
124
+ }
125
+ export async function updateSessionPage({ notion, pageId, title, updatedAt, }) {
126
+ const properties = {};
127
+ if (title) {
128
+ properties['Name'] = { title: [{ text: { content: title } }] };
129
+ }
130
+ if (updatedAt) {
131
+ properties['Updated'] = { date: { start: updatedAt } };
132
+ }
133
+ if (Object.keys(properties).length === 0) {
134
+ return;
135
+ }
136
+ await notion.pages.update({
137
+ page_id: pageId,
138
+ properties: properties,
139
+ });
140
+ }
141
+ // Rate-limited queue for Notion API calls (max 3/sec)
142
+ const RATE_LIMIT_MS = 350;
143
+ let lastCallTime = 0;
144
+ export async function rateLimitedCall(fn) {
145
+ const now = Date.now();
146
+ const elapsed = now - lastCallTime;
147
+ if (elapsed < RATE_LIMIT_MS) {
148
+ await new Promise((resolve) => {
149
+ setTimeout(resolve, RATE_LIMIT_MS - elapsed);
150
+ });
151
+ }
152
+ lastCallTime = Date.now();
153
+ return fn();
154
+ }
@@ -0,0 +1,9 @@
1
+ export type StartupServiceOptions = {
2
+ command: string;
3
+ args: string[];
4
+ };
5
+ export declare function enableStartupService({ command, args }: StartupServiceOptions): Promise<void>;
6
+ export declare function disableStartupService(): Promise<void>;
7
+ export declare function isStartupServiceEnabled(): Promise<boolean>;
8
+ export declare function getServiceLocationDescription(): string;
9
+ //# sourceMappingURL=startup-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"startup-service.d.ts","sourceRoot":"","sources":["../src/startup-service.ts"],"names":[],"mappings":"AAoFA,MAAM,MAAM,qBAAqB,GAAG;IAClC,OAAO,EAAE,MAAM,CAAA;IACf,IAAI,EAAE,MAAM,EAAE,CAAA;CACf,CAAA;AAED,wBAAsB,oBAAoB,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC,CAmClG;AAED,wBAAsB,qBAAqB,IAAI,OAAO,CAAC,IAAI,CAAC,CAe3D;AAED,wBAAsB,uBAAuB,IAAI,OAAO,CAAC,OAAO,CAAC,CAiBhE;AAED,wBAAgB,6BAA6B,IAAI,MAAM,CAYtD"}
@@ -0,0 +1,150 @@
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
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import { exec as _exec, execFile as _execFile } from 'node:child_process';
11
+ const SERVICE_NAME = 'com.openplexer';
12
+ function execAsync(command) {
13
+ return new Promise((resolve, reject) => {
14
+ _exec(command, { timeout: 5000 }, (error, stdout, stderr) => {
15
+ if (error) {
16
+ reject(error);
17
+ return;
18
+ }
19
+ resolve({ stdout, stderr });
20
+ });
21
+ });
22
+ }
23
+ function getServiceFilePath() {
24
+ switch (process.platform) {
25
+ case 'darwin':
26
+ return path.join(os.homedir(), 'Library', 'LaunchAgents', `${SERVICE_NAME}.plist`);
27
+ case 'linux':
28
+ return path.join(os.homedir(), '.config', 'autostart', 'openplexer.desktop');
29
+ case 'win32':
30
+ return 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\\openplexer';
31
+ default:
32
+ throw new Error(`Unsupported platform: ${process.platform}`);
33
+ }
34
+ }
35
+ function escapeXml(value) {
36
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
37
+ }
38
+ function shellEscape(value) {
39
+ if (/^[a-zA-Z0-9._/=-]+$/.test(value)) {
40
+ return value;
41
+ }
42
+ return `"${value.replace(/"/g, '\\"')}"`;
43
+ }
44
+ function buildMacOSPlist({ command, args }) {
45
+ const segments = [command, ...args];
46
+ return `<?xml version="1.0" encoding="UTF-8"?>
47
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
48
+ <plist version="1.0">
49
+ <dict>
50
+ <key>Label</key>
51
+ <string>${SERVICE_NAME}</string>
52
+ <key>ProgramArguments</key>
53
+ <array>
54
+ ${segments.map((s) => ` <string>${escapeXml(s)}</string>`).join('\n')}
55
+ </array>
56
+ <key>RunAtLoad</key>
57
+ <true/>
58
+ <key>KeepAlive</key>
59
+ <false/>
60
+ </dict>
61
+ </plist>
62
+ `;
63
+ }
64
+ function buildLinuxDesktop({ command, args }) {
65
+ const execLine = [command, ...args].map(shellEscape).join(' ');
66
+ return `[Desktop Entry]
67
+ Type=Application
68
+ Version=1.0
69
+ Name=openplexer
70
+ Comment=openplexer session sync daemon
71
+ Exec=${execLine}
72
+ StartupNotify=false
73
+ Terminal=false
74
+ `;
75
+ }
76
+ export async function enableStartupService({ command, args }) {
77
+ const platform = process.platform;
78
+ if (platform === 'darwin') {
79
+ const filePath = getServiceFilePath();
80
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
81
+ fs.writeFileSync(filePath, buildMacOSPlist({ command, args }));
82
+ }
83
+ else if (platform === 'linux') {
84
+ const filePath = getServiceFilePath();
85
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
86
+ fs.writeFileSync(filePath, buildLinuxDesktop({ command, args }));
87
+ }
88
+ else if (platform === 'win32') {
89
+ const execLine = [command, ...args]
90
+ .map((s) => {
91
+ return s.includes(' ') ? `"${s}"` : s;
92
+ })
93
+ .join(' ');
94
+ // Use execFile with args array to avoid shell-quoting issues
95
+ await new Promise((resolve, reject) => {
96
+ _execFile('reg', ['add', 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run', '/v', 'openplexer', '/t', 'REG_SZ', '/d', execLine, '/f'], { timeout: 5000 }, (error) => {
97
+ if (error) {
98
+ reject(error);
99
+ }
100
+ else {
101
+ resolve();
102
+ }
103
+ });
104
+ });
105
+ }
106
+ else {
107
+ throw new Error(`Unsupported platform: ${platform}`);
108
+ }
109
+ }
110
+ export async function disableStartupService() {
111
+ const platform = process.platform;
112
+ if (platform === 'darwin' || platform === 'linux') {
113
+ const filePath = getServiceFilePath();
114
+ if (fs.existsSync(filePath)) {
115
+ fs.unlinkSync(filePath);
116
+ }
117
+ }
118
+ else if (platform === 'win32') {
119
+ await execAsync(`reg delete "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" /v openplexer /f`).catch(() => { });
120
+ }
121
+ else {
122
+ throw new Error(`Unsupported platform: ${platform}`);
123
+ }
124
+ }
125
+ export async function isStartupServiceEnabled() {
126
+ const platform = process.platform;
127
+ if (platform === 'darwin' || platform === 'linux') {
128
+ return fs.existsSync(getServiceFilePath());
129
+ }
130
+ if (platform === 'win32') {
131
+ const result = await execAsync(`reg query "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" /v openplexer`).catch(() => {
132
+ return null;
133
+ });
134
+ return result !== null;
135
+ }
136
+ return false;
137
+ }
138
+ export function getServiceLocationDescription() {
139
+ const platform = process.platform;
140
+ if (platform === 'darwin') {
141
+ return `launchd: ${getServiceFilePath()}`;
142
+ }
143
+ if (platform === 'linux') {
144
+ return `XDG autostart: ${getServiceFilePath()}`;
145
+ }
146
+ if (platform === 'win32') {
147
+ return `registry: ${getServiceFilePath()}`;
148
+ }
149
+ return `unsupported platform: ${platform}`;
150
+ }
package/dist/sync.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ import type { OpenplexerConfig } from './config.ts';
2
+ import { type AcpConnection } from './acp-client.ts';
3
+ export declare function startSyncLoop({ config, acpConnections, }: {
4
+ config: OpenplexerConfig;
5
+ acpConnections: AcpConnection[];
6
+ }): Promise<void>;
7
+ //# sourceMappingURL=sync.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sync.d.ts","sourceRoot":"","sources":["../src/sync.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAmB,gBAAgB,EAAa,MAAM,aAAa,CAAA;AAE/E,OAAO,EAAmB,KAAK,aAAa,EAAE,MAAM,iBAAiB,CAAA;AAcrE,wBAAsB,aAAa,CAAC,EAClC,MAAM,EACN,cAAc,GACf,EAAE;IACD,MAAM,EAAE,gBAAgB,CAAA;IACxB,cAAc,EAAE,aAAa,EAAE,CAAA;CAChC,GAAG,OAAO,CAAC,IAAI,CAAC,CAgBhB"}
package/dist/sync.js ADDED
@@ -0,0 +1,139 @@
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
+ import { writeConfig } from "./config.js";
5
+ import { listAllSessions } from "./acp-client.js";
6
+ import { getRepoInfo } from "./git.js";
7
+ import { createNotionClient, createSessionPage, updateSessionPage, rateLimitedCall, } from "./notion.js";
8
+ import { execFile } from 'node:child_process';
9
+ const SYNC_INTERVAL_MS = 5000;
10
+ export async function startSyncLoop({ config, acpConnections, }) {
11
+ console.log(`Syncing ${config.boards.length} board(s) every ${SYNC_INTERVAL_MS / 1000}s`);
12
+ const tick = async () => {
13
+ try {
14
+ await syncOnce({ config, acpConnections });
15
+ }
16
+ catch (err) {
17
+ console.error('Sync error:', err);
18
+ }
19
+ };
20
+ // Initial sync
21
+ await tick();
22
+ // Then every 5 seconds
23
+ setInterval(tick, SYNC_INTERVAL_MS);
24
+ }
25
+ async function syncOnce({ config, acpConnections, }) {
26
+ // Collect sessions from all ACP connections, tagged with their source
27
+ const sessions = [];
28
+ const seenIds = new Set();
29
+ for (const acp of acpConnections) {
30
+ const clientSessions = await listAllSessions({ connection: acp.connection });
31
+ for (const session of clientSessions) {
32
+ if (!seenIds.has(session.sessionId)) {
33
+ seenIds.add(session.sessionId);
34
+ sessions.push({ ...session, source: acp.client });
35
+ }
36
+ }
37
+ }
38
+ for (const board of config.boards) {
39
+ await syncBoard({ board, sessions });
40
+ }
41
+ // Persist updated syncedSessions
42
+ writeConfig(config);
43
+ }
44
+ async function syncBoard({ board, sessions, }) {
45
+ const notion = createNotionClient({ token: board.notionToken });
46
+ // Filter sessions to tracked repos
47
+ const filteredSessions = [];
48
+ const connectedAtMs = new Date(board.connectedAt).getTime();
49
+ for (const session of sessions) {
50
+ if (!session.cwd) {
51
+ continue;
52
+ }
53
+ // Skip sessions that predate board creation (unless already synced)
54
+ if (!board.syncedSessions[session.sessionId]) {
55
+ const updatedAtMs = session.updatedAt ? new Date(session.updatedAt).getTime() : 0;
56
+ if (updatedAtMs < connectedAtMs) {
57
+ continue;
58
+ }
59
+ }
60
+ const repo = await getRepoInfo({ cwd: session.cwd });
61
+ if (!repo) {
62
+ continue;
63
+ }
64
+ // If trackedRepos is empty, track all repos
65
+ if (board.trackedRepos.length > 0 && !board.trackedRepos.includes(repo.slug)) {
66
+ continue;
67
+ }
68
+ filteredSessions.push({
69
+ session,
70
+ repoSlug: repo.slug,
71
+ repoUrl: repo.url,
72
+ branch: repo.branch,
73
+ });
74
+ }
75
+ // Sync each session
76
+ for (const { session, repoSlug, repoUrl, branch } of filteredSessions) {
77
+ const existingPageId = board.syncedSessions[session.sessionId];
78
+ if (existingPageId) {
79
+ // Update existing page
80
+ await rateLimitedCall(() => {
81
+ return updateSessionPage({
82
+ notion,
83
+ pageId: existingPageId,
84
+ title: session.title || undefined,
85
+ updatedAt: session.updatedAt || undefined,
86
+ });
87
+ });
88
+ }
89
+ else {
90
+ // Create new page
91
+ const title = session.title || `Session ${session.sessionId.slice(0, 8)}`;
92
+ const branchUrl = `${repoUrl}/tree/${branch}`;
93
+ const resumeCommand = (() => {
94
+ if (session.source === 'opencode') {
95
+ return `opencode --session ${session.sessionId}`;
96
+ }
97
+ return `claude --resume ${session.sessionId}`;
98
+ })();
99
+ // Try to get Discord URL if kimaki is available
100
+ const discordUrl = await getKimakiDiscordUrl(session.sessionId);
101
+ const pageId = await rateLimitedCall(() => {
102
+ return createSessionPage({
103
+ notion,
104
+ databaseId: board.notionDatabaseId,
105
+ title,
106
+ sessionId: session.sessionId,
107
+ status: 'In Progress',
108
+ repoSlug,
109
+ branchUrl,
110
+ resumeCommand,
111
+ assigneeId: board.notionUserId,
112
+ folder: session.cwd || '',
113
+ discordUrl: discordUrl || undefined,
114
+ updatedAt: session.updatedAt || undefined,
115
+ });
116
+ });
117
+ board.syncedSessions[session.sessionId] = pageId;
118
+ console.log(` + ${title} (${repoSlug})`);
119
+ }
120
+ }
121
+ }
122
+ // Try to get Discord URL for a session via kimaki CLI
123
+ async function getKimakiDiscordUrl(sessionId) {
124
+ return new Promise((resolve) => {
125
+ execFile('kimaki', ['session', 'discord-url', '--json', sessionId], { timeout: 3000 }, (error, stdout) => {
126
+ if (error) {
127
+ resolve(undefined);
128
+ return;
129
+ }
130
+ try {
131
+ const data = JSON.parse(stdout.trim());
132
+ resolve(data.url);
133
+ }
134
+ catch {
135
+ resolve(undefined);
136
+ }
137
+ });
138
+ });
139
+ }
@@ -0,0 +1,6 @@
1
+ import type { Env } from './env.ts';
2
+ declare const _default: {
3
+ fetch(request: Request, env: Env): Promise<Response>;
4
+ };
5
+ export default _default;
6
+ //# sourceMappingURL=worker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worker.d.ts","sourceRoot":"","sources":["../src/worker.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,UAAU,CAAA;;mBAiLlB,OAAO,OAAO,GAAG;;AADlC,wBAIC"}