@proagentstore/cli 0.4.37 → 0.4.39

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.
@@ -19,11 +19,35 @@ import { join } from "node:path";
19
19
  export function sanitizeSessionName(label) {
20
20
  return label.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "session";
21
21
  }
22
+ /**
23
+ * The clone URL with the credential embedded, or the URL untouched.
24
+ *
25
+ * PURE and exported so the one line that decides where a credential goes is unit-testable
26
+ * without a network or a git binary. `username` is provider-specific — GitHub wants
27
+ * `x-access-token`, GitLab `oauth2`, Bitbucket `x-token-auth` (#221) — and it defaults to the
28
+ * value this function used to hardcode, so a cloud that sends only `token` behaves as before.
29
+ *
30
+ * Only https carries a credential: git ignores userinfo on an ssh URL, so injecting there would
31
+ * be pure exposure for no effect. Both halves are percent-encoded — a secret containing `@`,
32
+ * `/` or `:` would otherwise re-parse the URL into a DIFFERENT host and send the credential
33
+ * there. GitHub's tokens contain none of those, so nothing changes for the existing provider.
34
+ */
35
+ export function authenticatedCloneUrl(cloneUrl, token, username) {
36
+ if (!token || !/^https:\/\//i.test(cloneUrl))
37
+ return cloneUrl;
38
+ const user = encodeURIComponent(username || "x-access-token");
39
+ return cloneUrl.replace(/^https:\/\//i, `https://${user}:${encodeURIComponent(token)}@`);
40
+ }
22
41
  /**
23
42
  * Ensure a repo is present at `dir`, cloning it from `cloneUrl` if not. Idempotent
24
- * — an existing checkout is left alone (no clobber). For private repos a GitHub
25
- * App installation token is injected as `x-access-token` into an https URL. The
26
- * coding CLI then runs in this directory.
43
+ * — an existing checkout is left alone (no clobber). For private repos the cloud
44
+ * sends a token, injected as the password half of an https URL. The coding CLI then
45
+ * runs in this directory.
46
+ *
47
+ * `tokenUsername` is the USERNAME half, and it is provider-specific: GitHub wants
48
+ * `x-access-token`, GitLab `oauth2`, Bitbucket `x-token-auth` (#221). It defaults to
49
+ * `x-access-token` — the value this function used to hardcode — so an older cloud that
50
+ * sends only `token` behaves exactly as before.
27
51
  *
28
52
  * Returns the absolute working directory. Throws on clone failure so the caller
29
53
  * can surface it (a session can't start without its repo).
@@ -50,10 +74,7 @@ export function ensureRepo(dir, opts = {}) {
50
74
  }
51
75
  rmSync(dir, { recursive: true, force: true });
52
76
  }
53
- let url = opts.cloneUrl;
54
- if (opts.token && /^https:\/\//.test(url)) {
55
- url = url.replace(/^https:\/\//, `https://x-access-token:${opts.token}@`);
56
- }
77
+ const url = authenticatedCloneUrl(opts.cloneUrl, opts.token, opts.tokenUsername);
57
78
  const args = ["clone", "--depth", "1"];
58
79
  if (opts.branch)
59
80
  args.push("--branch", opts.branch);
@@ -71,7 +71,7 @@ export class CodingRuntime {
71
71
  const workDir = input.workDir
72
72
  ? resolve(input.workDir.replace(/^~(?=$|\/)/, homedir()))
73
73
  : join(this.reposBaseDir, sanitizeSessionName(input.repoId));
74
- ensureRepo(workDir, { cloneUrl: input.cloneUrl, branch: input.branch, token: input.token });
74
+ ensureRepo(workDir, { cloneUrl: input.cloneUrl, branch: input.branch, token: input.token, tokenUsername: input.tokenUsername });
75
75
  session = new HeadlessSession({
76
76
  id: input.sessionId,
77
77
  workDir,
@@ -1,7 +1,7 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { homedir } from "node:os";
3
3
  import { resolve } from "node:path";
4
- import { capturePane, createSession, killSession, listSessionsDetailed, runCommand as tmuxRunCommand, sendKey as tmuxSendKey, sendText as tmuxSendText, sessionExists } from "./tmux.js";
4
+ import { capturePane, createSession, killSession, listSessionsDetailed, runCommand as tmuxRunCommand, sendKey as tmuxSendKey, sendText as tmuxSendText, sessionExists, tmuxExec } from "./tmux.js";
5
5
  function shellPath() {
6
6
  return process.env.SHELL || "/bin/zsh";
7
7
  }
@@ -51,6 +51,76 @@ export function captureTerminalTarget(target, opts = {}) {
51
51
  return itermSessionScript(t.id, "return contents of theSession");
52
52
  }
53
53
  }
54
+ /**
55
+ * The foreground command running in a target right now — `claude`, `node`, `zsh`, … (#348).
56
+ *
57
+ * The platform cannot measure what an AI CLI inside a pane spends: a pane holds rendered text, not
58
+ * the stream-json `result` event `headless.ts` reads. So the cloud records the drive as UNMETERED
59
+ * instead, and this is the one fact that makes that record specific rather than generic. It is a
60
+ * name, never a number — no cost figure is ever parsed out of pane output, because a scraped
61
+ * dollar amount would be a guess dressed as a measurement.
62
+ *
63
+ * Returns `null` when it cannot be read (no such target, iTerm2, a backend that will not say).
64
+ * `null` means UNREADABLE and must not be flattened into "nothing was running" upstream: the
65
+ * distinction is the entire point of recording this at all.
66
+ */
67
+ export function activeTerminalCommand(target, backend) {
68
+ let t;
69
+ try {
70
+ t = splitTerminalTarget(target, backend);
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ try {
76
+ switch (t.backend) {
77
+ case "tmux": {
78
+ const out = tmuxExec(["display-message", "-p", "-t", t.id, "#{pane_current_command}"]).trim();
79
+ return out || null;
80
+ }
81
+ case "kitty":
82
+ return kittyForegroundCommand(t.id);
83
+ // iTerm2 exposes no cheap, reliable foreground-process query. Saying so is the honest
84
+ // answer; guessing from the pane's last line would not be.
85
+ case "iterm2":
86
+ return null;
87
+ }
88
+ }
89
+ catch {
90
+ return null;
91
+ }
92
+ }
93
+ /** The foreground process of a kitty window, from `kitty @ ls`. Null if it can't be determined. */
94
+ function kittyForegroundCommand(id) {
95
+ try {
96
+ const parsed = JSON.parse(kittyExec(["@", "ls"], 5000));
97
+ if (!Array.isArray(parsed))
98
+ return null;
99
+ for (const osWindow of parsed) {
100
+ const tabs = Array.isArray(osWindow.tabs) ? osWindow.tabs : [];
101
+ for (const tab of tabs) {
102
+ const windows = Array.isArray(tab.windows) ? tab.windows : [];
103
+ for (const win of windows) {
104
+ const row = win;
105
+ if (String(row.id ?? "") !== id)
106
+ continue;
107
+ const procs = Array.isArray(row.foreground_processes) ? row.foreground_processes : [];
108
+ for (const p of procs) {
109
+ const cmdline = p.cmdline;
110
+ const first = Array.isArray(cmdline) ? String(cmdline[0] ?? "").trim() : "";
111
+ if (first)
112
+ return first;
113
+ }
114
+ return null;
115
+ }
116
+ }
117
+ }
118
+ return null;
119
+ }
120
+ catch {
121
+ return null;
122
+ }
123
+ }
54
124
  export function runTerminalCommand(target, command, backend) {
55
125
  const t = splitTerminalTarget(target, backend);
56
126
  if (!command.trim())
@@ -136,13 +136,25 @@ export class McpRuntime {
136
136
  this.client = new Client({ name: "pags-runner", version: "1.0.0" });
137
137
  await this.client.connect(clientTransport);
138
138
  }
139
+ /**
140
+ * The connected client, or a diagnosis. `client` is unset before `start()` AND cleared by
141
+ * `stop()`, so both call sites below are reachable with it missing — a use-after-stop is the
142
+ * likely one, since `stop()` runs on teardown while a slow in-flight action is still landing.
143
+ * A bare `this.client!` turned that into "Cannot read properties of undefined (reading
144
+ * 'callTool')" at the relay boundary, which names neither the runtime nor the lifecycle.
145
+ */
146
+ connected() {
147
+ if (!this.client)
148
+ throw new Error("MCP runtime is not running — call start() first (or it was already stopped).");
149
+ return this.client;
150
+ }
139
151
  /** The standard Playwright MCP tool schemas — advertised to the cloud brain verbatim. */
140
152
  async listTools() {
141
- const res = await this.client.listTools();
153
+ const res = await this.connected().listTools();
142
154
  return res.tools;
143
155
  }
144
156
  async callTool(name, args = {}) {
145
- return (await this.client.callTool({ name, arguments: args }));
157
+ return (await this.connected().callTool({ name, arguments: args }));
146
158
  }
147
159
  /** Text of the last tool result (the standard tools return their output as text content). */
148
160
  textOf(res) {
@@ -304,7 +304,10 @@ async function route(runner, req, res) {
304
304
  sendText(session, String(b.text));
305
305
  for (const k of b.keys ?? [])
306
306
  sendKey(session, String(k));
307
- return json(res, 200, { session, pane: capturePane(session, 200) });
307
+ // `activeCommand` rides along on every WRITE so the cloud can record what it just drove
308
+ // (#348). It is a process name, never a cost — see activeTerminalCommand's comment.
309
+ const { activeTerminalCommand } = await import("./coding/terminal.js");
310
+ return json(res, 200, { session, pane: capturePane(session, 200), activeCommand: activeTerminalCommand(session, "tmux") });
308
311
  }
309
312
  if (req.method === "POST" && path === "/tmux/run") {
310
313
  const { runCommand, capturePane, sessionExists } = await import("./coding/tmux.js");
@@ -318,7 +321,8 @@ async function route(runner, req, res) {
318
321
  if (!sessionExists(session))
319
322
  return json(res, 404, { error: `No tmux session "${session}".` });
320
323
  runCommand(session, command);
321
- return json(res, 200, { session, command, pane: capturePane(session, 200) });
324
+ const { activeTerminalCommand } = await import("./coding/terminal.js");
325
+ return json(res, 200, { session, command, pane: capturePane(session, 200), activeCommand: activeTerminalCommand(session, "tmux") });
322
326
  }
323
327
  if (req.method === "POST" && path === "/tmux/session") {
324
328
  const { createSession, killSession, sessionExists } = await import("./coding/tmux.js");
@@ -357,7 +361,7 @@ async function route(runner, req, res) {
357
361
  return json(res, 200, { target, pane: captureTerminalTarget(target, { backend, lines: b.lines }) });
358
362
  }
359
363
  if (req.method === "POST" && path === "/terminal/run") {
360
- const { runTerminalCommand } = await import("./coding/terminal.js");
364
+ const { runTerminalCommand, activeTerminalCommand } = await import("./coding/terminal.js");
361
365
  const b = await readJson(req);
362
366
  const target = String(b.target || "").trim();
363
367
  const command = String(b.command ?? "");
@@ -366,16 +370,20 @@ async function route(runner, req, res) {
366
370
  if (!command.trim())
367
371
  return json(res, 400, { error: "A `command` is required." });
368
372
  const backend = b.backend === "tmux" || b.backend === "kitty" || b.backend === "iterm2" ? b.backend : undefined;
369
- return json(res, 200, { target, command, pane: runTerminalCommand(target, command, backend) });
373
+ const pane = runTerminalCommand(target, command, backend);
374
+ // Read AFTER the command lands, so `claude "fix x"` reports `claude` rather than the shell
375
+ // that was sitting there a moment earlier (#348).
376
+ return json(res, 200, { target, command, pane, activeCommand: activeTerminalCommand(target, backend) });
370
377
  }
371
378
  if (req.method === "POST" && path === "/terminal/send") {
372
- const { sendTerminalKeys } = await import("./coding/terminal.js");
379
+ const { sendTerminalKeys, activeTerminalCommand } = await import("./coding/terminal.js");
373
380
  const b = await readJson(req);
374
381
  const target = String(b.target || "").trim();
375
382
  if (!target)
376
383
  return json(res, 400, { error: "A `target` is required." });
377
384
  const backend = b.backend === "tmux" || b.backend === "kitty" || b.backend === "iterm2" ? b.backend : undefined;
378
- return json(res, 200, { target, pane: sendTerminalKeys(target, { backend, text: b.text == null ? undefined : String(b.text), keys: b.keys ?? [] }) });
385
+ const pane = sendTerminalKeys(target, { backend, text: b.text == null ? undefined : String(b.text), keys: b.keys ?? [] });
386
+ return json(res, 200, { target, pane, activeCommand: activeTerminalCommand(target, backend) });
379
387
  }
380
388
  if (req.method === "POST" && path === "/terminal/session") {
381
389
  const { createTerminalTarget, killTerminalTarget } = await import("./coding/terminal.js");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.37",
3
+ "version": "0.4.39",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",