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/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 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.
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 AcpConnection } from './acp-client.ts';
2
+ import { type AgentConnection } from './acp-client.ts';
3
3
  export declare function startSyncLoop({ config, acpConnections, }: {
4
4
  config: OpenplexerConfig;
5
- acpConnections: AcpConnection[];
5
+ acpConnections: AgentConnection[];
6
6
  }): Promise<void>;
7
7
  //# sourceMappingURL=sync.d.ts.map
@@ -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,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"}
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 ACP connections, tagged with their source
25
+ // Collect sessions from all agent connections, tagged with their source
27
26
  const sessions = [];
28
27
  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 });
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
- await rateLimitedCall(() => {
81
- return updateSessionPage({
82
- notion,
83
- pageId: existingPageId,
84
- title: session.title || undefined,
85
- updatedAt: session.updatedAt || undefined,
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
- 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,
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
- board.syncedSessions[session.sessionId] = pageId;
118
- console.log(` + ${title} (${repoSlug})`);
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
  }
@@ -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;;mBAiLlB,OAAO,OAAO,GAAG;;AADlC,wBAIC"}
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/kimaki/tree/main/openplexer' },
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><title>openplexer - Connected</title>
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
- body { font-family: system-ui, sans-serif; display: flex; justify-content: center;
108
- align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
109
- .card { text-align: center; padding: 48px; border-radius: 12px;
110
- background: #16213e; max-width: 400px; }
111
- h1 { margin: 0 0 16px; font-size: 24px; }
112
- p { color: #999; margin: 0; }
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="card">
171
+ <div class="container">
172
+ <div class="checkmark">&#10003;</div>
117
173
  <h1>Connected to Notion</h1>
118
- <p>You can close this tab and return to the CLI.</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.1.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
- "goke": "^6.3.0",
34
- "spiceflow": "1.18.0-rsc.11",
35
- "errore": "^0.14.1"
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/kimaki",
47
- "directory": "openplexer"
50
+ "url": "https://github.com/remorses/openplexer"
48
51
  },
49
52
  "homepage": "https://openplexer.com",
50
- "bugs": "https://github.com/remorses/kimaki/issues",
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
+ })