codiedev 0.9.31 → 0.9.33

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.
@@ -7,8 +7,6 @@ import { execFileSync } from "node:child_process";
7
7
  import { existsSync } from "node:fs";
8
8
  import { dirname, isAbsolute } from "node:path";
9
9
 
10
- const skillUseBySession = new Map();
11
-
12
10
  /**
13
11
  * The directory the agent last WROTE to. Reading refs from the plugin's cwd is
14
12
  * only right when `codie` was launched where the work lands. A dev who launches
@@ -27,15 +25,61 @@ const skillUseBySession = new Map();
27
25
  * catch it.
28
26
  */
29
27
  export const workDirBySession = new Map();
30
- const WRITE_TOOLS = new Set(["write", "edit", "patch"]);
28
+ /**
29
+ * Tool ids as OpenCode actually registers them, captured from a live run — not
30
+ * read off the binary, which is what got this wrong twice. The patch tool is
31
+ * `apply_patch`; `patch` is kept only as a defensive alias.
32
+ */
33
+ const WRITE_TOOLS = new Set(["write", "edit", "apply_patch", "patch"]);
34
+
35
+ /**
36
+ * Which directory a tool call says the agent is working in, or null.
37
+ *
38
+ * Covering only `write`/`edit` was not enough. Two live `codie run` sessions
39
+ * were driven to create a file and the agent picked `bash` once and `patch`
40
+ * once — neither carries a `filePath` in its args, so both sessions reported
41
+ * the launch branch:
42
+ *
43
+ * write / edit args.filePath — known before the write
44
+ * apply_patch result.metadata.files[].filePath — only known AFTER it runs
45
+ *
46
+ * `bash` is deliberately NOT handled. Honouring its `workdir` re-anchors on
47
+ * READS too — `bash {command:"git log", workdir:"/repo"}` would move the
48
+ * anchor off a tracked worktree and back to the primary checkout, which
49
+ * passes the trust check because both share an origin. That breaks the rule
50
+ * this module is built on: writes are the signal, reads are not. Mining the
51
+ * command text was tried instead and did not work live. See NEXT_RELEASE.md.
52
+ *
53
+ * `patch` takes `patchText`, so its args never name a file. Shape confirmed
54
+ * against the opencode binary:
55
+ * metadata:{files:[{type:"update",filePath:…,relativePath:…,diff:…}]}
56
+ *
57
+ */
58
+ function workDirFromTool(tool, args, result) {
59
+ if (!WRITE_TOOLS.has(tool)) return null;
60
+ const candidates = [
61
+ args?.filePath,
62
+ args?.file_path,
63
+ ...(Array.isArray(result?.metadata?.files) ? result.metadata.files : []).flatMap((file) => [
64
+ file?.movePath, // a move reports the destination, which is where the work is
65
+ file?.filePath,
66
+ ]),
67
+ ];
68
+ // relativePath is deliberately excluded — it resolves against a cwd we may
69
+ // not share, so it is not a usable anchor.
70
+ const hit = candidates.find((path) => typeof path === "string" && isAbsolute(path));
71
+ return hit ? dirname(hit) : null;
72
+ }
31
73
 
32
- export function trackWorkPath(tool, args, sessionID, state = workDirBySession) {
33
- if (!WRITE_TOOLS.has(tool)) return;
34
- const filePath = args?.filePath ?? args?.file_path;
35
- // Relative paths are resolved against a cwd we may not share, so they are
36
- // not usable anchors.
37
- if (typeof filePath !== "string" || !isAbsolute(filePath)) return;
38
- state.set(sessionID, dirname(filePath));
74
+ export function trackWorkPath({
75
+ tool,
76
+ args,
77
+ sessionID,
78
+ result = null,
79
+ state = workDirBySession,
80
+ }) {
81
+ const dir = workDirFromTool(tool, args, result);
82
+ if (dir) state.set(sessionID, dir);
39
83
  }
40
84
 
41
85
  /**
@@ -93,7 +137,7 @@ function normalizeRemote(url) {
93
137
  const GIT_TTL_MS = 5_000;
94
138
  const gitCache = {};
95
139
 
96
- function clean(value, max) {
140
+ export function clean(value, max) {
97
141
  const text = typeof value === "string" ? value.trim() : "";
98
142
  return text ? text.slice(0, max) : "";
99
143
  }
@@ -4,6 +4,7 @@
4
4
  // names to inference requests handled by the company gateway.
5
5
 
6
6
  import {
7
+ clean,
7
8
  buildAttributionHeaders,
8
9
  resolveGitRefs,
9
10
  trackWorkPath,
@@ -34,10 +35,30 @@ export const CodiedevMetadata = async () => ({
34
35
  // Follow where the agent writes so refs come from the tree that becomes
35
36
  // the PR. Only the path's DIRECTORY is kept, and only to run git in it —
36
37
  // no path ever leaves this process.
37
- trackWorkPath(input.tool, output.args, input.sessionID);
38
+ //
39
+ // `write`/`edit` name the file up front; `bash` may carry a workdir. Both
40
+ // are worth catching here so the very next request already reports the
41
+ // right branch.
42
+ trackWorkPath({
43
+ tool: input.tool,
44
+ args: output.args,
45
+ sessionID: input.sessionID,
46
+ });
38
47
  if (input.tool !== "skill") return;
39
48
  addSkill(input.sessionID, output.args?.name || output.args?.skill || output.args?.key);
40
49
  },
50
+ "tool.execute.after": async (input, output) => {
51
+ // `patch` takes patchText, so the files it touched are only known once it
52
+ // has run — they arrive in the result's metadata.files[]. Without this
53
+ // hook a patch-driven session never moves off the launch branch, which is
54
+ // exactly what two live runs showed.
55
+ trackWorkPath({
56
+ tool: input.tool,
57
+ args: input.args,
58
+ sessionID: input.sessionID,
59
+ result: output,
60
+ });
61
+ },
41
62
  "chat.headers": async (input, output) => {
42
63
  Object.assign(
43
64
  output.headers,
package/dist/cli.js CHANGED
@@ -228,9 +228,13 @@ codiedev inbox — show messages from teammates
228
228
  Usage:
229
229
  codiedev inbox [--unread] [--limit N]
230
230
 
231
- Lists pings with sender, subject artifact, and preview. Each row
231
+ Lists pings with sender, subject artifact, and preview. Each ping row
232
232
  shows a ping-id you can pass to 'codiedev read'.
233
233
 
234
+ Also lists feed posts and comments where a teammate @-mentioned you.
235
+ Those rows end in a portal link — the thread lives in the feed, so
236
+ read and reply to it there.
237
+
234
238
  Unread rows show as ● ; read rows as · .
235
239
 
236
240
  Examples:
@@ -1,2 +1,3 @@
1
- export declare function runInbox(args: string[]): Promise<void>;
1
+ import type { CodiedevConfig } from "../utils";
2
+ export declare function runInbox(args: string[], configOverride?: CodiedevConfig): Promise<void>;
2
3
  export declare function runRead(args: string[]): Promise<void>;
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.runInbox = runInbox;
4
4
  exports.runRead = runRead;
5
5
  const shared_1 = require("./shared");
6
+ const inboxFormat_1 = require("../inboxFormat");
6
7
  function parseInboxArgs(args) {
7
8
  let unreadOnly = false;
8
9
  let limit;
@@ -23,9 +24,9 @@ function parseInboxArgs(args) {
23
24
  }
24
25
  return { unreadOnly, limit };
25
26
  }
26
- async function runInbox(args) {
27
+ async function runInbox(args, configOverride) {
27
28
  const { unreadOnly, limit } = parseInboxArgs(args);
28
- const config = (0, shared_1.requireConfig)();
29
+ const config = configOverride ?? (0, shared_1.requireConfig)();
29
30
  const start = Date.now();
30
31
  try {
31
32
  const res = await (0, shared_1.apiRequest)("GET", "/api/cli/inbox", {
@@ -40,26 +41,14 @@ async function runInbox(args) {
40
41
  ok: true,
41
42
  latencyMs: Date.now() - start,
42
43
  });
43
- const pings = res.pings;
44
- const unreadCount = pings.filter((p) => !p.readAt).length;
45
- if (pings.length === 0) {
46
- console.log(unreadOnly ? "No unread messages." : "Inbox is empty.");
47
- return;
48
- }
49
- console.log(`Inbox · ${pings.length} ${unreadOnly ? "unread" : "recent"}${unreadOnly ? "" : ` · ${unreadCount} unread`}`);
50
- console.log();
51
- for (const p of pings) {
52
- const dot = p.readAt ? "·" : "●";
53
- const from = p.from?.name ?? "unknown";
54
- const when = (0, shared_1.timeAgo)(p.createdAt);
55
- const where = p.subjectKey ? ` · on ${p.subjectKey}` : "";
56
- const preview = p.preview || "(no body)";
57
- console.log(`${dot} ${from}${where} · ${when}`);
58
- console.log(` ${preview}`);
59
- console.log(` id: ${p.pingId}`);
60
- console.log();
44
+ for (const line of (0, inboxFormat_1.formatInbox)({
45
+ pings: res.pings,
46
+ mentions: res.mentions,
47
+ unreadOnly,
48
+ limit,
49
+ })) {
50
+ console.log(line);
61
51
  }
62
- console.log(`Mark read: codiedev read <ping-id>`);
63
52
  }
64
53
  catch (err) {
65
54
  (0, shared_1.recordAgentEvent)(config, {
@@ -0,0 +1,38 @@
1
+ export interface InboxPing {
2
+ pingId: string;
3
+ createdAt: number;
4
+ readAt?: number;
5
+ from: {
6
+ name: string;
7
+ email: string;
8
+ } | null;
9
+ subjectKey: string | null;
10
+ subject: {
11
+ title?: string;
12
+ } | null;
13
+ preview: string;
14
+ }
15
+ export interface InboxMention {
16
+ mentionId: string;
17
+ createdAt: number;
18
+ readAt?: number | null;
19
+ from: {
20
+ name: string;
21
+ email: string;
22
+ } | null;
23
+ postTitle: string | null;
24
+ preview: string | null;
25
+ url: string | null;
26
+ }
27
+ /**
28
+ * Render the inbox for BOTH surfaces — `codiedev inbox` and the MCP
29
+ * `codiedev_inbox` tool an agent calls for "any messages?". Shared so the
30
+ * agent path can never drift back to pings-only while the human path shows
31
+ * mentions.
32
+ */
33
+ export declare function formatInbox(opts: {
34
+ pings: InboxPing[];
35
+ mentions?: InboxMention[];
36
+ unreadOnly: boolean;
37
+ limit?: number;
38
+ }): string[];
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formatInbox = formatInbox;
4
+ const shared_1 = require("./commands/shared");
5
+ /**
6
+ * Render the inbox for BOTH surfaces — `codiedev inbox` and the MCP
7
+ * `codiedev_inbox` tool an agent calls for "any messages?". Shared so the
8
+ * agent path can never drift back to pings-only while the human path shows
9
+ * mentions.
10
+ */
11
+ function formatInbox(opts) {
12
+ const mentions = opts.mentions ?? [];
13
+ // The backend applies `limit` to each source independently, so asking for N
14
+ // can return up to 2N — and with no `--limit` it defaults to 50 per source,
15
+ // i.e. 100 rows, twice the pre-change maximum. Cap the COMBINED list by
16
+ // recency, always (matching the backend's own default), so the header is
17
+ // truthful and `--limit` actually holds.
18
+ //
19
+ // Selected by identity rather than by a `createdAt >= cutoff` filter: rows
20
+ // tied on the cutoff millisecond are reachable (Convex freezes Date.now()
21
+ // per transaction, and seeded activity carries synthetic stamps) and a
22
+ // threshold filter lets every tied row through, overshooting the limit.
23
+ const DEFAULT_LIMIT = 50;
24
+ const cap = opts.limit ?? DEFAULT_LIMIT;
25
+ // Pings claim the budget first, then mentions fill whatever is left — both
26
+ // newest-first. Before this change pings owned the whole budget, so this is
27
+ // purely additive and can never hide a ping that used to be visible. A flat
28
+ // recency cap would instead let a busy feed evict every direct ping, and
29
+ // pings are the actionable half: they carry the id you reply to, and losing
30
+ // them all takes the mark-read footer with them.
31
+ const byRecency = (xs) => [...xs].sort((a, b) => b.createdAt - a.createdAt);
32
+ const keptPings = byRecency(opts.pings).slice(0, cap);
33
+ const keptMentions = byRecency(mentions).slice(0, Math.max(0, cap - keptPings.length));
34
+ const total = keptPings.length + keptMentions.length;
35
+ if (total === 0) {
36
+ return [opts.unreadOnly ? "No unread messages." : "Inbox is empty."];
37
+ }
38
+ const unreadCount = keptPings.filter((p) => !p.readAt).length +
39
+ keptMentions.filter((m) => !m.readAt).length;
40
+ const lines = [
41
+ `Inbox · ${total} ${opts.unreadOnly ? "unread" : "recent"}${opts.unreadOnly ? "" : ` · ${unreadCount} unread`}`,
42
+ "",
43
+ ];
44
+ for (const p of keptPings) {
45
+ const dot = p.readAt ? "·" : "●";
46
+ const from = p.from?.name ?? "unknown";
47
+ const where = p.subjectKey ? ` · on ${p.subjectKey}` : "";
48
+ lines.push(`${dot} ${from}${where} · ${(0, shared_1.timeAgo)(p.createdAt)}`);
49
+ lines.push(` ${p.preview || "(no body)"}`);
50
+ lines.push(` id: ${p.pingId}`);
51
+ lines.push("");
52
+ }
53
+ // Mentions carry no CLI-side body to reply to — the thread lives in the feed
54
+ // — so each row ends in the deep link rather than an id.
55
+ for (const m of keptMentions) {
56
+ const dot = m.readAt ? "·" : "●";
57
+ const from = m.from?.name ?? "someone";
58
+ const where = m.postTitle ? ` · on “${m.postTitle}”` : "";
59
+ lines.push(`${dot} ${from} mentioned you${where} · ${(0, shared_1.timeAgo)(m.createdAt)}`);
60
+ if (m.preview)
61
+ lines.push(` ${m.preview}`);
62
+ if (m.url)
63
+ lines.push(` ${m.url}`);
64
+ lines.push("");
65
+ }
66
+ if (keptPings.length > 0)
67
+ lines.push("Mark read: codiedev read <ping-id>");
68
+ return lines;
69
+ }
package/dist/mcp.js CHANGED
@@ -51,6 +51,7 @@ const fs = __importStar(require("fs"));
51
51
  const path = __importStar(require("path"));
52
52
  const utils_1 = require("./utils");
53
53
  const shared_1 = require("./commands/shared");
54
+ const inboxFormat_1 = require("./inboxFormat");
54
55
  const PKG_NAME = "codiedev";
55
56
  // Read version from the bundled package.json so the startup log + tool reports
56
57
  // always reflect the actually-installed version, rather than a stale constant.
@@ -748,33 +749,16 @@ async function handleInbox(args, config) {
748
749
  config,
749
750
  query: { unreadOnly: unreadOnly ? "1" : undefined, limit },
750
751
  });
751
- if (res.pings.length === 0) {
752
- return {
753
- content: [
754
- {
755
- type: "text",
756
- text: unreadOnly ? "No unread messages." : "Inbox is empty.",
757
- },
758
- ],
759
- };
760
- }
761
- const unreadCount = res.pings.filter((p) => !p.readAt).length;
762
- const lines = [
763
- `Inbox · ${res.pings.length} ${unreadOnly ? "unread" : "recent"}${unreadOnly ? "" : ` · ${unreadCount} unread`}`,
764
- "",
765
- ];
766
- for (const p of res.pings) {
767
- const status = p.readAt ? "·" : "●";
768
- const from = p.from?.name ?? "unknown";
769
- const where = p.subjectKey ? ` · on ${p.subjectKey}` : "";
770
- lines.push(`${status} ${from}${where} · ${(0, shared_1.timeAgo)(p.createdAt)}`);
771
- lines.push(` ${p.preview || "(no body)"}`);
772
- lines.push(` id: ${p.pingId}`);
773
- lines.push("");
774
- }
775
- return {
776
- content: [{ type: "text", text: lines.join("\n") }],
777
- };
752
+ // Shared with `codiedev inbox` so this agent-facing path can never drift
753
+ // back to pings-only. CLAUDE.md routes "any messages?" here, so a
754
+ // mentions-blind version reproduces the bug for agents.
755
+ const text = (0, inboxFormat_1.formatInbox)({
756
+ pings: res.pings,
757
+ mentions: res.mentions,
758
+ unreadOnly,
759
+ limit,
760
+ }).join("\n");
761
+ return { content: [{ type: "text", text }] };
778
762
  }
779
763
  async function handleNote(args, config) {
780
764
  const body = asString(args.body);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codiedev",
3
- "version": "0.9.31",
3
+ "version": "0.9.33",
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",