@toddzheng024/dscode-bundle 0.7.13 → 0.7.15

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.
@@ -0,0 +1,109 @@
1
+ // The approval question set and its decision policy. Jev returns calibrated
2
+ // probabilities, per-option probabilities and a score, so the mapping below uses
3
+ // signals an LLM reviewer cannot give us:
4
+ //
5
+ // * allowing is the dangerous direction and needs a confident allow plus a low
6
+ // risk profile;
7
+ // * denying is only ever a request to stop, so a decisive deny is honoured —
8
+ // but a silent rejection must be well corroborated, which is why a deny needs
9
+ // either a decisive confidence *and* probability, or a slightly lower
10
+ // confidence plus a risk score that already crossed its ceiling;
11
+ // * a retained instruction that Jev reads as authorizing the exact action vetoes
12
+ // an automatic deny, because the cost of a wrong deny is work the user wanted
13
+ // and never got a prompt for.
14
+ export const DEFAULT_THRESHOLDS = Object.freeze({
15
+ autoAllow: 0.85,
16
+ autoDeny: 0.95,
17
+ autoDenyProbability: 0.9,
18
+ autoDenyCorroborated: 0.9,
19
+ authorizedVeto: 0.6,
20
+ credentialRisk: 0.5,
21
+ destructiveCeiling: 2,
22
+ });
23
+
24
+ export const STATE_LIMIT = 8000;
25
+
26
+ export const DESTRUCTIVE_LEVELS = Object.freeze([
27
+ 'No effect beyond reading state',
28
+ 'A local write that is easy to undo',
29
+ 'A local write that is hard to undo, such as deleting or rewriting tracked work',
30
+ 'Touches shared, remote or published state',
31
+ ]);
32
+
33
+ export function approvalQuestions() {
34
+ return {
35
+ verdict: {
36
+ type: 'choice',
37
+ instructions: 'Should this tool call run now without asking the user first? Prefer ask when the action plausibly matches the instruction but you cannot tell; reserve deny for actions the instruction does not cover or that are unsafe.',
38
+ criteria: {
39
+ allow: 'Read-only, or a change the retained user instruction already asked for.',
40
+ ask: 'Plausibly within the task but not clearly authorized, or the blast radius is unclear.',
41
+ deny: 'Outside the user instruction, or it would disclose data, destroy unrelated work, or publish something.',
42
+ },
43
+ },
44
+ authorized: {
45
+ type: 'noul',
46
+ instructions: 'Does the retained user instruction explicitly authorize this exact action, including its target and effect?',
47
+ },
48
+ destructive: {
49
+ type: 'score',
50
+ instructions: 'If this action does the wrong thing, how hard is the damage to undo?',
51
+ criteria: [...DESTRUCTIVE_LEVELS],
52
+ },
53
+ credential_risk: {
54
+ type: 'noul',
55
+ instructions: 'Does this action read, print, transmit or embed credentials, tokens or other secrets?',
56
+ },
57
+ };
58
+ }
59
+
60
+ const clip = (value, limit) => {
61
+ const text = typeof value === 'string' ? value : JSON.stringify(value);
62
+ if (text === undefined) return '';
63
+ return text.length > limit ? `${text.slice(0, limit)}…[truncated]` : text;
64
+ };
65
+
66
+ // `state` carries what the decision needs and nothing else: the exact pending
67
+ // call, and the retained direct user instruction. It is posted to OpenRouter, so
68
+ // it is bounded and never carries credentials.
69
+ export function approvalState({ action, context } = {}) {
70
+ const instructions = (context?.userMessages ?? [])
71
+ .map(message => (message?.content ?? []).map(block => block?.text ?? '').join(' ').trim())
72
+ .filter(Boolean)
73
+ .join('\n---\n');
74
+ return {
75
+ pendingToolCall: clip(action, STATE_LIMIT),
76
+ userInstructions: clip(instructions, STATE_LIMIT),
77
+ };
78
+ }
79
+
80
+ export function approvalVerdict(answers, thresholds = DEFAULT_THRESHOLDS) {
81
+ const verdict = answers?.verdict;
82
+ if (!verdict || verdict.type !== 'choice') return undefined;
83
+ const confidence = Number(verdict.confidence ?? 0);
84
+ const denyProbability = Number(verdict.probabilities?.deny ?? 0);
85
+ const destructive = Number(answers?.destructive?.score ?? 0);
86
+ const credentialRisk = Number(answers?.credential_risk?.noul ?? 0);
87
+ const authorized = Number(answers?.authorized?.noul ?? 0);
88
+ const detail = { confidence, denyProbability, authorized, destructive, credentialRisk, choice: verdict.choice };
89
+ const highRisk = credentialRisk >= thresholds.credentialRisk || destructive >= thresholds.destructiveCeiling;
90
+
91
+ // A confident deny is honoured unless the instruction looks like it authorized
92
+ // the action: a wrong deny blocks work the user asked for, silently.
93
+ const decisiveDeny = confidence >= thresholds.autoDeny && denyProbability >= thresholds.autoDenyProbability;
94
+ const corroboratedDeny = confidence >= thresholds.autoDenyCorroborated && highRisk;
95
+ if (verdict.choice === 'deny' && (decisiveDeny || corroboratedDeny)) {
96
+ if (authorized >= thresholds.authorizedVeto) {
97
+ return { decision: 'human', reason: 'Automatic review wanted to reject this, but the instruction appears to authorize it.', ...detail };
98
+ }
99
+ return { decision: 'deny', reason: 'Automatic review rejected the action as outside the task or unsafe.', ...detail };
100
+ }
101
+
102
+ // Everything below guards the allow direction: high risk or a non-allowing
103
+ // answer never runs without the human.
104
+ if (credentialRisk >= thresholds.credentialRisk) return { decision: 'human', reason: 'Automatic review found possible credential handling.', ...detail };
105
+ if (destructive >= thresholds.destructiveCeiling) return { decision: 'human', reason: 'Automatic review judged this action hard to undo.', ...detail };
106
+ if (verdict.choice !== 'allow') return { decision: 'human', reason: 'Automatic review asked for a human decision.', ...detail };
107
+ if (confidence < thresholds.autoAllow) return { decision: 'human', reason: `Automatic review was not confident enough (${confidence.toFixed(2)}).`, ...detail };
108
+ return { decision: 'allow', reason: 'Automatic review approved the action.', ...detail };
109
+ }
@@ -0,0 +1,74 @@
1
+ // OpenRouter's alpha Decisions endpoint: one POST carries the state plus every
2
+ // typed question, and the model answers them in a single pass. This is not the
3
+ // chat-completions shape our llm provider uses, so it is a separate client.
4
+ export const DEFAULT_ENDPOINT = 'https://openrouter.ai';
5
+ export const DECISIONS_PATH = '/api/alpha/decisions';
6
+ export const DEFAULT_MODEL = '~typesafe/jev-latest';
7
+ export const QUESTION_TYPES = Object.freeze(['noul', 'choice', 'score']);
8
+ export const MAX_CHOICE_OPTIONS = 255;
9
+ export const SCORE_LEVELS = Object.freeze({ min: 2, max: 10 });
10
+
11
+ export function decisionsUrl(endpoint = DEFAULT_ENDPOINT) {
12
+ const base = String(endpoint).replace(/\/+$/, '');
13
+ if (!URL.canParse(base)) throw new Error(`jev: endpoint must be a URL, got ${endpoint}`);
14
+ return base + DECISIONS_PATH;
15
+ }
16
+
17
+ // The request shape is validated here rather than trusted to the caller: a
18
+ // malformed question set is rejected locally instead of burning a round trip.
19
+ export function validateQuestions(questions) {
20
+ if (!questions || typeof questions !== 'object' || Array.isArray(questions)) throw new Error('jev: questions must be an object');
21
+ for (const [key, question] of Object.entries(questions)) {
22
+ if (!question || typeof question !== 'object') throw new Error(`jev: ${key} must be a question object`);
23
+ if (!QUESTION_TYPES.includes(question.type)) throw new Error(`jev: ${key} has unsupported type ${question.type}`);
24
+ if (typeof question.instructions !== 'string' || !question.instructions.trim()) throw new Error(`jev: ${key} needs instructions`);
25
+ if (question.type === 'noul') continue;
26
+ if (question.type === 'choice') {
27
+ const criteria = question.criteria;
28
+ if (!criteria || typeof criteria !== 'object' || Array.isArray(criteria)) throw new Error(`jev: ${key} choice needs a criteria map`);
29
+ const count = Object.keys(criteria).length;
30
+ if (count === 0) throw new Error(`jev: ${key} choice needs at least one option`);
31
+ if (count > MAX_CHOICE_OPTIONS) throw new Error(`jev: ${key} choice has ${count} options, the limit is ${MAX_CHOICE_OPTIONS}`);
32
+ continue;
33
+ }
34
+ const criteria = question.criteria;
35
+ if (!Array.isArray(criteria)) throw new Error(`jev: ${key} score needs an ordered criteria array`);
36
+ if (criteria.length < SCORE_LEVELS.min || criteria.length > SCORE_LEVELS.max) throw new Error(`jev: ${key} score needs ${SCORE_LEVELS.min}-${SCORE_LEVELS.max} ordered levels`);
37
+ }
38
+ return questions;
39
+ }
40
+
41
+ export function buildBody({ model = DEFAULT_MODEL, state, questions, sessionId }) {
42
+ validateQuestions(questions);
43
+ if (state === undefined || state === null) throw new Error('jev: state is required');
44
+ return { model, state, questions, ...(sessionId ? { session_id: String(sessionId).slice(0, 256) } : {}) };
45
+ }
46
+
47
+ export function readAnswers(body) {
48
+ if (!body || typeof body !== 'object') throw new Error('jev: response is not an object');
49
+ if (!body.answers || typeof body.answers !== 'object' || Array.isArray(body.answers)) throw new Error('jev: response is missing answers');
50
+ return { id: body.id, model: body.model, provider: body.provider, answers: body.answers, usage: body.usage ?? null };
51
+ }
52
+
53
+ export async function requestDecisions({
54
+ endpoint = DEFAULT_ENDPOINT, model = DEFAULT_MODEL, apiKey, state, questions, sessionId,
55
+ timeoutMs = 8000, signal, fetchImpl = globalThis.fetch,
56
+ } = {}) {
57
+ if (!apiKey) throw new Error('jev: no API key resolved');
58
+ if (typeof fetchImpl !== 'function') throw new Error('jev: no fetch implementation available');
59
+ const controller = new AbortController();
60
+ const timer = setTimeout(() => controller.abort(new Error('jev: request timed out')), timeoutMs);
61
+ const composed = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
62
+ try {
63
+ const response = await fetchImpl(decisionsUrl(endpoint), {
64
+ method: 'POST',
65
+ headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
66
+ body: JSON.stringify(buildBody({ model, state, questions, sessionId })),
67
+ signal: composed,
68
+ });
69
+ if (!response.ok) throw new Error(`jev: HTTP ${response.status}`);
70
+ return readAnswers(await response.json());
71
+ } finally {
72
+ clearTimeout(timer);
73
+ }
74
+ }
@@ -0,0 +1,77 @@
1
+ import z from '@deepseek-ai/schemastery';
2
+ import { credentialRef } from '@deepseek-ai/dsh-credentials';
3
+ import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment';
4
+ import { DEFAULT_ENDPOINT, DEFAULT_MODEL, requestDecisions } from './client.mjs';
5
+ import { DEFAULT_THRESHOLDS, approvalQuestions, approvalState, approvalVerdict } from './approval.mjs';
6
+
7
+ // Jev is a decisions model, not a chat model: it answers typed questions about
8
+ // supplied state and never generates prose. The service below exposes exactly one
9
+ // consumer so far — the automatic permission review — and stays inert unless the
10
+ // deployment has an OpenRouter key, so mounting it changes nothing by itself.
11
+ export const name = 'dscode-jev';
12
+
13
+ export const Config = z.object({
14
+ enabled: z.boolean().default(true),
15
+ endpoint: z.string().default(DEFAULT_ENDPOINT),
16
+ model: z.string().default(DEFAULT_MODEL),
17
+ apiKeyEnv: z.string().role('credential-ref').default('OPENROUTER_API_KEY'),
18
+ timeoutMs: z.number().step(1).min(1).default(8000),
19
+ autoAllow: z.number().min(0).max(1).default(DEFAULT_THRESHOLDS.autoAllow),
20
+ autoDeny: z.number().min(0).max(1).default(DEFAULT_THRESHOLDS.autoDeny),
21
+ autoDenyProbability: z.number().min(0).max(1).default(DEFAULT_THRESHOLDS.autoDenyProbability),
22
+ autoDenyCorroborated: z.number().min(0).max(1).default(DEFAULT_THRESHOLDS.autoDenyCorroborated),
23
+ authorizedVeto: z.number().min(0).max(1).default(DEFAULT_THRESHOLDS.authorizedVeto),
24
+ credentialRisk: z.number().min(0).max(1).default(DEFAULT_THRESHOLDS.credentialRisk),
25
+ destructiveCeiling: z.number().min(0).default(DEFAULT_THRESHOLDS.destructiveCeiling),
26
+ });
27
+
28
+ export function resolveOptions(config = {}) {
29
+ if (config.endpoint !== undefined && !URL.canParse(String(config.endpoint))) throw new Error(`${name}: endpoint must be a URL`);
30
+ return {
31
+ enabled: config.enabled !== false,
32
+ endpoint: config.endpoint || DEFAULT_ENDPOINT,
33
+ model: config.model || DEFAULT_MODEL,
34
+ apiKeyEnv: credentialRef(config.apiKeyEnv || 'OPENROUTER_API_KEY'),
35
+ timeoutMs: config.timeoutMs ?? 8000,
36
+ thresholds: Object.fromEntries(Object.keys(DEFAULT_THRESHOLDS).map(key => [key, config[key] ?? DEFAULT_THRESHOLDS[key]])),
37
+ };
38
+ }
39
+
40
+ export function apply(ctx, config = {}) {
41
+ const options = resolveOptions(config);
42
+ const resolveKey = async () => {
43
+ const credentials = ctx.get('credentials');
44
+ if (credentials !== undefined) return (await credentials.resolve(options.apiKeyEnv))?.value || undefined;
45
+ return launchEnvironmentOf(ctx).get(options.apiKeyEnv)?.value || undefined;
46
+ };
47
+ const service = {
48
+ name,
49
+ model: options.model,
50
+ enabled: () => options.enabled,
51
+ configured: async () => options.enabled && (await resolveKey()) !== undefined,
52
+ // Returns a verdict, or undefined when Jev is unavailable, unconfigured, or
53
+ // failed. The caller keeps its own reviewer for that case, so a Jev outage
54
+ // degrades to the previous behaviour instead of allowing anything.
55
+ approval: async ({ action, context, sessionId, signal } = {}) => {
56
+ if (!options.enabled) return undefined;
57
+ const apiKey = await resolveKey();
58
+ if (!apiKey) return undefined;
59
+ const started = Date.now();
60
+ try {
61
+ const response = await requestDecisions({
62
+ endpoint: options.endpoint, model: options.model, apiKey,
63
+ state: approvalState({ action, context }), questions: approvalQuestions(),
64
+ sessionId, timeoutMs: options.timeoutMs, signal,
65
+ });
66
+ const verdict = approvalVerdict(response.answers, options.thresholds);
67
+ if (verdict === undefined) return undefined;
68
+ return { ...verdict, source: 'jev', model: response.model ?? options.model, usage: response.usage, durationMs: Date.now() - started };
69
+ } catch (error) {
70
+ ctx.logger.warn(`jev: ${error.message}`);
71
+ return undefined;
72
+ }
73
+ },
74
+ };
75
+ ctx.provide('jev', service);
76
+ return service;
77
+ }
@@ -7,6 +7,8 @@ export const PROVIDERS = Object.freeze([
7
7
  { id: 'deepseek-official', name: 'DeepSeek', aliases: ['deepseek', 'deepseek-official', 'official'], credentialRef: 'DEEPSEEK_API_KEY', defaultModel: 'deepseek-flash' },
8
8
  // The optional management key reads account data only; it cannot call models.
9
9
  { id: 'openrouter', name: 'OpenRouter', aliases: ['openrouter', 'open-router'], credentialRef: 'OPENROUTER_API_KEY', managementRef: 'OPENROUTER_MANAGEMENT_KEY', defaultModel: 'deepseek/deepseek-v4-flash' },
10
+ // The Grok subscription rail: the token is the local 'grok login', read-only (plugins/grok).
11
+ { id: 'grok', name: 'Grok', aliases: ['grok', 'xai', 'x-ai'], credentialRef: 'GROK_CLI_TOKEN', defaultModel: 'grok-4.6' },
10
12
  ]);
11
13
 
12
14
  // The pi-ai adapter served OpenRouter until 0.7.6, from this settings section.
@@ -2,6 +2,7 @@ import { readMetrics } from './store.mjs';
2
2
  import { t } from '../i18n/messages.mjs';
3
3
  import { estimateCost, peakEmoji } from './pricing.mjs';
4
4
  import { balanceNow, trustedNow } from './balance.mjs';
5
+ import { grokSubscriptionNow } from '../grok/billing.mjs';
5
6
  import { sessionAverageTps } from './rate.mjs';
6
7
  import { providerOfHeader } from '../providers/catalog.mjs';
7
8
  let source;
@@ -58,6 +59,28 @@ export function displayWidth(text) {
58
59
  for (const char of text) width += ZERO_WIDTH.test(char) ? 0 : WIDE.test(char) ? 2 : 1;
59
60
  return width;
60
61
  }
62
+ /**
63
+ * The money slot for the Grok subscription rail: a plan has credits and a reset time, not a
64
+ * bill. The percentage is optional (the server omits it for a period without usage), and an
65
+ * unread window shows the tier alone instead of a wrong number.
66
+ */
67
+ export function grokFooterFact(subscription, locale = 'en', now = Date.now()) {
68
+ const parts = [subscription?.tier ?? 'Grok'];
69
+ const used = subscription?.usedPercent;
70
+ if (Number.isFinite(used)) parts.push((Number.isInteger(used) ? String(used) : used.toFixed(1)) + '% ' + t(locale, 'footer.grokUsed'));
71
+ const reset = resetStamp(subscription?.periodEnd, now);
72
+ if (reset !== undefined) parts.push(t(locale, 'footer.grokResets') + ' ' + reset);
73
+ return parts.join(' · ');
74
+ }
75
+
76
+ /** Local `MM-DD HH:MM` for a reset time, or undefined when the window is unknown or past. */
77
+ function resetStamp(iso, now) {
78
+ const at = Date.parse(typeof iso === 'string' ? iso : '');
79
+ if (!Number.isFinite(at) || at <= now) return undefined;
80
+ const pad = value => String(value).padStart(2, '0');
81
+ const date = new Date(at);
82
+ return pad(date.getMonth() + 1) + '-' + pad(date.getDate()) + ' ' + pad(date.getHours()) + ':' + pad(date.getMinutes());
83
+ }
61
84
  export function formatFooter(metrics, context, columns = 80, rates, locale = 'en', header = '', provider = providerOfHeader(header) ?? 'deepseek-official') {
62
85
  const label = key => t(locale, key);
63
86
  const ctx = Number.isFinite(context) ? `${Math.round(context)}%` : '--';
@@ -65,7 +88,7 @@ export function formatFooter(metrics, context, columns = 80, rates, locale = 'en
65
88
  // The balance belongs to the provider the header names; only DeepSeek's official route bills by a peak window.
66
89
  const balance = balanceNow(provider);
67
90
  const spend = metrics.unknown && metrics.cost === 0 ? '--' : `$${metrics.cost.toFixed(2)}${metrics.unknown ? '+' : ''}${metrics.pending ? '…' : ''}`;
68
- const dollars = `${spend} / ${balance === null ? '$--' : '$' + balance.toFixed(2)}${provider === 'deepseek-official' ? ' ' + peakEmoji(trustedNow()) : ''}`;
91
+ const dollars = provider === 'grok' ? grokFooterFact(grokSubscriptionNow(), locale) : `${spend} / ${balance === null ? '$--' : '$' + balance.toFixed(2)}${provider === 'deepseek-official' ? ' ' + peakEmoji(trustedNow()) : ''}`;
69
92
  const base = rates ? [
70
93
  `${label('footer.current')}: ${Number.isFinite(rates.current) ? '~' + rates.current.toFixed(1) : '--'} tps`,
71
94
  `${label('footer.average')}: ${Number.isFinite(rates.average) ? rates.average.toFixed(1) : '--'} tps`,
@@ -0,0 +1,113 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { hookEvents, validateHooks } from './hooks.mjs';
4
+ import { enabledFlag } from './workspace-discovery.mjs';
5
+
6
+ // Project hook files layer on top of the installation file by default. They run as
7
+ // the OS user outside tool approval, so a cloned repository can install gates;
8
+ // DSCODE_PROJECT_HOOKS=0/off/false loads the installation file alone.
9
+ // `.codex/hooks.json` and `.dsh/hooks.json` are the bridge's own shape;
10
+ // `.claude/settings.json` keeps hooks under a top-level `hooks` key and is skipped
11
+ // when that key is absent.
12
+ export const projectHookFiles = Object.freeze([
13
+ { path: '.codex/hooks.json' },
14
+ { path: '.dsh/hooks.json' },
15
+ { path: '.claude/settings.json', nested: true },
16
+ ]);
17
+
18
+ export const resolvedHookFile = 'hooks.resolved.json';
19
+ export const hookReportFile = 'hooks.resolved.report.json';
20
+
21
+ export function projectHooksEnabled(env = process.env) {
22
+ const value = env.DSCODE_PROJECT_HOOKS;
23
+ return value === undefined ? true : enabledFlag(value);
24
+ }
25
+
26
+ function extractHooks(parsed, nested) {
27
+ const hooks = parsed?.hooks !== undefined ? parsed.hooks : (nested ? undefined : parsed);
28
+ if (hooks === undefined) return undefined;
29
+ if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks)) throw new Error('hooks must be an event map');
30
+ return hooks;
31
+ }
32
+
33
+ // The installation file is trusted and must fail loudly on anything this bridge
34
+ // cannot run.
35
+ export function readInstallationHooks(path) {
36
+ return validateHooks(JSON.parse(readFileSync(path, 'utf8')));
37
+ }
38
+
39
+ // A project file is only filtered: a Claude Code settings.json routinely carries
40
+ // events this bridge does not implement, and skipping them must not stop dscode
41
+ // from starting. Every skip is reported so it is visible rather than silent.
42
+ export function readProjectHooks(path, { nested = false } = {}) {
43
+ const hooks = extractHooks(JSON.parse(readFileSync(path, 'utf8')), nested);
44
+ if (hooks === undefined) return undefined;
45
+ const kept = {};
46
+ const skipped = [];
47
+ for (const [event, groups] of Object.entries(hooks)) {
48
+ if (!hookEvents.includes(event)) { skipped.push(event); continue; }
49
+ // A project file is untrusted input: a gate this bridge cannot run is dropped
50
+ // with its reason rather than allowed to stop dscode from starting.
51
+ try {
52
+ validateHooks({ hooks: { [event]: groups } });
53
+ } catch (error) {
54
+ const reason = error.message.startsWith(`${event}: `) ? error.message.slice(event.length + 2) : error.message;
55
+ skipped.push(`${event} (${reason})`);
56
+ continue;
57
+ }
58
+ kept[event] = groups;
59
+ }
60
+ return { hooks: Object.keys(kept).length ? kept : undefined, skipped };
61
+ }
62
+
63
+ export function resolveHookSources({ root, cwd = root, env = process.env }) {
64
+ const installation = join(root, 'config/hooks.local.json');
65
+ const sources = [{ path: installation, hooks: readInstallationHooks(installation) }];
66
+ const skipped = [];
67
+ if (!projectHooksEnabled(env)) return { sources, skipped };
68
+ for (const file of projectHookFiles) {
69
+ const path = join(cwd, file.path);
70
+ if (!existsSync(path)) continue;
71
+ let parsed;
72
+ try {
73
+ parsed = readProjectHooks(path, file);
74
+ } catch (error) {
75
+ // Unreadable JSON (a Claude settings file may even carry comments) is a
76
+ // report, not a startup failure, for a file dscode did not write.
77
+ skipped.push({ path: file.path, events: [`(not loaded: ${error.message})`] });
78
+ continue;
79
+ }
80
+ if (!parsed) continue;
81
+ if (parsed.skipped.length) skipped.push({ path: file.path, events: parsed.skipped });
82
+ if (parsed.hooks) sources.push({ path, hooks: parsed.hooks });
83
+ }
84
+ return { sources, skipped };
85
+ }
86
+
87
+ export function mergeHooks(sources) {
88
+ const hooks = {};
89
+ for (const source of sources) {
90
+ for (const [event, groups] of Object.entries(source.hooks ?? {})) hooks[event] = [...(hooks[event] ?? []), ...groups];
91
+ }
92
+ return { hooks };
93
+ }
94
+
95
+ // The pinned bridge takes one config path for the whole process, so layered files
96
+ // are merged into a single resolved file. A single source stays where it is edited.
97
+ export function writeHookConfig({ root, cwd = root, home, env = process.env }) {
98
+ const { sources, skipped } = resolveHookSources({ root, cwd, env });
99
+ const paths = sources.map(source => source.path);
100
+ const resolved = join(home, resolvedHookFile);
101
+ const report = join(home, hookReportFile);
102
+ if (sources.length === 1) {
103
+ // A stale merge must not outlive the layers it came from: /hooks reads the
104
+ // report, and a leftover one would describe sources that are no longer loaded.
105
+ if (existsSync(resolved)) rmSync(resolved);
106
+ if (existsSync(report)) rmSync(report);
107
+ return { path: sources[0].path, sources: paths, skipped };
108
+ }
109
+ mkdirSync(home, { recursive: true });
110
+ writeFileSync(resolved, JSON.stringify(mergeHooks(sources), null, 2) + '\n', { mode: 0o600 });
111
+ writeFileSync(report, JSON.stringify({ sources: paths, skipped }, null, 2) + '\n', { mode: 0o600 });
112
+ return { path: resolved, sources: paths, skipped };
113
+ }
@@ -1,7 +1,8 @@
1
1
  import { runShell } from './shell.mjs';
2
2
  import { VERSION_PATTERN, scheduleUpdate } from './update.mjs';
3
3
  import { readLanguage, t } from '../i18n/messages.mjs';
4
- import { readFile, readdir, access } from 'node:fs/promises';
4
+ import { hookReportFile } from './hook-sources.mjs';
5
+ import { readFile, readdir, access, stat } from 'node:fs/promises';
5
6
  import { dirname, join, resolve } from 'node:path';
6
7
  import { parse } from 'yaml';
7
8
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
@@ -63,6 +64,22 @@ export async function findConflicts(cwd, configs, winners, env = process.env) {
63
64
  return [...lines, ...errors, 'Scope: filesystem roots only; runtime/remote provider shadowed candidates are not exposed by DSH.'].join('\n');
64
65
  }
65
66
 
67
+ // The pinned bridge reads one merged file for the whole process, and /hooks reload
68
+ // remounts the plugin with the same path, so an edited layer is not picked up until
69
+ // dscode restarts. Report that instead of letting the user believe the edit is live.
70
+ export async function staleLayers(resolvedPath, sources = []) {
71
+ const mergedAt = await stat(resolvedPath).then(value => value.mtimeMs).catch(() => undefined);
72
+ if (mergedAt === undefined) return [];
73
+ const stale = [];
74
+ for (const source of sources) {
75
+ if (source === resolvedPath) continue;
76
+ const at = await stat(source).then(value => value.mtimeMs).catch(() => undefined);
77
+ if (at === undefined) stale.push(`${source} (missing)`);
78
+ else if (at > mergedAt) stale.push(`${source} (newer than the merge)`);
79
+ }
80
+ return stale;
81
+ }
82
+
66
83
  export function apply(ctx) {
67
84
  const diagnosticsHome = process.env.DSH_HOME ?? process.env.DSCODE_HOME;
68
85
  // A minimal composition (and the unit fixtures) may mount no logger; diagnostics are best-effort.
@@ -196,6 +213,8 @@ export function apply(ctx) {
196
213
  const path = hookPath(entry);
197
214
  const raw = JSON.parse(await readFile(path, 'utf8'));
198
215
  const hooks = raw.hooks ?? raw;
199
- return ok(`Hooks: ${state(entry)}\nConfig: ${path}\n${Object.entries(hooks).map(([event, groups]) => `${event}: ${hookEvents.includes(event) ? 'supported' : 'UNSUPPORTED'}; ${Array.isArray(groups) ? groups.length : 0} groups`).join('\n')}\nSupported: ${hookEvents.join(', ')}\nOnly synchronous command hooks. Runs as your OS user, outside tool approval. Edit only trusted installation-owned config; /hooks reload applies it. No project hook auto-loading.\nPreCompact/PostCompact, PermissionRequest and subagent events are not supported by this bridge.`);
216
+ const report = await readFile(join(dirname(path), hookReportFile), 'utf8').then(JSON.parse).catch(() => undefined);
217
+ const stale = report ? await staleLayers(path, report.sources) : [];
218
+ return ok(`Hooks: ${state(entry)}\nConfig: ${path}\n${Object.entries(hooks).map(([event, groups]) => `${event}: ${hookEvents.includes(event) ? 'supported' : 'UNSUPPORTED'}; ${Array.isArray(groups) ? groups.length : 0} groups`).join('\n')}\nSupported: ${hookEvents.join(', ')}\nOnly synchronous command hooks. Runs as your OS user, outside tool approval. Edit only trusted installation-owned config; /hooks reload applies it. Project files (.codex/hooks.json, .dsh/hooks.json, .claude/settings.json) layer on top unless DSCODE_PROJECT_HOOKS=0; events this bridge does not support are skipped rather than fatal, and any edit to a layer needs a restart.${report ? `\nLayers: ${report.sources.join(', ')}${report.skipped.length ? `\nSkipped by this bridge: ${report.skipped.map(entry => `${entry.path}: ${entry.events.join(', ')}`).join('; ')}` : ''}` : ''}${stale.length ? `\nNeeds restart: ${stale.join('; ')} — /hooks reload re-reads the merged file, not its sources.` : ''}\nPreCompact/PostCompact, PermissionRequest and subagent events are not supported by this bridge.`);
200
219
  });
201
220
  }
@@ -34,7 +34,9 @@ export async function fetchLatestVersion({ fetchImpl = fetch, timeoutMs = 8000 }
34
34
  const controller = new AbortController();
35
35
  const timer = setTimeout(() => controller.abort(), timeoutMs);
36
36
  try {
37
- const response = await fetchImpl(REGISTRY_URL, { headers: { accept: 'application/vnd.npm.install-v1+json' }, signal: controller.signal });
37
+ // Plain JSON: the registry answers 406 for the abbreviated metadata type on the
38
+ // `/latest` dist-tag endpoint.
39
+ const response = await fetchImpl(REGISTRY_URL, { headers: { accept: 'application/json' }, signal: controller.signal });
38
40
  if (!response.ok) return undefined;
39
41
  const version = (await response.json())?.version;
40
42
  return typeof version === 'string' && VERSION_PATTERN.test(version) ? version : undefined;
@@ -0,0 +1,122 @@
1
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join, resolve, sep } from 'node:path';
3
+
4
+ // Discovery above the project root, bounded at the home directory rather than the
5
+ // filesystem root: a shared workspace directory such as ~/Workspace can contribute
6
+ // skills and instructions to every project below it, while nothing outside the
7
+ // operator's own tree can.
8
+ export const ancestorSkillRoots = Object.freeze(['.dsh/skills', '.agents/skills', '.claude/skills']);
9
+ export const instructionFileCandidates = Object.freeze(['AGENTS.md', 'CLAUDE.md']);
10
+ export const projectRootMarkers = Object.freeze(['.git']);
11
+
12
+ export const ENABLED_VALUES = Object.freeze(['1', 'true', 'on', 'yes']);
13
+ // Fail closed: only the documented enable spellings arm a switch, so an operator
14
+ // writing "no", "none" or "disable" cannot accidentally turn on a feature that
15
+ // reads untrusted project content.
16
+ export const enabledFlag = (value) => ENABLED_VALUES.includes(String(value ?? '').trim().toLowerCase());
17
+ export const skillAncestorsEnabled = (env = process.env) => enabledFlag(env.DSCODE_SKILL_ANCESTORS);
18
+
19
+ // A symlinked $HOME (or a /Volumes mount) must not break the boundary test: the
20
+ // launcher passes an already-resolved session directory while os.homedir() returns
21
+ // whatever $HOME says, so the comparison canonicalizes both sides. The chain itself
22
+ // keeps the caller's logical paths, which is what the skill and instruction probes
23
+ // then stat.
24
+ const canonical = path => { try { return realpathSync(path); } catch { return resolve(path); } };
25
+
26
+ // Every directory from the working directory up to home, nearest first. An empty
27
+ // list means home is not an ancestor, and the mode then contributes nothing.
28
+ export function ancestorChain({ cwd, home }) {
29
+ const start = resolve(cwd);
30
+ const stop = resolve(home);
31
+ const canonicalStop = canonical(stop);
32
+ const canonicalStart = canonical(start);
33
+ if (canonicalStart !== canonicalStop && !canonicalStart.startsWith(canonicalStop + sep)) return [];
34
+ const chain = [];
35
+ for (let current = start; ;) {
36
+ chain.push(current);
37
+ if (canonical(current) === canonicalStop) break;
38
+ const parent = dirname(current);
39
+ if (parent === current) break;
40
+ current = parent;
41
+ }
42
+ return chain;
43
+ }
44
+
45
+ export function projectRootOf({ cwd, markers = projectRootMarkers }) {
46
+ const start = resolve(cwd);
47
+ for (let current = start; ;) {
48
+ if (markers.some(marker => existsSync(join(current, marker)))) return current;
49
+ const parent = dirname(current);
50
+ if (parent === current) return start;
51
+ current = parent;
52
+ }
53
+ }
54
+
55
+ export function ancestorSkillDirs({ cwd, home, env = process.env }) {
56
+ if (!skillAncestorsEnabled(env)) return [];
57
+ const projectRoot = projectRootOf({ cwd });
58
+ const dirs = [];
59
+ for (const dir of ancestorChain({ cwd, home })) {
60
+ for (const root of ancestorSkillRoots) {
61
+ // The provider already scans these two at rank 100/200 for the owning project.
62
+ if (dir === projectRoot && (root === '.dsh/skills' || root === '.agents/skills')) continue;
63
+ const path = join(dir, root);
64
+ if (existsSync(path) && !dirs.includes(path)) dirs.push(path);
65
+ }
66
+ }
67
+ return dirs;
68
+ }
69
+
70
+ // Instruction files strictly above the project root, farthest first so the chain
71
+ // still reads broad to specific. The project chain itself stays upstream's job.
72
+ export function ancestorInstructionFiles({ cwd, home }) {
73
+ const chain = ancestorChain({ cwd, home });
74
+ // The chain runs from the working directory outward, so everything after the
75
+ // project root is above it; reversing restores broad-to-specific order.
76
+ const stop = chain.indexOf(projectRootOf({ cwd }));
77
+ const above = (stop === -1 ? chain : chain.slice(stop + 1)).reverse();
78
+ const files = [];
79
+ for (const dir of above) {
80
+ for (const name of instructionFileCandidates) {
81
+ const path = join(dir, name);
82
+ if (existsSync(path)) files.push(path);
83
+ }
84
+ }
85
+ return files;
86
+ }
87
+
88
+ // The provider ignores a source file that cannot fit its render budget, and the
89
+ // aggregate below occupies the broadest (user-global) slot: without a bound, one
90
+ // oversized ancestor file would take the user-global instructions down with it.
91
+ // Stay under the preset's 64 KiB maxBytes with headroom.
92
+ export const AGGREGATE_BUDGET_BYTES = 60 * 1024;
93
+
94
+ // The upstream provider owns exactly one user-global instruction file, so ancestor
95
+ // files are folded in behind it. With no ancestor file this writes nothing and
96
+ // changes nothing: the provider keeps reading $DSH_HOME/AGENTS.md where it always did.
97
+ // The user-global file is always written: dropping it in favour of its descendants
98
+ // would silently replace what the user wrote with what the project wrote. Ancestors
99
+ // are then added nearest-first while the budget allows, and a source that would
100
+ // overflow is skipped whole rather than truncated mid-file.
101
+ export function writeWorkspaceInstructions({ cwd, home, stateDir }) {
102
+ const files = ancestorInstructionFiles({ cwd, home });
103
+ if (!files.length) return undefined;
104
+ const userGlobal = join(stateDir, 'AGENTS.md');
105
+ const texts = new Map();
106
+ if (existsSync(userGlobal)) texts.set(userGlobal, readFileSync(userGlobal, 'utf8').trim());
107
+ for (const file of files) texts.set(file, readFileSync(file, 'utf8').trim());
108
+ const written = [...texts.keys()];
109
+ const priority = written[0] === userGlobal ? [userGlobal, ...written.slice(1).reverse()] : [...written].reverse();
110
+ const kept = new Set();
111
+ let bytes = 0;
112
+ for (const source of priority) {
113
+ const size = Buffer.byteLength(texts.get(source), 'utf8') + 2;
114
+ if (kept.size > 0 && bytes + size > AGGREGATE_BUDGET_BYTES) continue;
115
+ kept.add(source);
116
+ bytes += size;
117
+ }
118
+ const directory = join(stateDir, 'workspace-instructions');
119
+ mkdirSync(directory, { recursive: true });
120
+ writeFileSync(join(directory, 'AGENTS.md'), written.filter(source => kept.has(source)).map(source => texts.get(source)).join('\n\n') + '\n', { mode: 0o600 });
121
+ return directory;
122
+ }