codiedev 0.9.25 → 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
+ }
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;
@@ -171,6 +176,7 @@ export interface CaptureUploadJob {
171
176
  body: Record<string, unknown>;
172
177
  attempts?: number;
173
178
  queuedAt?: number;
179
+ scopePaths?: string[];
174
180
  }
175
181
  export declare const MAX_UPLOAD_ATTEMPTS = 5;
176
182
  export declare const UPLOAD_TTL_MS: number;
@@ -262,6 +268,7 @@ interface UploadDeps {
262
268
  recordSuccess?: (job: CaptureUploadJob) => void;
263
269
  log?: (entry: CaptureLogEntry) => void;
264
270
  writeSessionState?: (sessionId: string, state: SessionUploadState) => void;
271
+ shouldSkipJob?: (job: CaptureUploadJob) => boolean;
265
272
  }
266
273
  export interface LastCaptureRecord {
267
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;
@@ -122,10 +123,19 @@ function readConfig() {
122
123
  }
123
124
  }
124
125
  function writeConfig(config) {
125
- if (!fs.existsSync(CONFIG_DIR)) {
126
- 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 });
127
132
  }
128
- 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);
129
139
  }
130
140
  // Parse a remote URL out of git-config INI text WITHOUT invoking git. Prefers
131
141
  // the "origin" remote; falls back to the first remote found. Returns null if
@@ -2199,6 +2209,19 @@ async function runCaptureUpload(jobFile, deps) {
2199
2209
  return;
2200
2210
  }
2201
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
+ }
2202
2225
  try {
2203
2226
  // Big transcript + capable deps → direct-to-storage: fetch a one-time
2204
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.25",
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",