codiedev 0.9.52 → 0.10.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +9 -1
  2. package/dist/captureLog.d.ts +1 -1
  3. package/dist/cli.js +17 -12
  4. package/dist/codie.js +146 -1
  5. package/dist/codieLauncher.d.ts +5 -0
  6. package/dist/codieLauncher.js +22 -1
  7. package/dist/codieRuntime.d.ts +11 -1
  8. package/dist/codieRuntime.js +20 -3
  9. package/dist/codieRuntimeInstall.d.ts +12 -11
  10. package/dist/codieRuntimeInstall.js +3 -1
  11. package/dist/codieStartup.d.ts +26 -0
  12. package/dist/codieStartup.js +30 -0
  13. package/dist/commands/doctor.js +8 -0
  14. package/dist/commands/security.d.ts +1 -0
  15. package/dist/commands/security.js +156 -0
  16. package/dist/connect.js +56 -4
  17. package/dist/cursorHooks.d.ts +23 -0
  18. package/dist/cursorHooks.js +81 -0
  19. package/dist/hook.js +272 -26
  20. package/dist/limitEvent.d.ts +5 -1
  21. package/dist/limitEvent.js +5 -1
  22. package/dist/mcp.js +1 -93
  23. package/dist/repoResolver.js +10 -4
  24. package/dist/security.d.ts +102 -0
  25. package/dist/security.js +437 -0
  26. package/dist/securityDeepScan.d.ts +41 -0
  27. package/dist/securityDeepScan.js +248 -0
  28. package/dist/securityDoctor.d.ts +32 -0
  29. package/dist/securityDoctor.js +63 -0
  30. package/dist/securityFeature.d.ts +5 -0
  31. package/dist/securityFeature.js +7 -0
  32. package/dist/securityInstall.d.ts +63 -0
  33. package/dist/securityInstall.js +239 -0
  34. package/dist/securityInventory.d.ts +54 -0
  35. package/dist/securityInventory.js +556 -0
  36. package/dist/securityPilot.d.ts +19 -0
  37. package/dist/securityPilot.js +61 -0
  38. package/dist/securityPolicy.d.ts +33 -0
  39. package/dist/securityPolicy.js +188 -0
  40. package/dist/securitySession.d.ts +6 -0
  41. package/dist/securitySession.js +72 -0
  42. package/dist/upgradeNudge.d.ts +1 -0
  43. package/dist/upgradeNudge.js +1 -0
  44. package/dist/utils.d.ts +16 -6
  45. package/dist/utils.js +311 -70
  46. package/package.json +14 -3
  47. package/dist/commands/post.d.ts +0 -2
  48. package/dist/commands/post.js +0 -96
package/dist/hook.js CHANGED
@@ -37,7 +37,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
37
37
  exports.buildDiscoveryContext = buildDiscoveryContext;
38
38
  const fs = __importStar(require("fs"));
39
39
  const os = __importStar(require("os"));
40
- const path = __importStar(require("path"));
41
40
  const https = __importStar(require("https"));
42
41
  const http = __importStar(require("http"));
43
42
  const child_process_1 = require("child_process");
@@ -45,7 +44,13 @@ const utils_1 = require("./utils");
45
44
  const limitEventReporter_1 = require("./limitEventReporter");
46
45
  const version_1 = require("./version");
47
46
  const captureLog_1 = require("./captureLog");
47
+ const security_1 = require("./security");
48
+ const securitySession_1 = require("./securitySession");
49
+ const securityPolicy_1 = require("./securityPolicy");
50
+ const securityFeature_1 = require("./securityFeature");
51
+ const securityInventory_1 = require("./securityInventory");
48
52
  const captureExclusions_1 = require("./captureExclusions");
53
+ const cursorHooks_1 = require("./cursorHooks");
49
54
  // Pure builder for the discovery additionalContext. Returns null when there are
50
55
  // no matches. The suggested `codiedev pull` command carries the EXACT top key
51
56
  // (e.g. "pr-push.md") rather than a "<key>" placeholder — otherwise agents
@@ -118,6 +123,8 @@ async function readAndPostLimitEvent(hookInput, source, emitMinimalOnMiss) {
118
123
  const tp = hookInput.transcript_path;
119
124
  if (tp) {
120
125
  try {
126
+ // Codex invokes Stop after every turn. Bound this read so long-running
127
+ // sessions do not repeatedly load their entire rollout into memory.
121
128
  transcript = readTranscriptTailFile(tp);
122
129
  }
123
130
  catch {
@@ -231,7 +238,7 @@ timeoutMs) {
231
238
  // postJson variant that returns the parsed response body — the upload-URL
232
239
  // handshake needs the { uploadUrl } the server hands back. No timeout: runs
233
240
  // only in detached uploader children, never on the shutdown clock.
234
- function postJsonForResponse(url, body, bearerToken) {
241
+ function postJsonForResponse(url, body, bearerToken, timeoutMs) {
235
242
  return new Promise((resolve, reject) => {
236
243
  const parsed = new URL(url);
237
244
  const data = JSON.stringify(body);
@@ -265,10 +272,145 @@ function postJsonForResponse(url, body, bearerToken) {
265
272
  });
266
273
  });
267
274
  req.on("error", reject);
275
+ if (timeoutMs) {
276
+ req.setTimeout(timeoutMs, () => {
277
+ req.destroy(new Error(`timeout after ${timeoutMs}ms`));
278
+ });
279
+ }
268
280
  req.write(data);
269
281
  req.end();
270
282
  });
271
283
  }
284
+ // Local policy runs synchronously before a tool executes. Workflow telemetry
285
+ // is dispatched separately and fail-open, so a detector outage or timeout can
286
+ // never delay or override the deterministic endpoint decision.
287
+ async function runSecurityHook(hookInput, phase, source, uploadTelemetry = true) {
288
+ const config = (0, utils_1.readConfig)();
289
+ if (!config || !(0, securityFeature_1.isAgentSecurityEnabled)(config))
290
+ return;
291
+ const policy = (0, securityPolicy_1.readEndpointSecurityPolicy)(undefined, undefined, config.companyId);
292
+ const sessionId = hookInput.session_id ?? hookInput.sessionId;
293
+ const sessionSignal = phase === "pre" ? (0, securitySession_1.readSessionSecuritySignal)(sessionId) : undefined;
294
+ const localDecision = (0, security_1.evaluateToolSecurity)(hookInput, phase, policy, source, sessionSignal);
295
+ if (phase === "post" && (0, security_1.securityInputHasSensitivePath)(hookInput)) {
296
+ (0, securitySession_1.recordSensitiveReadSignal)(sessionId);
297
+ }
298
+ const event = (0, security_1.buildSecurityWorkflowEvent)(hookInput, phase, source);
299
+ if (event) {
300
+ event.localDecision = localDecision.action;
301
+ event.localCategory = localDecision.category;
302
+ }
303
+ if (localDecision.action === "block" && phase === "pre") {
304
+ (0, captureLog_1.appendCaptureLog)({
305
+ stage: "security",
306
+ sessionId: hookInput.session_id ?? hookInput.sessionId ?? "unknown",
307
+ outcome: "ok",
308
+ detail: `phase=${phase} localDecision=block category=${localDecision.category}`,
309
+ });
310
+ if (uploadTelemetry)
311
+ dispatchSecurityEventUpload(event);
312
+ const response = source === "cursor"
313
+ ? (0, cursorHooks_1.cursorPreToolBlockResponse)(localDecision.reason ?? "Codie blocked this action")
314
+ : {
315
+ hookSpecificOutput: {
316
+ hookEventName: "PreToolUse",
317
+ permissionDecision: "deny",
318
+ permissionDecisionReason: localDecision.reason,
319
+ },
320
+ };
321
+ process.stdout.write(JSON.stringify(response));
322
+ return;
323
+ }
324
+ if (!event)
325
+ return;
326
+ if (uploadTelemetry)
327
+ dispatchSecurityEventUpload(event);
328
+ }
329
+ function runPromptSecurityHook(hookInput, source, uploadTelemetry = true) {
330
+ const config = (0, utils_1.readConfig)();
331
+ if (!config || !(0, securityFeature_1.isAgentSecurityEnabled)(config))
332
+ return false;
333
+ const decision = (0, security_1.evaluatePromptSecurity)(hookInput.prompt, (0, securityPolicy_1.readEndpointSecurityPolicy)(undefined, undefined, config.companyId), source);
334
+ if (decision.action !== "block")
335
+ return false;
336
+ (0, captureLog_1.appendCaptureLog)({
337
+ stage: "security",
338
+ sessionId: hookInput.session_id ?? hookInput.sessionId ?? "unknown",
339
+ outcome: "ok",
340
+ detail: `phase=prompt localDecision=block category=${decision.category} telemetry=${uploadTelemetry ? "enabled" : "suppressed"} cwd=${hookInput.cwd ?? "unknown"}`,
341
+ });
342
+ if (uploadTelemetry) {
343
+ dispatchSecurityEventUpload((0, security_1.buildBlockedSecurityPromptEvent)(hookInput, source, decision.category ?? "secret_in_prompt"));
344
+ }
345
+ const response = source === "cursor"
346
+ ? (0, cursorHooks_1.cursorPromptBlockResponse)(decision.reason ?? "Codie blocked this prompt")
347
+ : { decision: "block", reason: decision.reason };
348
+ process.stdout.write(JSON.stringify(response));
349
+ return true;
350
+ }
351
+ function dispatchAllowedPromptSecurityEvent(hookInput, source) {
352
+ const config = (0, utils_1.readConfig)();
353
+ if (!config || !(0, securityFeature_1.isAgentSecurityEnabled)(config))
354
+ return;
355
+ const event = (0, security_1.buildSecurityPromptEvent)(hookInput, source, Date.now, (0, securityPolicy_1.readEndpointSecurityPolicy)(undefined, undefined, config.companyId));
356
+ if (event)
357
+ dispatchSecurityEventUpload(event);
358
+ }
359
+ function dispatchSecurityEventUpload(event) {
360
+ if (!event || !(0, securityFeature_1.isAgentSecurityEnabled)((0, utils_1.readConfig)()))
361
+ return;
362
+ try {
363
+ const child = (0, child_process_1.spawn)(process.execPath, [process.argv[1], "security-upload"], {
364
+ detached: true,
365
+ stdio: ["pipe", "ignore", "ignore"],
366
+ });
367
+ child.stdin?.end(JSON.stringify(event));
368
+ child.unref();
369
+ }
370
+ catch {
371
+ // Telemetry is best-effort and must never delay local enforcement.
372
+ }
373
+ }
374
+ async function runSecurityEventUpload() {
375
+ const config = (0, utils_1.readConfig)();
376
+ if (!config || !(0, securityFeature_1.isAgentSecurityEnabled)(config))
377
+ return;
378
+ const startedAt = Date.now();
379
+ try {
380
+ const raw = await readStdin();
381
+ const event = JSON.parse(raw);
382
+ const cachedPolicy = (0, securityPolicy_1.readEndpointSecurityPolicy)(undefined, undefined, config.companyId);
383
+ const eventWithEndpoint = (0, security_1.withEndpointIdentity)(event, {
384
+ endpointId: config.deviceId,
385
+ endpointName: config.deviceName,
386
+ cliVersion: version_1.CLI_VERSION,
387
+ securityRuntimeVersion: security_1.SECURITY_RUNTIME_VERSION,
388
+ securityPolicyVersion: cachedPolicy?.policyVersion,
389
+ });
390
+ const result = await postJsonForResponse(`${config.backendUrl}/api/cli/securityEvent`, { ...eventWithEndpoint }, config.token,
391
+ // Leave room for a cold backend and durable scheduling. This uploader is
392
+ // detached, so the network budget never delays the developer's prompt or
393
+ // tool call. Policy refreshes use the separately signed sync endpoint.
394
+ 5_000);
395
+ if (result?.policy?.schemaVersion === 1) {
396
+ (0, securityPolicy_1.writeEndpointSecurityPolicy)(result.policy, undefined, undefined, config.companyId);
397
+ }
398
+ (0, captureLog_1.appendCaptureLog)({
399
+ stage: "security",
400
+ sessionId: event.sessionId,
401
+ outcome: "ok",
402
+ detail: `phase=${event.phase} elapsedMs=${Date.now() - startedAt} verdict=${String(result?.verdict ?? "unknown")}`,
403
+ });
404
+ }
405
+ catch (err) {
406
+ (0, captureLog_1.appendCaptureLog)({
407
+ stage: "security",
408
+ sessionId: "upload",
409
+ outcome: "ERROR",
410
+ detail: `elapsedMs=${Date.now() - startedAt} ${err.message}`,
411
+ });
412
+ }
413
+ }
272
414
  // Raw-bytes POST to a Convex storage upload URL (no auth header — the URL
273
415
  // itself is the one-time credential). Returns the parsed { storageId }.
274
416
  function postBytes(url, bytes, contentType) {
@@ -324,7 +466,7 @@ async function readStdin() {
324
466
  function resolveMode(arg) {
325
467
  if (arg === "capture-codex")
326
468
  return "codex";
327
- if (arg === "capture-cursor")
469
+ if (arg === "capture-cursor" || arg === "backup-cursor")
328
470
  return "cursor";
329
471
  if (arg === "capture-opencode" || arg === "capture-opencode-final")
330
472
  return "opencode";
@@ -392,6 +534,20 @@ async function main() {
392
534
  }
393
535
  process.exit(0);
394
536
  }
537
+ // Daily component drift scan runs in a detached hook process. The parent
538
+ // SessionStart path only performs a timestamp read, so agent startup never
539
+ // waits for filesystem traversal or an inventory upload.
540
+ if (process.argv[2] === "security-inventory-upload") {
541
+ const config = (0, utils_1.readConfig)();
542
+ if (config && (0, securityFeature_1.isAgentSecurityEnabled)(config)) {
543
+ const inventory = (0, securityInventory_1.buildSecurityInventory)({ workspace: process.argv[3] || process.cwd() });
544
+ try {
545
+ await (0, securityInventory_1.uploadSecurityInventory)(inventory, config);
546
+ }
547
+ catch { /* retry after the daily inventory window */ }
548
+ }
549
+ process.exit(0);
550
+ }
395
551
  // Detached backup uploader child (spawned by runBackup below). The job file
396
552
  // carries only { root, files }; the child re-reads config from disk. No stdin
397
553
  // needed, so handle it before readStdin() like the capture uploader above.
@@ -428,28 +584,73 @@ async function main() {
428
584
  }
429
585
  process.exit(0);
430
586
  }
587
+ // Detached, best-effort security telemetry. Local policy decisions have
588
+ // already completed in the parent hook; this child can never delay or alter
589
+ // the developer's action.
590
+ if (process.argv[2] === "security-upload") {
591
+ await runSecurityEventUpload();
592
+ process.exit(0);
593
+ }
431
594
  let hookInput = {};
432
595
  try {
433
596
  const raw = await readStdin();
434
597
  if (raw.trim()) {
435
- hookInput = JSON.parse(raw);
598
+ const parsed = JSON.parse(raw);
599
+ hookInput = (process.argv[2]?.endsWith("-cursor")
600
+ ? (0, cursorHooks_1.normalizeCursorHookInput)(parsed)
601
+ : parsed);
436
602
  }
437
603
  }
438
604
  catch {
439
605
  process.exit(0);
440
606
  }
441
- // Local privacy boundary: every stdin-driven hook mode passes through this
442
- // check BEFORE prompt discovery, limit-event tails, SessionStart pings,
443
- // backup, self-heal, transcript reads, or detached dispatch. Ignored means
444
- // no Codiedev request at all for this session.
445
- if ((0, captureExclusions_1.shouldIgnoreCaptureInput)((0, utils_1.readConfig)(), hookInput)) {
607
+ // Privacy exclusions suppress every Codiedev upload, but they must never
608
+ // disable local security enforcement. Security hooks run first with telemetry
609
+ // disabled; all other hook modes still exit before making a network request.
610
+ const captureIgnored = (0, captureExclusions_1.shouldIgnoreCaptureInput)((0, utils_1.readConfig)(), hookInput);
611
+ const securityCommand = process.argv[2] ?? "";
612
+ const securityMatch = securityCommand.match(/^security-(pre|post|failure)(?:-(codex|cursor|codie))?$/);
613
+ if (securityMatch) {
614
+ const source = securityMatch[2] === "codex"
615
+ ? "codex"
616
+ : securityMatch[2] === "cursor"
617
+ ? "cursor"
618
+ : securityMatch[2] === "codie"
619
+ ? "codie-harness"
620
+ : "claude-code";
621
+ await runSecurityHook(hookInput, securityMatch[1], source, !captureIgnored);
446
622
  process.exit(0);
447
623
  }
448
624
  // UserPromptSubmit discovery path — separate from session capture.
449
- if (process.argv[2] === "discover") {
625
+ const promptCommand = process.argv[2] ?? "";
626
+ if (promptCommand === "security-prompt-cursor") {
627
+ if (runPromptSecurityHook(hookInput, "cursor", !captureIgnored))
628
+ return;
629
+ if (captureIgnored)
630
+ return;
631
+ dispatchAllowedPromptSecurityEvent(hookInput, "cursor");
632
+ process.exit(0);
633
+ }
634
+ if (process.argv[2] === "discover" ||
635
+ process.argv[2] === "discover-codex" ||
636
+ process.argv[2] === "discover-codie") {
637
+ const source = promptCommand === "discover-codex"
638
+ ? "codex"
639
+ : promptCommand === "discover-codie"
640
+ ? "codie-harness"
641
+ : "claude-code";
642
+ // Security runs first and locally. A blocked prompt is erased before it
643
+ // reaches the model, so discovery must not send it to the backend.
644
+ if (runPromptSecurityHook(hookInput, source, !captureIgnored))
645
+ return;
646
+ if (captureIgnored)
647
+ return;
648
+ dispatchAllowedPromptSecurityEvent(hookInput, source);
450
649
  await runDiscover(hookInput.prompt);
451
650
  return;
452
651
  }
652
+ if (captureIgnored)
653
+ process.exit(0);
453
654
  // StopFailure (matcher rate_limit): post the rate-limit event.
454
655
  if (process.argv[2] === "limit") {
455
656
  await runStopFailure(hookInput);
@@ -460,8 +661,24 @@ async function main() {
460
661
  return;
461
662
  }
462
663
  // SessionStart: post the capture-coverage denominator ping. Tiny + fast.
463
- if (process.argv[2] === "session-start" || process.argv[2] === "session-start-opencode") {
464
- await runSessionStart(hookInput, process.argv[2] === "session-start-opencode" ? "opencode" : "claude-code");
664
+ if (process.argv[2] === "session-start" ||
665
+ process.argv[2] === "session-start-cursor" ||
666
+ process.argv[2] === "session-start-opencode") {
667
+ if ((0, securityFeature_1.isAgentSecurityEnabled)((0, utils_1.readConfig)()) && (0, securityInventory_1.securityInventoryDue)()) {
668
+ try {
669
+ const child = (0, child_process_1.spawn)(process.execPath, [process.argv[1], "security-inventory-upload", hookInput.cwd || process.cwd()], {
670
+ detached: true,
671
+ stdio: "ignore",
672
+ });
673
+ child.unref();
674
+ }
675
+ catch { /* daily inventory is best-effort */ }
676
+ }
677
+ await runSessionStart(hookInput, process.argv[2] === "session-start-opencode"
678
+ ? "opencode"
679
+ : process.argv[2] === "session-start-cursor"
680
+ ? "cursor"
681
+ : "claude-code");
465
682
  return;
466
683
  }
467
684
  // Self-heal: a user who upgraded the CLI but hasn't re-run `connect` gets
@@ -469,7 +686,12 @@ async function main() {
469
686
  // no self-heal and could stay stripped forever while SessionStart kept
470
687
  // pinging (the Sonifi "starts arrive, captures never" loss) — re-wired on
471
688
  // their next Stop/SessionEnd. One read + one write, only when missing.
472
- (0, utils_1.ensureAllCodiedevHooksInstalled)();
689
+ if ((process.argv[2] ?? "").endsWith("-cursor")) {
690
+ (0, utils_1.ensureCursorCodiedevHooksInstalled)();
691
+ }
692
+ else {
693
+ (0, utils_1.ensureAllCodiedevHooksInstalled)();
694
+ }
473
695
  if (process.argv[2] === "capture-codex")
474
696
  (0, utils_1.ensureCodexLimitHookInstalled)();
475
697
  const { session_id, transcript_path, cwd, workspace_roots } = hookInput;
@@ -484,13 +706,11 @@ async function main() {
484
706
  // extension) where SessionEnd may never come — the silent zero-capture bug.
485
707
  // Same detached capture-finish child as SessionEnd, marked non-final so the
486
708
  // server renews the session lease instead of summarizing immediately.
487
- if (process.argv[2] === "backup") {
709
+ if (process.argv[2] === "backup" || process.argv[2] === "backup-cursor") {
488
710
  if (transcript_path) {
489
711
  try {
490
712
  (0, utils_1.dispatchCaptureFinish)({
491
- // "capture" = the claude-code entrypoint literal, same as the
492
- // SessionEnd path below (resolveMode maps it to claude-code).
493
- mode: "capture",
713
+ mode: process.argv[2] === "backup-cursor" ? "capture-cursor" : "capture",
494
714
  session_id,
495
715
  transcript_path,
496
716
  cwd,
@@ -550,10 +770,9 @@ async function main() {
550
770
  transcript_path,
551
771
  cwd,
552
772
  workspace_roots,
553
- // final:true ONLY for claude-code's SessionEnd. Codex routes its
554
- // per-turn Stop hook through "capture-codex" — final:true there would
555
- // close the server lease every turn. Codex/Cursor keep legacy
556
- // behaviour (final omitted).
773
+ // final:true for real SessionEnd signals (Claude Code and Cursor).
774
+ // Codex routes its per-turn Stop hook through "capture-codex" —
775
+ // final:true there would close the server lease every turn.
557
776
  final: (0, utils_1.resolveCaptureFinal)(process.argv[2]),
558
777
  }, process.argv[1], { spawn: child_process_1.spawn, writeFileSync: fs.writeFileSync, tmpFile: jobFile, openSync: fs.openSync, closeSync: fs.closeSync });
559
778
  }
@@ -630,7 +849,9 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
630
849
  outcome: "skip",
631
850
  detail: reason,
632
851
  });
633
- await postSessionSkip(config, session_id, reason);
852
+ if ((0, utils_1.shouldReportCaptureSkip)(input.final)) {
853
+ await postSessionSkip(config, session_id, reason);
854
+ }
634
855
  return;
635
856
  }
636
857
  // Snapshot the RAW transcript before normalization below flattens it to
@@ -642,6 +863,9 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
642
863
  if (parserMode === "opencode") {
643
864
  transcriptContent = (0, utils_1.normalizeOpenCodeTranscript)(transcriptContent);
644
865
  }
866
+ else if (parserMode === "cursor") {
867
+ transcriptContent = (0, cursorHooks_1.normalizeCursorTranscript)(transcriptContent);
868
+ }
645
869
  // Resolve repo URL. Codex embeds it in session_meta; CC and Cursor both
646
870
  // need the git remote of the workspace.
647
871
  let remoteUrl = null;
@@ -668,9 +892,26 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
668
892
  const matchedRepo = remoteUrl ? (0, utils_1.matchRepo)(remoteUrl, config.repos) : null;
669
893
  const stats = parserMode === "codex"
670
894
  ? (0, utils_1.parseCodexStats)(transcriptContent)
671
- : parserMode === "cursor"
672
- ? { messageCount: 0, toolCallCount: 0 }
673
- : (0, utils_1.parseClaudeCodeStats)(transcriptContent);
895
+ : (0, utils_1.parseClaudeCodeStats)(transcriptContent);
896
+ // Cursor can emit duplicate lifecycle notifications while one transcript
897
+ // path is still byte-empty. Never use parsed message count here: Cursor's
898
+ // private JSONL shape can evolve independently, and parser drift must not
899
+ // silently discard a real non-empty session.
900
+ if (parserMode === "cursor" && (0, cursorHooks_1.isCursorTranscriptEmpty)(rawTranscriptContent)) {
901
+ (0, captureLog_1.appendCaptureLog)({
902
+ stage: "capture-finish",
903
+ sessionId: session_id,
904
+ outcome: "skip",
905
+ detail: "empty-cursor-transcript",
906
+ });
907
+ // A non-final stop snapshot is only a backup; reporting it as a skipped
908
+ // session would close/remove server-side start coverage while the session
909
+ // is still alive. The real SessionEnd remains responsible for a skip ping.
910
+ if ((0, utils_1.shouldReportCaptureSkip)(input.final)) {
911
+ await postSessionSkip(config, session_id, "empty-cursor-transcript");
912
+ }
913
+ return;
914
+ }
674
915
  const transcriptBase64 = Buffer.from(transcriptContent, "utf8").toString("base64");
675
916
  // Session→merge join keys: the branch + HEAD sha the work happened on. Matched
676
917
  // server-side against a merged MR/PR's source_branch / commits for AI attribution.
@@ -763,7 +1004,7 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
763
1004
  // which is the "supersede" mechanism for the deferred path.
764
1005
  try {
765
1006
  const hookScriptPath = process.argv[1];
766
- const tmpFile = path.join(os.tmpdir(), `codiedev-capture-${session_id ?? process.pid}.json`);
1007
+ const tmpFile = (0, utils_1.captureUploadTmpFile)(session_id, decision.mode);
767
1008
  if (decision.mode === "immediate") {
768
1009
  (0, utils_1.dispatchCaptureDetached)({
769
1010
  url: `${config.backendUrl}/api/captureSession`,
@@ -771,6 +1012,11 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
771
1012
  body: payload,
772
1013
  scopePaths,
773
1014
  }, hookScriptPath, { spawn: child_process_1.spawn, writeFileSync: fs.writeFileSync, tmpFile, openSync: fs.openSync, closeSync: fs.closeSync });
1015
+ // The immediate payload is durable before removing the older scheduled
1016
+ // snapshot. Do this for every immediate dispatch, not only SessionEnd:
1017
+ // first/outside-window snapshots also supersede a sleeping deferred job.
1018
+ if (session_id)
1019
+ (0, utils_1.cancelDeferredCaptureUpload)(session_id);
774
1020
  // Retry any earlier capture jobs that failed to upload (transient network
775
1021
  // blips), excluding the one we just dispatched. Best-effort, detached.
776
1022
  // Skip the sweep on deferred path — the deferred child will handle uploading.
@@ -7,6 +7,10 @@ export interface LimitEvent {
7
7
  }
8
8
  export declare function classifyLimitKind(text: string): LimitKind;
9
9
  export declare function parseLimitEventFromTranscript(transcript: string): LimitEvent | null;
10
- /** Parse the structured rate-limit snapshot written to Codex rollout JSONL. */
10
+ /**
11
+ * Codex writes a structured RateLimitSnapshot on token_count events. A normal
12
+ * turn has `rate_limit_reached_type: null`; a blocked turn names the exact
13
+ * boundary. Reading that field avoids locale-dependent terminal copy.
14
+ */
11
15
  export declare function parseCodexLimitEventFromTranscript(transcript: string): LimitEvent | null;
12
16
  export declare function tailForLimitEvent(transcript: string, maxChars?: number): string;
@@ -69,7 +69,11 @@ function codexResetText(rateLimits) {
69
69
  .sort((a, b) => a - b)[0];
70
70
  return resetAt ? new Date(resetAt * 1000).toISOString() : null;
71
71
  }
72
- /** Parse the structured rate-limit snapshot written to Codex rollout JSONL. */
72
+ /**
73
+ * Codex writes a structured RateLimitSnapshot on token_count events. A normal
74
+ * turn has `rate_limit_reached_type: null`; a blocked turn names the exact
75
+ * boundary. Reading that field avoids locale-dependent terminal copy.
76
+ */
73
77
  function parseCodexLimitEventFromTranscript(transcript) {
74
78
  let found = null;
75
79
  for (const line of transcript.split("\n")) {
package/dist/mcp.js CHANGED
@@ -260,68 +260,6 @@ const TOOLS = [
260
260
  required: ["artifactId"],
261
261
  },
262
262
  },
263
- {
264
- name: "codiedev_post_to_feed",
265
- description: "Publish an artifact to the team's break-room feed with an intent " +
266
- "and optional @mentions. Use when the user asks to 'post this to " +
267
- "the team', 'publish to the feed', 'share with the team', 'ask the " +
268
- "team', or when they want broader visibility than a single ping. " +
269
- "Pass the artifact's filename (e.g., 'spec-214.md') and pick an " +
270
- "intent that matches the request: share = here's a skill/spec; " +
271
- "request_review = please review; request_expertise = I'm stuck, " +
272
- "who knows this; link_share = fyi; rfc = proposing a change. " +
273
- "Optionally @mention teammates (by first name) so they get a " +
274
- "notification. Returns the feed post id.",
275
- inputSchema: {
276
- type: "object",
277
- properties: {
278
- filename: {
279
- type: "string",
280
- description: "Artifact filename to attach (e.g., 'spec-214.md'). The post " +
281
- "will render a preview card linking to it.",
282
- },
283
- title: {
284
- type: "string",
285
- description: "Headline of the post. Short, imperative — e.g., 'Check my " +
286
- "OAuth middleware spec' or 'Anyone know how we handle X?'.",
287
- },
288
- body: {
289
- type: "string",
290
- description: "Body of the post in markdown. A short explanation of why the " +
291
- "user is posting (the artifact carries the detail).",
292
- },
293
- intent: {
294
- type: "string",
295
- enum: [
296
- "share",
297
- "request_review",
298
- "request_expertise",
299
- "link_share",
300
- "rfc",
301
- ],
302
- description: "Why the user is posting. See tool description.",
303
- },
304
- mentions: {
305
- type: "array",
306
- items: { type: "string" },
307
- description: "Teammate first names (or handles) to @mention. Each mention " +
308
- "creates a notification for that teammate.",
309
- },
310
- format: {
311
- type: "string",
312
- enum: ["article", "quick", "workflow", "review"],
313
- description: "Post format. Default 'article' if omitted. Use 'quick' for " +
314
- "short one-liners.",
315
- },
316
- tags: {
317
- type: "array",
318
- items: { type: "string" },
319
- description: "Optional tags, lowercase, hyphen-safe.",
320
- },
321
- },
322
- required: ["title", "body"],
323
- },
324
- },
325
263
  {
326
264
  name: "codiedev_share_with",
327
265
  description: "Grant a teammate persistent read (or edit) access to an artifact " +
@@ -374,7 +312,7 @@ const TOOLS = [
374
312
  "to that post with X', 'mark as used', or 'upvote'. The 🛠 " +
375
313
  "reaction is the signal for 'I used this' and bumps the linked " +
376
314
  "artifact's reuse count. Pass the post id (get it from " +
377
- "codiedev_get_library or a previous post-to-feed response).",
315
+ "codiedev_get_library or a previous feed response).",
378
316
  inputSchema: {
379
317
  type: "object",
380
318
  properties: {
@@ -602,8 +540,6 @@ async function dispatchTool(name, args, config) {
602
540
  return await handleNote(args, config);
603
541
  case "codiedev_promote":
604
542
  return await handlePromote(args, config);
605
- case "codiedev_post_to_feed":
606
- return await handlePostToFeed(args, config);
607
543
  case "codiedev_share_with":
608
544
  return await handleShareWith(args, config);
609
545
  case "codiedev_send_to":
@@ -784,34 +720,6 @@ async function handlePromote(args, config) {
784
720
  ],
785
721
  };
786
722
  }
787
- async function handlePostToFeed(args, config) {
788
- const filename = asStringOrUndefined(args.filename);
789
- const title = asString(args.title);
790
- const body = asString(args.body);
791
- const intent = asStringOrUndefined(args.intent);
792
- const format = asStringOrUndefined(args.format);
793
- const mentionsRaw = Array.isArray(args.mentions) ? args.mentions : [];
794
- const mentions = mentionsRaw.filter((m) => typeof m === "string");
795
- const tagsRaw = Array.isArray(args.tags) ? args.tags : [];
796
- const tags = tagsRaw.filter((t) => typeof t === "string");
797
- if (!title)
798
- throw new Error("title required");
799
- if (!body)
800
- throw new Error("body required");
801
- const res = await (0, shared_1.apiRequest)("POST", "/api/cli/post-to-feed", {
802
- config,
803
- body: { filename, title, body, intent, format, mentions, tags },
804
- });
805
- const pieces = [`✓ Posted to feed (id=${res.postId}).`];
806
- if (res.linkedArtifact?.key)
807
- pieces.push(` attached: ${res.linkedArtifact.key}`);
808
- if (res.intent)
809
- pieces.push(` intent: ${res.intent}`);
810
- if (res.mentions?.length) {
811
- pieces.push(` mentioned: ${res.mentions.map((m) => m.name).join(", ")}`);
812
- }
813
- return { content: [{ type: "text", text: pieces.join("\n") }] };
814
- }
815
723
  async function handleShareWith(args, config) {
816
724
  const filename = asString(args.filename);
817
725
  const to = asString(args.to);
@@ -13,14 +13,20 @@ function parseRemoteUrl(url) {
13
13
  let s = url.trim();
14
14
  if (s.endsWith(".git"))
15
15
  s = s.slice(0, -4);
16
- const ssh = s.match(/^git@([^:]+):(.+)$/);
17
- if (ssh) {
18
- return { host: ssh[1].toLowerCase(), ownerName: ssh[2].toLowerCase() };
19
- }
20
16
  const https = s.match(/^https?:\/\/([^/]+)\/(.+)$/);
21
17
  if (https) {
22
18
  return { host: https[1].toLowerCase(), ownerName: https[2].toLowerCase() };
23
19
  }
20
+ // Codie strips SSH userinfo before putting a remote in the gateway header,
21
+ // so `git@gitlab.com:owner/repo.git` arrives here as
22
+ // `gitlab.com:owner/repo.git`. Accept both forms; otherwise every SSH-cloned
23
+ // Harness checkout loses its repoId even though transcript capture can
24
+ // resolve the same raw remote correctly. Check this after URL forms so the
25
+ // colon in `https://` is never mistaken for an SCP separator.
26
+ const scp = s.match(/^(?:[^@\s]+@)?([^:/\s]+):([^/]+\/.+)$/);
27
+ if (scp) {
28
+ return { host: scp[1].toLowerCase(), ownerName: scp[2].toLowerCase() };
29
+ }
24
30
  return null;
25
31
  }
26
32
  function resolveRepoForCapture(input) {