@toddzheng024/dscode-bundle 0.2.0 → 0.4.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/bin/apply_patch CHANGED
@@ -1,4 +1,9 @@
1
1
  #!/bin/sh
2
2
  # Accept a standard unified diff on stdin. Works without a Git repository.
3
3
  # git apply checks all hunks before writing; --check performs a dry run.
4
+ prefix=$(git rev-parse --show-prefix 2>/dev/null) || prefix=
5
+ if [ -n "$prefix" ]; then
6
+ root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 1
7
+ exec git -C "$root" apply --directory="$prefix" --whitespace=nowarn "$@"
8
+ fi
4
9
  exec git apply --whitespace=nowarn "$@"
package/cordis.patch.yml CHANGED
@@ -95,7 +95,7 @@
95
95
  # Adapters resolve references per request; the Models page writes only the
96
96
  # managed document, which is never materialized into the process environment.
97
97
  - id: credentials
98
- name: '@deepseek-ai/dsh-credentials-local'
98
+ name: '@toddzheng024/dscode-bundle/credentials'
99
99
 
100
100
  # The pi-ai multi-provider twin, mounted dormant: zero routes (and no extra
101
101
  # models in the picker) until a `llm-pi-ai:` settings section supplies provider
@@ -672,21 +672,8 @@
672
672
  pointerInputPolicy: targeted
673
673
  cursorVisualization: visible
674
674
 
675
- # Official stdio MCP bridge; Chrome starts lazily at the first browser call.
676
- # Local launcher resolves the pinned, preinstalled entrypoint. A Hub import
677
- # uses the equally pinned npx fallback and needs npm access on first launch.
678
- - insert:
679
- - id: mcp-chrome
680
- name: '@deepseek-ai/dsh-mcp-client'
681
- config:
682
- serverName: chrome
683
- transport: stdio
684
- command: !!js "process.env.DSH_TUI_CHROME_ENTRY ? process.execPath : 'npx'"
685
- args: !!js "[...(process.env.DSH_TUI_CHROME_ENTRY ? [process.env.DSH_TUI_CHROME_ENTRY] : ['--yes', 'chrome-devtools-mcp@1.9.0']), '--isolated', '--no-usage-statistics', '--no-performance-crux']"
686
- env:
687
- CHROME_DEVTOOLS_MCP_NO_UPDATE_CHECKS: '1'
688
- failOnStartupError: true
689
- toolCallTimeoutMs: 60000
675
+ # Chrome MCP belongs to the DSCODE agent preset. A fresh, prompt-free TUI
676
+ # launch can render before composing an agent or connecting the browser server.
690
677
 
691
678
  - id: permission
692
679
  config:
@@ -712,51 +699,47 @@
712
699
  approval: never
713
700
 
714
701
 
715
- - id: llm-deepseek
716
- disabled: true
702
+ - id: credentials
703
+ name: "@toddzheng024/dscode-bundle/credentials"
717
704
  - insert:
718
- - id: dscode-deepseek
719
- name: '@toddzheng024/dscode-bundle/deepseek'
720
- - id: dscode-bootstrap
721
- name: '@toddzheng024/dscode-bundle/bootstrap'
722
705
  - id: dscode-auto-review
723
- name: '@toddzheng024/dscode-bundle/auto-review'
706
+ name: "@toddzheng024/dscode-bundle/auto-review"
724
707
  config:
725
708
  timeoutMs: 30000
726
709
  maxOutputTokens: 768
727
710
  maxReviewsPerTurn: 20
728
711
  - id: dscode-session-metrics
729
- name: '@toddzheng024/dscode-bundle/session-metrics'
730
- - id: dscode-memory
731
- name: '@toddzheng024/dscode-bundle/memory'
732
- - id: dscode-session-bridge
733
- name: '@toddzheng024/dscode-bundle/session-bridge'
712
+ name: "@toddzheng024/dscode-bundle/session-metrics"
734
713
  - id: dscode-session-cards
735
- name: '@toddzheng024/dscode-bundle/session-cards'
714
+ name: "@toddzheng024/dscode-bundle/session-cards"
715
+ - id: dscode-session-bridge
716
+ name: "@toddzheng024/dscode-bundle/session-bridge"
717
+ - id: dscode-memory
718
+ name: "@toddzheng024/dscode-bundle/memory"
736
719
  - id: dscode-tui-tools
737
- name: '@toddzheng024/dscode-bundle/tui-tools'
720
+ name: "@toddzheng024/dscode-bundle/tui-tools"
721
+ - id: dscode-email-tools
722
+ name: "@toddzheng024/dscode-bundle/email-tools"
738
723
  - id: dscode-hooks
739
- name: '@deepseek-ai/dsh-hooks-codex'
740
- inject: [dscodePaths]
724
+ name: "@deepseek-ai/dsh-hooks-codex"
725
+ inject: &a1
726
+ - dscodePaths
741
727
  config:
742
728
  configPath: !!js ctx.dscodePaths.hooks
743
729
  defaultTimeoutMs: 10000
744
730
  stderrSummaryMaxChars: 500
745
731
  - id: agent-presets
746
- inject: [dscodePaths]
732
+ inject: *a1
747
733
  config:
748
734
  default: dscode
749
735
  roots:
750
736
  - path: !!js ctx.dscodePaths.presets
751
737
  trust: system
752
- - id: mcp-chrome
753
- inject: [dscodePaths]
754
- config:
755
- serverName: chrome
756
- transport: stdio
757
- command: !!js process.execPath
758
- args: !!js "[ctx.dscodePaths.chrome, '--isolated', '--no-usage-statistics', '--no-performance-crux']"
759
- env:
760
- CHROME_DEVTOOLS_MCP_NO_UPDATE_CHECKS: '1'
761
- failOnStartupError: true
762
- toolCallTimeoutMs: 60000
738
+
739
+ - id: llm-deepseek
740
+ disabled: true
741
+ - insert:
742
+ - id: dscode-deepseek
743
+ name: '@toddzheng024/dscode-bundle/deepseek'
744
+ - id: dscode-bootstrap
745
+ name: '@toddzheng024/dscode-bundle/bootstrap'
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.2.0",
2
+ "version": "0.4.0",
3
3
  "type": "module",
4
4
  "license": "MIT",
5
5
  "author": "Todd Zheng",
@@ -25,8 +25,13 @@
25
25
  "THIRD_PARTY_NOTICES.md"
26
26
  ],
27
27
  "exports": {
28
+ "./email-tools": "./plugins/email-tools/index.mjs",
29
+ "./imap": "./plugins/email/imap.mjs",
30
+ "./gmail": "./plugins/email/gmail.mjs",
31
+ "./email": "./plugins/email/inbox.mjs",
28
32
  "./package.json": "./package.json",
29
33
  "./cordis.patch.yml": "./cordis.patch.yml",
34
+ "./credentials": "./plugins/credentials/index.mjs",
30
35
  "./memory": "./plugins/memory/index.mjs",
31
36
  "./session-bridge": "./plugins/session-bridge/index.mjs",
32
37
  "./session-cards": "./plugins/session-cards/index.mjs",
@@ -289,6 +294,9 @@
289
294
  "dsh-code": "1.0.6",
290
295
  "chrome-devtools-mcp": "1.9.0",
291
296
  "react": "18.3.1",
297
+ "imapflow": "2.0.2",
298
+ "mailparser": "3.9.26",
299
+ "nodemailer": "10.0.9",
292
300
  "commander": "15.0.0",
293
301
  "eventsource-parser": "3.1.1"
294
302
  },
@@ -0,0 +1,50 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { LocalCredentialProvider } from '@deepseek-ai/dsh-credentials-local';
4
+ import { Context, Service } from '@deepseek-ai/cordis';
5
+ import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment';
6
+
7
+ const DEEPSEEK = 'DEEPSEEK_API_KEY';
8
+
9
+ // Use the native locked, watched, owner-only store. Keep this independent of
10
+ // the installed profile so upgrades and different projects share credentials.
11
+ export default class DscodeCredentials extends LocalCredentialProvider {
12
+ shared;
13
+ sharedConfig;
14
+ constructor(ctx, config = {}) {
15
+ // Retain the old profile store, including other providers and OAuth records.
16
+ super(ctx, { ...config, path: undefined });
17
+ this.sharedConfig = { ...config, path: config.path ?? join(homedir(), '.dscode', 'credentials.yaml') };
18
+ }
19
+ async *[Service.init]() {
20
+ const sharedContext = new Context();
21
+ yield () => sharedContext.fiber.dispose();
22
+ sharedContext.provide('launchEnvironment', launchEnvironmentOf(this.ctx));
23
+ sharedContext.on('credentials/reference-updated', ref => this.notifyUpdated(ref));
24
+ await sharedContext.plugin(LocalCredentialProvider, this.sharedConfig);
25
+ this.shared = sharedContext.credentials;
26
+ yield* super[Service.init]();
27
+ }
28
+ async resolve(ref) {
29
+ if (ref === DEEPSEEK) {
30
+ const stored = await this.shared.resolve(ref);
31
+ if (stored?.source === 'env' || stored?.source === 'file') return stored;
32
+ }
33
+ return super.resolve(ref);
34
+ }
35
+ async describe(ref) {
36
+ if (ref === DEEPSEEK) {
37
+ const facts = await this.shared.describe(ref);
38
+ if (facts.source === 'env' || facts.source === 'file') return facts;
39
+ }
40
+ return super.describe(ref);
41
+ }
42
+ set(ref, value) {
43
+ return ref === DEEPSEEK ? this.shared.set(ref, value) : super.set(ref, value);
44
+ }
45
+ async unset(ref) {
46
+ // Explicit removal must not uncover a previously configured legacy key.
47
+ if (ref === DEEPSEEK) await this.shared.unset(ref);
48
+ await super.unset(ref);
49
+ }
50
+ }
@@ -0,0 +1,32 @@
1
+ import { createEmailContacts } from './contacts.mjs';
2
+ import { createGmailConnector } from './gmail.mjs';
3
+ import { createImapConnector } from './imap.mjs';
4
+
5
+ export async function runEmailClient(args, { connector = createGmailConnector(), imap = createImapConnector(), contacts = createEmailContacts(), output = console.log } = {}) {
6
+ const [command = 'status', ...rest] = args;
7
+ if (command === 'alias') {
8
+ const [action = 'list', name, address] = rest;
9
+ let result;
10
+ if (action === 'list' && rest.length <= 1) result = contacts.list();
11
+ else if (action === 'set' && rest.length === 3) result = await contacts.set(name, address);
12
+ else if (action === 'remove' && rest.length === 2) result = await contacts.remove(name);
13
+ else throw Error('Usage: dscode email alias list | set NAME ADDRESS | remove NAME');
14
+ if (result.busy === true) throw Error('Contacts are busy in another session. Retry shortly.');
15
+ output(JSON.stringify(result, null, 2)); return;
16
+ }
17
+ if (!['configure', 'connect', 'sync', 'status', 'imap-status', 'imap-sync'].includes(command) || (command === 'configure' ? rest.length !== 1 : rest.length !== 0)) throw Error('Usage: dscode email configure CLIENT_JSON | connect | sync | status | imap-status | imap-sync (IMAP setup: /email then i)');
18
+ if (command.startsWith('imap-')) connector = imap;
19
+ if (command === 'imap-status') { output(JSON.stringify(connector.status(), null, 2)); return; }
20
+ if (command === 'status') { output(JSON.stringify(connector.status(), null, 2)); return; }
21
+ const controller = new AbortController();
22
+ const abort = () => controller.abort();
23
+ process.once('SIGINT', abort); process.once('SIGTERM', abort);
24
+ try {
25
+ if (command === 'connect') output('Complete Gmail authorization in your browser. Ctrl+C cancels.');
26
+ const result = command === 'configure' ? await connector.configure(rest[0])
27
+ : command === 'connect' ? await connector.connect({ signal: controller.signal })
28
+ : await connector.sync({ signal: controller.signal, force: true });
29
+ if (result.busy) throw Error('Gmail is busy in another session. Retry shortly.');
30
+ output(command === 'configure' ? 'Google Desktop OAuth client saved privately. Open /email and press g to connect.' : JSON.stringify(result, null, 2));
31
+ } finally { process.off('SIGINT', abort); process.off('SIGTERM', abort); }
32
+ }
@@ -0,0 +1,36 @@
1
+ import { join } from 'node:path';
2
+ import { createEmailInbox } from './inbox.mjs';
3
+ import { emailStore } from './store.mjs';
4
+
5
+ export const emailAddress = value => typeof value === 'string' && value.length <= 254 && /^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?\.[A-Za-z]{2,}$/.test(value);
6
+ const aliasKey = value => {
7
+ if (typeof value !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(value)) throw Error('Alias must be 1..64 letters, digits, underscores or hyphens.');
8
+ return value.toLowerCase();
9
+ };
10
+ export function createEmailContacts({ directory = createEmailInbox().directory } = {}) {
11
+ const store = emailStore(join(directory, 'contacts'));
12
+ const list = () => store.read('aliases.json') ?? {};
13
+ return {
14
+ list,
15
+ resolve(value) {
16
+ if (emailAddress(value)) return { to: value };
17
+ const alias = aliasKey(value), entries = list();
18
+ const to = Object.hasOwn(entries, alias) ? entries[alias] : null;
19
+ if (!emailAddress(to)) throw Error('Unknown email alias: ' + alias + '. Add it with dscode email alias set NAME ADDRESS.');
20
+ return { alias, to };
21
+ },
22
+ async set(name, address) {
23
+ const alias = aliasKey(name);
24
+ if (!emailAddress(address)) throw Error('Specify one plain email address.');
25
+ return store.locked(async () => {
26
+ const entries = list();
27
+ Object.defineProperty(entries, alias, { value: address, enumerable: true, configurable: true, writable: true });
28
+ store.write('aliases.json', entries); return { alias, to: address };
29
+ });
30
+ },
31
+ async remove(name) {
32
+ const alias = aliasKey(name);
33
+ return store.locked(async () => { const entries = list(); delete entries[alias]; store.write('aliases.json', entries); return { removed: alias }; });
34
+ },
35
+ };
36
+ }
@@ -0,0 +1,69 @@
1
+ import { createServer } from 'node:http';
2
+ import { randomBytes, createHash } from 'node:crypto';
3
+ import { execFile } from 'node:child_process';
4
+ import { readFileSync } from 'node:fs';
5
+
6
+ export const GMAIL_SCOPES = ['gmail.readonly', 'gmail.labels', 'gmail.settings.basic'].map(scope => 'https://www.googleapis.com/auth/' + scope);
7
+ export function readGoogleClient(path) {
8
+ let value;
9
+ try { value = JSON.parse(readFileSync(path, 'utf8')).installed; } catch { /* Report no content or secrets. */ }
10
+ if (!value || typeof value.client_id !== 'string' || !value.client_id.endsWith('.apps.googleusercontent.com')) throw Error('Set DSCODE_GMAIL_CLIENT_FILE to a Google Desktop OAuth client JSON file.');
11
+ return { client_id: value.client_id, ...(typeof value.client_secret === 'string' ? { client_secret: value.client_secret } : {}) };
12
+ }
13
+
14
+ export async function tokenRequest(params, { fetchImpl = fetch, signal } = {}) {
15
+ const response = await fetchImpl('https://oauth2.googleapis.com/token', {
16
+ method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
17
+ body: new URLSearchParams(params).toString(), redirect: 'error',
18
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(20000)]) : AbortSignal.timeout(20000),
19
+ });
20
+ if (!response.ok) throw Error(response.status === 400 ? 'Google authorization expired or was rejected. Reconnect Gmail.' : 'Google token request failed. Retry later.');
21
+ const token = await response.json();
22
+ if (typeof token.access_token !== 'string' || !token.access_token || !Number.isFinite(token.expires_in) || token.expires_in <= 0) throw Error('Google returned an invalid access token.');
23
+ if (token.scope && !GMAIL_SCOPES.every(scope => token.scope.split(' ').includes(scope))) throw Error('Grant all three Gmail permissions, then reconnect.');
24
+ return { access_token: token.access_token, expiresAt: Date.now() + token.expires_in * 1000,
25
+ ...(typeof token.refresh_token === 'string' ? { refresh_token: token.refresh_token } : {}) };
26
+ }
27
+
28
+ export const openGoogleBrowser = url => new Promise((resolve, reject) => {
29
+ execFile(process.platform === 'darwin' ? 'open' : 'xdg-open', [url], { timeout: 10000 }, error => error ? reject(Error('Could not open the browser for Google login.')) : resolve());
30
+ });
31
+
32
+ export async function authorizeGoogle(client, { fetchImpl = fetch, openBrowser = openGoogleBrowser, signal, timeoutMs = 180000 } = {}) {
33
+ const verifier = randomBytes(32).toString('base64url'), state = randomBytes(32).toString('base64url');
34
+ const controller = new AbortController();
35
+ const abort = () => controller.abort();
36
+ signal?.addEventListener('abort', abort, { once: true });
37
+ const timer = setTimeout(abort, timeoutMs);
38
+ let resolveCode, rejectCode;
39
+ const codeResult = new Promise((resolve, reject) => { resolveCode = resolve; rejectCode = reject; });
40
+ codeResult.catch(() => {});
41
+ const server = createServer((req, res) => {
42
+ const url = new URL(req.url, 'http://127.0.0.1');
43
+ res.setHeader('Cache-Control', 'no-store'); res.setHeader('Content-Type', 'text/plain; charset=utf-8');
44
+ if (req.method !== 'GET' || url.pathname !== '/oauth/callback' || url.searchParams.get('state') !== state) { res.writeHead(400).end('Invalid callback.'); return; }
45
+ if (url.searchParams.has('error')) { res.end('Google authorization was declined. Return to DSCODE.'); rejectCode(Error('Google authorization was declined.')); return; }
46
+ const code = url.searchParams.get('code');
47
+ if (!code) { res.writeHead(400).end('Missing code.'); return; }
48
+ res.end('Authorization received. Return to DSCODE to see connection status.');
49
+ resolveCode(code);
50
+ });
51
+ controller.signal.addEventListener('abort', () => rejectCode(Error('Google login cancelled or timed out.')), { once: true });
52
+ try {
53
+ if (signal?.aborted) throw Error('Google login cancelled.');
54
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
55
+ const redirect = 'http://127.0.0.1:' + server.address().port + '/oauth/callback';
56
+ const url = new URL('https://accounts.google.com/o/oauth2/v2/auth');
57
+ url.search = new URLSearchParams({ client_id: client.client_id, redirect_uri: redirect,
58
+ response_type: 'code', scope: GMAIL_SCOPES.join(' '), access_type: 'offline', prompt: 'consent select_account',
59
+ state, code_challenge: createHash('sha256').update(verifier).digest('base64url'), code_challenge_method: 'S256' }).toString();
60
+ await openBrowser(url.href);
61
+ const code = await codeResult;
62
+ const token = await tokenRequest({ ...client, code, redirect_uri: redirect, code_verifier: verifier, grant_type: 'authorization_code' }, { fetchImpl, signal: controller.signal });
63
+ if (!token.refresh_token) throw Error('Google did not grant offline access. Reconnect Gmail.');
64
+ return token;
65
+ } finally {
66
+ clearTimeout(timer); signal?.removeEventListener('abort', abort);
67
+ server.close(); server.closeAllConnections();
68
+ }
69
+ }
@@ -0,0 +1,2 @@
1
+ import { emailStore } from './store.mjs';
2
+ export const gmailStore = directory => emailStore(directory, 'Gmail');
@@ -0,0 +1,194 @@
1
+ import { join } from 'node:path';
2
+ import { existsSync } from 'node:fs';
3
+ import { createEmailInbox, normalizeEmail } from './inbox.mjs';
4
+ import { gmailStore } from './gmail-store.mjs';
5
+ import { readGoogleClient, authorizeGoogle, tokenRequest } from './gmail-oauth.mjs';
6
+
7
+ const LABEL = 'ToAgent';
8
+ const FILTER = { subject: '[ToAgent]' };
9
+ const isId = value => typeof value === 'string' && /^[a-zA-Z0-9_-]+$/.test(value);
10
+ const historyId = value => { if (typeof value !== 'string' || !/^\d+$/.test(value)) throw Error('Invalid Gmail sync cursor.'); return value; };
11
+ const header = (part, name) => (part?.headers || []).find(h => h.name?.toLowerCase() === name.toLowerCase())?.value || '';
12
+ const decode = (data, charset = 'utf-8') => {
13
+ if (typeof data !== 'string' || data.length > 400000 || !/^[A-Za-z0-9_-]*={0,2}$/.test(data)) throw Error('Invalid or oversized plain-text body.');
14
+ try { return new TextDecoder(charset, { fatal: true }).decode(Buffer.from(data, 'base64url')); }
15
+ catch { throw Error('Invalid plain-text encoding.'); }
16
+ };
17
+ export function decodeHeader(value) {
18
+ return value.replace(/(\?=)\s+(=\?)/g, '$1$2').replace(/=\?([^?]+)\?([bq])\?([^?]*)\?=/gi, (_, charset, type, data) => {
19
+ const bytes = type.toLowerCase() === 'b' ? Buffer.from(data, 'base64') : Buffer.from(data.replace(/_/g, ' ').replace(/=([a-f0-9]{2})/gi, (_, n) => String.fromCharCode(parseInt(n, 16))), 'latin1');
20
+ try { return new TextDecoder(charset, { fatal: true }).decode(bytes); }
21
+ catch { throw Error('Invalid header encoding.'); }
22
+ });
23
+ }
24
+ export function matchesGmailMessage(message, state) {
25
+ const date = Number(message.internalDate);
26
+ return Array.isArray(message.labelIds) && message.labelIds.includes(state.labelId)
27
+ && !message.labelIds.some(id => ['SPAM', 'TRASH', 'DRAFT', 'SENT'].includes(id))
28
+ && Number.isFinite(date) && date >= state.connectedAt
29
+ && /^\[ToAgent\](?:\s|$)/.test(decodeHeader(header(message.payload, 'subject')));
30
+ }
31
+ export async function gmailEnvelope(message, state, getAttachment) {
32
+ if (!matchesGmailMessage(message, state)) return null;
33
+ const findPlain = (part, depth = 0) => {
34
+ if (!part || depth > 20 || part.filename || /^attachment\b/i.test(header(part, 'content-disposition'))) return null;
35
+ if (part.mimeType === 'text/plain') return part;
36
+ if (!part.mimeType?.startsWith('multipart/')) return null;
37
+ for (const child of part.parts || []) { const found = findPlain(child, depth + 1); if (found) return found; }
38
+ return null;
39
+ };
40
+ const part = findPlain(message.payload);
41
+ if (!part || part.body?.size > 262144) return null;
42
+ const body = part.body?.data ?? (part.body?.attachmentId ? (await getAttachment(part.body.attachmentId)).data : '');
43
+ const charset = header(part, 'content-type').match(/charset\s*=\s*"?([^;"\s]+)/i)?.[1] || 'utf-8';
44
+ return normalizeEmail({ format: 'dscode.email.v1', connector: 'gmail', account: state.account,
45
+ id: message.id, from: decodeHeader(header(message.payload, 'from')), subject: decodeHeader(header(message.payload, 'subject')),
46
+ body: decode(body, charset), receivedAt: new Date(Number(message.internalDate)).toISOString(),
47
+ updatedAt: new Date(Number(message.internalDate)).toISOString() });
48
+ }
49
+
50
+ export function createGmailConnector({ inbox = createEmailInbox(), directory = join(inbox.directory, 'gmail'), fetchImpl = fetch, authorize = authorizeGoogle, openBrowser, now = Date.now } = {}) {
51
+ const store = gmailStore(directory);
52
+ const clientPath = () => process.env.DSCODE_GMAIL_CLIENT_FILE || join(directory, 'client.json');
53
+ let working = false;
54
+ const exclusive = async action => {
55
+ if (working) return { busy: true };
56
+ working = true;
57
+ try { return await store.locked(action); } finally { working = false; }
58
+ };
59
+ async function api(token, path, { method = 'GET', body, signal } = {}) {
60
+ const response = await fetchImpl('https://gmail.googleapis.com/gmail/v1/users/me/' + path, {
61
+ method, headers: { Authorization: 'Bearer ' + token.access_token, ...(body ? { 'Content-Type': 'application/json' } : {}) },
62
+ ...(body ? { body: JSON.stringify(body) } : {}), redirect: 'error',
63
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(20000)]) : AbortSignal.timeout(20000),
64
+ });
65
+ if (!response.ok) {
66
+ const error = Error(response.status === 401 ? 'Gmail authorization expired. Press g to reconnect.' : response.status === 403 ? 'Gmail access denied. Check API enablement and granted permissions, then reconnect.' : 'Gmail request failed (' + response.status + '). Retry sync.');
67
+ error.status = response.status; throw error;
68
+ }
69
+ return response.status === 204 ? {} : response.json();
70
+ }
71
+ async function access(signal, force = false) {
72
+ const token = store.read('tokens.json');
73
+ if (!token?.refresh_token) throw Error('Gmail is not connected. Press g to connect.');
74
+ if (!force && token.expiresAt > now() + 60000) return token;
75
+ const next = { ...token, ...await tokenRequest({ ...readGoogleClient(clientPath()), grant_type: 'refresh_token', refresh_token: token.refresh_token }, { fetchImpl, signal }) };
76
+ store.write('tokens.json', next); return next;
77
+ }
78
+ async function provision(token, signal) {
79
+ const labels = await api(token, 'labels', { signal });
80
+ let label = labels.labels?.find(label => label.name === LABEL);
81
+ if (!label) label = await api(token, 'labels', { method: 'POST', body: { name: LABEL, labelListVisibility: 'labelShow', messageListVisibility: 'show' }, signal });
82
+ if (!isId(label.id)) throw Error('Invalid Gmail ToAgent label.');
83
+ const filters = await api(token, 'settings/filters', { signal });
84
+ const found = filters.filter?.some(filter => JSON.stringify(filter.criteria) === JSON.stringify(FILTER)
85
+ && filter.action?.addLabelIds?.includes(label.id) && !filter.action?.removeLabelIds?.length && !filter.action?.forward);
86
+ if (!found) await api(token, 'settings/filters', { method: 'POST', body: { criteria: FILTER, action: { addLabelIds: [label.id] } }, signal });
87
+ return label.id;
88
+ }
89
+ async function collect(token, state, signal) {
90
+ const ids = new Set();
91
+ let cursor, pageToken;
92
+ try {
93
+ do {
94
+ const query = new URLSearchParams({ startHistoryId: state.historyId, labelId: state.labelId, maxResults: '100', ...(pageToken ? { pageToken } : {}) });
95
+ const page = await api(token, 'history?' + query, { signal });
96
+ for (const event of page.history || []) for (const change of [...(event.messagesAdded || []), ...(event.labelsAdded || [])]) {
97
+ if (isId(change.message?.id)) ids.add(change.message.id);
98
+ }
99
+ cursor = historyId(page.historyId); pageToken = page.nextPageToken;
100
+ } while (pageToken);
101
+ } catch (error) {
102
+ if (error.status !== 404) throw error;
103
+ // Expired history: capture a new boundary BEFORE listing so changes that
104
+ // race this recovery are replayed on the next incremental pass.
105
+ cursor = historyId((await api(token, 'profile', { signal })).historyId);
106
+ pageToken = undefined;
107
+ do {
108
+ const query = new URLSearchParams({ labelIds: state.labelId, q: 'after:' + (Math.floor(state.connectedAt / 1000) - 1), maxResults: '100', ...(pageToken ? { pageToken } : {}) });
109
+ const page = await api(token, 'messages?' + query, { signal });
110
+ for (const message of page.messages || []) if (isId(message.id)) ids.add(message.id);
111
+ pageToken = page.nextPageToken;
112
+ } while (pageToken);
113
+ }
114
+ let accepted = 0, skipped = 0;
115
+ for (const id of ids) {
116
+ let metadata;
117
+ try { metadata = await api(token, 'messages/' + id + '?format=metadata&metadataHeaders=Subject', { signal }); }
118
+ catch (error) { if (error.status === 404) { skipped++; continue; } throw error; }
119
+ let matches;
120
+ try { matches = matchesGmailMessage(metadata, state); } catch { matches = false; }
121
+ if (!matches) { skipped++; continue; }
122
+ let message;
123
+ try { message = await api(token, 'messages/' + id + '?format=full', { signal }); }
124
+ catch (error) { if (error.status === 404) { skipped++; continue; } throw error; }
125
+ let envelope;
126
+ try { envelope = await gmailEnvelope(message, state, attachmentId => {
127
+ if (!isId(attachmentId)) throw Error('Invalid body attachment.');
128
+ return api(token, 'messages/' + id + '/attachments/' + attachmentId, { signal });
129
+ }); } catch (error) {
130
+ // Network/body fetch failures must retain the cursor for retry.
131
+ if (error.status || signal?.aborted || error.name === 'TypeError' || error.name === 'TimeoutError') throw error;
132
+ skipped++; continue;
133
+ }
134
+ if (envelope) { inbox.receive(envelope); accepted++; } else skipped++;
135
+ }
136
+ return { ...state, historyId: cursor, lastSyncAt: now(), accepted, skipped, error: null };
137
+ }
138
+ return {
139
+ directory,
140
+ configure(path) {
141
+ return exclusive(async () => {
142
+ if (process.env.DSCODE_GMAIL_CLIENT_FILE) throw Error('DSCODE_GMAIL_CLIENT_FILE is set. Unset it before importing a client.');
143
+ const client = readGoogleClient(path);
144
+ if (store.exists('tokens.json') && readGoogleClient(clientPath()).client_id !== client.client_id) throw Error('Gmail already uses a different OAuth client. Keep the connected client.');
145
+ store.write('client.json', { installed: client });
146
+ return { configured: true };
147
+ });
148
+ },
149
+ status() {
150
+ try {
151
+ const state = store.read('state.json');
152
+ return { configured: existsSync(clientPath()), connected: !!state?.account && store.exists('tokens.json'), account: state?.account,
153
+ lastSyncAt: state?.lastSyncAt, accepted: state?.accepted || 0, skipped: state?.skipped || 0, error: state?.error || null };
154
+ } catch { return { connected: false, error: 'Cannot read local Gmail state.' }; }
155
+ },
156
+ connect({ signal } = {}) {
157
+ return exclusive(async () => {
158
+ const client = readGoogleClient(clientPath());
159
+ const token = await authorize(client, { fetchImpl, openBrowser, signal });
160
+ const profile = await api(token, 'profile', { signal });
161
+ if (typeof profile.emailAddress !== 'string' || !profile.emailAddress.includes('@')) throw Error('Google returned an invalid account.');
162
+ const old = store.read('state.json');
163
+ if (old?.account && old.account !== profile.emailAddress) throw Error('One Gmail account is supported. Reconnect the original account.');
164
+ const labelId = await provision(token, signal);
165
+ const boundary = historyId((await api(token, 'profile', { signal })).historyId);
166
+ const state = { account: profile.emailAddress, labelId, connectedAt: old?.connectedAt ?? now(),
167
+ historyId: old?.historyId ?? boundary, lastSyncAt: old?.lastSyncAt ?? null, error: null };
168
+ signal?.throwIfAborted();
169
+ store.write('tokens.json', token);
170
+ store.write('state.json', state);
171
+ return { connected: true, account: state.account };
172
+ });
173
+ },
174
+ async sync({ signal, force = false } = {}) {
175
+ if (!store.exists('state.json')) return { connected: false };
176
+ return exclusive(async () => {
177
+ const state = store.read('state.json');
178
+ if (!state?.account) return { connected: false };
179
+ if (!force && now() - (state.lastAttemptAt || 0) < 30000) return { throttled: true };
180
+ state.lastAttemptAt = now(); store.write('state.json', state);
181
+ try {
182
+ let next;
183
+ try { next = await collect(await access(signal), state, signal); }
184
+ catch (error) { if (error.status !== 401) throw error; next = await collect(await access(signal, true), state, signal); }
185
+ signal?.throwIfAborted();
186
+ store.write('state.json', next); return { accepted: next.accepted, skipped: next.skipped };
187
+ } catch (error) {
188
+ const safe = error.status ? error.message : /^(Google|Gmail|Grant|Set DSCODE_|Cannot read|Invalid Gmail)/.test(error.message) ? error.message : 'Gmail sync interrupted. Retry sync.';
189
+ store.write('state.json', { ...state, error: safe }); throw Error(safe);
190
+ }
191
+ });
192
+ },
193
+ };
194
+ }