pikiloom 0.4.21 → 0.4.23

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.
Files changed (41) hide show
  1. package/dashboard/dist/assets/{AgentTab-Crg9GEUv.js → AgentTab-hzdSX7gP.js} +1 -1
  2. package/dashboard/dist/assets/ConnectionModal-Dj2eoHWz.js +1 -0
  3. package/dashboard/dist/assets/{DirBrowser-Do4XzF2X.js → DirBrowser-C4DPPZ1V.js} +1 -1
  4. package/dashboard/dist/assets/ExtensionsTab-BdGHiEnP.js +1 -0
  5. package/dashboard/dist/assets/{IMAccessTab-BX90Aiuq.js → IMAccessTab-C4RlXeCP.js} +1 -1
  6. package/dashboard/dist/assets/{Modal-Dd5twuoE.js → Modal-D22Z00Ux.js} +1 -1
  7. package/dashboard/dist/assets/{Modals--Xl6dsUf.js → Modals-CHXLG4V2.js} +1 -1
  8. package/dashboard/dist/assets/{Select-Bp6SwWv6.js → Select-D4JIewGZ.js} +1 -1
  9. package/dashboard/dist/assets/{SessionPanel-CHaOUor-.js → SessionPanel-Ba93D9jh.js} +1 -1
  10. package/dashboard/dist/assets/{SystemTab-MVpMgFFD.js → SystemTab-M8r-o2KI.js} +1 -1
  11. package/dashboard/dist/assets/index-C02W4e24.js +3 -0
  12. package/dashboard/dist/assets/index-CyCspgtc.css +1 -0
  13. package/dashboard/dist/assets/{index-7pyJSn4B.js → index-p2XCgnzw.js} +17 -17
  14. package/dashboard/dist/assets/{shared-CxR9M3rn.js → shared-DW8iXiGd.js} +1 -1
  15. package/dashboard/dist/index.html +2 -2
  16. package/dist/agent/index.js +1 -1
  17. package/dist/agent/skill-installer.js +95 -0
  18. package/dist/catalog/skill-repos.js +12 -0
  19. package/dist/cli/main.js +31 -1
  20. package/dist/core/secrets/ref.js +8 -0
  21. package/dist/core/secrets/store.js +31 -11
  22. package/dist/dashboard/routes/extensions.js +137 -24
  23. package/dist/dashboard/server.js +27 -64
  24. package/dist/pikichannel/adapter-pikiloom.js +220 -0
  25. package/dist/pikichannel/code.js +35 -0
  26. package/dist/pikichannel/codec.js +50 -0
  27. package/dist/pikichannel/host.js +252 -0
  28. package/dist/pikichannel/protocol.js +105 -0
  29. package/dist/pikichannel/rendezvous-broker.js +114 -0
  30. package/dist/pikichannel/rendezvous-host.js +138 -0
  31. package/dist/pikichannel/server.js +284 -0
  32. package/dist/pikichannel/transport.js +49 -0
  33. package/dist/pikichannel/transports/webrtc-host.js +61 -0
  34. package/dist/pikichannel/transports/webrtc-shared.js +132 -0
  35. package/dist/pikichannel/transports/websocket-host.js +78 -0
  36. package/dist/pikichannel/web/demo.html +246 -0
  37. package/dist/pikichannel/web/sdk.js +361 -0
  38. package/package.json +4 -2
  39. package/dashboard/dist/assets/ExtensionsTab-j77Yqoz1.js +0 -1
  40. package/dashboard/dist/assets/index-BiPI4uFE.js +0 -3
  41. package/dashboard/dist/assets/index-dzfjF9Js.css +0 -1
@@ -1 +1 @@
1
- import{j as s}from"./react-vendor-C7Sl8SE7.js";import{K as t,k as i,S as a}from"./index-BiPI4uFE.js";function o({primary:e,secondary:l,tertiary:n}){return s.jsxs("div",{className:"flex flex-col gap-2 pt-1 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between",children:[s.jsxs("div",{className:"flex flex-wrap items-center gap-2",children:[e&&s.jsxs(i,{tone:"primary",onClick:e.onClick,disabled:e.disabled,children:[e.loading&&s.jsx(a,{}),e.label]}),l&&s.jsx(i,{tone:"secondary",onClick:l.onClick,disabled:l.disabled,children:l.label})]}),n&&s.jsx("div",{className:"text-xs leading-relaxed text-fg-4",children:n})]})}function c({children:e,className:l}){return s.jsx(t,{padding:"md",elevation:"flat",className:l,children:e})}export{o as A,c as S};
1
+ import{j as s}from"./react-vendor-C7Sl8SE7.js";import{N as t,m as i,S as a}from"./index-C02W4e24.js";function o({primary:e,secondary:l,tertiary:n}){return s.jsxs("div",{className:"flex flex-col gap-2 pt-1 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between",children:[s.jsxs("div",{className:"flex flex-wrap items-center gap-2",children:[e&&s.jsxs(i,{tone:"primary",onClick:e.onClick,disabled:e.disabled,children:[e.loading&&s.jsx(a,{}),e.label]}),l&&s.jsx(i,{tone:"secondary",onClick:l.onClick,disabled:l.disabled,children:l.label})]}),n&&s.jsx("div",{className:"text-xs leading-relaxed text-fg-4",children:n})]})}function c({children:e,className:l}){return s.jsx(t,{padding:"md",elevation:"flat",className:l,children:e})}export{o as A,c as S};
@@ -6,10 +6,10 @@
6
6
  <link rel="icon" type="image/png" href="/logo.png">
7
7
  <title>Pikiloom</title>
8
8
  <link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
9
- <script type="module" crossorigin src="/assets/index-BiPI4uFE.js"></script>
9
+ <script type="module" crossorigin src="/assets/index-C02W4e24.js"></script>
10
10
  <link rel="modulepreload" crossorigin href="/assets/react-vendor-C7Sl8SE7.js">
11
11
  <link rel="modulepreload" crossorigin href="/assets/router-DHISdpPk.js">
12
- <link rel="stylesheet" crossorigin href="/assets/index-dzfjF9Js.css">
12
+ <link rel="stylesheet" crossorigin href="/assets/index-CyCspgtc.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
@@ -42,7 +42,7 @@ export { getClaudeNativeGoal, buildClaudeSetGoalPrompt, buildClaudeClearGoalProm
42
42
  export { listAllMcpExtensions, addGlobalMcpExtension, removeGlobalMcpExtension, updateGlobalMcpExtension, addWorkspaceMcpExtension, removeWorkspaceMcpExtension, updateWorkspaceMcpExtension, loadGlobalMcpExtensions, loadWorkspaceMcpExtensions, getCatalogItems, getCatalogItem, buildInstalledConfigFromRecommended, checkMcpHealth, getCachedHealth, cacheHealth, } from './mcp/extensions.js';
43
43
  export { getRecommendedMcpServers, getRecommendedMcpServer, getRecommendedSkillRepos, searchMcpServers, searchSkills as searchSkillRepos, } from './mcp/registry.js';
44
44
  export { getMcpToken, saveMcpToken, deleteMcpToken, hasValidMcpToken, startAuthorization, completeAuthorization, refreshMcpToken, injectOAuthHeaders, } from './mcp/oauth.js';
45
- export { installSkill, removeSkill, checkSkillUpdates, getGlobalSkillsDir, } from './skill-installer.js';
45
+ export { installSkill, removeSkill, checkSkillUpdates, getGlobalSkillsDir, recordSkillInstall, getSkillLedgerEntry, forgetSkillInstall, normalizeSkillSourceKey, } from './skill-installer.js';
46
46
  // ── Re-export: CLI extensions ───────────────────────────────────────────────
47
47
  export { getRecommendedClis, getRecommendedCli, detectCli, getCachedCliStatus, invalidateCliStatus, currentPlatform, getCliCatalog, refreshCliStatus, startCliAuthSession, getAuthSession, cancelAuthSession, applyCliToken, logoutCli, startCliInstallSession, } from './cli/index.js';
48
48
  // ── Re-export: driver-specific functions ────────────────────────────────────
@@ -20,6 +20,93 @@ const GLOBAL_SKILLS_DIR = path.join(os.homedir(), STATE_DIR_NAME, 'skills');
20
20
  const INSTALL_TIMEOUT_MS = 60_000;
21
21
  const REMOVE_TIMEOUT_MS = 10_000;
22
22
  // ---------------------------------------------------------------------------
23
+ // Install ledger — provenance for update detection
24
+ //
25
+ // `npx skills add` drops skill directories but records no source or version, so
26
+ // there's nothing to diff a local install against a moving remote. We keep a
27
+ // sidecar ledger next to the skills (one per scope: global beside
28
+ // ~/.pikiloom/skills, project beside <workdir>/.pikiloom/skills) mapping each
29
+ // installed collection (its GitHub source) to the default-branch commit SHA it
30
+ // was pulled from. The dashboard reads the live remote HEAD, diffs it against
31
+ // the ledger to surface "update available", then re-runs install to advance it.
32
+ //
33
+ // The file is a hidden sibling of the skill dirs; skill discovery and removal
34
+ // only ever touch named sub-directories, so it's invisible to both.
35
+ // ---------------------------------------------------------------------------
36
+ const SKILL_LEDGER_FILE = '.pikiloom-skills-ledger.json';
37
+ function ledgerPath(opts) {
38
+ if (opts.global)
39
+ return path.join(GLOBAL_SKILLS_DIR, SKILL_LEDGER_FILE);
40
+ if (opts.workdir)
41
+ return path.join(opts.workdir, STATE_DIR_NAME, 'skills', SKILL_LEDGER_FILE);
42
+ return null;
43
+ }
44
+ /** Normalize a source to a stable key: lowercase `owner/repo`, URL/.git stripped. */
45
+ export function normalizeSkillSourceKey(source) {
46
+ return String(source || '')
47
+ .trim()
48
+ .replace(/^https?:\/\/(www\.)?github\.com\//i, '')
49
+ .replace(/\.git$/i, '')
50
+ .replace(/\/+$/, '')
51
+ .toLowerCase();
52
+ }
53
+ function readLedger(opts) {
54
+ const p = ledgerPath(opts);
55
+ if (!p)
56
+ return { version: 1, entries: {} };
57
+ try {
58
+ const parsed = JSON.parse(fs.readFileSync(p, 'utf-8'));
59
+ if (parsed && typeof parsed === 'object' && parsed.entries && typeof parsed.entries === 'object') {
60
+ return { version: 1, entries: parsed.entries };
61
+ }
62
+ }
63
+ catch { /* missing or corrupt — treat as empty */ }
64
+ return { version: 1, entries: {} };
65
+ }
66
+ function writeLedger(ledger, opts) {
67
+ const p = ledgerPath(opts);
68
+ if (!p)
69
+ return;
70
+ try {
71
+ fs.mkdirSync(path.dirname(p), { recursive: true });
72
+ fs.writeFileSync(p, JSON.stringify(ledger, null, 2));
73
+ }
74
+ catch { /* best effort — provenance is non-critical */ }
75
+ }
76
+ /** Look up the recorded provenance for a source in the given scope. */
77
+ export function getSkillLedgerEntry(source, opts) {
78
+ const key = normalizeSkillSourceKey(source);
79
+ if (!key)
80
+ return null;
81
+ return readLedger(opts).entries[key] || null;
82
+ }
83
+ /** Record (or refresh) the provenance for a source after a successful install/update. */
84
+ export function recordSkillInstall(source, opts) {
85
+ const key = normalizeSkillSourceKey(source);
86
+ if (!key)
87
+ return;
88
+ const ledger = readLedger(opts);
89
+ const prev = ledger.entries[key];
90
+ ledger.entries[key] = {
91
+ source: source.trim(),
92
+ sha: opts.sha ?? prev?.sha ?? null,
93
+ installedAt: Date.now(),
94
+ names: opts.names && opts.names.length ? opts.names : prev?.names,
95
+ };
96
+ writeLedger(ledger, opts);
97
+ }
98
+ /** Drop a source's provenance — call when its skills are removed. */
99
+ export function forgetSkillInstall(source, opts) {
100
+ const key = normalizeSkillSourceKey(source);
101
+ if (!key)
102
+ return;
103
+ const ledger = readLedger(opts);
104
+ if (ledger.entries[key]) {
105
+ delete ledger.entries[key];
106
+ writeLedger(ledger, opts);
107
+ }
108
+ }
109
+ // ---------------------------------------------------------------------------
23
110
  // Helpers
24
111
  // ---------------------------------------------------------------------------
25
112
  /**
@@ -98,6 +185,14 @@ export async function installSkill(source, opts = {}) {
98
185
  const errorMsg = result.stderr.trim().split('\n').pop()?.trim() || 'installation failed';
99
186
  return { ok: false, error: errorMsg, output: result.stdout + result.stderr };
100
187
  }
188
+ // Record provenance so the dashboard can later detect remote updates. Scoped
189
+ // to where the skill landed; best-effort, never fails the install.
190
+ recordSkillInstall(source, {
191
+ global: isGlobal,
192
+ workdir,
193
+ sha: opts.sourceSha ?? null,
194
+ names: opts.sourceNames ?? (skill ? [skill] : undefined),
195
+ });
101
196
  return { ok: true, output: result.stdout };
102
197
  }
103
198
  // ---------------------------------------------------------------------------
@@ -28,6 +28,18 @@
28
28
  */
29
29
  export const SKILL_REPOS = [
30
30
  // ── Official / first-party ────────────────────────────────────────────────
31
+ {
32
+ id: 'pikiloom-skills',
33
+ name: 'Pikiloom Skills',
34
+ description: 'First-party Pikiloom skills — image generation, recorded demo videos, and the embeddable tech-intel signal pipeline',
35
+ descriptionZh: 'Pikiloom 官方技能 — 生图、产品导览视频、可嵌入的 tech-intel 信号流水线',
36
+ source: 'xiaotonng/pikiloom-skills',
37
+ category: 'productivity',
38
+ recommendedScope: 'both',
39
+ homepage: 'https://github.com/xiaotonng/pikiloom-skills',
40
+ // First-party pack: keep it at the top even before it accrues stars.
41
+ pinned: true,
42
+ },
31
43
  {
32
44
  id: 'anthropics-skills',
33
45
  name: 'Anthropic Skills',
package/dist/cli/main.js CHANGED
@@ -20,6 +20,7 @@ import { hasConfiguredChannelToken, resolveConfiguredChannels } from './channels
20
20
  import { ChannelSupervisor } from './channel-supervisor.js';
21
21
  import { listAgents } from '../agent/index.js';
22
22
  import { startDashboard } from '../dashboard/server.js';
23
+ import { buildServerCode } from '../pikichannel/code.js';
23
24
  import { buildSetupGuide, collectSetupState, hasReadyAgent, isSetupReady } from './onboarding.js';
24
25
  import { buildRestartCommand, clearDaemonPidFile, clearRestartStateFile, consumeRestartStateFile, createRestartStateFilePath, isProcessAlive, PROCESS_RESTART_EXIT_CODE, readDaemonPidFile, requestProcessRestart, writeDaemonPidFile, } from '../core/process-control.js';
25
26
  import { runSetupWizard } from './setup-wizard.js';
@@ -185,6 +186,9 @@ function parseArgs(argv) {
185
186
  case '--no-dashboard':
186
187
  args.noDashboard = true;
187
188
  break;
189
+ case '--server':
190
+ args.server = true;
191
+ break;
188
192
  case '--dashboard-port':
189
193
  args.dashboardPort = parseInt(it.next().value ?? '', 10);
190
194
  break;
@@ -254,6 +258,7 @@ Options:
254
258
  --timeout <seconds> Max seconds per agent request [default: ${DEFAULT_RUN_TIMEOUT_S}]
255
259
  --doctor Run setup checks and exit
256
260
  --setup Run the interactive setup wizard
261
+ --server Headless server: keep the host running, don't open a browser, print a connection code
257
262
  --no-daemon Disable watchdog (auto-restart on crash is ON by default)
258
263
  --no-dashboard Skip the web dashboard
259
264
  --dashboard-port <port> Dashboard port [default: 3939]
@@ -521,13 +526,16 @@ async function runSetupPhase(args, userConfig, configOverrides, channels, channe
521
526
  // Suppress the browser pop on auto-start when there's no user-facing
522
527
  // terminal: launchd-spawned bots, Docker/headless server runs, or when
523
528
  // the user explicitly set PIKILOOM_OPEN_BROWSER=0.
524
- const openBrowser = !process.env[FROM_LAUNCHD_ENV]
529
+ const openBrowser = !args.server
530
+ && !process.env[FROM_LAUNCHD_ENV]
525
531
  && !envBool('PIKILOOM_DOCKER', false)
526
532
  && envBool('PIKILOOM_OPEN_BROWSER', true);
527
533
  dashboard = await startDashboard({
528
534
  port: args.dashboardPort || 3939,
529
535
  open: openBrowser,
530
536
  });
537
+ if (args.server)
538
+ printServerConnectionCode(dashboard);
531
539
  if (needsSetup) {
532
540
  const ctx = { userConfig, configOverrides, args };
533
541
  const resolved = await awaitDashboardConfig(dashboard, ctx);
@@ -560,6 +568,28 @@ async function runSetupPhase(args, userConfig, configOverrides, channels, channe
560
568
  }
561
569
  return { dashboard, userConfig, channels, channel };
562
570
  }
571
+ /** Print the shareable connection code for `--server` (headless) mode. */
572
+ function printServerConnectionCode(dashboard) {
573
+ const c = loadUserConfig();
574
+ const sc = buildServerCode({
575
+ token: process.env.PIKICHANNEL_TOKEN || c.pikichannelToken,
576
+ nodeId: c.pikichannelNodeId,
577
+ publicHost: process.env.PIKICHANNEL_PUBLIC_HOST || c.pikichannelPublicHost,
578
+ rendezvous: process.env.PIKICHANNEL_RENDEZVOUS || c.pikichannelRendezvous,
579
+ });
580
+ const ts = new Date().toTimeString().slice(0, 8);
581
+ process.stdout.write(`\n[pikiloom ${ts}] server mode — host running, browser not opened\n`);
582
+ process.stdout.write(` local console: ${dashboard.url}\n`);
583
+ if (sc.mode === 'none') {
584
+ process.stdout.write(' to let others connect: set a public address (env PIKICHANNEL_PUBLIC_HOST,\n');
585
+ process.stdout.write(' or the dashboard 连接 → 分享 panel), or enable internet access, then restart.\n\n');
586
+ }
587
+ else {
588
+ process.stdout.write(` connection code (${sc.mode === 'direct' ? 'direct → ' : 'NAT via '}${sc.detail}):\n`);
589
+ process.stdout.write(` ${sc.code}\n`);
590
+ process.stdout.write(' paste it into a client → 连接 → 互联网/局域网.\n\n');
591
+ }
592
+ }
563
593
  /* ── Phase: post-setup validation ─────────────────────────────────── */
564
594
  /**
565
595
  * Re-resolve channels after the setup phase and validate we have a runnable
@@ -10,6 +10,14 @@
10
10
  */
11
11
  /** Stable service name used when writing to the OS keychain. */
12
12
  export const KEYCHAIN_SERVICE = 'pikiloom';
13
+ /**
14
+ * Legacy keychain service names this build still reads from. The project was
15
+ * renamed (pikiclaw → pikiloom); credentials written under the old service are
16
+ * orphaned because lookups now use `KEYCHAIN_SERVICE`. `readKeychain` falls
17
+ * back to these on a miss and lazily re-writes the secret under the current
18
+ * service, so each orphaned item self-heals on first use.
19
+ */
20
+ export const LEGACY_KEYCHAIN_SERVICES = ['pikiclaw'];
13
21
  export function isCredentialRef(value) {
14
22
  if (!value || typeof value !== 'object')
15
23
  return false;
@@ -2,7 +2,7 @@
2
2
  * OS keychain access — lazy import of @napi-rs/keyring (optional dep).
3
3
  * If keychain is unavailable, callers fall back to inline-seal.
4
4
  */
5
- import { KEYCHAIN_SERVICE } from './ref.js';
5
+ import { KEYCHAIN_SERVICE, LEGACY_KEYCHAIN_SERVICES } from './ref.js';
6
6
  let keyringModule; // undefined = not tried, null = failed
7
7
  async function loadKeyring() {
8
8
  if (keyringModule !== undefined)
@@ -30,17 +30,37 @@ export async function readKeychain(account) {
30
30
  const mod = await loadKeyring();
31
31
  if (!mod)
32
32
  throw new Error('OS keychain unavailable (install @napi-rs/keyring)');
33
- try {
34
- const entry = new mod.Entry(KEYCHAIN_SERVICE, account);
35
- const value = entry.getPassword();
36
- return typeof value === 'string' && value.length > 0 ? value : null;
37
- }
38
- catch (e) {
39
- // keyring-rs returns NoEntry as an error — treat as missing
40
- if (/NoEntry|no.such|not.found/i.test(e?.message || ''))
41
- return null;
42
- throw e;
33
+ const readUnder = (service) => {
34
+ try {
35
+ const entry = new mod.Entry(service, account);
36
+ const value = entry.getPassword();
37
+ return typeof value === 'string' && value.length > 0 ? value : null;
38
+ }
39
+ catch (e) {
40
+ // keyring-rs returns NoEntry as an error — treat as missing
41
+ if (/NoEntry|no.such|not.found/i.test(e?.message || ''))
42
+ return null;
43
+ throw e;
44
+ }
45
+ };
46
+ const current = readUnder(KEYCHAIN_SERVICE);
47
+ if (current != null)
48
+ return current;
49
+ // Pre-rename fallback: recover orphaned items and migrate them forward so the
50
+ // legacy lookup only ever happens once per account.
51
+ for (const legacy of LEGACY_KEYCHAIN_SERVICES) {
52
+ const value = readUnder(legacy);
53
+ if (value == null)
54
+ continue;
55
+ try {
56
+ new mod.Entry(KEYCHAIN_SERVICE, account).setPassword(value);
57
+ }
58
+ catch {
59
+ // best-effort migration — return the recovered secret regardless
60
+ }
61
+ return value;
43
62
  }
63
+ return null;
44
64
  }
45
65
  export async function writeKeychain(account, value) {
46
66
  const mod = await loadKeyring();
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { Hono } from 'hono';
15
15
  import { execFile } from 'node:child_process';
16
- import { addGlobalMcpExtension, removeGlobalMcpExtension, updateGlobalMcpExtension, addWorkspaceMcpExtension, removeWorkspaceMcpExtension, updateWorkspaceMcpExtension, getCatalogItems, buildInstalledConfigFromRecommended, checkMcpHealth, getCachedHealth, cacheHealth, getRecommendedMcpServer, listSkills, installSkill, removeSkill, getRecommendedSkillRepos, searchSkillRepos, searchMcpServers, startAuthorization, completeAuthorization, deleteMcpToken, getMcpToken, } from '../../agent/index.js';
16
+ import { addGlobalMcpExtension, removeGlobalMcpExtension, updateGlobalMcpExtension, addWorkspaceMcpExtension, removeWorkspaceMcpExtension, updateWorkspaceMcpExtension, getCatalogItems, buildInstalledConfigFromRecommended, checkMcpHealth, getCachedHealth, cacheHealth, getRecommendedMcpServer, listSkills, installSkill, removeSkill, recordSkillInstall, getSkillLedgerEntry, getRecommendedSkillRepos, searchSkillRepos, searchMcpServers, startAuthorization, completeAuthorization, deleteMcpToken, getMcpToken, } from '../../agent/index.js';
17
17
  import { loadUserConfig, saveUserConfig } from '../../core/config/user-config.js';
18
18
  import { ensurePeekabooWarm } from '../../agent/mcp/bridge.js';
19
19
  import { runtime } from '../runtime.js';
@@ -582,6 +582,59 @@ async function fetchOneRepoMeta(source) {
582
582
  return null;
583
583
  }
584
584
  }
585
+ /**
586
+ * Default-branch HEAD commit SHA per source — the signal we diff installed
587
+ * skills against. Cached with a much shorter TTL than the star/pushedAt meta
588
+ * (10 min vs 24h) so "update available" stays close to real-time without
589
+ * hammering the API; we only fetch it for collections that are actually
590
+ * installed. Cache only on success so a transient null never sticks.
591
+ */
592
+ const repoHeadShaCache = new Map();
593
+ const REPO_HEAD_SHA_TTL_MS = 10 * 60 * 1000;
594
+ const repoHeadShaInflight = new Map();
595
+ async function fetchRepoHeadSha(source) {
596
+ const parsed = parseSourceToOwnerRepo(source);
597
+ if (!parsed)
598
+ return null;
599
+ const slug = `${parsed.owner}/${parsed.repo}`;
600
+ const cached = repoHeadShaCache.get(slug);
601
+ if (cached && Date.now() - cached.cachedAt < REPO_HEAD_SHA_TTL_MS)
602
+ return cached.value;
603
+ const inflight = repoHeadShaInflight.get(slug);
604
+ if (inflight)
605
+ return inflight;
606
+ const promise = (async () => {
607
+ try {
608
+ const controller = new AbortController();
609
+ const timer = setTimeout(() => controller.abort(), 5_000);
610
+ const token = await resolveGithubToken();
611
+ // `commits?per_page=1` returns the latest commit on the default branch
612
+ // without needing to know the branch name first.
613
+ const res = await fetch(`https://api.github.com/repos/${slug}/commits?per_page=1`, {
614
+ signal: controller.signal,
615
+ headers: {
616
+ 'Accept': 'application/vnd.github+json',
617
+ 'User-Agent': 'pikiloom-dashboard',
618
+ 'X-GitHub-Api-Version': '2022-11-28',
619
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
620
+ },
621
+ });
622
+ clearTimeout(timer);
623
+ if (!res.ok)
624
+ return null;
625
+ const data = await res.json();
626
+ const sha = Array.isArray(data) && data[0]?.sha ? String(data[0].sha) : null;
627
+ if (sha)
628
+ repoHeadShaCache.set(slug, { value: sha, cachedAt: Date.now() });
629
+ return sha;
630
+ }
631
+ catch {
632
+ return null;
633
+ }
634
+ })().finally(() => repoHeadShaInflight.delete(slug));
635
+ repoHeadShaInflight.set(slug, promise);
636
+ return promise;
637
+ }
585
638
  async function ensureRepoMeta(sources) {
586
639
  const now = Date.now();
587
640
  const stale = sources.filter(s => {
@@ -656,30 +709,56 @@ app.get('/api/extensions/skills/catalog', async (c) => {
656
709
  const installedByName = new Map();
657
710
  for (const s of scopedInstalled)
658
711
  installedByName.set(s.name.toLowerCase(), s);
659
- const items = filtered.map(repo => {
660
- const meta = githubMetaCache.get(repo.source)?.value;
712
+ // Resolve each repo's installed sub-skills first (sync). Prefer the remote
713
+ // listing intersection over the static `repo.skills` hint — most catalog
714
+ // entries don't fill the hint, and the GitHub listing is authoritative.
715
+ const computeInstalledNames = (repo) => {
661
716
  const remote = remoteSkillsCache.get(repo.source)?.value;
662
- const owner = extractGithubOwner(repo.source);
663
- const iconUrl = repo.iconUrl
664
- ?? (owner ? `https://github.com/${owner}.png?size=80` : undefined);
665
- // Prefer the remote-listing intersection over the static `repo.skills` hint
666
- // — most catalog entries don't bother filling the hint, and the GitHub
667
- // listing is authoritative when cached.
668
- let installedSkillNames;
669
717
  if (remote) {
670
- installedSkillNames = remote.skills
718
+ return remote.skills
671
719
  .map(s => s.name)
672
720
  .filter(name => installedByName.has(name.toLowerCase()));
673
721
  }
674
- else {
675
- const hints = (repo.skills || []).map(s => s.toLowerCase());
676
- installedSkillNames = scopedInstalled
677
- .filter(s => hints.includes(s.name.toLowerCase()))
678
- .map(s => s.name);
722
+ const hints = (repo.skills || []).map(s => s.toLowerCase());
723
+ return scopedInstalled
724
+ .filter(s => hints.includes(s.name.toLowerCase()))
725
+ .map(s => s.name);
726
+ };
727
+ const perRepo = filtered.map(repo => ({ repo, installedNames: computeInstalledNames(repo) }));
728
+ // Update detection — only for installed collections (it's the only place the
729
+ // signal is meaningful, and it keeps API calls proportional to what's
730
+ // actually installed). Diff the live remote HEAD against the provenance
731
+ // ledger. For installs predating the ledger (e.g. via skills.sh) we baseline
732
+ // the current HEAD so future commits surface as updates, rather than
733
+ // fabricating one we can't prove.
734
+ const ledgerScope = scope === 'workspace' ? { workdir } : { global: true };
735
+ const updateBySource = new Map();
736
+ await Promise.all(perRepo.map(async ({ repo, installedNames }) => {
737
+ if (installedNames.length === 0)
738
+ return;
739
+ const latestSha = await fetchRepoHeadSha(repo.source).catch(() => null);
740
+ const entry = getSkillLedgerEntry(repo.source, ledgerScope);
741
+ let installedSha = entry?.sha ?? null;
742
+ if (!entry && latestSha) {
743
+ recordSkillInstall(repo.source, { ...ledgerScope, sha: latestSha, names: installedNames });
744
+ installedSha = latestSha;
679
745
  }
680
- const firstMatch = installedSkillNames[0]
681
- ? installedByName.get(installedSkillNames[0].toLowerCase())
746
+ updateBySource.set(repo.source, {
747
+ installedSha,
748
+ latestSha,
749
+ updateAvailable: !!(installedSha && latestSha && installedSha !== latestSha),
750
+ });
751
+ }));
752
+ const items = perRepo.map(({ repo, installedNames }) => {
753
+ const meta = githubMetaCache.get(repo.source)?.value;
754
+ const remote = remoteSkillsCache.get(repo.source)?.value;
755
+ const owner = extractGithubOwner(repo.source);
756
+ const iconUrl = repo.iconUrl
757
+ ?? (owner ? `https://github.com/${owner}.png?size=80` : undefined);
758
+ const firstMatch = installedNames[0]
759
+ ? installedByName.get(installedNames[0].toLowerCase())
682
760
  : undefined;
761
+ const upd = updateBySource.get(repo.source);
683
762
  return {
684
763
  id: repo.id,
685
764
  name: repo.name,
@@ -689,19 +768,27 @@ app.get('/api/extensions/skills/catalog', async (c) => {
689
768
  category: repo.category,
690
769
  recommendedScope: repo.recommendedScope,
691
770
  homepage: repo.homepage,
692
- installed: installedSkillNames.length > 0,
771
+ installed: installedNames.length > 0,
693
772
  scope: firstMatch?.scope,
694
- installedNames: installedSkillNames,
773
+ installedNames,
695
774
  stars: meta?.stars,
696
775
  pushedAt: meta?.pushedAt,
697
776
  iconUrl,
698
777
  totalCount: remote?.skills.length,
699
778
  partial: remote?.partial,
779
+ pinned: repo.pinned,
780
+ updateAvailable: upd?.updateAvailable,
781
+ installedSha: upd?.installedSha,
782
+ latestSha: upd?.latestSha,
700
783
  };
701
784
  });
702
- // Authority = community popularity. Sort by stars desc, with no-data entries
703
- // sinking to the bottom so the most-loved repos surface first.
704
- items.sort((a, b) => (b.stars ?? -1) - (a.stars ?? -1));
785
+ // Pinned first-party packs lead; then community popularity (stars desc, with
786
+ // no-data entries sinking to the bottom so the most-loved repos surface first).
787
+ items.sort((a, b) => {
788
+ if (!!a.pinned !== !!b.pinned)
789
+ return a.pinned ? -1 : 1;
790
+ return (b.stars ?? -1) - (a.stars ?? -1);
791
+ });
705
792
  return c.json({ ok: true, items, installed });
706
793
  });
707
794
  /** POST /api/extensions/skills/install — install a skill via npx skills add. */
@@ -715,13 +802,39 @@ app.post('/api/extensions/skills/install', async (c) => {
715
802
  if (!isGlobal && !isValidWorkdir(workdir)) {
716
803
  return c.json({ ok: false, error: 'valid workdir is required for project-scoped install' }, 400);
717
804
  }
718
- const result = await installSkill(source.trim(), { global: isGlobal, skill, workdir });
805
+ // Resolve the remote HEAD so installSkill can record provenance for update
806
+ // detection. Best-effort — a failed lookup just installs without a baseline.
807
+ const sourceSha = await fetchRepoHeadSha(source.trim()).catch(() => null);
808
+ const result = await installSkill(source.trim(), { global: isGlobal, skill, workdir, sourceSha });
719
809
  return c.json(result);
720
810
  }
721
811
  catch (e) {
722
812
  return c.json({ ok: false, error: e?.message || 'installation failed' }, 500);
723
813
  }
724
814
  });
815
+ /**
816
+ * POST /api/extensions/skills/update — pull the latest version of an installed
817
+ * collection. Re-runs `skills add <source>` (no `-s`, so every skill from the
818
+ * source is refreshed) and advances the provenance ledger to the new HEAD.
819
+ */
820
+ app.post('/api/extensions/skills/update', async (c) => {
821
+ try {
822
+ const body = await c.req.json();
823
+ const { source, global: isGlobal, workdir: reqWorkdir } = body;
824
+ if (!source?.trim())
825
+ return c.json({ ok: false, error: 'source is required' }, 400);
826
+ const workdir = reqWorkdir || runtime.getRequestWorkdir();
827
+ if (!isGlobal && !isValidWorkdir(workdir)) {
828
+ return c.json({ ok: false, error: 'valid workdir is required for project-scoped update' }, 400);
829
+ }
830
+ const sourceSha = await fetchRepoHeadSha(source.trim()).catch(() => null);
831
+ const result = await installSkill(source.trim(), { global: isGlobal, workdir, sourceSha });
832
+ return c.json({ ...result, sha: sourceSha });
833
+ }
834
+ catch (e) {
835
+ return c.json({ ok: false, error: e?.message || 'update failed' }, 500);
836
+ }
837
+ });
725
838
  /** POST /api/extensions/skills/remove — remove an installed skill. */
726
839
  app.post('/api/extensions/skills/remove', async (c) => {
727
840
  try {