codiedev 0.9.24 → 0.9.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,12 +17,28 @@ If both are installed, hooks are wired up for both. If neither is installed, the
17
17
 
18
18
  ## What gets captured
19
19
 
20
- - Only sessions inside repos your workspace already tracks
20
+ - Agent sessions from connected devices (capture is enabled by default)
21
21
  - Transcript + basic stats (message count, tool calls)
22
22
  - Processed into summaries and embeddings server-side for search in `/portal/memories`
23
23
 
24
24
  Each captured memory records its `source` (`claude-code` or `codex`) so you can filter in the portal.
25
25
 
26
+ ## Keep folders local
27
+
28
+ Folder exclusions are device-specific and include every child folder. The CLI
29
+ writes the rule locally before attempting to sync its read-only status to the
30
+ portal, so an offline or failed status sync never weakens the exclusion.
31
+
32
+ ```bash
33
+ codiedev ignore ~/Obsidian
34
+ codiedev ignored ~/Obsidian/People # check whether a path is ignored
35
+ codiedev ignored # list every ignored folder
36
+ codiedev unignore ~/Obsidian
37
+ ```
38
+
39
+ Run `ignore` once per device or persistent VM/container. A new ephemeral
40
+ container needs its own rule unless `~/.codiedev/config.json` is persisted.
41
+
26
42
  ## Codex note
27
43
 
28
44
  Codex doesn't currently expose a `SessionEnd` event, so the hook runs on `Stop` (fires after each assistant turn). The backend dedupes by `(company, session_id)` and replaces the stored transcript on re-upload so the latest turn's snapshot wins. Summarization happens once per session; subsequent turn uploads keep the raw transcript fresh but don't re-summarize (to avoid running up LLM cost).
@@ -86,6 +86,7 @@ const child_process_1 = require("child_process");
86
86
  const utils_1 = require("../utils");
87
87
  const cache_2 = require("./cache");
88
88
  const push = __importStar(require("../commands/push"));
89
+ const captureExclusions_1 = require("../captureExclusions");
89
90
  function gitRoot(cwd) {
90
91
  try {
91
92
  return (0, child_process_1.execFileSync)("git", ["-C", cwd, "rev-parse", "--show-toplevel"], {
@@ -143,6 +144,8 @@ function runBackup(cwd, hookScriptPath) {
143
144
  const config = (0, utils_1.readConfig)();
144
145
  if (!config)
145
146
  return;
147
+ if ((0, captureExclusions_1.isCaptureIgnored)(cwd, config.captureIgnoredPaths))
148
+ return;
146
149
  const root = gitRoot(cwd);
147
150
  if (!root)
148
151
  return;
@@ -195,6 +198,8 @@ async function runBackupUpload(jobFile, deps = DEFAULT_BACKUP_UPLOAD_DEPS) {
195
198
  const config = deps.readConfig(); // same machine — read full config from disk
196
199
  if (!config)
197
200
  return;
201
+ if ((0, captureExclusions_1.isCaptureIgnored)(job.root, config.captureIgnoredPaths))
202
+ return;
198
203
  const cache = deps.loadCache();
199
204
  let dirty = false;
200
205
  for (const f of job.files) {
@@ -0,0 +1,30 @@
1
+ import type { CodiedevConfig } from "./utils";
2
+ export interface NormalizeCapturePathDeps {
3
+ cwd?: () => string;
4
+ homedir?: () => string;
5
+ realpathSync?: (value: string) => string;
6
+ }
7
+ /**
8
+ * Convert a user-entered path into the canonical absolute form persisted in
9
+ * ~/.codiedev/config.json. Existing symlinks resolve to their real target so a
10
+ * session cannot bypass an exclusion by entering the same folder through a
11
+ * different alias. Non-existent paths still normalize deterministically.
12
+ */
13
+ export declare function normalizeCapturePath(input: string, deps?: NormalizeCapturePathDeps): string;
14
+ export declare function findMatchingCaptureExclusion(candidate: string | string[] | undefined, ignoredPaths: string[] | undefined): string | null;
15
+ export declare function isCaptureIgnored(candidate: string | string[] | undefined, ignoredPaths: string[] | undefined): boolean;
16
+ export interface CaptureScopeInput {
17
+ cwd?: string;
18
+ workspace_roots?: string[];
19
+ }
20
+ /** Every local root associated with a hook event, deduped in stable order. */
21
+ export declare function captureScopePaths(input: CaptureScopeInput, fallback?: string): string[];
22
+ export declare function shouldIgnoreCaptureInput(config: Pick<CodiedevConfig, "captureIgnoredPaths"> | null | undefined, input: CaptureScopeInput, fallback?: string): boolean;
23
+ /**
24
+ * Final egress decision for queued transcript uploads. Jobs written by older
25
+ * CLI versions have no scope metadata; once any exclusion exists, dropping
26
+ * those legacy jobs is safer than guessing that they are public.
27
+ */
28
+ export declare function shouldSkipCaptureUploadJob(job: {
29
+ scopePaths?: string[];
30
+ }, config: Pick<CodiedevConfig, "captureIgnoredPaths"> | null | undefined): boolean;
@@ -0,0 +1,118 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.normalizeCapturePath = normalizeCapturePath;
37
+ exports.findMatchingCaptureExclusion = findMatchingCaptureExclusion;
38
+ exports.isCaptureIgnored = isCaptureIgnored;
39
+ exports.captureScopePaths = captureScopePaths;
40
+ exports.shouldIgnoreCaptureInput = shouldIgnoreCaptureInput;
41
+ exports.shouldSkipCaptureUploadJob = shouldSkipCaptureUploadJob;
42
+ const fs = __importStar(require("fs"));
43
+ const os = __importStar(require("os"));
44
+ const path = __importStar(require("path"));
45
+ const DEFAULT_NORMALIZE_DEPS = {
46
+ cwd: () => process.cwd(),
47
+ homedir: () => os.homedir(),
48
+ realpathSync: (value) => fs.realpathSync.native(value),
49
+ };
50
+ /**
51
+ * Convert a user-entered path into the canonical absolute form persisted in
52
+ * ~/.codiedev/config.json. Existing symlinks resolve to their real target so a
53
+ * session cannot bypass an exclusion by entering the same folder through a
54
+ * different alias. Non-existent paths still normalize deterministically.
55
+ */
56
+ function normalizeCapturePath(input, deps = {}) {
57
+ const d = { ...DEFAULT_NORMALIZE_DEPS, ...deps };
58
+ const trimmed = input.trim();
59
+ if (!trimmed)
60
+ throw new Error("Folder path is required");
61
+ const expanded = /^~(?=$|[\\/])/.test(trimmed)
62
+ ? path.join(d.homedir(), trimmed.slice(1))
63
+ : trimmed;
64
+ const absolute = path.resolve(d.cwd(), expanded);
65
+ try {
66
+ return path.normalize(d.realpathSync(absolute));
67
+ }
68
+ catch {
69
+ return path.normalize(absolute);
70
+ }
71
+ }
72
+ function comparisonKey(value) {
73
+ const normalized = path.normalize(value);
74
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
75
+ }
76
+ function containsPath(root, candidate) {
77
+ const relative = path.relative(comparisonKey(root), comparisonKey(candidate));
78
+ return (relative === "" ||
79
+ (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)));
80
+ }
81
+ function findMatchingCaptureExclusion(candidate, ignoredPaths) {
82
+ if (!candidate || !ignoredPaths || ignoredPaths.length === 0)
83
+ return null;
84
+ const candidates = Array.isArray(candidate) ? candidate : [candidate];
85
+ for (const ignored of ignoredPaths) {
86
+ const root = normalizeCapturePath(ignored);
87
+ for (const value of candidates) {
88
+ if (containsPath(root, normalizeCapturePath(value)))
89
+ return root;
90
+ }
91
+ }
92
+ return null;
93
+ }
94
+ function isCaptureIgnored(candidate, ignoredPaths) {
95
+ return findMatchingCaptureExclusion(candidate, ignoredPaths) !== null;
96
+ }
97
+ /** Every local root associated with a hook event, deduped in stable order. */
98
+ function captureScopePaths(input, fallback = process.cwd()) {
99
+ const values = [input.cwd, ...(input.workspace_roots ?? [])]
100
+ .filter((value) => typeof value === "string" && value.length > 0);
101
+ return [...new Set(values.length > 0 ? values : [fallback])];
102
+ }
103
+ function shouldIgnoreCaptureInput(config, input, fallback) {
104
+ return isCaptureIgnored(captureScopePaths(input, fallback), config?.captureIgnoredPaths);
105
+ }
106
+ /**
107
+ * Final egress decision for queued transcript uploads. Jobs written by older
108
+ * CLI versions have no scope metadata; once any exclusion exists, dropping
109
+ * those legacy jobs is safer than guessing that they are public.
110
+ */
111
+ function shouldSkipCaptureUploadJob(job, config) {
112
+ const ignoredPaths = config?.captureIgnoredPaths ?? [];
113
+ if (ignoredPaths.length === 0)
114
+ return false;
115
+ if (!job.scopePaths || job.scopePaths.length === 0)
116
+ return true;
117
+ return isCaptureIgnored(job.scopePaths, ignoredPaths);
118
+ }
package/dist/cli.js CHANGED
@@ -33,6 +33,7 @@ const post_1 = require("./commands/post");
33
33
  const share_1 = require("./commands/share");
34
34
  const send_1 = require("./commands/send");
35
35
  const react_1 = require("./commands/react");
36
+ const capture_1 = require("./commands/capture");
36
37
  const version_1 = require("./version");
37
38
  const HELP = `
38
39
  Codiedev CLI
@@ -95,6 +96,11 @@ Feed:
95
96
  [--mention <name>]... Publish to the team feed
96
97
  codiedev react <postId> <emoji> Toggle a reaction on a feed post
97
98
 
99
+ Privacy:
100
+ codiedev ignore <path> Never capture this folder or its children
101
+ codiedev unignore <path> Stop ignoring a folder
102
+ codiedev ignored [path] List ignored folders, or check one path
103
+
98
104
  Capture:
99
105
  codiedev note "<text>" Capture a passing thought
100
106
 
@@ -130,6 +136,18 @@ Backend: ${process.env.CODIEDEV_URL ?? "https://codiedev.com"}
130
136
  Token: https://codiedev.com/portal/integrations/claude-code
131
137
  Docs: codiedev docs
132
138
  `.trim();
139
+ const IGNORE_HELP = `
140
+ codiedev ignore — keep folders out of session capture
141
+
142
+ codiedev ignore <path> Never capture this folder or its children
143
+ codiedev unignore <path> Stop ignoring a folder
144
+ codiedev ignored List ignored folders on this device
145
+ codiedev ignored <path> Check whether a specific path is ignored
146
+
147
+ Exclusions are per-device and cover every child folder. The rule is written
148
+ locally before any capture leaves your machine, so an offline or failed portal
149
+ status sync never weakens it.
150
+ `.trim();
133
151
  const COMMAND_HELP = {
134
152
  connect: `
135
153
  codiedev connect — link your agent CLI to Codiedev
@@ -237,6 +255,10 @@ codiedev note — capture a passing thought, linked to your session
237
255
  codiedev note "idempotency key is worth a follow-up"
238
256
  codiedev note "timeout threshold feels arbitrary — validate on slow networks"
239
257
  `.trim(),
258
+ ignore: IGNORE_HELP,
259
+ unignore: IGNORE_HELP,
260
+ ignored: IGNORE_HELP,
261
+ capture: IGNORE_HELP,
240
262
  promote: `
241
263
  codiedev promote — promote an auto-extracted artifact to authored
242
264
 
@@ -482,6 +504,15 @@ async function main() {
482
504
  case "react":
483
505
  await (0, react_1.runReact)(rest);
484
506
  return;
507
+ case "ignore":
508
+ case "unignore":
509
+ case "ignored":
510
+ await (0, capture_1.runCaptureCommand)([command, ...rest]);
511
+ return;
512
+ case "capture":
513
+ // Back-compat alias for the earlier `codiedev capture <sub>` form.
514
+ await (0, capture_1.runCaptureCommand)(rest);
515
+ return;
485
516
  default:
486
517
  console.error(`Unknown command: ${command}`);
487
518
  console.error(HELP);
@@ -0,0 +1,14 @@
1
+ import { type CodiedevConfig } from "../utils";
2
+ import { apiRequest } from "./shared";
3
+ export interface CaptureCommandDeps {
4
+ readConfig: () => CodiedevConfig | null;
5
+ writeConfig: (config: CodiedevConfig) => void;
6
+ apiRequest: typeof apiRequest;
7
+ log: (line: string) => void;
8
+ warn: (line: string) => void;
9
+ randomUUID: () => string;
10
+ hostname: () => string;
11
+ normalizePath: (value: string) => string;
12
+ configForRequest: (config: CodiedevConfig) => CodiedevConfig;
13
+ }
14
+ export declare function runCaptureCommand(args: string[], depsOverride?: Partial<CaptureCommandDeps>): Promise<void>;
@@ -0,0 +1,148 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.runCaptureCommand = runCaptureCommand;
37
+ const crypto = __importStar(require("crypto"));
38
+ const os = __importStar(require("os"));
39
+ const captureExclusions_1 = require("../captureExclusions");
40
+ const utils_1 = require("../utils");
41
+ const shared_1 = require("./shared");
42
+ const DEFAULT_DEPS = {
43
+ readConfig: utils_1.readConfig,
44
+ writeConfig: utils_1.writeConfig,
45
+ apiRequest: shared_1.apiRequest,
46
+ log: console.log,
47
+ warn: console.warn,
48
+ randomUUID: () => crypto.randomUUID(),
49
+ hostname: () => os.hostname(),
50
+ normalizePath: captureExclusions_1.normalizeCapturePath,
51
+ configForRequest: shared_1.applyConfigEnvOverrides,
52
+ };
53
+ function requireConnectedConfig(deps) {
54
+ const config = deps.readConfig();
55
+ if (!config) {
56
+ throw new Error("Not connected. Run `npx codiedev connect` first.");
57
+ }
58
+ return config;
59
+ }
60
+ function withDeviceIdentity(config, deps) {
61
+ return {
62
+ ...config,
63
+ deviceId: config.deviceId || deps.randomUUID(),
64
+ deviceName: config.deviceName || deps.hostname(),
65
+ };
66
+ }
67
+ async function syncExclusions(config, deps) {
68
+ try {
69
+ await deps.apiRequest("POST", "/api/cli/capture-exclusions", {
70
+ config: deps.configForRequest(config),
71
+ body: {
72
+ deviceId: config.deviceId,
73
+ deviceName: config.deviceName,
74
+ ignoredPaths: config.captureIgnoredPaths ?? [],
75
+ },
76
+ });
77
+ deps.log("Portal status synced.");
78
+ }
79
+ catch (error) {
80
+ deps.warn(`Privacy setting saved locally, but portal status did not sync: ${error.message}`);
81
+ }
82
+ }
83
+ function requirePath(args) {
84
+ const value = args.slice(1).join(" ").trim();
85
+ if (!value) {
86
+ throw new Error("Usage: codiedev ignore <path> | codiedev unignore <path>");
87
+ }
88
+ return value;
89
+ }
90
+ function reportCheck(rawPath, current, deps) {
91
+ const target = deps.normalizePath(rawPath);
92
+ const match = (0, captureExclusions_1.findMatchingCaptureExclusion)(target, current.captureIgnoredPaths);
93
+ if (match) {
94
+ deps.log(`IGNORED: ${target}`);
95
+ deps.log(`Matched exclusion: ${match}`);
96
+ }
97
+ else {
98
+ deps.log(`CAPTURED: ${target}`);
99
+ deps.log("No ignored-folder rule matches this path.");
100
+ }
101
+ }
102
+ async function runCaptureCommand(args, depsOverride = {}) {
103
+ const deps = { ...DEFAULT_DEPS, ...depsOverride };
104
+ const subcommand = args[0];
105
+ const current = requireConnectedConfig(deps);
106
+ if (subcommand === "ignore") {
107
+ const target = deps.normalizePath(requirePath(args));
108
+ const existing = (current.captureIgnoredPaths ?? []).map(deps.normalizePath);
109
+ const nextPaths = existing.includes(target) ? existing : [...existing, target];
110
+ const next = withDeviceIdentity({ ...current, captureIgnoredPaths: nextPaths }, deps);
111
+ deps.writeConfig(next);
112
+ deps.log(`Ignoring ${target} and all child folders.`);
113
+ await syncExclusions(next, deps);
114
+ return;
115
+ }
116
+ if (subcommand === "unignore") {
117
+ const target = deps.normalizePath(requirePath(args));
118
+ const existing = (current.captureIgnoredPaths ?? []).map(deps.normalizePath);
119
+ const next = withDeviceIdentity({ ...current, captureIgnoredPaths: existing.filter((value) => value !== target) }, deps);
120
+ deps.writeConfig(next);
121
+ deps.log(`No longer ignoring ${target}.`);
122
+ await syncExclusions(next, deps);
123
+ return;
124
+ }
125
+ if (subcommand === "ignored") {
126
+ // `ignored` lists everything; `ignored <path>` answers "is this ignored?".
127
+ const query = args.slice(1).join(" ").trim();
128
+ if (query) {
129
+ reportCheck(query, current, deps);
130
+ return;
131
+ }
132
+ const paths = current.captureIgnoredPaths ?? [];
133
+ if (paths.length === 0) {
134
+ deps.log("No ignored folders configured on this device.");
135
+ return;
136
+ }
137
+ deps.log("Ignored folders:");
138
+ for (const value of paths)
139
+ deps.log(` ${value}`);
140
+ return;
141
+ }
142
+ // Back-compat: the earlier `codiedev capture check <path>` form.
143
+ if (subcommand === "check") {
144
+ reportCheck(requirePath(args), current, deps);
145
+ return;
146
+ }
147
+ throw new Error(`Unknown command: ${subcommand ?? "(missing)"}. Use ignore, unignore, or ignored.`);
148
+ }
@@ -165,6 +165,9 @@ async function runDoctor(_args) {
165
165
  catch {
166
166
  // malformed backendUrl in config — keep the generic placeholder
167
167
  }
168
+ // Live tracked-repo list fetched from validateToken below. Null until (and
169
+ // unless) the backend answers; the checks fall back to `config.repos`.
170
+ let liveRepos = null;
168
171
  try {
169
172
  const res = await fetch(`${config.backendUrl}/api/cli/validateToken`, {
170
173
  method: "POST",
@@ -180,6 +183,21 @@ async function runDoctor(_args) {
180
183
  status: "pass",
181
184
  detail: backendHost,
182
185
  });
186
+ // validateToken returns the company's CURRENT tracked repos. Capture
187
+ // them: the repo-resolution checks below must reflect what the SERVER
188
+ // will resolve at capture time (repoResolver.ts resolves against the
189
+ // live list), not the possibly-stale `config.repos` frozen at the last
190
+ // `connect`. A repo linked in the portal after that connect otherwise
191
+ // reports a false "isn't a tracked repo — link it in the portal" here
192
+ // even though sessions/MRs for it attribute fine server-side.
193
+ try {
194
+ const data = (await res.json());
195
+ if (Array.isArray(data.repos))
196
+ liveRepos = data.repos;
197
+ }
198
+ catch {
199
+ liveRepos = null; // malformed body → fall back to the cached list
200
+ }
183
201
  }
184
202
  else {
185
203
  checks.push({
@@ -219,13 +237,46 @@ async function runDoctor(_args) {
219
237
  detail: err.message,
220
238
  });
221
239
  }
222
- // 4. Tracked repos
223
- const repoCount = config.repos?.length ?? 0;
240
+ // 4. Tracked repos. Prefer the live list from validateToken over the cache:
241
+ // a repo linked after the last `connect` is tracked server-side but absent
242
+ // from `config.repos`, and resolving against the stale cache is exactly what
243
+ // produced the false "isn't a tracked repo — link it in the portal" warning.
244
+ // Treat an EMPTY live list as "no usable live data", NOT an authoritative
245
+ // zero: a transient permission glitch / backend hiccup can return `repos: []`,
246
+ // and both driving the checks from it AND healing the cache to empty would be
247
+ // wrong — the write would durably break every offline doctor run (and the
248
+ // capture hook's providedRepoId) until the next successful connect. So when
249
+ // live is empty, fall back to the cache and skip the write, exactly like
250
+ // backend-unreachable. A genuinely repo-less company also has an empty cache,
251
+ // so it still reports "0 repos" correctly.
252
+ const trackedRepos = liveRepos !== null && liveRepos.length > 0 ? liveRepos : (config.repos ?? []);
253
+ // Detect drift by CONTENT, not count: a same-size swap (one repo removed +
254
+ // another added between connects) or a repo whose path/URL changed while its
255
+ // id stayed stable (GitLab move/rename — exactly the SONIFI migration case)
256
+ // both leave the count unchanged. A length-only check would skip the heal and
257
+ // leave the capture hook's cache — and its advisory providedRepoId — stale.
258
+ const canonRepos = (rs) => JSON.stringify([...rs].map((r) => [r.repoId, r.url, r.fullName]).sort());
259
+ const cacheRefreshed = liveRepos !== null &&
260
+ liveRepos.length > 0 &&
261
+ canonRepos(liveRepos) !== canonRepos(config.repos ?? []);
262
+ // Heal the on-disk cache so the capture hook (which reads `config.repos` for
263
+ // the advisory providedRepoId) stops trailing the portal too. Best-effort:
264
+ // doctor must never fail because the config couldn't be rewritten.
265
+ if (cacheRefreshed && liveRepos) {
266
+ try {
267
+ (0, utils_1.writeConfig)({ ...config, repos: liveRepos, lastSynced: new Date().toISOString() });
268
+ }
269
+ catch {
270
+ // read-only home / permission issue — the in-memory list still drives
271
+ // the checks below; the user just re-syncs on the next `connect`.
272
+ }
273
+ }
274
+ const repoCount = trackedRepos.length;
224
275
  checks.push({
225
276
  name: "Tracked repos",
226
277
  status: repoCount > 0 ? "pass" : "warn",
227
278
  detail: repoCount > 0
228
- ? `${repoCount} repo${repoCount === 1 ? "" : "s"}`
279
+ ? `${repoCount} repo${repoCount === 1 ? "" : "s"}${cacheRefreshed ? " (refreshed from portal)" : ""}`
229
280
  : "0 — link repos in the portal so sessions get captured",
230
281
  });
231
282
  // 4b. Capture readiness — the part that actually decides whether a session
@@ -234,7 +285,7 @@ async function runDoctor(_args) {
234
285
  // last one?" — the blind spot behind lean-container / submodule / no-remote
235
286
  // zeros.
236
287
  const cwd = process.cwd();
237
- const readiness = (0, captureReadiness_1.assessCaptureReadiness)(cwd, config.repos ?? [], {
288
+ const readiness = (0, captureReadiness_1.assessCaptureReadiness)(cwd, trackedRepos, {
238
289
  exec: (cmd, dir) => (0, child_process_1.execSync)(cmd, { cwd: dir, stdio: ["pipe", "pipe", "pipe"] }).toString(),
239
290
  getRemoteUrl: utils_1.getGitRemoteUrl,
240
291
  matchRepo: utils_1.matchRepo,
@@ -247,7 +298,7 @@ async function runDoctor(_args) {
247
298
  // instead of flatly warning "not a work tree / no remote" — the exact output
248
299
  // that made working workspace-root setups read as broken.
249
300
  const workspaceScan = readiness.gitOnPath && !readiness.insideWorkTree
250
- ? (0, captureReadiness_1.assessWorkspaceRepos)(cwd, config.repos ?? [], {
301
+ ? (0, captureReadiness_1.assessWorkspaceRepos)(cwd, trackedRepos, {
251
302
  listChildDirs: (dir) => fs
252
303
  .readdirSync(dir, { withFileTypes: true })
253
304
  .filter((e) => e.isDirectory())
package/dist/connect.js CHANGED
@@ -37,6 +37,8 @@ exports.runConnect = runConnect;
37
37
  const readline = __importStar(require("readline"));
38
38
  const https = __importStar(require("https"));
39
39
  const http = __importStar(require("http"));
40
+ const crypto = __importStar(require("crypto"));
41
+ const os = __importStar(require("os"));
40
42
  const utils_1 = require("./utils");
41
43
  const connectFlow_1 = require("./connectFlow");
42
44
  const version_1 = require("./version");
@@ -210,6 +212,9 @@ async function runConnect(args = []) {
210
212
  repos,
211
213
  lastSynced: new Date().toISOString(),
212
214
  backendUrl: BACKEND_URL,
215
+ deviceId: saved?.deviceId || crypto.randomUUID(),
216
+ deviceName: saved?.deviceName || os.hostname(),
217
+ captureIgnoredPaths: saved?.captureIgnoredPaths ?? [],
213
218
  };
214
219
  try {
215
220
  (0, utils_1.writeConfig)(config);
@@ -223,6 +228,20 @@ async function runConnect(args = []) {
223
228
  // a timeline when a customer says "it connected but it doesn't work."
224
229
  // Fire-and-forget — never blocks the connect flow.
225
230
  (0, shared_1.recordInstallEvent)(config, { step: "started", ok: true });
231
+ // One-way device status sync. Local config remains authoritative: a network
232
+ // failure here never removes or weakens an ignored path. This also registers
233
+ // a freshly connected device with an empty list so the portal can distinguish
234
+ // "connected, no exclusions" from "old CLI has never reported status".
235
+ try {
236
+ await postJson(`${BACKEND_URL}/api/cli/capture-exclusions`, {
237
+ deviceId: config.deviceId,
238
+ deviceName: config.deviceName,
239
+ ignoredPaths: config.captureIgnoredPaths,
240
+ }, { Authorization: `Bearer ${token}` });
241
+ }
242
+ catch (err) {
243
+ console.warn(`Warning: Local capture settings are active, but portal status did not sync — ${err.message}`);
244
+ }
226
245
  // Migration: strip MCP server entries written by pre-CLI-only versions
227
246
  // of `codiedev connect`. These pointed at `npx codiedev-mcp`, a binary
228
247
  // that was removed in 0.3.4 and that 0.7.11+ no longer ships. Without
package/dist/hook.js CHANGED
@@ -45,6 +45,7 @@ const utils_1 = require("./utils");
45
45
  const limitEvent_1 = require("./limitEvent");
46
46
  const version_1 = require("./version");
47
47
  const captureLog_1 = require("./captureLog");
48
+ const captureExclusions_1 = require("./captureExclusions");
48
49
  function getJson(url, bearerToken) {
49
50
  return new Promise((resolve, reject) => {
50
51
  const parsed = new URL(url);
@@ -368,6 +369,12 @@ async function main() {
368
369
  // Write per-session upload state so the throttle can defer later
369
370
  // non-final uploads within the same window.
370
371
  writeSessionState: utils_1.writeSessionUploadState,
372
+ // Re-read local config in the uploader so adding an exclusion after a
373
+ // job was queued still prevents its transcript from leaving the device.
374
+ shouldSkipJob: (job) => {
375
+ const config = (0, utils_1.readConfig)();
376
+ return (0, captureExclusions_1.shouldSkipCaptureUploadJob)(job, config);
377
+ },
371
378
  };
372
379
  }
373
380
  // Detached uploader child (spawned by the capture path below). The job —
@@ -441,6 +448,13 @@ async function main() {
441
448
  catch {
442
449
  process.exit(0);
443
450
  }
451
+ // Local privacy boundary: every stdin-driven hook mode passes through this
452
+ // check BEFORE prompt discovery, limit-event tails, SessionStart pings,
453
+ // backup, self-heal, transcript reads, or detached dispatch. Ignored means
454
+ // no Codiedev request at all for this session.
455
+ if ((0, captureExclusions_1.shouldIgnoreCaptureInput)((0, utils_1.readConfig)(), hookInput)) {
456
+ process.exit(0);
457
+ }
444
458
  // UserPromptSubmit discovery path — separate from session capture.
445
459
  if (process.argv[2] === "discover") {
446
460
  await runDiscover(hookInput.prompt);
@@ -563,6 +577,17 @@ async function runCaptureFinish(input) {
563
577
  const config = (0, utils_1.readConfig)();
564
578
  if (!config)
565
579
  return;
580
+ // Re-check in the detached child: the user may have added an exclusion
581
+ // after the parent hook dispatched this job but before it started.
582
+ if ((0, captureExclusions_1.shouldIgnoreCaptureInput)(config, input)) {
583
+ (0, captureLog_1.appendCaptureLog)({
584
+ stage: "capture-finish",
585
+ sessionId: input.session_id,
586
+ outcome: "skip",
587
+ detail: "ignored-path",
588
+ });
589
+ return;
590
+ }
566
591
  const { session_id, transcript_path, cwd, workspace_roots } = input;
567
592
  if (!transcript_path) {
568
593
  await postSessionSkip(config, session_id, "no-transcript-path");
@@ -588,6 +613,7 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
588
613
  const workspaceRoot = workspace_roots && workspace_roots.length > 0
589
614
  ? workspace_roots[0]
590
615
  : cwd || process.cwd();
616
+ const scopePaths = (0, captureExclusions_1.captureScopePaths)({ cwd, workspace_roots }, workspaceRoot);
591
617
  let transcriptContent;
592
618
  try {
593
619
  transcriptContent = fs.readFileSync(transcript_path, "utf8");
@@ -718,7 +744,12 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
718
744
  const hookScriptPath = process.argv[1];
719
745
  const tmpFile = path.join(os.tmpdir(), `codiedev-capture-${session_id ?? process.pid}.json`);
720
746
  if (decision.mode === "immediate") {
721
- (0, utils_1.dispatchCaptureDetached)({ url: `${config.backendUrl}/api/captureSession`, token: config.token, body: payload }, hookScriptPath, { spawn: child_process_1.spawn, writeFileSync: fs.writeFileSync, tmpFile, openSync: fs.openSync, closeSync: fs.closeSync });
747
+ (0, utils_1.dispatchCaptureDetached)({
748
+ url: `${config.backendUrl}/api/captureSession`,
749
+ token: config.token,
750
+ body: payload,
751
+ scopePaths,
752
+ }, hookScriptPath, { spawn: child_process_1.spawn, writeFileSync: fs.writeFileSync, tmpFile, openSync: fs.openSync, closeSync: fs.closeSync });
722
753
  // Retry any earlier capture jobs that failed to upload (transient network
723
754
  // blips), excluding the one we just dispatched. Best-effort, detached.
724
755
  // Skip the sweep on deferred path — the deferred child will handle uploading.
@@ -754,7 +785,12 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
754
785
  // Deferred: write the job file (newer turns overwrite = supersede), then
755
786
  // spawn the deferred-upload child which will sleep, claim, and upload.
756
787
  // Same stdio/fd-hygiene contract as dispatchCaptureDetached.
757
- (0, utils_1.dispatchDeferredUpload)({ url: `${config.backendUrl}/api/captureSession`, token: config.token, body: payload }, hookScriptPath, { spawn: child_process_1.spawn, writeFileSync: fs.writeFileSync, tmpFile, openSync: fs.openSync, closeSync: fs.closeSync }, decision.delayMs);
788
+ (0, utils_1.dispatchDeferredUpload)({
789
+ url: `${config.backendUrl}/api/captureSession`,
790
+ token: config.token,
791
+ body: payload,
792
+ scopePaths,
793
+ }, hookScriptPath, { spawn: child_process_1.spawn, writeFileSync: fs.writeFileSync, tmpFile, openSync: fs.openSync, closeSync: fs.closeSync }, decision.delayMs);
758
794
  }
759
795
  }
760
796
  catch (err) {
package/dist/utils.d.ts CHANGED
@@ -12,9 +12,14 @@ export interface CodiedevConfig {
12
12
  }>;
13
13
  lastSynced: string;
14
14
  backendUrl: string;
15
+ deviceId?: string;
16
+ deviceName?: string;
17
+ captureIgnoredPaths?: string[];
15
18
  }
16
19
  export declare function readConfig(): CodiedevConfig | null;
17
20
  export declare function writeConfig(config: CodiedevConfig): void;
21
+ /** Exported for permission-focused tests; normal callers use writeConfig. */
22
+ export declare function writeConfigAtPath(config: CodiedevConfig, configDir: string, configPath: string): void;
18
23
  export declare function parseGitRemoteFromConfig(configText: string): string | null;
19
24
  export declare function readGitRemoteFromConfig(startDir: string): string | null;
20
25
  export declare function getGitRemoteUrl(cwd: string): string | null;
@@ -67,6 +72,8 @@ export interface CapturePayloadInput {
67
72
  }
68
73
  export declare function buildCapturePayload(input: CapturePayloadInput): Record<string, unknown>;
69
74
  export declare function hashToken(token: string): string;
75
+ export declare function extractHookNodePath(cmd: string | undefined): string | undefined;
76
+ export declare function resetStableNodeCacheForTests(): void;
70
77
  export declare function claudeCodeInstalled(): boolean;
71
78
  export declare function codexInstalled(): boolean;
72
79
  export declare function cursorInstalled(): boolean;
@@ -169,6 +176,7 @@ export interface CaptureUploadJob {
169
176
  body: Record<string, unknown>;
170
177
  attempts?: number;
171
178
  queuedAt?: number;
179
+ scopePaths?: string[];
172
180
  }
173
181
  export declare const MAX_UPLOAD_ATTEMPTS = 5;
174
182
  export declare const UPLOAD_TTL_MS: number;
@@ -260,6 +268,7 @@ interface UploadDeps {
260
268
  recordSuccess?: (job: CaptureUploadJob) => void;
261
269
  log?: (entry: CaptureLogEntry) => void;
262
270
  writeSessionState?: (sessionId: string, state: SessionUploadState) => void;
271
+ shouldSkipJob?: (job: CaptureUploadJob) => boolean;
263
272
  }
264
273
  export interface LastCaptureRecord {
265
274
  sessionId: string | undefined;
package/dist/utils.js CHANGED
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.DIRECT_UPLOAD_THRESHOLD_CHARS = exports.SWEEP_MIN_AGE_MS = exports.CAPTURE_THROTTLE_MS = exports.FINISH_ORPHAN_AGE_MS = exports.CLAIM_STALE_MS = exports.UPLOAD_TTL_MS = exports.MAX_UPLOAD_ATTEMPTS = void 0;
37
37
  exports.readConfig = readConfig;
38
38
  exports.writeConfig = writeConfig;
39
+ exports.writeConfigAtPath = writeConfigAtPath;
39
40
  exports.parseGitRemoteFromConfig = parseGitRemoteFromConfig;
40
41
  exports.readGitRemoteFromConfig = readGitRemoteFromConfig;
41
42
  exports.getGitRemoteUrl = getGitRemoteUrl;
@@ -52,6 +53,8 @@ exports.getGitBranch = getGitBranch;
52
53
  exports.getGitHeadSha = getGitHeadSha;
53
54
  exports.buildCapturePayload = buildCapturePayload;
54
55
  exports.hashToken = hashToken;
56
+ exports.extractHookNodePath = extractHookNodePath;
57
+ exports.resetStableNodeCacheForTests = resetStableNodeCacheForTests;
55
58
  exports.claudeCodeInstalled = claudeCodeInstalled;
56
59
  exports.codexInstalled = codexInstalled;
57
60
  exports.cursorInstalled = cursorInstalled;
@@ -120,10 +123,19 @@ function readConfig() {
120
123
  }
121
124
  }
122
125
  function writeConfig(config) {
123
- if (!fs.existsSync(CONFIG_DIR)) {
124
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
126
+ writeConfigAtPath(config, CONFIG_DIR, CONFIG_PATH);
127
+ }
128
+ /** Exported for permission-focused tests; normal callers use writeConfig. */
129
+ function writeConfigAtPath(config, configDir, configPath) {
130
+ if (!fs.existsSync(configDir)) {
131
+ fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
125
132
  }
126
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), "utf8");
133
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), {
134
+ encoding: "utf8",
135
+ mode: 0o600,
136
+ });
137
+ // writeFile's mode does not replace permissions on a pre-existing file.
138
+ fs.chmodSync(configPath, 0o600);
127
139
  }
128
140
  // Parse a remote URL out of git-config INI text WITHOUT invoking git. Prefers
129
141
  // the "origin" remote; falls back to the first remote found. Returns null if
@@ -733,7 +745,94 @@ const VSCODE_USER_MCP_PATH = path.join(VSCODE_USER_DIR, "mcp.json");
733
745
  function shellQuote(s) {
734
746
  return `'${s.replace(/'/g, `'\\''`)}'`;
735
747
  }
736
- function resolveHookCommand(subcommand) {
748
+ // Pull the `<node>` prefix out of a codiedev hook command we previously wrote.
749
+ // Commands take the form `'<node>' '<hookScript>' <subcommand>` (each path
750
+ // single-quoted by shellQuote). Returns undefined for the legacy
751
+ // `npx codiedev-hook <subcommand>` form (no explicit node path) or if the
752
+ // command doesn't start with a quoted token. Exported for unit testing.
753
+ function extractHookNodePath(cmd) {
754
+ if (!cmd)
755
+ return undefined;
756
+ // Match the first single-quoted token, honoring shellQuote's `'\''` escape.
757
+ const m = cmd.match(/^'((?:[^']|'\\'')*)'/);
758
+ if (!m)
759
+ return undefined;
760
+ return m[1].replace(/'\\''/g, "'");
761
+ }
762
+ // `process.execPath` is the *resolved* path to the running node binary, so under
763
+ // Homebrew it's the version-specific Cellar path (e.g.
764
+ // /opt/homebrew/Cellar/node/26.4.0/bin/node). Baking that into the hook means a
765
+ // later `brew upgrade node` moves the Cellar dir and the hook path 404s. Prefer
766
+ // a stable alias for the *same* binary:
767
+ // 1. A node path from a hook we previously wrote that still resolves — usually
768
+ // a symlink the user deliberately pinned (e.g. /opt/homebrew/bin/node). A
769
+ // re-`connect`/self-heal then honors it instead of clobbering it.
770
+ // 2. Otherwise, `node` as found on PATH when it points at the binary we're
771
+ // running (the Homebrew symlink, an nvm `current` alias, etc.).
772
+ // 3. Last resort: the resolved `process.execPath`. Always valid right now, and
773
+ // still an absolute path a GUI-launched agent can execute without PATH.
774
+ function realpathOrSelf(p) {
775
+ try {
776
+ return fs.realpathSync(p);
777
+ }
778
+ catch {
779
+ return p;
780
+ }
781
+ }
782
+ // A stable `node` alias on PATH (Homebrew symlink, nvm `current`, …) that points
783
+ // at the very binary we're running, or undefined if none. `installHook` writes
784
+ // five hooks per run, so memoize: `command -v node` is a synchronous subprocess
785
+ // spawn and its result can't change within a process. `null` = "looked, none".
786
+ let cachedStableNodeOnPath;
787
+ // Test-only: the memo lives for the process lifetime, so suites that stage
788
+ // their own PATH/symlink fixtures must clear it between tests.
789
+ function resetStableNodeCacheForTests() {
790
+ cachedStableNodeOnPath = undefined;
791
+ }
792
+ function stableNodeOnPath() {
793
+ if (cachedStableNodeOnPath !== undefined) {
794
+ return cachedStableNodeOnPath ?? undefined;
795
+ }
796
+ const execReal = realpathOrSelf(process.execPath);
797
+ try {
798
+ const onPath = (0, child_process_1.execSync)("command -v node", {
799
+ stdio: ["pipe", "pipe", "pipe"],
800
+ })
801
+ .toString()
802
+ .trim();
803
+ if (onPath && onPath !== process.execPath && realpathOrSelf(onPath) === execReal) {
804
+ cachedStableNodeOnPath = onPath;
805
+ return onPath;
806
+ }
807
+ }
808
+ catch {
809
+ // No node on PATH (e.g. GUI-launched agent) — fall through.
810
+ }
811
+ cachedStableNodeOnPath = null;
812
+ return undefined;
813
+ }
814
+ function resolveHookNodePath(preferredNodePath) {
815
+ const execReal = realpathOrSelf(process.execPath);
816
+ // 1. Honor a still-working node path from the existing hook — but only when
817
+ // it's a *different* string than the resolved realpath. If the existing
818
+ // path already IS `process.execPath` it's the version-specific realpath we
819
+ // want to upgrade away from (that's the Homebrew bug), so fall through to
820
+ // hunt for a stable alias in step 2.
821
+ if (preferredNodePath &&
822
+ preferredNodePath !== process.execPath &&
823
+ fs.existsSync(preferredNodePath) &&
824
+ realpathOrSelf(preferredNodePath) === execReal) {
825
+ return preferredNodePath;
826
+ }
827
+ // 2. Prefer a stable `node` on PATH over the version-specific realpath.
828
+ const onPath = stableNodeOnPath();
829
+ if (onPath)
830
+ return onPath;
831
+ // 3. Last resort: the resolved absolute path. Always valid right now, and
832
+ // still an absolute path a GUI-launched agent can execute without PATH.
833
+ return process.execPath;
834
+ }
835
+ function resolveHookCommand(subcommand, preferredNodePath) {
737
836
  try {
738
837
  const cliEntry = process.argv[1];
739
838
  if (cliEntry) {
@@ -741,7 +840,8 @@ function resolveHookCommand(subcommand) {
741
840
  const distDir = path.dirname(realCli);
742
841
  const hookScript = path.join(distDir, "hook.js");
743
842
  if (fs.existsSync(hookScript)) {
744
- return `${shellQuote(process.execPath)} ${shellQuote(hookScript)} ${subcommand}`;
843
+ const nodePath = resolveHookNodePath(preferredNodePath);
844
+ return `${shellQuote(nodePath)} ${shellQuote(hookScript)} ${subcommand}`;
745
845
  }
746
846
  }
747
847
  }
@@ -798,6 +898,22 @@ function upsertCodiedevHook(hooks, eventName, subcommand, matcher = ".*") {
798
898
  if (!hooks[eventName])
799
899
  hooks[eventName] = [];
800
900
  const existing = hooks[eventName];
901
+ // Before stripping the old codiedev entry, remember the node path it used so a
902
+ // re-run/upgrade can honor a stable symlink the user pinned instead of
903
+ // overwriting it with the version-specific `process.execPath` (see
904
+ // resolveHookNodePath). Done in an explicit scan rather than as a side effect
905
+ // of the filter predicate below, which stays a pure predicate.
906
+ let preservedNodePath;
907
+ for (const hook of existing) {
908
+ const inner = hook.hooks;
909
+ if (!Array.isArray(inner))
910
+ continue;
911
+ const codiedevInner = inner.find((h) => (0, detection_1.isCodiedevHookCommand)(h.command));
912
+ if (codiedevInner) {
913
+ preservedNodePath = extractHookNodePath(codiedevInner.command);
914
+ break;
915
+ }
916
+ }
801
917
  const filtered = existing.filter((hook) => {
802
918
  const inner = hook.hooks;
803
919
  if (Array.isArray(inner)) {
@@ -807,7 +923,7 @@ function upsertCodiedevHook(hooks, eventName, subcommand, matcher = ".*") {
807
923
  });
808
924
  filtered.push({
809
925
  matcher,
810
- hooks: [{ type: "command", command: resolveHookCommand(subcommand) }],
926
+ hooks: [{ type: "command", command: resolveHookCommand(subcommand, preservedNodePath) }],
811
927
  });
812
928
  hooks[eventName] = filtered;
813
929
  }
@@ -2093,6 +2209,19 @@ async function runCaptureUpload(jobFile, deps) {
2093
2209
  return;
2094
2210
  }
2095
2211
  const sessionId = job.body?.sessionId;
2212
+ if (deps.shouldSkipJob?.(job)) {
2213
+ if (deps.log) {
2214
+ try {
2215
+ deps.log({ stage: "upload", sessionId, outcome: "skip", detail: "ignored-path" });
2216
+ }
2217
+ catch { /* log is best-effort */ }
2218
+ }
2219
+ try {
2220
+ deps.unlinkSync(jobFile);
2221
+ }
2222
+ catch { /* gone */ }
2223
+ return;
2224
+ }
2096
2225
  try {
2097
2226
  // Big transcript + capable deps → direct-to-storage: fetch a one-time
2098
2227
  // upload URL, PUT the raw bytes, send captureSession the storageId. Any
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codiedev",
3
- "version": "0.9.24",
3
+ "version": "0.9.26",
4
4
  "description": "Connect Claude Code, Codex, Cursor, or VS Code Copilot to Codiedev for org-wide session capture and artifact collaboration",
5
5
  "bin": {
6
6
  "codiedev": "dist/cli.js",