feishu-user-plugin 1.3.10 → 1.3.11

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.
@@ -1,28 +1,30 @@
1
1
  #!/usr/bin/env bash
2
2
  # scripts/sync-team-skills.sh — post-merge hook on main.
3
3
  #
4
- # What this does (zero manual steps, no degradation):
5
- # 1. Copy skills/ + .claude-plugin/plugin.json into team-skills repo
6
- # 2. Run team-skills' generate-catalog.py (forced manual-yaml path for byte
7
- # parity with CI)
8
- # 3. Run our scripts/generate-release-artifacts.js to produce
9
- # changelog + readme-row from CHANGELOG.md
10
- # 4. Inject the changelog block into team-skills child README before the
11
- # previous version's heading
12
- # 5. Replace the team-skills root README catalog row matching feishu-user-plugin
13
- # 6. Commit + push branch + open PR
14
- # 7. Auto-merge: --admin --squash (we have admin on team-skills repo;
15
- # org-level setting blocks repo PATCH for allow_auto_merge so we use
16
- # --admin to bypass review wait. CI is non-blocking via "Check catalog"
17
- # drift never happening since step 2 produced byte-identical output.)
4
+ # Idempotent + conflict-resilient sync from feishu-user-plugin's main into
5
+ # zhuzhen-team/team-skills. Designed so retries always converge:
18
6
  #
19
- # Failure modes are now narrow:
7
+ # Flow:
8
+ # 1. Generate release artifacts in feishu repo (changelog block + readme row).
9
+ # 2. cd team-skills repo, fetch origin main.
10
+ # 3. Close any stale OPEN sync PRs whose branch is for an older version
11
+ # (so v1.3.10 sync doesn't pile up behind a never-merged v1.3.9 sync).
12
+ # 4. Delete any local stale sync/feishu-v$VERSION branch and recreate from
13
+ # origin/main — always starts fresh, never carries leftover commits.
14
+ # 5. Copy plugin tree + inject changelog + replace catalog row + regen catalog.
15
+ # 6. If nothing changed → exit 0 (already in sync for v$VERSION).
16
+ # 7. Commit + push --force-with-lease (safe: only this script writes to sync/* branches).
17
+ # 8. Open PR if not exists; merge --admin --squash.
18
+ #
19
+ # Failure modes:
20
20
  # - team-skills repo not cloned at expected path → clean skip
21
- # - branch already exists from previous attempt clean skip
22
- # - generate-release-artifacts.js fails exit non-zero (visible to user
23
- # via post-merge stderr; user fixes CHANGELOG and re-pushes)
21
+ # - generate-release-artifacts.js fails exit non-zero (visible in stderr)
22
+ # - PR merge fails (rare; should be impossible after force-recreate from origin/main)
23
+ # exit non-zero, post-merge wrapper labels as "non-fatal" but user sees stderr
24
24
  set -e
25
- TEAM_SKILLS="/Users/abble/team-skills/plugins/feishu-user-plugin"
25
+
26
+ TEAM_SKILLS_REPO="/Users/abble/team-skills"
27
+ TEAM_SKILLS="$TEAM_SKILLS_REPO/plugins/feishu-user-plugin"
26
28
  if [ ! -d "$TEAM_SKILLS" ]; then echo "[hook] team-skills not present, skip"; exit 0; fi
27
29
 
28
30
  ROOT="$(git rev-parse --show-toplevel)"
@@ -30,25 +32,43 @@ cd "$ROOT"
30
32
 
31
33
  VERSION=$(node -e "console.log(require('./package.json').version)")
32
34
  ARTIFACTS="/tmp/feishu-release/v${VERSION}"
35
+ BRANCH="sync/feishu-v$VERSION"
33
36
 
34
- # Generate release artifacts FIRST so we can inject them into team-skills.
35
- # This reads CHANGELOG.md for the v$VERSION section and emits team-skills
36
- # changelog markdown + root readme row + announcement card JSON.
37
+ # 1. Generate release artifacts FIRST so we can inject them into team-skills.
37
38
  node scripts/generate-release-artifacts.js "$VERSION" >/dev/null
38
39
 
39
- # Copy plugin tree.
40
- cp -r skills/. "$TEAM_SKILLS/skills/"
41
- cp .claude-plugin/plugin.json "$TEAM_SKILLS/.claude-plugin/"
40
+ # 2. cd team-skills, fetch origin main.
41
+ cd "$TEAM_SKILLS_REPO"
42
+ git fetch origin main --quiet
43
+
44
+ # 3. Close any stale OPEN sync PRs (different version branch). Idempotent —
45
+ # any matching PR for this same $VERSION is preserved (we'll force-update
46
+ # its branch in step 7 instead).
47
+ STALE_PRS=$(gh pr list --state open --search "Sync feishu-user-plugin in:title" \
48
+ --json number,headRefName --jq ".[] | select(.headRefName != \"$BRANCH\") | .number")
49
+ if [ -n "$STALE_PRS" ]; then
50
+ for stale_num in $STALE_PRS; do
51
+ gh pr close "$stale_num" \
52
+ --comment "Superseded by sync/feishu-v$VERSION (auto-closed by sync-team-skills.sh)" \
53
+ --delete-branch 2>&1 | tail -1 || true
54
+ echo "[hook] closed stale sync PR #$stale_num"
55
+ done
56
+ fi
57
+
58
+ # 4. Delete any local stale sync branch + recreate from origin/main.
59
+ # `git checkout -B` is "create or reset". We always start from latest main
60
+ # so there are no inherited commits from older sync attempts.
61
+ git checkout -B "$BRANCH" origin/main
62
+
63
+ # 5. Copy plugin tree from feishu repo, inject changelog, regen catalog.
64
+ cp -r "$ROOT/skills/." "$TEAM_SKILLS/skills/"
65
+ cp "$ROOT/.claude-plugin/plugin.json" "$TEAM_SKILLS/.claude-plugin/"
42
66
 
43
- # Inject changelog block into team-skills/plugins/feishu-user-plugin/README.md.
44
- # Insert just before the existing first "### vX.Y.Z" heading, OR after
45
- # "## 更新日志" if no prior version exists.
67
+ # 5a. Inject changelog block into team-skills child README (idempotent).
46
68
  README="$TEAM_SKILLS/README.md"
47
69
  if grep -q "^### v${VERSION} " "$README"; then
48
70
  echo "[hook] team-skills child README already has v${VERSION} section, skipping inject"
49
71
  else
50
- # awk: print everything; when we hit the FIRST `### vX.Y.Z (date)` heading,
51
- # insert the new block before it.
52
72
  awk -v block_file="$ARTIFACTS/team-skills-changelog.md" '
53
73
  BEGIN { inserted = 0 }
54
74
  /^### v[0-9]+\.[0-9]+\.[0-9]+ \(/ && !inserted {
@@ -61,14 +81,12 @@ else
61
81
  echo "[hook] injected v${VERSION} changelog block into child README"
62
82
  fi
63
83
 
64
- # Replace the team-skills root README catalog row matching feishu-user-plugin.
65
- ROOT_README="$TEAM_SKILLS/../../README.md"
84
+ # 5b. Replace root README catalog row matching feishu-user-plugin.
85
+ ROOT_README="$TEAM_SKILLS_REPO/README.md"
66
86
  NEW_ROW=$(cat "$ARTIFACTS/team-skills-readme-row.md")
67
87
  if grep -q "^| \\*\\*feishu-user-plugin\\*\\* |" "$ROOT_README"; then
68
- # Replace the line in-place. Use Python (sed regex with table chars + |
69
- # quotes is brittle across BSD/GNU).
70
88
  python3 -c "
71
- import sys, re
89
+ import re
72
90
  p = '$ROOT_README'
73
91
  new_row = '''$NEW_ROW'''.strip()
74
92
  text = open(p, 'r', encoding='utf-8').read()
@@ -78,47 +96,44 @@ open(p, 'w', encoding='utf-8').write(text)
78
96
  echo "[hook] updated root README catalog row to v${VERSION}"
79
97
  fi
80
98
 
81
- # Switch into team-skills repo root (two parents up from $TEAM_SKILLS).
82
- cd "$TEAM_SKILLS/../.."
83
-
84
- BRANCH="sync/feishu-v$VERSION"
85
- if git rev-parse --verify "$BRANCH" >/dev/null 2>&1; then
86
- echo "[hook] branch $BRANCH already exists locally, skipping"; exit 0
87
- fi
88
- git checkout -b "$BRANCH"
89
-
90
- # team-skills CI runs generate-catalog.py without PyYAML; force the same path
91
- # locally for byte-identical output. Verified in PR #36.
99
+ # 5c. Regenerate catalog.yaml (force PyYAML-less path for byte parity with CI).
92
100
  if [ -f "scripts/generate-catalog.py" ]; then
93
101
  python3 -c "import sys, runpy; sys.modules['yaml']=None; runpy.run_path('scripts/generate-catalog.py', run_name='__main__')" >/dev/null 2>&1
94
102
  fi
95
103
 
96
- # Stage every file the hook might have touched. Each `git add` is idempotent
97
- # on already-clean files, so unchanged ones stage as no-op. Files that don't
98
- # exist (e.g., catalog.yaml when team-skills repo doesn't have the generator)
99
- # would fail under `set -e`, so guard explicitly.
104
+ # 6. Stage everything the hook touched.
100
105
  git add "plugins/feishu-user-plugin/"
101
106
  [ -f "README.md" ] && git add README.md
102
107
  [ -f "catalog.yaml" ] && git add catalog.yaml
103
108
 
104
- # If nothing actually changed, exit 0 — the v$VERSION sync was already done.
105
109
  if git diff --cached --quiet; then
106
110
  echo "[hook] nothing to sync (working tree clean for v$VERSION)"; exit 0
107
111
  fi
108
112
 
109
113
  git commit -m "chore: sync feishu-user-plugin v$VERSION (skills + plugin.json + README changelog + catalog)"
110
- git push -u origin "$BRANCH"
111
114
 
112
- gh pr create --title "Sync feishu-user-plugin v$VERSION" --body "Auto-sync from feishu-user-plugin main. Includes:
115
+ # 7. Push (force-with-lease since this branch is exclusively written by this
116
+ # script — safe even if a previous run pushed something we just rebuilt).
117
+ git push --force-with-lease -u origin "$BRANCH"
118
+
119
+ # 8. Open PR if not exists, then merge.
120
+ PR_NUM=$(gh pr list --head "$BRANCH" --state open --json number --jq ".[0].number // empty")
121
+ if [ -z "$PR_NUM" ]; then
122
+ gh pr create --title "Sync feishu-user-plugin v$VERSION" --body "Auto-sync from feishu-user-plugin main. Includes:
113
123
  - plugins/feishu-user-plugin/.claude-plugin/plugin.json bumped to v$VERSION
114
124
  - plugins/feishu-user-plugin/skills/ regenerated
115
125
  - plugins/feishu-user-plugin/README.md: v$VERSION changelog section auto-generated from feishu-user-plugin's CHANGELOG.md
116
126
  - README.md (root): catalog row updated
117
127
  - catalog.yaml regenerated"
128
+ PR_NUM=$(gh pr view "$BRANCH" --json number --jq .number)
129
+ echo "[hook] opened sync PR #$PR_NUM"
130
+ else
131
+ echo "[hook] reusing existing sync PR #$PR_NUM (branch force-updated)"
132
+ fi
118
133
 
119
- PR_NUM=$(gh pr view --json number --jq .number)
120
- # Use --admin --squash: we have admin permissions on team-skills (verified) and
121
- # the team-skills org has auto-merge disabled at org level. --admin merges
122
- # without waiting for required reviews. CI is informational only here.
134
+ # Use --admin --squash: we have admin permissions on team-skills (verified).
135
+ # After step 4's force-recreate from origin/main, this PR is always cleanly
136
+ # mergeable (no carried conflicts). --admin bypasses required reviews; CI
137
+ # is informational since step 5c produced byte-identical catalog output.
123
138
  gh pr merge "$PR_NUM" --admin --squash
124
139
  echo "[hook] team-skills sync PR #$PR_NUM merged"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: feishu-user-plugin
3
- version: "1.3.10"
3
+ version: "1.3.11"
4
4
  description: "All-in-one Feishu plugin — send messages as yourself (incl. batch_send), read group/P2P chats (auto-expands merge_forward), manage docs/tables/wiki (full CRUD)/drive, OKR (with progress writes), calendar (read+write), Tasks v2, multi-profile auto-switch, real-time WS events. v1.3.8: multi-profile auto-switch on read errors (B), WebSocket realtime im.message events via get_new_events (C), credential pointer-only mode (E), CI gates (F), auth/uat.js + auth/cookie.js extracts (D)."
5
5
  allowed-tools: send_to_user, send_to_group, send_as_user, send_image_as_user, send_file_as_user, send_post_as_user, batch_send, send_card_as_user, search_contacts, create_p2p_chat, get_chat_info, get_user_info, get_login_status, list_profiles, switch_profile, manage_profile_hints, read_p2p_messages, list_user_chats, list_chats, read_messages, send_message_as_bot, reply_message, forward_message, delete_message, update_message, add_reaction, delete_reaction, pin_message, create_group, update_group, list_members, manage_members, search_docs, read_doc, get_doc_blocks, create_doc, manage_doc_block, read_doc_markdown, manage_bitable_app, manage_bitable_table, manage_bitable_field, manage_bitable_view, manage_bitable_record, upload_bitable_attachment, list_wiki_spaces, search_wiki, list_wiki_nodes, get_wiki_node, create_wiki_node, update_wiki_node, move_wiki_node, copy_wiki_node, delete_wiki_node, list_files, create_folder, upload_drive_file, manage_drive_file, upload_image, upload_file, download_message_resource, download_doc_image, list_user_okrs, get_okrs, list_okr_periods, create_okr_progress_record, list_okr_progress_records, delete_okr_progress_record, list_calendars, list_calendar_events, get_calendar_event, create_calendar_event, update_calendar_event, delete_calendar_event, respond_calendar_event, get_freebusy, list_tasks, get_task, create_task, update_task, complete_task, delete_task, manage_task_members, get_new_events, manage_ws_status
6
6
  user_invocable: true
@@ -197,6 +197,7 @@ For more profiles beyond the default, set `LARK_PROFILES_JSON` in the MCP env (o
197
197
  - `~/.feishu-user-plugin/ws-owner.lock`: lock file owned by the one MCP process driving the WS connection (O_CREAT|O_EXCL, 30 s stale).
198
198
  - `~/.feishu-user-plugin/events.jsonl`: append-only event log written by the WS owner; 10 MB soft / 20 MB hard cap then rotated to `events.jsonl.old`.
199
199
  - `~/.feishu-user-plugin/events.cursor.json`: global drain cursor shared across all MCP processes — advancing it marks events as consumed for all harnesses on the machine.
200
+ - **Lark Desktop multi-account auto-switch (v1.3.11)**: when `credentials.json::profiles[*].larkHash` is bound, the owner heartbeat (15 s) watches `~/Library/.../sdk_storage/<hash>/cookie_store.db` mtime; switching the active Lark Desktop account auto-flips `credentials.json::active` to the bound profile. macOS-only. Bind via `setup --bind-hash <hash>` or auto-detect on `setup` (single account binds silently; multiple prompts in interactive mode / picks most-recent in non-interactive mode). Cookies stay per-profile in `LARK_COOKIE`; the encrypted `cookie_store.db` is never read.
200
201
 
201
202
  ### Credentials store (v1.3.7+)
202
203
  Single source of truth at `~/.feishu-user-plugin/credentials.json` (mode 0600). Schema documented at `docs/CREDENTIALS-FORMAT.md`. The MCP server reads from this file when present; cookie heartbeat and UAT refresh persist back to it atomically. Multiple harnesses (Claude Code, Codex) sharing the same file see token rotations consistently — no more "Codex still has the old UAT" drift after a refresh in Claude Code.
@@ -360,6 +360,51 @@ function setProfileEvents(name, eventList) {
360
360
  return true;
361
361
  }
362
362
 
363
+ // --- Lark Desktop hash bindings (v1.3.11 §A) ---
364
+ //
365
+ // Each profile may carry an optional `larkHash` 32-char-hex field binding it
366
+ // to one of `~/Library/Containers/com.bytedance.macos.feishu/.../sdk_storage/<hash>/`.
367
+ // The owner heartbeat reactor uses this binding to decide which profile to
368
+ // switch to when the user changes account in Lark Desktop. See
369
+ // src/auth/lark-desktop.js for the detection / switch logic; this module
370
+ // just owns the persisted binding.
371
+
372
+ const _LARK_HASH_RE = /^[a-f0-9]{32}$/;
373
+
374
+ function getProfileLarkHash(name) {
375
+ const f = _readFile();
376
+ const target = name || (f ? f.active : 'default');
377
+ if (f && f.profiles[target] && typeof f.profiles[target].larkHash === 'string') {
378
+ return f.profiles[target].larkHash;
379
+ }
380
+ return null;
381
+ }
382
+
383
+ function setProfileLarkHash(name, hash) {
384
+ if (hash !== null && (typeof hash !== 'string' || !_LARK_HASH_RE.test(hash))) {
385
+ throw new Error('setProfileLarkHash: hash must be 32-char hex (a-f, 0-9) or null');
386
+ }
387
+ const f = _readFile();
388
+ if (!f) throw new Error('No credentials.json — cannot set profile larkHash. Run `npx feishu-user-plugin migrate --confirm` first.');
389
+ if (!f.profiles[name]) {
390
+ throw new Error(`setProfileLarkHash: profile "${name}" not found. Available: ${Object.keys(f.profiles).join(', ')}`);
391
+ }
392
+ if (hash === null) delete f.profiles[name].larkHash;
393
+ else f.profiles[name].larkHash = hash;
394
+ _atomicWriteJson(_credentialsPath(), f);
395
+ return true;
396
+ }
397
+
398
+ function findProfileByHash(hash) {
399
+ if (typeof hash !== 'string' || !_LARK_HASH_RE.test(hash)) return null;
400
+ const f = _readFile();
401
+ if (!f) return null;
402
+ for (const [name, profile] of Object.entries(f.profiles)) {
403
+ if (profile.larkHash === hash) return name;
404
+ }
405
+ return null;
406
+ }
407
+
363
408
  // --- Profile hints (v1.3.8) ---
364
409
  //
365
410
  // profileHints maps resourceKey → profileName, persisted in credentials.json.
@@ -419,6 +464,10 @@ module.exports = {
419
464
  // per-profile events list (v1.3.9 A.4)
420
465
  getProfileEvents,
421
466
  setProfileEvents,
467
+ // Lark Desktop hash bindings (v1.3.11 §A)
468
+ getProfileLarkHash,
469
+ setProfileLarkHash,
470
+ findProfileByHash,
422
471
  // profile hints (v1.3.8)
423
472
  getProfileHints,
424
473
  setProfileHint,
@@ -0,0 +1,135 @@
1
+ // src/auth/lark-desktop.js — Lark Desktop sdk_storage detection (v1.3.11 §A).
2
+ //
3
+ // macOS-only: Linux/Windows return null from getSdkStorageDir() and all
4
+ // callers no-op gracefully. We never read the encrypted cookie_store.db —
5
+ // only stat its mtime to detect account switches. Profile↔hash bindings
6
+ // live in credentials.json::profiles[*].larkHash.
7
+
8
+ 'use strict';
9
+
10
+ const fs = require('fs');
11
+ const os = require('os');
12
+ const path = require('path');
13
+
14
+ const HASH_RE = /^[a-f0-9]{32}$/;
15
+
16
+ // Debounce + freshness windows for the heartbeat reactor.
17
+ const SWITCH_DEBOUNCE_MS = 5_000;
18
+ const UNBOUND_FRESH_WINDOW_MS = 60_000;
19
+
20
+ function _macSdkStorageDir() {
21
+ return path.join(
22
+ os.homedir(),
23
+ 'Library/Containers/com.bytedance.macos.feishu/Data/Library/Application Support/LarkShell/sdk_storage'
24
+ );
25
+ }
26
+
27
+ function getSdkStorageDir() {
28
+ if (process.platform !== 'darwin') return null;
29
+ const dir = _macSdkStorageDir();
30
+ try {
31
+ return fs.statSync(dir).isDirectory() ? dir : null;
32
+ } catch (_) {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ // List Lark account hash directories under sdk_storage, sorted by
38
+ // cookie_store.db mtime descending. Hash dirs without a cookie_store.db
39
+ // are filtered (account never logged in / cleared).
40
+ //
41
+ // Returns: [{ hash, mtimeMs, dir }]
42
+ function listAccountHashes({ dir } = {}) {
43
+ const root = dir || getSdkStorageDir();
44
+ if (!root) return [];
45
+ let entries;
46
+ try { entries = fs.readdirSync(root); } catch (_) { return []; }
47
+ const out = [];
48
+ for (const name of entries) {
49
+ if (!HASH_RE.test(name)) continue;
50
+ const accountDir = path.join(root, name);
51
+ const dbPath = path.join(accountDir, 'cookie_store.db');
52
+ let mtimeMs;
53
+ try { mtimeMs = fs.statSync(dbPath).mtimeMs; } catch (_) { continue; }
54
+ out.push({ hash: name, mtimeMs, dir: accountDir });
55
+ }
56
+ out.sort((a, b) => b.mtimeMs - a.mtimeMs);
57
+ return out;
58
+ }
59
+
60
+ function mostRecentHash(opts = {}) {
61
+ const list = listAccountHashes(opts);
62
+ return list.length > 0 ? list[0] : null;
63
+ }
64
+
65
+ // Pure-ish reactor logic, dependency-injected for unit tests.
66
+ //
67
+ // Inputs:
68
+ // prevSnapshot: { [hash]: mtimeMs } from the previous heartbeat
69
+ // lastSwitchAt: ms timestamp of the last auto-switch (debounce key)
70
+ // seenUnboundHashes: Set<hash> — emit hint once per hash per session
71
+ // credsApi: { getActiveProfileName, getProfileLarkHash, findProfileByHash }
72
+ // listFn: () => [...] — defaults to listAccountHashes() with auto-detected dir
73
+ // now: ms — defaults to Date.now()
74
+ // log: (msg) => void — defaults to console.error (the unbound-hash hint goes here)
75
+ //
76
+ // Returns:
77
+ // { switchTo: { hash, profile } | null, isUnbound: boolean, hash?: string }
78
+ //
79
+ // Mutates seenUnboundHashes (adds the hash when it emits a hint).
80
+ function detectSwitch({
81
+ prevSnapshot,
82
+ lastSwitchAt,
83
+ seenUnboundHashes,
84
+ credsApi,
85
+ listFn,
86
+ now,
87
+ log,
88
+ } = {}) {
89
+ if (!credsApi) credsApi = require('./credentials');
90
+ if (!listFn) listFn = () => listAccountHashes();
91
+ if (typeof now !== 'number') now = Date.now();
92
+ if (typeof log !== 'function') log = console.error;
93
+
94
+ if (now - lastSwitchAt < SWITCH_DEBOUNCE_MS) {
95
+ return { switchTo: null, isUnbound: false };
96
+ }
97
+
98
+ const list = listFn();
99
+ if (list.length === 0) return { switchTo: null, isUnbound: false };
100
+
101
+ const top = list[0];
102
+ const activeProfile = credsApi.getActiveProfileName();
103
+ const activeHash = credsApi.getProfileLarkHash(activeProfile);
104
+ if (top.hash === activeHash) return { switchTo: null, isUnbound: false };
105
+
106
+ // Only act on a true mtime advance — this prevents repeatedly switching
107
+ // when the snapshot baseline shows a stable older delta.
108
+ const prev = prevSnapshot[top.hash] || 0;
109
+ if (top.mtimeMs <= prev) return { switchTo: null, isUnbound: false };
110
+
111
+ const targetProfile = credsApi.findProfileByHash(top.hash);
112
+ if (!targetProfile) {
113
+ const isFresh = (now - top.mtimeMs) < UNBOUND_FRESH_WINDOW_MS;
114
+ if (isFresh && seenUnboundHashes && !seenUnboundHashes.has(top.hash)) {
115
+ seenUnboundHashes.add(top.hash);
116
+ log(
117
+ `[feishu-user-plugin] Lark Desktop active account hash ${top.hash} is not bound to any MCP profile. ` +
118
+ `Run: npx feishu-user-plugin setup --profile <name> --bind-hash ${top.hash}`
119
+ );
120
+ }
121
+ return { switchTo: null, isUnbound: true, hash: top.hash };
122
+ }
123
+
124
+ return { switchTo: { hash: top.hash, profile: targetProfile }, isUnbound: false };
125
+ }
126
+
127
+ module.exports = {
128
+ HASH_RE,
129
+ SWITCH_DEBOUNCE_MS,
130
+ UNBOUND_FRESH_WINDOW_MS,
131
+ getSdkStorageDir,
132
+ listAccountHashes,
133
+ mostRecentHash,
134
+ detectSwitch,
135
+ };
package/src/server.js CHANGED
@@ -80,6 +80,11 @@ let ownerHeartbeatTimer = null;
80
80
  let nonOwnerPollTimer = null;
81
81
  let _ownerStartCallbacks = [];
82
82
 
83
+ // Lark Desktop reactor state (v1.3.11 §A) — owned by the heartbeat callback.
84
+ let _lastHashMtimes = {};
85
+ let _lastSwitchAt = 0;
86
+ const _seenUnboundHashes = new Set();
87
+
83
88
  function _onBecomeOwner(cb) { _ownerStartCallbacks.push(cb); }
84
89
 
85
90
  function _stopHeartbeat() {
@@ -203,6 +208,9 @@ async function _claimAndStart() {
203
208
 
204
209
  // Heartbeat + check active changes every 15s.
205
210
  let lastCredMtime = _credMtime();
211
+ // Bootstrap baseline so the very first heartbeat doesn't trigger a switch.
212
+ _lastHashMtimes = require('./auth/lark-desktop').listAccountHashes()
213
+ .reduce((acc, h) => { acc[h.hash] = h.mtimeMs; return acc; }, {});
206
214
  ownerHeartbeatTimer = setInterval(() => {
207
215
  if (ownerHandle) ownerHandle.heartbeat();
208
216
  const m = _credMtime();
@@ -210,6 +218,12 @@ async function _claimAndStart() {
210
218
  lastCredMtime = m;
211
219
  _maybeReconfigure().catch((e) => console.error(`[feishu-user-plugin] reconfigure failed: ${e.message}`));
212
220
  }
221
+ // Lark Desktop reactor (v1.3.11 §A)
222
+ try {
223
+ _runLarkDesktopReactor();
224
+ } catch (e) {
225
+ console.error(`[feishu-user-plugin] Lark reactor error: ${e.message}`);
226
+ }
213
227
  // Defer-rotate check
214
228
  try {
215
229
  const snap = events.cursor.readSnapshot(FEISHU_HOME);
@@ -242,6 +256,34 @@ function _credMtime() {
242
256
  } catch (_) { return null; }
243
257
  }
244
258
 
259
+ // Lark Desktop reactor (v1.3.11 §A).
260
+ // Called from the owner heartbeat. When the most-recently-active hash differs
261
+ // from the active profile's bound hash AND its mtime advanced since the last
262
+ // snapshot, flip credentials.json::active to the matching profile (the existing
263
+ // _credMtime delta on the next tick triggers _maybeReconfigure which restarts
264
+ // the WS client with the new profile's events list).
265
+ function _runLarkDesktopReactor() {
266
+ const ld = require('./auth/lark-desktop');
267
+ const out = ld.detectSwitch({
268
+ prevSnapshot: _lastHashMtimes,
269
+ lastSwitchAt: _lastSwitchAt,
270
+ seenUnboundHashes: _seenUnboundHashes,
271
+ });
272
+ if (out.switchTo) {
273
+ _lastSwitchAt = Date.now();
274
+ console.error(
275
+ `[feishu-user-plugin] Lark Desktop account changed; switching profile to ` +
276
+ `"${out.switchTo.profile}" (hash ${out.switchTo.hash})`
277
+ );
278
+ try { credentials.setActiveProfile(out.switchTo.profile); }
279
+ catch (e) { console.error(`[feishu-user-plugin] setActiveProfile failed: ${e.message}`); }
280
+ }
281
+ // Refresh snapshot regardless of switch outcome — keeps debounce + advance
282
+ // detection consistent on subsequent ticks.
283
+ _lastHashMtimes = ld.listAccountHashes()
284
+ .reduce((acc, h) => { acc[h.hash] = h.mtimeMs; return acc; }, {});
285
+ }
286
+
245
287
  // Cross-process active-profile sync (v1.3.9 A.2).
246
288
  // Each tool call: stat credentials.json; if mtime changed AND active differs
247
289
  // from in-memory currentProfile, do an in-process setActiveProfile().
package/src/setup.js CHANGED
@@ -28,6 +28,8 @@ function parseArgs() {
28
28
  else if (argv[i] === '--force') args.force = true;
29
29
  else if (argv[i] === '--profile' && argv[i + 1]) args.profile = argv[++i];
30
30
  else if (argv[i] === '--activate') args.activate = true;
31
+ else if (argv[i] === '--bind-hash' && argv[i + 1]) args.bindHash = argv[++i];
32
+ else if (argv[i] === '--no-bind-hash') args.noBindHash = true;
31
33
  }
32
34
  return args;
33
35
  }
@@ -238,6 +240,48 @@ async function main() {
238
240
  }
239
241
  // mode === 'preserve': credentials.json is unchanged; we only update the harness pointer.
240
242
 
243
+ // --- Lark Desktop hash auto-bind (v1.3.11 §A) ---
244
+ // Triggers on fresh / update (i.e. whenever credentials.json was just modified).
245
+ // Skipped via --no-bind-hash. Explicit --bind-hash overrides auto-detect.
246
+ if ((mode === 'fresh' || mode === 'update') && !cliArgs.noBindHash) {
247
+ try {
248
+ const larkDesktop = require('./auth/lark-desktop');
249
+ const hashes = larkDesktop.listAccountHashes();
250
+ if (hashes.length > 0) {
251
+ let chosenHash = cliArgs.bindHash;
252
+ if (!chosenHash) {
253
+ if (hashes.length === 1) {
254
+ chosenHash = hashes[0].hash;
255
+ console.log(`\n[Lark Desktop] Detected single account hash: ${chosenHash}`);
256
+ } else if (nonInteractive) {
257
+ chosenHash = hashes[0].hash;
258
+ console.log(`\n[Lark Desktop] Detected ${hashes.length} accounts; auto-binding "${targetProfile}" to most-recent: ${chosenHash}`);
259
+ console.log(` Other hashes (run setup --profile <name> --bind-hash <hash> to bind):`);
260
+ hashes.slice(1).forEach((h) => {
261
+ const ts = new Date(h.mtimeMs).toISOString();
262
+ console.log(` - ${h.hash} (last active ${ts})`);
263
+ });
264
+ } else {
265
+ console.log(`\n[Lark Desktop] Multiple accounts detected:`);
266
+ hashes.forEach((h, i) => {
267
+ const ts = new Date(h.mtimeMs).toISOString();
268
+ console.log(` ${i + 1}. ${h.hash} (last active ${ts})`);
269
+ });
270
+ const pick = (await ask(`Bind profile "${targetProfile}" to which? [1]: `)).trim() || '1';
271
+ const idx = parseInt(pick, 10) - 1;
272
+ chosenHash = (idx >= 0 && idx < hashes.length) ? hashes[idx].hash : hashes[0].hash;
273
+ }
274
+ }
275
+ credentials.setProfileLarkHash(targetProfile, chosenHash);
276
+ console.log(`Bound profile "${targetProfile}" to Lark account hash ${chosenHash}`);
277
+ console.log(` → MCP will auto-switch to this profile when Lark Desktop activates this account.`);
278
+ }
279
+ // hashes.length === 0 → silent (Lark not installed, or non-darwin) — don't disrupt setup
280
+ } catch (e) {
281
+ console.error(`[Lark Desktop] auto-bind skipped: ${e.message}`);
282
+ }
283
+ }
284
+
241
285
  // --- Write harness config ---
242
286
  // Always write pointer-only env to harness configs (v1.3.9 SSOT).
243
287
  // The harness env block only needs FEISHU_PLUGIN_PROFILE; all real creds