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/dist/notion.js
CHANGED
|
@@ -5,16 +5,14 @@ export const STATUS_OPTIONS = [
|
|
|
5
5
|
{ name: 'Not Started', color: 'default' },
|
|
6
6
|
{ name: 'In Progress', color: 'blue' },
|
|
7
7
|
{ name: 'Done', color: 'green' },
|
|
8
|
-
{ name: 'Needs Attention', color: 'red' },
|
|
9
|
-
{ name: 'Ignored', color: 'gray' },
|
|
10
8
|
];
|
|
11
9
|
export function createNotionClient({ token }) {
|
|
12
10
|
return new Client({ auth: token });
|
|
13
11
|
}
|
|
14
|
-
// Get
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
12
|
+
// Get root-level pages (parent.type === 'workspace') using notion.search.
|
|
13
|
+
// Only pages are returned (not databases). With OAuth integrations,
|
|
14
|
+
// only pages the user explicitly shared during consent are searchable,
|
|
15
|
+
// so users must share root-level pages for them to appear here.
|
|
18
16
|
export async function getRootPages({ notion }) {
|
|
19
17
|
const pages = [];
|
|
20
18
|
let startCursor;
|
|
@@ -29,6 +27,10 @@ export async function getRootPages({ notion }) {
|
|
|
29
27
|
if (!('parent' in result) || !('properties' in result)) {
|
|
30
28
|
continue;
|
|
31
29
|
}
|
|
30
|
+
// Only show root pages (direct children of workspace), skip databases
|
|
31
|
+
if (result.object !== 'page' || result.parent.type !== 'workspace') {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
32
34
|
const titleProp = Object.values(result.properties).find((p) => p.type === 'title');
|
|
33
35
|
const title = (() => {
|
|
34
36
|
if (!titleProp || titleProp.type !== 'title') {
|
|
@@ -57,6 +59,7 @@ export async function getRootPages({ notion }) {
|
|
|
57
59
|
export async function createBoardDatabase({ notion, pageId, }) {
|
|
58
60
|
const database = await notion.databases.create({
|
|
59
61
|
parent: { type: 'page_id', page_id: pageId },
|
|
62
|
+
is_inline: true,
|
|
60
63
|
title: [{ text: { content: 'openplexer - Coding Sessions' } }],
|
|
61
64
|
initial_data_source: {
|
|
62
65
|
properties: {
|
|
@@ -78,20 +81,159 @@ export async function createBoardDatabase({ notion, pageId, }) {
|
|
|
78
81
|
},
|
|
79
82
|
});
|
|
80
83
|
// Database is created with a default Table view. Create a Board view
|
|
81
|
-
// grouped by Status so sessions show as a kanban board
|
|
84
|
+
// grouped by Status so sessions show as a kanban board, then delete the
|
|
85
|
+
// default Table view so Board becomes the default.
|
|
82
86
|
const dataSourceId = 'data_sources' in database
|
|
83
87
|
? database.data_sources?.[0]?.id
|
|
84
88
|
: undefined;
|
|
85
89
|
if (dataSourceId) {
|
|
90
|
+
// Retrieve the data source to get the Status property ID for group_by
|
|
91
|
+
const dataSource = await notion.dataSources.retrieve({ data_source_id: dataSourceId });
|
|
92
|
+
const statusPropertyId = 'properties' in dataSource
|
|
93
|
+
? Object.entries(dataSource.properties)
|
|
94
|
+
.find(([name]) => name === 'Status')?.[1]?.id
|
|
95
|
+
: undefined;
|
|
96
|
+
// List existing views (should contain the auto-created Table view)
|
|
97
|
+
const existingViews = await notion.views.list({ database_id: database.id });
|
|
98
|
+
const tableViewIds = existingViews.results.map((v) => v.id);
|
|
99
|
+
// Create the Board view, grouped by Status
|
|
86
100
|
await notion.views.create({
|
|
87
101
|
database_id: database.id,
|
|
88
102
|
data_source_id: dataSourceId,
|
|
89
103
|
name: 'Board',
|
|
90
104
|
type: 'board',
|
|
105
|
+
...(statusPropertyId && {
|
|
106
|
+
configuration: {
|
|
107
|
+
type: 'board',
|
|
108
|
+
group_by: {
|
|
109
|
+
type: 'select',
|
|
110
|
+
property_id: statusPropertyId,
|
|
111
|
+
sort: { type: 'manual' },
|
|
112
|
+
hide_empty_groups: false,
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
}),
|
|
91
116
|
});
|
|
117
|
+
// Delete the default Table view(s) so Board is the only (and default) view
|
|
118
|
+
for (const viewId of tableViewIds) {
|
|
119
|
+
await notion.views.delete({ view_id: viewId }).catch(() => {
|
|
120
|
+
// Ignore errors — can't delete the last view, but we just created Board
|
|
121
|
+
});
|
|
122
|
+
}
|
|
92
123
|
}
|
|
93
124
|
return { databaseId: database.id };
|
|
94
125
|
}
|
|
126
|
+
// Create an example page in the database explaining how sessions appear.
|
|
127
|
+
export async function createExamplePage({ notion, databaseId, }) {
|
|
128
|
+
const page = await notion.pages.create({
|
|
129
|
+
parent: { database_id: databaseId },
|
|
130
|
+
properties: {
|
|
131
|
+
Name: { title: [{ text: { content: 'Sessions will appear here automatically' } }] },
|
|
132
|
+
Status: { select: { name: 'In Progress' } },
|
|
133
|
+
'Session ID': { rich_text: [{ text: { content: 'example' } }] },
|
|
134
|
+
Repo: { select: { name: 'owner/repo' } },
|
|
135
|
+
Resume: { rich_text: [{ text: { content: 'opencode --session <id>' } }] },
|
|
136
|
+
Folder: { rich_text: [{ text: { content: '/path/to/project' } }] },
|
|
137
|
+
},
|
|
138
|
+
children: [
|
|
139
|
+
{
|
|
140
|
+
type: 'paragraph',
|
|
141
|
+
paragraph: {
|
|
142
|
+
rich_text: [
|
|
143
|
+
{
|
|
144
|
+
type: 'text',
|
|
145
|
+
text: { content: 'Each card on this board represents a coding session from OpenCode, Claude Code, or Codex. openplexer syncs them automatically every few seconds.' },
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
{ type: 'divider', divider: {} },
|
|
151
|
+
{
|
|
152
|
+
type: 'heading_3',
|
|
153
|
+
heading_3: {
|
|
154
|
+
rich_text: [{ type: 'text', text: { content: 'What each field means' } }],
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
type: 'bulleted_list_item',
|
|
159
|
+
bulleted_list_item: {
|
|
160
|
+
rich_text: [
|
|
161
|
+
{ type: 'text', text: { content: 'Status' }, annotations: { bold: true } },
|
|
162
|
+
{ type: 'text', text: { content: ' — In Progress while the session is active, Done when finished. You can set Needs Attention or Ignored manually.' } },
|
|
163
|
+
],
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
type: 'bulleted_list_item',
|
|
168
|
+
bulleted_list_item: {
|
|
169
|
+
rich_text: [
|
|
170
|
+
{ type: 'text', text: { content: 'Repo' }, annotations: { bold: true } },
|
|
171
|
+
{ type: 'text', text: { content: ' — The GitHub repository the session is working in (owner/repo).' } },
|
|
172
|
+
],
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
type: 'bulleted_list_item',
|
|
177
|
+
bulleted_list_item: {
|
|
178
|
+
rich_text: [
|
|
179
|
+
{ type: 'text', text: { content: 'Branch' }, annotations: { bold: true } },
|
|
180
|
+
{ type: 'text', text: { content: ' — Link to the git branch on GitHub.' } },
|
|
181
|
+
],
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
type: 'bulleted_list_item',
|
|
186
|
+
bulleted_list_item: {
|
|
187
|
+
rich_text: [
|
|
188
|
+
{ type: 'text', text: { content: 'Resume' }, annotations: { bold: true } },
|
|
189
|
+
{ type: 'text', text: { content: ' — Command to resume the session in your terminal.' } },
|
|
190
|
+
],
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
type: 'bulleted_list_item',
|
|
195
|
+
bulleted_list_item: {
|
|
196
|
+
rich_text: [
|
|
197
|
+
{ type: 'text', text: { content: 'Share URL' }, annotations: { bold: true } },
|
|
198
|
+
{ type: 'text', text: { content: ' — Public share link for the session (if available).' } },
|
|
199
|
+
],
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
type: 'bulleted_list_item',
|
|
204
|
+
bulleted_list_item: {
|
|
205
|
+
rich_text: [
|
|
206
|
+
{ type: 'text', text: { content: 'Discord' }, annotations: { bold: true } },
|
|
207
|
+
{ type: 'text', text: { content: ' — Link to the Discord thread (if using kimaki).' } },
|
|
208
|
+
],
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
type: 'bulleted_list_item',
|
|
213
|
+
bulleted_list_item: {
|
|
214
|
+
rich_text: [
|
|
215
|
+
{ type: 'text', text: { content: 'Assignee' }, annotations: { bold: true } },
|
|
216
|
+
{ type: 'text', text: { content: ' — The Notion user who authorized the integration.' } },
|
|
217
|
+
],
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
{ type: 'divider', divider: {} },
|
|
221
|
+
{
|
|
222
|
+
type: 'paragraph',
|
|
223
|
+
paragraph: {
|
|
224
|
+
rich_text: [
|
|
225
|
+
{
|
|
226
|
+
type: 'text',
|
|
227
|
+
text: { content: 'You can archive this card once real sessions start appearing.' },
|
|
228
|
+
annotations: { italic: true, color: 'gray' },
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
],
|
|
234
|
+
});
|
|
235
|
+
return page.id;
|
|
236
|
+
}
|
|
95
237
|
export async function createSessionPage({ notion, databaseId, title, sessionId, status, repoSlug, branchUrl, shareUrl, resumeCommand, assigneeId, folder, discordUrl, updatedAt, }) {
|
|
96
238
|
const properties = {
|
|
97
239
|
Name: { title: [{ text: { content: title } }] },
|
package/dist/sync.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { OpenplexerConfig } from './config.ts';
|
|
2
|
-
import { type
|
|
2
|
+
import { type AgentConnection } from './acp-client.ts';
|
|
3
3
|
export declare function startSyncLoop({ config, acpConnections, }: {
|
|
4
4
|
config: OpenplexerConfig;
|
|
5
|
-
acpConnections:
|
|
5
|
+
acpConnections: AgentConnection[];
|
|
6
6
|
}): Promise<void>;
|
|
7
7
|
//# sourceMappingURL=sync.d.ts.map
|
package/dist/sync.d.ts.map
CHANGED
|
@@ -1 +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,
|
|
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,EAAE,KAAK,eAAe,EAAE,MAAM,iBAAiB,CAAA;AActD,wBAAsB,aAAa,CAAC,EAClC,MAAM,EACN,cAAc,GACf,EAAE;IACD,MAAM,EAAE,gBAAgB,CAAA;IACxB,cAAc,EAAE,eAAe,EAAE,CAAA;CAClC,GAAG,OAAO,CAAC,IAAI,CAAC,CAgBhB"}
|
package/dist/sync.js
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
// Runs every 5 seconds, creates new pages for untracked sessions,
|
|
3
3
|
// updates existing ones when title/updatedAt changes.
|
|
4
4
|
import { writeConfig } from "./config.js";
|
|
5
|
-
import { listAllSessions } from "./acp-client.js";
|
|
6
5
|
import { getRepoInfo } from "./git.js";
|
|
7
6
|
import { createNotionClient, createSessionPage, updateSessionPage, rateLimitedCall, } from "./notion.js";
|
|
8
7
|
import { execFile } from 'node:child_process';
|
|
@@ -23,17 +22,22 @@ export async function startSyncLoop({ config, acpConnections, }) {
|
|
|
23
22
|
setInterval(tick, SYNC_INTERVAL_MS);
|
|
24
23
|
}
|
|
25
24
|
async function syncOnce({ config, acpConnections, }) {
|
|
26
|
-
// Collect sessions from all
|
|
25
|
+
// Collect sessions from all agent connections, tagged with their source
|
|
27
26
|
const sessions = [];
|
|
28
27
|
const seenIds = new Set();
|
|
29
|
-
for (const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
seenIds.
|
|
34
|
-
|
|
28
|
+
for (const agent of acpConnections) {
|
|
29
|
+
try {
|
|
30
|
+
const clientSessions = await agent.listSessions();
|
|
31
|
+
for (const session of clientSessions) {
|
|
32
|
+
if (!seenIds.has(session.sessionId)) {
|
|
33
|
+
seenIds.add(session.sessionId);
|
|
34
|
+
sessions.push({ ...session, source: agent.client });
|
|
35
|
+
}
|
|
35
36
|
}
|
|
36
37
|
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
console.error(`Error listing sessions from ${agent.client}:`, err instanceof Error ? err.message : err);
|
|
40
|
+
}
|
|
37
41
|
}
|
|
38
42
|
for (const board of config.boards) {
|
|
39
43
|
await syncBoard({ board, sessions });
|
|
@@ -75,47 +79,61 @@ async function syncBoard({ board, sessions, }) {
|
|
|
75
79
|
// Sync each session
|
|
76
80
|
for (const { session, repoSlug, repoUrl, branch } of filteredSessions) {
|
|
77
81
|
const existingPageId = board.syncedSessions[session.sessionId];
|
|
82
|
+
const title = session.title || `Session ${session.sessionId.slice(0, 8)}`;
|
|
78
83
|
if (existingPageId) {
|
|
79
84
|
// Update existing page
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
85
|
+
try {
|
|
86
|
+
await rateLimitedCall(() => {
|
|
87
|
+
return updateSessionPage({
|
|
88
|
+
notion,
|
|
89
|
+
pageId: existingPageId,
|
|
90
|
+
title: session.title || undefined,
|
|
91
|
+
updatedAt: session.updatedAt || undefined,
|
|
92
|
+
});
|
|
86
93
|
});
|
|
87
|
-
}
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
console.error(`Error updating "${title}" (${repoSlug}):`, err instanceof Error ? err.message : err);
|
|
97
|
+
}
|
|
88
98
|
}
|
|
89
99
|
else {
|
|
90
100
|
// Create new page
|
|
91
|
-
const title = session.title || `Session ${session.sessionId.slice(0, 8)}`;
|
|
92
101
|
const branchUrl = `${repoUrl}/tree/${branch}`;
|
|
93
102
|
const resumeCommand = (() => {
|
|
94
103
|
if (session.source === 'opencode') {
|
|
95
104
|
return `opencode --session ${session.sessionId}`;
|
|
96
105
|
}
|
|
106
|
+
if (session.source === 'codex') {
|
|
107
|
+
return `codex resume ${session.sessionId}`;
|
|
108
|
+
}
|
|
97
109
|
return `claude --resume ${session.sessionId}`;
|
|
98
110
|
})();
|
|
99
111
|
// Try to get Discord URL if kimaki is available
|
|
100
112
|
const discordUrl = await getKimakiDiscordUrl(session.sessionId);
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
113
|
+
try {
|
|
114
|
+
const pageId = await rateLimitedCall(() => {
|
|
115
|
+
return createSessionPage({
|
|
116
|
+
notion,
|
|
117
|
+
databaseId: board.notionDatabaseId,
|
|
118
|
+
title,
|
|
119
|
+
sessionId: session.sessionId,
|
|
120
|
+
status: 'In Progress',
|
|
121
|
+
repoSlug,
|
|
122
|
+
branchUrl,
|
|
123
|
+
resumeCommand,
|
|
124
|
+
assigneeId: board.notionUserId,
|
|
125
|
+
folder: session.cwd || '',
|
|
126
|
+
discordUrl: discordUrl || undefined,
|
|
127
|
+
updatedAt: session.updatedAt || undefined,
|
|
128
|
+
});
|
|
115
129
|
});
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
130
|
+
board.syncedSessions[session.sessionId] = pageId;
|
|
131
|
+
const notionUrl = `https://notion.so/${pageId.replace(/-/g, '')}`;
|
|
132
|
+
console.log(`+ Added "${title}" (${repoSlug}) → ${notionUrl}`);
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
console.error(`Error adding "${title}" (${repoSlug}):`, err instanceof Error ? err.message : err);
|
|
136
|
+
}
|
|
119
137
|
}
|
|
120
138
|
}
|
|
121
139
|
}
|
package/dist/worker.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"worker.d.ts","sourceRoot":"","sources":["../src/worker.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,UAAU,CAAA;;
|
|
1
|
+
{"version":3,"file":"worker.d.ts","sourceRoot":"","sources":["../src/worker.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,UAAU,CAAA;;mBA6OlB,OAAO,OAAO,GAAG;;AADlC,wBAIC"}
|
package/dist/worker.js
CHANGED
|
@@ -17,7 +17,7 @@ const app = new Spiceflow()
|
|
|
17
17
|
handler() {
|
|
18
18
|
return new Response(null, {
|
|
19
19
|
status: 302,
|
|
20
|
-
headers: { Location: 'https://github.com/remorses/
|
|
20
|
+
headers: { Location: 'https://github.com/remorses/openplexer' },
|
|
21
21
|
});
|
|
22
22
|
},
|
|
23
23
|
})
|
|
@@ -87,6 +87,11 @@ const app = new Spiceflow()
|
|
|
87
87
|
return new Response(`Notion authorization failed: ${errorBody}`, { status: 500 });
|
|
88
88
|
}
|
|
89
89
|
const tokenData = (await tokenResponse.json());
|
|
90
|
+
// Build Notion page URL from duplicated template ID (if present)
|
|
91
|
+
const duplicatedTemplateId = tokenData.duplicated_template_id ?? null;
|
|
92
|
+
const notionPageUrl = duplicatedTemplateId
|
|
93
|
+
? `https://notion.so/${duplicatedTemplateId.replace(/-/g, '')}`
|
|
94
|
+
: null;
|
|
90
95
|
// Store tokens in KV with 5 minute TTL
|
|
91
96
|
const kvPayload = {
|
|
92
97
|
accessToken: tokenData.access_token,
|
|
@@ -95,27 +100,79 @@ const app = new Spiceflow()
|
|
|
95
100
|
workspaceName: tokenData.workspace_name,
|
|
96
101
|
notionUserId: tokenData.owner?.user?.id,
|
|
97
102
|
notionUserName: tokenData.owner?.user?.name,
|
|
103
|
+
duplicatedTemplateId,
|
|
98
104
|
};
|
|
99
105
|
await env.OPENPLEXER_KV.put(`auth:${stateParam}`, JSON.stringify(kvPayload), {
|
|
100
106
|
expirationTtl: 300,
|
|
101
107
|
});
|
|
102
|
-
// Show success page
|
|
108
|
+
// Show success page with link to the created Notion page (if template was used)
|
|
109
|
+
const pageLink = notionPageUrl
|
|
110
|
+
? `<a class="button" href="${notionPageUrl}" target="_blank" rel="noopener">Open in Notion <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M6 3h7v7M13 3L5 11"/></svg></a>`
|
|
111
|
+
: '';
|
|
112
|
+
const subtitle = notionPageUrl
|
|
113
|
+
? 'Your board page has been created. You can close this tab and return to the CLI.'
|
|
114
|
+
: 'You can close this tab and return to the CLI.';
|
|
103
115
|
return new Response(`<!DOCTYPE html>
|
|
104
116
|
<html>
|
|
105
|
-
<head
|
|
117
|
+
<head>
|
|
118
|
+
<title>openplexer - Connected</title>
|
|
119
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
120
|
+
<meta name="color-scheme" content="light dark">
|
|
106
121
|
<style>
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
122
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
123
|
+
:root {
|
|
124
|
+
--bg: #fff;
|
|
125
|
+
--fg: #000;
|
|
126
|
+
--muted: #666;
|
|
127
|
+
--border: #eaeaea;
|
|
128
|
+
--link: #000;
|
|
129
|
+
--link-hover: #666;
|
|
130
|
+
--checkmark-bg: #000;
|
|
131
|
+
--checkmark-fg: #fff;
|
|
132
|
+
}
|
|
133
|
+
@media (prefers-color-scheme: dark) {
|
|
134
|
+
:root {
|
|
135
|
+
--bg: #000;
|
|
136
|
+
--fg: #fff;
|
|
137
|
+
--muted: #888;
|
|
138
|
+
--border: #333;
|
|
139
|
+
--link: #fff;
|
|
140
|
+
--link-hover: #999;
|
|
141
|
+
--checkmark-bg: #fff;
|
|
142
|
+
--checkmark-fg: #000;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
body {
|
|
146
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
|
147
|
+
display: flex; justify-content: center; align-items: center;
|
|
148
|
+
min-height: 100vh; background: var(--bg); color: var(--fg);
|
|
149
|
+
-webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
|
|
150
|
+
}
|
|
151
|
+
.container { text-align: center; padding: 32px; max-width: 380px; }
|
|
152
|
+
.checkmark {
|
|
153
|
+
width: 48px; height: 48px; border-radius: 50%;
|
|
154
|
+
background: var(--checkmark-bg); color: var(--checkmark-fg);
|
|
155
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
156
|
+
margin-bottom: 24px; font-size: 20px;
|
|
157
|
+
}
|
|
158
|
+
h1 { font-size: 20px; font-weight: 600; letter-spacing: -0.02em; margin-bottom: 8px; }
|
|
159
|
+
p { font-size: 14px; color: var(--muted); line-height: 1.5; margin-bottom: 24px; }
|
|
160
|
+
a.button {
|
|
161
|
+
display: inline-flex; align-items: center; gap: 6px;
|
|
162
|
+
padding: 8px 16px; border-radius: 6px; font-size: 14px; font-weight: 500;
|
|
163
|
+
color: var(--link); text-decoration: none;
|
|
164
|
+
border: 1px solid var(--border); transition: color 0.15s;
|
|
165
|
+
}
|
|
166
|
+
a.button:hover { color: var(--link-hover); }
|
|
167
|
+
a.button svg { width: 16px; height: 16px; }
|
|
113
168
|
</style>
|
|
114
169
|
</head>
|
|
115
170
|
<body>
|
|
116
|
-
<div class="
|
|
171
|
+
<div class="container">
|
|
172
|
+
<div class="checkmark">✓</div>
|
|
117
173
|
<h1>Connected to Notion</h1>
|
|
118
|
-
<p
|
|
174
|
+
<p>${subtitle}</p>
|
|
175
|
+
${pageLink}
|
|
119
176
|
</div>
|
|
120
177
|
</body>
|
|
121
178
|
</html>`, { status: 200, headers: { 'Content-Type': 'text/html' } });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openplexer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Track coding sessions in Notion boards. Syncs ACP sessions from OpenCode and Claude Code to collaborative Notion databases.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/cli.js",
|
|
@@ -30,24 +30,27 @@
|
|
|
30
30
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
31
31
|
"@clack/prompts": "^0.10.0",
|
|
32
32
|
"@notionhq/client": "^5.13.0",
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
"
|
|
33
|
+
"@opencode-ai/sdk": "^1.2.27",
|
|
34
|
+
"@zed-industries/claude-agent-acp": "^0.22.2",
|
|
35
|
+
"@zed-industries/codex-acp": "^0.10.0",
|
|
36
|
+
"errore": "*",
|
|
37
|
+
"goke": "^6.3.0"
|
|
36
38
|
},
|
|
37
39
|
"devDependencies": {
|
|
38
40
|
"@cloudflare/workers-types": "^4.20260130.0",
|
|
39
41
|
"@types/node": "^22.0.0",
|
|
42
|
+
"spiceflow": "1.18.0-rsc.11",
|
|
40
43
|
"tsx": "^4.21.0",
|
|
41
44
|
"typescript": "5.8.3",
|
|
45
|
+
"vitest": "^4.1.0",
|
|
42
46
|
"wrangler": "^4.61.1"
|
|
43
47
|
},
|
|
44
48
|
"repository": {
|
|
45
49
|
"type": "git",
|
|
46
|
-
"url": "https://github.com/remorses/
|
|
47
|
-
"directory": "openplexer"
|
|
50
|
+
"url": "https://github.com/remorses/openplexer"
|
|
48
51
|
},
|
|
49
52
|
"homepage": "https://openplexer.com",
|
|
50
|
-
"bugs": "https://github.com/remorses/
|
|
53
|
+
"bugs": "https://github.com/remorses/openplexer/issues",
|
|
51
54
|
"keywords": [
|
|
52
55
|
"notion",
|
|
53
56
|
"coding-sessions",
|
|
@@ -58,6 +61,7 @@
|
|
|
58
61
|
],
|
|
59
62
|
"scripts": {
|
|
60
63
|
"build": "rm -rf dist *.tsbuildinfo && tsc && chmod +x dist/cli.js",
|
|
64
|
+
"cli": "pnpm tsx src/cli.ts",
|
|
61
65
|
"dev": "doppler run --mount .dev.vars --mount-format env -- wrangler dev --port 8790",
|
|
62
66
|
"deployment": "tsc --noEmit && wrangler deploy --env preview",
|
|
63
67
|
"deployment:production": "tsc --noEmit && wrangler deploy",
|
|
@@ -0,0 +1,95 @@
|
|
|
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
|
+
|
|
5
|
+
import { describe, it, expect } from 'vitest'
|
|
6
|
+
import { connectAgent } from './acp-client.ts'
|
|
7
|
+
import { getRepoInfo } from './git.ts'
|
|
8
|
+
|
|
9
|
+
describe('agent-client', () => {
|
|
10
|
+
it('listSessions returns sessions from at least 2 different projects', async () => {
|
|
11
|
+
const agent = await connectAgent({ client: 'opencode' })
|
|
12
|
+
try {
|
|
13
|
+
const sessions = await agent.listSessions()
|
|
14
|
+
|
|
15
|
+
// Collect unique cwd values
|
|
16
|
+
const cwds = [...new Set(sessions.map((s) => s.cwd).filter(Boolean))]
|
|
17
|
+
|
|
18
|
+
console.log(`Found ${sessions.length} sessions across ${cwds.length} directories:`)
|
|
19
|
+
for (const cwd of cwds) {
|
|
20
|
+
const count = sessions.filter((s) => s.cwd === cwd).length
|
|
21
|
+
console.log(` ${cwd}: ${count} sessions`)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
expect(sessions.length).toBeGreaterThan(0)
|
|
25
|
+
expect(cwds.length).toBeGreaterThanOrEqual(2)
|
|
26
|
+
} finally {
|
|
27
|
+
agent.kill()
|
|
28
|
+
}
|
|
29
|
+
}, 30_000)
|
|
30
|
+
|
|
31
|
+
it('debug: show which sessions would pass sync filters', async () => {
|
|
32
|
+
const connectedAt = '2026-03-21T23:03:40.127Z'
|
|
33
|
+
const connectedAtMs = new Date(connectedAt).getTime()
|
|
34
|
+
|
|
35
|
+
console.log(`connectedAt: ${connectedAt} (${connectedAtMs})`)
|
|
36
|
+
console.log(`now: ${new Date().toISOString()} (${Date.now()})`)
|
|
37
|
+
console.log()
|
|
38
|
+
|
|
39
|
+
const agent = await connectAgent({ client: 'opencode' })
|
|
40
|
+
try {
|
|
41
|
+
const sessions = await agent.listSessions()
|
|
42
|
+
|
|
43
|
+
// Sort by updatedAt descending (most recent first)
|
|
44
|
+
const sorted = [...sessions].sort((a, b) => {
|
|
45
|
+
const aMs = a.updatedAt ? new Date(a.updatedAt).getTime() : 0
|
|
46
|
+
const bMs = b.updatedAt ? new Date(b.updatedAt).getTime() : 0
|
|
47
|
+
return bMs - aMs
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
// Show the 10 most recent sessions with all their timestamps and filter results
|
|
51
|
+
console.log('=== 10 most recent sessions ===')
|
|
52
|
+
for (const session of sorted.slice(0, 10)) {
|
|
53
|
+
const updatedAt = session.updatedAt || '(none)'
|
|
54
|
+
const updatedAtMs = session.updatedAt ? new Date(session.updatedAt).getTime() : 0
|
|
55
|
+
const passesTimeFilter = updatedAtMs >= connectedAtMs
|
|
56
|
+
const hasCwd = !!session.cwd
|
|
57
|
+
const repo = hasCwd ? await getRepoInfo({ cwd: session.cwd! }) : undefined
|
|
58
|
+
|
|
59
|
+
console.log(` session: ${session.sessionId.slice(0, 12)}`)
|
|
60
|
+
console.log(` title: ${(session.title || '(none)').slice(0, 80)}`)
|
|
61
|
+
console.log(` cwd: ${session.cwd || '(none)'}`)
|
|
62
|
+
console.log(` updatedAt: ${updatedAt}`)
|
|
63
|
+
console.log(` passTime: ${passesTimeFilter}`)
|
|
64
|
+
console.log(` repo: ${repo ? repo.slug : '(no repo)'}`)
|
|
65
|
+
console.log()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Summary: how many pass each filter
|
|
69
|
+
let noCwd = 0
|
|
70
|
+
let tooOld = 0
|
|
71
|
+
let noRepo = 0
|
|
72
|
+
let wouldSync = 0
|
|
73
|
+
|
|
74
|
+
for (const session of sessions) {
|
|
75
|
+
if (!session.cwd) { noCwd++; continue }
|
|
76
|
+
const updatedAtMs = session.updatedAt ? new Date(session.updatedAt).getTime() : 0
|
|
77
|
+
if (updatedAtMs < connectedAtMs) { tooOld++; continue }
|
|
78
|
+
const repo = await getRepoInfo({ cwd: session.cwd })
|
|
79
|
+
if (!repo) { noRepo++; continue }
|
|
80
|
+
wouldSync++
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
console.log('=== Summary ===')
|
|
84
|
+
console.log(` Total sessions: ${sessions.length}`)
|
|
85
|
+
console.log(` No cwd: ${noCwd}`)
|
|
86
|
+
console.log(` Too old: ${tooOld}`)
|
|
87
|
+
console.log(` No git repo: ${noRepo}`)
|
|
88
|
+
console.log(` Would sync: ${wouldSync}`)
|
|
89
|
+
|
|
90
|
+
expect(sessions.length).toBeGreaterThan(0)
|
|
91
|
+
} finally {
|
|
92
|
+
agent.kill()
|
|
93
|
+
}
|
|
94
|
+
}, 120_000)
|
|
95
|
+
})
|