edsger 0.71.0 → 0.72.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.
@@ -21,8 +21,15 @@ export async function callMcpEndpoint(method, params) {
21
21
  if (!mcpToken) {
22
22
  throw new Error('Not authenticated. Run `edsger login` or set EDSGER_MCP_TOKEN environment variable.');
23
23
  }
24
+ // The platform splits its JSON-RPC surface across two edge functions that
25
+ // share the same base URL and MCP-token auth:
26
+ // - `/mcp` — slash-notation methods (issues/list, github/org_repos, ...)
27
+ // - `/agents` — dot-notation methods (agents.*, services.*, skills.*, ...)
28
+ // Route by the method's separator so callers don't have to care which
29
+ // function owns a given method.
30
+ const endpoint = method.includes('.') ? 'agents' : 'mcp';
24
31
  try {
25
- const response = await fetch(`${mcpServerUrl}/mcp`, {
32
+ const response = await fetch(`${mcpServerUrl}/${endpoint}`, {
26
33
  method: 'POST',
27
34
  headers: {
28
35
  'Content-Type': 'application/json',
@@ -215,5 +215,5 @@ async function resolveRepositoryFullName(repositoryId) {
215
215
  .select('full_name')
216
216
  .eq('id', repositoryId)
217
217
  .maybeSingle();
218
- return (data)?.full_name ?? null;
218
+ return data?.full_name ?? null;
219
219
  }
@@ -29,9 +29,16 @@ export async function runSyncTerraform(teamId, options = {}) {
29
29
  repoFullName = result?.team?.terraform_repo_full_name ?? null;
30
30
  }
31
31
  catch {
32
- // Fallback: read directly from supabase if MCP doesn't have the endpoint
32
+ // Fallback: the caller (e.g. the desktop app) may have already resolved
33
+ // the team's repo and injected it as EDSGER_TERRAFORM_REPO, so MCP isn't
34
+ // strictly required.
33
35
  logWarning('Could not fetch team via MCP, attempting to read terraform_repo_full_name from env');
34
36
  }
37
+ // Env fallback works whether or not MCP returned a repo — it lets the
38
+ // desktop app drive the sync without depending on the MCP team endpoint.
39
+ if (!repoFullName) {
40
+ repoFullName = process.env.EDSGER_TERRAFORM_REPO ?? null;
41
+ }
35
42
  if (!repoFullName) {
36
43
  logError('No Terraform repo configured for this team. ' +
37
44
  'Go to Team Settings and set a Terraform repo, or use --dir to point at a local directory.');
package/dist/index.js CHANGED
@@ -58,6 +58,7 @@ import { runWorkflow } from './commands/workflow/index.js';
58
58
  import { DEFAULT_MAX_FILES as FIND_ARCHITECTURE_DEFAULT_MAX_FILES } from './phases/find-architecture/index.js';
59
59
  import { DEFAULT_MAX_FILES as FIND_SMELLS_DEFAULT_MAX_FILES } from './phases/find-smells/index.js';
60
60
  import { SMELL_CATEGORIES, } from './phases/find-smells/types.js';
61
+ import { deregisterSession, registerSession, } from './system/session-manager.js';
61
62
  import { logError, logInfo } from './utils/logger.js';
62
63
  // Get package.json version dynamically
63
64
  // eslint-disable-next-line @typescript-eslint/naming-convention -- ESM __filename/__dirname polyfill
@@ -77,6 +78,24 @@ program
77
78
  .name('edsger')
78
79
  .description('AI-powered workflow automation and code review CLI tool')
79
80
  .version(version);
81
+ // When the desktop app spawns a streamed CLI it sets EDSGER_PROCESS_KEY (its
82
+ // process-registry key with the `:output` marker stripped, e.g.
83
+ // `sync-terraform` or `recipes:<productId>`). Open a cli_sessions row whose
84
+ // `command` is that key so the originating tab can find the still-running
85
+ // session and replay its logs across tab switches / restarts / devices.
86
+ // registerSession is idempotent: commands that open their own (richer) session
87
+ // just enrich the row started here (the process key stays in `command`), and
88
+ // deregisterSession is a no-op once they've closed it.
89
+ program.hook('preAction', async (_thisCommand, actionCommand) => {
90
+ if (process.env.EDSGER_PROCESS_KEY) {
91
+ await registerSession({ command: actionCommand.name() });
92
+ }
93
+ });
94
+ program.hook('postAction', async () => {
95
+ if (process.env.EDSGER_PROCESS_KEY) {
96
+ await deregisterSession();
97
+ }
98
+ });
80
99
  // ============================================================
81
100
  // Subcommand: edsger login
82
101
  // ============================================================
@@ -20,6 +20,14 @@ export interface CliSession {
20
20
  /**
21
21
  * Register this CLI session with the server.
22
22
  * Optionally accepts command name and product ID for filtering on detail pages.
23
+ *
24
+ * Idempotent: if a session is already active (e.g. registered by the top-level
25
+ * command hook), this enriches that row with the command/product instead of
26
+ * creating a second session, and returns the existing id.
27
+ *
28
+ * The stored `command` is the desktop process key when the CLI was spawned by
29
+ * the app (EDSGER_PROCESS_KEY), so a feature/tab can find this session; see
30
+ * `resolvedCommand`.
23
31
  */
24
32
  export declare function registerSession(options?: {
25
33
  command?: string;
@@ -25,13 +25,42 @@ function generateSessionId() {
25
25
  const random = Math.random().toString(36).substring(2, 8);
26
26
  return `cli-${timestamp}-${random}`;
27
27
  }
28
+ /**
29
+ * The value stored in `cli_sessions.command`.
30
+ *
31
+ * When the desktop app spawns a streamed CLI it injects EDSGER_PROCESS_KEY --
32
+ * its process-registry key (`:output` channel marker already stripped), e.g.
33
+ * `sync-terraform`, `recipes:<productId>`, `pr-review:<prId>`. That key is what
34
+ * the originating tab looks up by, so it takes precedence over the bare command
35
+ * name; standalone runs fall back to the supplied command name.
36
+ */
37
+ function resolvedCommand(options) {
38
+ return process.env.EDSGER_PROCESS_KEY || options?.command;
39
+ }
28
40
  /**
29
41
  * Register this CLI session with the server.
30
42
  * Optionally accepts command name and product ID for filtering on detail pages.
43
+ *
44
+ * Idempotent: if a session is already active (e.g. registered by the top-level
45
+ * command hook), this enriches that row with the command/product instead of
46
+ * creating a second session, and returns the existing id.
47
+ *
48
+ * The stored `command` is the desktop process key when the CLI was spawned by
49
+ * the app (EDSGER_PROCESS_KEY), so a feature/tab can find this session; see
50
+ * `resolvedCommand`.
31
51
  */
32
52
  export async function registerSession(options) {
53
+ // Already in a session for this process — enrich, don't double-register.
54
+ if (currentSessionId) {
55
+ await enrichSession(options);
56
+ return currentSessionId;
57
+ }
33
58
  const sessionId = generateSessionId();
34
59
  currentSessionId = sessionId;
60
+ const command = resolvedCommand(options);
61
+ // Full invocation (subcommand + args) for readable run history. `command`
62
+ // stays the stable, entity-scoped lookup key; this carries the variable args.
63
+ const invocation = process.argv.slice(2).join(' ') || undefined;
35
64
  try {
36
65
  const userId = getUserId();
37
66
  if (hasSupabaseSession() && userId) {
@@ -44,8 +73,11 @@ export async function registerSession(options) {
44
73
  started_at: new Date().toISOString(),
45
74
  last_heartbeat: new Date().toISOString(),
46
75
  };
47
- if (options?.command) {
48
- row.command = options.command;
76
+ if (command) {
77
+ row.command = command;
78
+ }
79
+ if (invocation) {
80
+ row.invocation = invocation;
49
81
  }
50
82
  if (options?.productId) {
51
83
  row.product_id = options.productId;
@@ -64,8 +96,11 @@ export async function registerSession(options) {
64
96
  version: getVersion(),
65
97
  status: 'running',
66
98
  };
67
- if (options?.command) {
68
- payload.command = options.command;
99
+ if (command) {
100
+ payload.command = command;
101
+ }
102
+ if (invocation) {
103
+ payload.invocation = invocation;
69
104
  }
70
105
  if (options?.productId) {
71
106
  payload.product_id = options.productId;
@@ -82,6 +117,41 @@ export async function registerSession(options) {
82
117
  initLogSync(sessionId);
83
118
  return sessionId;
84
119
  }
120
+ /**
121
+ * Best-effort enrichment of the already-active session row with command /
122
+ * product info (e.g. when a command registers itself after the top-level hook
123
+ * already opened the session). Direct-SDK path only; the MCP heartbeat path
124
+ * keeps whatever the initial register supplied.
125
+ */
126
+ async function enrichSession(options) {
127
+ const command = resolvedCommand(options);
128
+ if (!currentSessionId || (!command && !options?.productId)) {
129
+ return;
130
+ }
131
+ try {
132
+ const userId = getUserId();
133
+ if (!hasSupabaseSession() || !userId) {
134
+ return;
135
+ }
136
+ const patch = {};
137
+ // With a desktop process key present, `command` resolves to that key, so
138
+ // a self-registering command can't clobber it with its bare name.
139
+ if (command) {
140
+ patch.command = command;
141
+ }
142
+ if (options?.productId) {
143
+ patch.product_id = options.productId;
144
+ }
145
+ await getSupabase()
146
+ .from('cli_sessions')
147
+ .update(patch)
148
+ .eq('session_id', currentSessionId)
149
+ .eq('user_id', userId);
150
+ }
151
+ catch {
152
+ // best-effort
153
+ }
154
+ }
85
155
  /**
86
156
  * Send a heartbeat to keep the session alive and check for commands
87
157
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edsger",
3
- "version": "0.71.0",
3
+ "version": "0.72.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "edsger": "dist/index.js"
@@ -50,8 +50,8 @@
50
50
  "commander": "^12.0.0",
51
51
  "cosmiconfig": "^9.0.0",
52
52
  "dotenv": "^16.4.5",
53
- "edsger-contract": "0.7.0",
54
- "edsger-tools": "0.9.0",
53
+ "edsger-contract": "0.9.1",
54
+ "edsger-tools": "0.9.1",
55
55
  "gray-matter": "^4.0.3",
56
56
  "zod": "^4.0.0"
57
57
  },