codiedev 0.9.58 → 0.9.60

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.
@@ -31,6 +31,7 @@ const MARK = [
31
31
  const REFRESH_MS = 30_000
32
32
 
33
33
  const [usageLine, setUsageLine] = createSignal("")
34
+ const [usageTone, setUsageTone] = createSignal("normal")
34
35
 
35
36
  async function startUsagePolling() {
36
37
  let usage: any
@@ -56,7 +57,11 @@ async function startUsagePolling() {
56
57
  })
57
58
  // A failed poll leaves the last good line up rather than blanking it:
58
59
  // a number that stopped updating beats a number that vanished.
59
- if (snapshot) setUsageLine(usage.formatUsageLine(snapshot))
60
+ if (snapshot) {
61
+ setUsageLine(usage.formatUsageLine(snapshot))
62
+ // Older installs of the usage module have no tone; stay muted there.
63
+ if (typeof usage.usageTone === "function") setUsageTone(usage.usageTone(snapshot))
64
+ }
60
65
  } catch {}
61
66
  }
62
67
  void poll()
@@ -69,7 +74,14 @@ void startUsagePolling().catch(() => {})
69
74
 
70
75
  function StatusLine(props: { theme: any }) {
71
76
  return (
72
- <text fg={props.theme.textMuted} selectable={false}>
77
+ <text
78
+ fg={usageTone() === "critical"
79
+ ? (props.theme.error ?? props.theme.textMuted)
80
+ : usageTone() === "warn"
81
+ ? (props.theme.warning ?? props.theme.textMuted)
82
+ : props.theme.textMuted}
83
+ selectable={false}
84
+ >
73
85
  {usageLine()}
74
86
  </text>
75
87
  )
@@ -8,12 +8,29 @@ export type CaptureSignals = {
8
8
  activeMs: number;
9
9
  idleThresholdMs: number;
10
10
  skillInvocations: string[];
11
+ /** How many times each invoked skill ran (each Skill tool call and each
12
+ * slash command): three review runs in one session count three. Absent
13
+ * from older CLIs, which only sent the unique set above. */
14
+ skillInvocationCounts?: Array<{
15
+ skill: string;
16
+ count: number;
17
+ }>;
11
18
  skillPulls: string[];
12
19
  toolCalls: Array<{
13
20
  name: string;
14
21
  calls: number;
15
22
  }>;
23
+ /** How the harness was launched, raw: Claude Code's "cli" / "sdk-cli",
24
+ * Codex's "codex:cli" / "codex:exec". Absent when the harness records none. */
25
+ entrypoints?: string[];
26
+ /** Claude Code subagent transcripts folded into these signals. */
27
+ subagents?: {
28
+ count: number;
29
+ truncated: boolean;
30
+ };
16
31
  truncated: boolean;
17
32
  };
18
33
  /** Compact diagnostic inputs only: never persist prompts, commands or tool output. */
19
34
  export declare function parseCaptureSignals(content: string, source?: string): CaptureSignals;
35
+ /** Add a launch value the hook detected (harnesses that do not record one). */
36
+ export declare function withLaunch(signals: CaptureSignals, launch: string | undefined): CaptureSignals;
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.CAPTURE_SIGNALS_VERSION = void 0;
4
4
  exports.parseCaptureSignals = parseCaptureSignals;
5
+ exports.withLaunch = withLaunch;
5
6
  const transcriptModels_1 = require("./transcriptModels");
6
7
  exports.CAPTURE_SIGNALS_VERSION = 1;
7
8
  const IDLE_MS = 5 * 60_000;
@@ -17,7 +18,8 @@ const skillKey = (value) => typeof value === "string"
17
18
  /** Compact diagnostic inputs only: never persist prompts, commands or tool output. */
18
19
  function parseCaptureSignals(content, source) {
19
20
  const timestamps = new Set();
20
- const invocations = new Set(), pulls = new Set();
21
+ const invocationCounts = new Map();
22
+ const invocations = new Set(), pulls = new Set(), entrypoints = new Set();
21
23
  const tools = new Map(), calls = new Set();
22
24
  let parsedRecords = 0, truncated = false;
23
25
  const addSkill = (set, value) => {
@@ -29,6 +31,8 @@ function parseCaptureSignals(content, source) {
29
31
  return;
30
32
  }
31
33
  set.add(key);
34
+ if (set === invocations)
35
+ invocationCounts.set(key, (invocationCounts.get(key) ?? 0) + 1);
32
36
  };
33
37
  const command = (value) => {
34
38
  if (typeof value !== "string")
@@ -66,6 +70,24 @@ function parseCaptureSignals(content, source) {
66
70
  continue;
67
71
  const e = record;
68
72
  parsedRecords++;
73
+ const entrypoint = (value) => {
74
+ if (typeof value === "string" &&
75
+ entrypoints.size < 10 &&
76
+ /^[a-z0-9][a-z0-9_.:-]{0,39}$/i.test(value))
77
+ entrypoints.add(value.toLowerCase());
78
+ };
79
+ // Claude Code: "cli" at the terminal, "sdk-cli" for `claude -p` / SDK.
80
+ entrypoint(e.entrypoint);
81
+ // Codex: session_meta.source is "cli", "vscode", "exec" (`codex exec`),
82
+ // "mcp", or an object for sub-agents. Namespaced so values never collide.
83
+ if (e.type === "session_meta" && e.payload) {
84
+ const src = e.payload.source;
85
+ entrypoint(typeof src === "string"
86
+ ? `codex:${src}`
87
+ : src && typeof src === "object"
88
+ ? `codex:${Object.keys(src)[0] ?? "other"}`
89
+ : undefined);
90
+ }
69
91
  const time = typeof e.timestamp === "string"
70
92
  ? Date.parse(e.timestamp)
71
93
  : e.info?.time?.created;
@@ -110,10 +132,21 @@ function parseCaptureSignals(content, source) {
110
132
  activeMs,
111
133
  idleThresholdMs: IDLE_MS,
112
134
  skillInvocations: [...invocations].sort(),
135
+ skillInvocationCounts: [...invocationCounts]
136
+ .map(([skill, count]) => ({ skill, count }))
137
+ .sort((a, b) => a.skill.localeCompare(b.skill)),
113
138
  skillPulls: [...pulls].sort(),
114
139
  toolCalls: [...tools]
115
140
  .map(([name, calls]) => ({ name, calls }))
116
141
  .sort((a, b) => a.name.localeCompare(b.name)),
142
+ ...(entrypoints.size ? { entrypoints: [...entrypoints].sort() } : {}),
117
143
  truncated,
118
144
  };
119
145
  }
146
+ /** Add a launch value the hook detected (harnesses that do not record one). */
147
+ function withLaunch(signals, launch) {
148
+ if (!launch || !/^[a-z0-9][a-z0-9_.:-]{0,39}$/.test(launch))
149
+ return signals;
150
+ const all = new Set([...(signals.entrypoints ?? []), launch]);
151
+ return all.size > 10 ? signals : { ...signals, entrypoints: [...all].sort() };
152
+ }
@@ -39,6 +39,13 @@ export interface CodieUsage {
39
39
  * Returns "" when there is nothing honest to say.
40
40
  */
41
41
  export declare function formatUsageLine(usage: CodieUsage | null | undefined): string;
42
+ export type UsageTone = "normal" | "warn" | "critical";
43
+ /**
44
+ * How urgently the status line should read, from the limit that will stop
45
+ * this developer first: their own cap, or the shared pool when they have no
46
+ * cap or the pool runs out sooner. 75% warns, 90% is critical.
47
+ */
48
+ export declare function usageTone(usage: CodieUsage | null | undefined): UsageTone;
42
49
  /**
43
50
  * What this session just cost. Returns "" when the session billed nothing,
44
51
  * because a developer who spent nothing does not need to be told a number.
@@ -9,6 +9,7 @@
9
9
  // delay, or break a coding session. No snapshot means no line.
10
10
  Object.defineProperty(exports, "__esModule", { value: true });
11
11
  exports.formatUsageLine = formatUsageLine;
12
+ exports.usageTone = usageTone;
12
13
  exports.formatSessionCostLine = formatSessionCostLine;
13
14
  exports.fetchCodieUsage = fetchCodieUsage;
14
15
  const USAGE_TIMEOUT_MS = 4000;
@@ -51,6 +52,16 @@ function formatUsageLine(usage) {
51
52
  const share = percent(budget.userSpentUsd, budget.userBudgetUsd);
52
53
  if (share)
53
54
  segments.push(share);
55
+ // A personal cap does not help once the shared pool is nearly gone: say
56
+ // so when the pool is running low and will stop this developer before
57
+ // their own cap does.
58
+ if (poolBindsFirst(budget) && typeof budget.companyBudgetUsd === "number"
59
+ && budget.companyBudgetUsd > 0
60
+ && budget.companySpentUsd / budget.companyBudgetUsd >= 0.75) {
61
+ const share = percent(budget.companySpentUsd, budget.companyBudgetUsd);
62
+ if (share)
63
+ segments.push(`team pool ${share}`);
64
+ }
54
65
  }
55
66
  else {
56
67
  segments.push(money(budget.userSpentUsd));
@@ -62,6 +73,10 @@ function formatUsageLine(usage) {
62
73
  segments.push(`team ${share}`);
63
74
  }
64
75
  }
76
+ if (typeof budget.companyBudgetUsd === "number" && budget.companyBudgetUsd > 0
77
+ && budget.companySpentUsd >= budget.companyBudgetUsd) {
78
+ segments.push("team pool used up");
79
+ }
65
80
  if (inFlight.requests > 0)
66
81
  segments.push(`${inFlight.requests} in flight`);
67
82
  // Stale holds cap a budget while doing no work. Naming them is the
@@ -70,6 +85,37 @@ function formatUsageLine(usage) {
70
85
  segments.push(`${money(inFlight.staleHeldUsd)} held stale`);
71
86
  return segments.join(" · ");
72
87
  }
88
+ function poolBindsFirst(budget) {
89
+ if (typeof budget.companyBudgetUsd !== "number" || typeof budget.userBudgetUsd !== "number")
90
+ return false;
91
+ const poolLeft = Math.max(0, budget.companyBudgetUsd - budget.companySpentUsd);
92
+ const userLeft = Math.max(0, budget.userBudgetUsd - budget.userSpentUsd);
93
+ return budget.userBudgetUsd > 0 && poolLeft < userLeft;
94
+ }
95
+ /**
96
+ * How urgently the status line should read, from the limit that will stop
97
+ * this developer first: their own cap, or the shared pool when they have no
98
+ * cap or the pool runs out sooner. 75% warns, 90% is critical.
99
+ */
100
+ function usageTone(usage) {
101
+ if (!usage || !usage.enabled)
102
+ return "normal";
103
+ const { budget } = usage;
104
+ const ratios = [];
105
+ if (typeof budget.userBudgetUsd === "number" && budget.userBudgetUsd > 0) {
106
+ ratios.push(budget.userSpentUsd / budget.userBudgetUsd);
107
+ }
108
+ if (typeof budget.companyBudgetUsd === "number" && budget.companyBudgetUsd > 0
109
+ && (typeof budget.userBudgetUsd !== "number" || poolBindsFirst(budget))) {
110
+ ratios.push(budget.companySpentUsd / budget.companyBudgetUsd);
111
+ }
112
+ const worst = ratios.length ? Math.max(...ratios) : 0;
113
+ if (worst >= 0.9)
114
+ return "critical";
115
+ if (worst >= 0.75)
116
+ return "warn";
117
+ return "normal";
118
+ }
73
119
  function tokens(total) {
74
120
  // Rounded by arithmetic, not toFixed: 2.55 is not exactly representable, so
75
121
  // toFixed(1) rounds it down and the same count can read two ways.
package/dist/hook.js CHANGED
@@ -45,8 +45,10 @@ const child_process_1 = require("child_process");
45
45
  const utils_1 = require("./utils");
46
46
  const limitEventReporter_1 = require("./limitEventReporter");
47
47
  const codexLimitScan_1 = require("./codexLimitScan");
48
+ const launchContext_1 = require("./launchContext");
48
49
  const version_1 = require("./version");
49
50
  const captureLog_1 = require("./captureLog");
51
+ const subagents_1 = require("./subagents");
50
52
  const captureExclusions_1 = require("./captureExclusions");
51
53
  const cursorHooks_1 = require("./cursorHooks");
52
54
  // Pure builder for the discovery additionalContext. Returns null when there are
@@ -583,6 +585,9 @@ async function main() {
583
585
  if (process.argv[2] === "capture-codex")
584
586
  (0, utils_1.ensureCodexLimitHookInstalled)();
585
587
  const { session_id, transcript_path, cwd, workspace_roots } = hookInput;
588
+ // Read here, in the process the harness spawned: the detached capture child
589
+ // is reparented once this exits and can no longer see who launched it.
590
+ const launch = (0, launchContext_1.hookLaunch)(process.argv[2], hookInput);
586
591
  const workspaceRoot = workspace_roots && workspace_roots.length > 0
587
592
  ? workspace_roots[0]
588
593
  : cwd || process.cwd();
@@ -605,6 +610,7 @@ async function main() {
605
610
  transcript_path,
606
611
  cwd,
607
612
  workspace_roots,
613
+ ...(launch ? { launch } : {}),
608
614
  // non-final: server should renew the lease, not close it yet.
609
615
  final: false,
610
616
  }, process.argv[1], {
@@ -660,6 +666,7 @@ async function main() {
660
666
  transcript_path,
661
667
  cwd,
662
668
  workspace_roots,
669
+ ...(launch ? { launch } : {}),
663
670
  // final:true ONLY for claude-code's SessionEnd. Codex routes its
664
671
  // per-turn Stop hook through "capture-codex" — final:true there would
665
672
  // close the server lease every turn. Cursor SessionEnd is final:true;
@@ -834,6 +841,20 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
834
841
  // pre-normalization content for desktop; parseSteering routes through
835
842
  // parserMode like the parsers above.
836
843
  const usage = (0, utils_1.parseUsage)(mode === "desktop" ? rawTranscriptContent : transcriptContent, mode);
844
+ // Claude Code subagents: their transcripts sit beside this one and are part
845
+ // of this session's work (skill runs, tool calls, tokens). Folded in here,
846
+ // never uploaded as sessions of their own.
847
+ const subagents = mode === "claude-code"
848
+ ? (0, subagents_1.readSubagentTranscripts)(transcript_path, session_id)
849
+ : { contents: [], count: 0, truncated: false };
850
+ const subagentUsage = (0, subagents_1.foldSubagentModelUsage)([], subagents, mode);
851
+ for (const u of subagentUsage) {
852
+ usage.inputTokens = (usage.inputTokens ?? 0) + u.inputTokens;
853
+ usage.outputTokens = (usage.outputTokens ?? 0) + u.outputTokens;
854
+ usage.cacheReadTokens = (usage.cacheReadTokens ?? 0) + u.cacheReadTokens;
855
+ usage.cacheCreationTokens = (usage.cacheCreationTokens ?? 0) + u.cacheCreationTokens;
856
+ usage.cacheTokens = (usage.cacheTokens ?? 0) + u.cacheReadTokens + u.cacheCreationTokens;
857
+ }
837
858
  const sessionStartedAt = (0, utils_1.parseSessionStart)(transcriptContent);
838
859
  const steering = (0, utils_1.parseSteering)(transcriptContent, parserMode);
839
860
  const payload = (0, utils_1.buildCapturePayload)({
@@ -858,8 +879,8 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
858
879
  cacheReadTokens: usage.cacheReadTokens,
859
880
  cacheCreationTokens: usage.cacheCreationTokens,
860
881
  model: usage.model,
861
- captureSignals: (0, captureSignals_1.parseCaptureSignals)(mode === "desktop" || mode === "opencode" ? rawTranscriptContent : transcriptContent, mode),
862
- modelUsage: (0, transcriptModels_1.parseTranscriptModels)(mode === "desktop" || mode === "opencode" ? rawTranscriptContent : transcriptContent, mode),
882
+ captureSignals: (0, subagents_1.foldSubagentSignals)((0, captureSignals_1.withLaunch)((0, captureSignals_1.parseCaptureSignals)(mode === "desktop" || mode === "opencode" ? rawTranscriptContent : transcriptContent, mode), input.launch), subagents, mode),
883
+ modelUsage: (0, subagents_1.foldSubagentModelUsage)((0, transcriptModels_1.parseTranscriptModels)(mode === "desktop" || mode === "opencode" ? rawTranscriptContent : transcriptContent, mode), subagents, mode),
863
884
  sessionStartedAt,
864
885
  interruptCount: steering.interrupts,
865
886
  rejectionCount: steering.rejections,
@@ -0,0 +1,32 @@
1
+ export declare function codieLaunchFromArgv(argv: readonly string[]): string;
2
+ /** One launching process's command line → a Cursor launch value. */
3
+ export declare function cursorLaunchFromCommand(command: string): string | undefined;
4
+ type Ps = (pid: number) => {
5
+ ppid: number;
6
+ command: string;
7
+ } | null;
8
+ /**
9
+ * Windows: one PowerShell call returns the whole ancestor chain (spawning
10
+ * PowerShell per level would cost ~0.5s each). CommandLine needs CIM, which
11
+ * every supported Windows has; any failure means "unknown", never a guess.
12
+ */
13
+ export declare function windowsAncestors(startPid: number, depth: number): Array<{
14
+ pid: number;
15
+ ppid: number;
16
+ command: string;
17
+ }> | null;
18
+ /**
19
+ * Walk up to `depth` ancestors (a shell or `npx` usually sits between Cursor
20
+ * and the hook) and return the first Cursor launch found.
21
+ */
22
+ export declare function detectCursorLaunch(startPid?: number, deps?: {
23
+ platform?: string;
24
+ ps?: Ps;
25
+ depth?: number;
26
+ windows?: typeof windowsAncestors;
27
+ }): string | undefined;
28
+ /** The launch value a hook invocation should carry into its capture job. */
29
+ export declare function hookLaunch(hookCommand: string | undefined, input: {
30
+ launch?: unknown;
31
+ }): string | undefined;
32
+ export {};
@@ -0,0 +1,145 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.codieLaunchFromArgv = codieLaunchFromArgv;
4
+ exports.cursorLaunchFromCommand = cursorLaunchFromCommand;
5
+ exports.windowsAncestors = windowsAncestors;
6
+ exports.detectCursorLaunch = detectCursorLaunch;
7
+ exports.hookLaunch = hookLaunch;
8
+ const node_child_process_1 = require("node:child_process");
9
+ /**
10
+ * How a harness that records nothing about its own launch was started.
11
+ * Claude Code (`entrypoint`) and Codex (`session_meta.source`) stamp this in
12
+ * the transcript; Cursor and Codie do not, so the hook reads it from the
13
+ * process that launched it. Raw, namespaced values; what counts as
14
+ * "automated" is decided in reporting, not here.
15
+ */
16
+ const VALUE = /^[a-z0-9][a-z0-9_.:-]{0,39}$/;
17
+ /** Codie (OpenCode) subcommands, read in-process by the capture plugin. */
18
+ const CODIE_SUBCOMMANDS = ["run", "serve", "acp", "web", "attach"];
19
+ function codieLaunchFromArgv(argv) {
20
+ const sub = argv.find((a) => CODIE_SUBCOMMANDS.includes(a));
21
+ return sub ? `codie:${sub}` : "codie:tui";
22
+ }
23
+ /**
24
+ * Split a command line into tokens, honouring double quotes (Windows quotes
25
+ * paths with spaces: "C:\\Program Files\\…\\node.exe" "…\\index.js" -p hi).
26
+ */
27
+ function tokenize(command) {
28
+ const tokens = [];
29
+ for (const m of command.matchAll(/"([^"]*)"|(\S+)/g))
30
+ tokens.push(m[1] ?? m[2]);
31
+ return tokens;
32
+ }
33
+ /** A path segment named cursor-agent / agent (Unix or Windows separators). */
34
+ const CURSOR_AGENT_PATH = /(^|[/\\])cursor-agent([/\\]|\.(exe|cmd|ps1)$|$)/i;
35
+ const AGENT_BIN = /(^|[/\\])(cursor-)?agent(\.(exe|cmd|ps1))?$/i;
36
+ /** One launching process's command line → a Cursor launch value. */
37
+ function cursorLaunchFromCommand(command) {
38
+ const tokens = tokenize(command);
39
+ // The CLI runs as `<launcher> [node] …/cursor-agent/versions/<v>/index.js [flags] [prompt]`
40
+ // on macOS/Linux (~/.local/share/cursor-agent) and Windows (%LOCALAPPDATA%\cursor-agent).
41
+ const isAgent = tokens.some((t) => CURSOR_AGENT_PATH.test(t) || /[/\\]\.local[/\\]share[/\\]cursor/i.test(t))
42
+ && tokens.some((t) => AGENT_BIN.test(t) || /index\.js$/i.test(t));
43
+ if (isAgent) {
44
+ return cursorFlags(tokens).some((f) => f === "-p" || f === "--print" || f.startsWith("--print="))
45
+ ? "cursor:print"
46
+ : "cursor:cli";
47
+ }
48
+ if (/Cursor\.app|Cursor Helper|[/\\]cursor(\.exe)?(\s|"|$)/i.test(command))
49
+ return "cursor:ide";
50
+ return undefined;
51
+ }
52
+ /** Cursor flags that take a value; the token after them is not the prompt. */
53
+ const CURSOR_VALUE_FLAGS = new Set(["--model", "-m", "--output-format", "--api-key", "--resume", "--workspace", "--mode", "-H", "--header"]);
54
+ /**
55
+ * The flags before the prompt. The prompt is on the command line too, so a
56
+ * prompt mentioning "-p" must not read as headless: stop at the first token
57
+ * that is neither a flag nor a flag's value. Starts after the launcher script
58
+ * (`…/index.js`) when present, else after the agent binary.
59
+ */
60
+ function cursorFlags(tokens) {
61
+ let start = tokens.findIndex((t) => /index\.js$/i.test(t));
62
+ if (start < 0)
63
+ start = tokens.findIndex((t) => AGENT_BIN.test(t));
64
+ const flags = [];
65
+ for (let i = (start >= 0 ? start : 0) + 1; i < tokens.length; i++) {
66
+ const t = tokens[i];
67
+ if (!t.startsWith("-"))
68
+ break;
69
+ flags.push(t);
70
+ if (CURSOR_VALUE_FLAGS.has(t))
71
+ i++;
72
+ }
73
+ return flags;
74
+ }
75
+ const psPosix = (pid) => {
76
+ const result = (0, node_child_process_1.spawnSync)("ps", ["-o", "ppid=,args=", "-p", String(pid)], { encoding: "utf8", timeout: 500 });
77
+ const line = result.status === 0 ? result.stdout.trim() : "";
78
+ const match = line.match(/^(\d+)\s+(.*)$/s);
79
+ return match ? { ppid: Number(match[1]), command: match[2] } : null;
80
+ };
81
+ /**
82
+ * Windows: one PowerShell call returns the whole ancestor chain (spawning
83
+ * PowerShell per level would cost ~0.5s each). CommandLine needs CIM, which
84
+ * every supported Windows has; any failure means "unknown", never a guess.
85
+ */
86
+ function windowsAncestors(startPid, depth) {
87
+ const script = `$p=${Math.trunc(startPid)};$o=@();for($i=0;$i -lt ${Math.trunc(depth)} -and $p -gt 0;$i++){` +
88
+ `$x=Get-CimInstance Win32_Process -Filter "ProcessId=$p" -ErrorAction SilentlyContinue;if(!$x){break};` +
89
+ `$o+=[pscustomobject]@{pid=[int]$p;ppid=[int]$x.ParentProcessId;command=[string]$x.CommandLine};$p=$x.ParentProcessId};` +
90
+ `ConvertTo-Json -InputObject @($o) -Compress`;
91
+ const result = (0, node_child_process_1.spawnSync)("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
92
+ encoding: "utf8",
93
+ // A cold PowerShell start can take several seconds on a busy machine; the
94
+ // capture hook has 30s, and an unanswered walk just means "unknown".
95
+ timeout: 10000,
96
+ windowsHide: true,
97
+ });
98
+ if (result.status !== 0 || !result.stdout.trim())
99
+ return null;
100
+ try {
101
+ const rows = JSON.parse(result.stdout);
102
+ return rows.map((r) => ({ pid: Number(r.pid), ppid: Number(r.ppid), command: r.command ?? "" }));
103
+ }
104
+ catch {
105
+ return null;
106
+ }
107
+ }
108
+ /**
109
+ * Walk up to `depth` ancestors (a shell or `npx` usually sits between Cursor
110
+ * and the hook) and return the first Cursor launch found.
111
+ */
112
+ function detectCursorLaunch(startPid = process.ppid, deps = {}) {
113
+ const depth = deps.depth ?? 5;
114
+ if ((deps.platform ?? process.platform) === "win32" && !deps.ps) {
115
+ const chain = (deps.windows ?? windowsAncestors)(startPid, depth);
116
+ if (!chain)
117
+ return undefined;
118
+ for (const row of chain) {
119
+ const launch = cursorLaunchFromCommand(row.command);
120
+ if (launch)
121
+ return launch;
122
+ }
123
+ return undefined;
124
+ }
125
+ const ps = deps.ps ?? psPosix;
126
+ let pid = startPid;
127
+ for (let i = 0; i < depth && pid > 1; i++) {
128
+ const row = ps(pid);
129
+ if (!row)
130
+ return undefined;
131
+ const launch = cursorLaunchFromCommand(row.command);
132
+ if (launch)
133
+ return launch;
134
+ pid = row.ppid;
135
+ }
136
+ return undefined;
137
+ }
138
+ /** The launch value a hook invocation should carry into its capture job. */
139
+ function hookLaunch(hookCommand, input) {
140
+ if (typeof input.launch === "string" && VALUE.test(input.launch))
141
+ return input.launch;
142
+ if (hookCommand?.endsWith("-cursor"))
143
+ return detectCursorLaunch();
144
+ return undefined;
145
+ }
@@ -0,0 +1,36 @@
1
+ import { type CaptureSignals } from "./captureSignals";
2
+ import { type TranscriptModelUsage } from "./transcriptModels";
3
+ /**
4
+ * Claude Code writes each subagent's transcript next to its parent's:
5
+ *
6
+ * ~/.claude/projects/<project>/<sessionId>.jsonl (parent)
7
+ * ~/.claude/projects/<project>/<sessionId>/subagents/agent-*.jsonl
8
+ *
9
+ * Lines there carry `isSidechain: true` and the parent's sessionId. They are
10
+ * part of the parent session's work, not sessions of their own: their skill
11
+ * runs, tool calls and token usage fold into the parent's signals and usage,
12
+ * and they are never uploaded separately. Other harnesses (Codex, Codie,
13
+ * Cursor) keep sub-agents inside the one transcript or don't have them, so
14
+ * this is Claude Code only.
15
+ */
16
+ export declare const SUBAGENT_LIMITS: {
17
+ files: number;
18
+ bytes: number;
19
+ };
20
+ export declare function subagentDir(transcriptPath: string, sessionId?: string): string;
21
+ export type SubagentTranscripts = {
22
+ contents: string[];
23
+ count: number;
24
+ truncated: boolean;
25
+ };
26
+ /** Read the session's subagent transcripts in full, up to 20 files / 20 MB
27
+ * in total (oldest first); anything past that is counted as truncated. */
28
+ export declare function readSubagentTranscripts(transcriptPath: string, sessionId?: string, limits?: {
29
+ files: number;
30
+ bytes: number;
31
+ }): SubagentTranscripts;
32
+ /** Parent signals plus every subagent's skill runs, pulls and tool calls.
33
+ * Launch values, timing and record counts stay the parent's own. */
34
+ export declare function foldSubagentSignals(parent: CaptureSignals, subs: SubagentTranscripts, source?: string): CaptureSignals;
35
+ /** Parent model usage plus each subagent's, summed per model. */
36
+ export declare function foldSubagentModelUsage(parent: TranscriptModelUsage[], subs: SubagentTranscripts, source?: string): TranscriptModelUsage[];
@@ -0,0 +1,146 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.SUBAGENT_LIMITS = void 0;
7
+ exports.subagentDir = subagentDir;
8
+ exports.readSubagentTranscripts = readSubagentTranscripts;
9
+ exports.foldSubagentSignals = foldSubagentSignals;
10
+ exports.foldSubagentModelUsage = foldSubagentModelUsage;
11
+ const node_fs_1 = __importDefault(require("node:fs"));
12
+ const node_path_1 = __importDefault(require("node:path"));
13
+ const captureSignals_1 = require("./captureSignals");
14
+ const transcriptModels_1 = require("./transcriptModels");
15
+ /**
16
+ * Claude Code writes each subagent's transcript next to its parent's:
17
+ *
18
+ * ~/.claude/projects/<project>/<sessionId>.jsonl (parent)
19
+ * ~/.claude/projects/<project>/<sessionId>/subagents/agent-*.jsonl
20
+ *
21
+ * Lines there carry `isSidechain: true` and the parent's sessionId. They are
22
+ * part of the parent session's work, not sessions of their own: their skill
23
+ * runs, tool calls and token usage fold into the parent's signals and usage,
24
+ * and they are never uploaded separately. Other harnesses (Codex, Codie,
25
+ * Cursor) keep sub-agents inside the one transcript or don't have them, so
26
+ * this is Claude Code only.
27
+ */
28
+ exports.SUBAGENT_LIMITS = { files: 20, bytes: 20 * 1024 * 1024 };
29
+ function subagentDir(transcriptPath, sessionId) {
30
+ const dir = node_path_1.default.dirname(transcriptPath);
31
+ const id = sessionId || node_path_1.default.basename(transcriptPath).replace(/\.jsonl$/i, "");
32
+ return node_path_1.default.join(dir, id, "subagents");
33
+ }
34
+ /** Read the session's subagent transcripts in full, up to 20 files / 20 MB
35
+ * in total (oldest first); anything past that is counted as truncated. */
36
+ function readSubagentTranscripts(transcriptPath, sessionId, limits = exports.SUBAGENT_LIMITS) {
37
+ const dir = subagentDir(transcriptPath, sessionId);
38
+ let names;
39
+ try {
40
+ names = node_fs_1.default.readdirSync(dir).filter((n) => /^agent-[^/]+\.jsonl$/.test(n));
41
+ }
42
+ catch {
43
+ return { contents: [], count: 0, truncated: false };
44
+ }
45
+ const files = names
46
+ .map((n) => {
47
+ const full = node_path_1.default.join(dir, n);
48
+ try {
49
+ const st = node_fs_1.default.statSync(full);
50
+ return st.isFile() ? { full, size: st.size, mtime: st.mtimeMs } : null;
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ })
56
+ .filter((f) => f !== null)
57
+ .sort((a, b) => a.mtime - b.mtime || a.full.localeCompare(b.full));
58
+ const contents = [];
59
+ let bytes = 0;
60
+ let truncated = false;
61
+ for (const f of files) {
62
+ if (contents.length >= limits.files || bytes + f.size > limits.bytes) {
63
+ truncated = true;
64
+ break;
65
+ }
66
+ try {
67
+ contents.push(node_fs_1.default.readFileSync(f.full, "utf8"));
68
+ bytes += f.size;
69
+ }
70
+ catch {
71
+ truncated = true;
72
+ }
73
+ }
74
+ return { contents, count: files.length, truncated };
75
+ }
76
+ /** Parent signals plus every subagent's skill runs, pulls and tool calls.
77
+ * Launch values, timing and record counts stay the parent's own. */
78
+ function foldSubagentSignals(parent, subs, source) {
79
+ if (subs.count === 0)
80
+ return parent;
81
+ const counts = new Map();
82
+ const add = (list, fallback) => {
83
+ for (const c of list ?? fallback.map((skill) => ({ skill, count: 1 })))
84
+ counts.set(c.skill, (counts.get(c.skill) ?? 0) + c.count);
85
+ };
86
+ add(parent.skillInvocationCounts, parent.skillInvocations);
87
+ const invocations = new Set(parent.skillInvocations);
88
+ const pulls = new Set(parent.skillPulls);
89
+ const tools = new Map(parent.toolCalls.map((t) => [t.name, t.calls]));
90
+ let truncated = parent.truncated;
91
+ for (const content of subs.contents) {
92
+ const s = (0, captureSignals_1.parseCaptureSignals)(content, source);
93
+ add(s.skillInvocationCounts, s.skillInvocations);
94
+ for (const k of s.skillInvocations)
95
+ invocations.add(k);
96
+ for (const k of s.skillPulls)
97
+ pulls.add(k);
98
+ for (const t of s.toolCalls)
99
+ tools.set(t.name, (tools.get(t.name) ?? 0) + t.calls);
100
+ truncated ||= s.truncated;
101
+ }
102
+ const cap = (xs) => {
103
+ if (xs.length > 100)
104
+ truncated = true;
105
+ return xs.slice(0, 100);
106
+ };
107
+ return {
108
+ ...parent,
109
+ skillInvocations: cap([...invocations].sort()),
110
+ skillInvocationCounts: cap([...counts].map(([skill, count]) => ({ skill, count: Math.min(count, 10_000) })).sort((a, b) => a.skill.localeCompare(b.skill))),
111
+ skillPulls: cap([...pulls].sort()),
112
+ toolCalls: cap([...tools].map(([name, calls]) => ({ name, calls })).sort((a, b) => a.name.localeCompare(b.name))),
113
+ subagents: { count: subs.count, truncated: subs.truncated },
114
+ truncated,
115
+ };
116
+ }
117
+ /** Parent model usage plus each subagent's, summed per model. */
118
+ function foldSubagentModelUsage(parent, subs, source) {
119
+ if (subs.contents.length === 0)
120
+ return parent;
121
+ const byModel = new Map();
122
+ const add = (u) => {
123
+ const cur = byModel.get(u.model);
124
+ if (!cur) {
125
+ byModel.set(u.model, { ...u });
126
+ return;
127
+ }
128
+ cur.requests += u.requests;
129
+ cur.inputTokens += u.inputTokens;
130
+ cur.outputTokens += u.outputTokens;
131
+ cur.cacheReadTokens += u.cacheReadTokens;
132
+ cur.cacheCreationTokens += u.cacheCreationTokens;
133
+ if (u.costUsd !== undefined)
134
+ cur.costUsd = (cur.costUsd ?? 0) + u.costUsd;
135
+ if (u.reportedCostUsd !== undefined)
136
+ cur.reportedCostUsd = (cur.reportedCostUsd ?? 0) + u.reportedCostUsd;
137
+ if (u.reasoningTokens !== undefined)
138
+ cur.reasoningTokens = (cur.reasoningTokens ?? 0) + u.reasoningTokens;
139
+ };
140
+ for (const u of parent)
141
+ add(u);
142
+ for (const content of subs.contents)
143
+ for (const u of (0, transcriptModels_1.parseTranscriptModels)(content, source))
144
+ add(u);
145
+ return [...byModel.values()].slice(0, 64);
146
+ }
package/dist/utils.d.ts CHANGED
@@ -231,6 +231,9 @@ export interface CaptureFinishInput {
231
231
  cwd?: string;
232
232
  workspace_roots?: string[];
233
233
  final?: boolean;
234
+ /** How the harness was launched when its transcript does not record it
235
+ * (Cursor, Codie). See launchContext.ts. */
236
+ launch?: string;
234
237
  }
235
238
  export declare function dispatchCaptureFinish(input: CaptureFinishInput, hookScriptPath: string, deps: DispatchDeps): void;
236
239
  export declare function dispatchCaptureDetached(job: CaptureUploadJob, hookScriptPath: string, deps: DispatchDeps): void;
package/dist/utils.js CHANGED
@@ -1072,6 +1072,9 @@ function installOpenCodeCapturePlugin() {
1072
1072
  `const CAPTURE = ${JSON.stringify(capture)};\n` +
1073
1073
  `const FINISH = ${JSON.stringify(finish)};\n` +
1074
1074
  `const PROMPT = ${JSON.stringify(prompt)};\n` +
1075
+ `// How this Codie process was started (run / serve / tui). OpenCode records\n` +
1076
+ `// nothing about it, so the plugin reads its own argv; see launchContext.ts.\n` +
1077
+ `const LAUNCH = (() => { const sub = process.argv.slice(2).find((a) => ["run", "serve", "acp", "web", "attach"].includes(a)); return sub ? "codie:" + sub : "codie:tui"; })();\n` +
1075
1078
  `const fingerprints = new Map();\n\n` +
1076
1079
  `const transcriptPaths = new Map();\n\n` +
1077
1080
  `function sessionId(event) {\n` +
@@ -1098,7 +1101,7 @@ function installOpenCodeCapturePlugin() {
1098
1101
  ` const finalizeAll = () => {\n` +
1099
1102
  ` for (const [id, transcriptPath] of transcriptPaths) {\n` +
1100
1103
  ` const cwd = worktree || directory || process.cwd();\n` +
1101
- ` dispatchSync(FINISH, { session_id: id, transcript_path: transcriptPath, cwd, workspace_roots: [cwd] });\n` +
1104
+ ` dispatchSync(FINISH, { session_id: id, transcript_path: transcriptPath, cwd, workspace_roots: [cwd], launch: LAUNCH });\n` +
1102
1105
  ` }\n` +
1103
1106
  ` };\n` +
1104
1107
  ` process.once("exit", finalizeAll);\n` +
@@ -1116,12 +1119,12 @@ function installOpenCodeCapturePlugin() {
1116
1119
  ` if (!id) return;\n` +
1117
1120
  ` const cwd = worktree || directory || process.cwd();\n` +
1118
1121
  ` if (event.type === "session.created") {\n` +
1119
- ` dispatch(START, { session_id: id, cwd, workspace_roots: [cwd] });\n` +
1122
+ ` dispatch(START, { session_id: id, cwd, workspace_roots: [cwd], launch: LAUNCH });\n` +
1120
1123
  ` return;\n` +
1121
1124
  ` }\n` +
1122
1125
  ` if (event.type === "session.deleted") {\n` +
1123
1126
  ` const transcriptPath = transcriptPaths.get(id);\n` +
1124
- ` if (transcriptPath) dispatch(FINISH, { session_id: id, transcript_path: transcriptPath, cwd, workspace_roots: [cwd] });\n` +
1127
+ ` if (transcriptPath) dispatch(FINISH, { session_id: id, transcript_path: transcriptPath, cwd, workspace_roots: [cwd], launch: LAUNCH });\n` +
1125
1128
  ` transcriptPaths.delete(id);\n` +
1126
1129
  ` fingerprints.delete(id);\n` +
1127
1130
  ` return;\n` +
@@ -1141,7 +1144,7 @@ function installOpenCodeCapturePlugin() {
1141
1144
  ` const transcriptPath = path.join(captureDir, safeId + ".json");\n` +
1142
1145
  ` await writeFile(transcriptPath, JSON.stringify(list), { mode: 0o600 });\n` +
1143
1146
  ` transcriptPaths.set(id, transcriptPath);\n` +
1144
- ` dispatch(CAPTURE, { session_id: id, transcript_path: transcriptPath, cwd, workspace_roots: [cwd] });\n` +
1147
+ ` dispatch(CAPTURE, { session_id: id, transcript_path: transcriptPath, cwd, workspace_roots: [cwd], launch: LAUNCH });\n` +
1145
1148
  ` } catch {}\n` +
1146
1149
  ` },\n` +
1147
1150
  ` });\n` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codiedev",
3
- "version": "0.9.58",
3
+ "version": "0.9.60",
4
4
  "description": "Connect Claude Code, Codex, Cursor, OpenCode, or VS Code Copilot to Codiedev for team session capture and artifact collaboration — and run the governed Codie harness",
5
5
  "bin": {
6
6
  "codiedev": "dist/cli.js",