@prohost/cli 0.12.3 → 0.13.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,55 @@ Versions follow [semver](https://semver.org/). Publishing is automated: merging
4
4
  a version bump to `main` triggers `.github/workflows/npm-publish-cli.yml`, which
5
5
  builds via `prepack`, runs the suite, publishes, and tags `cli-v<version>`.
6
6
 
7
+ ## 0.13.1
8
+
9
+ - **A connected team's thread is never answered after the run's grant ended.**
10
+ A venue session that has already expired (or has less than 30 seconds left)
11
+ is refused when it opens, and the grant is checked again right before
12
+ Claude Code starts. A run that waited in the queue past its grant is never
13
+ started; it is closed as failed with its own token while the token still
14
+ works, so the thread's thinking indicator clears. A `--dry-run` only logs
15
+ that it would.
16
+ - **The grant is timed on both of this machine's clocks**, from the server's
17
+ own remaining lifetime (`expires_in_seconds`) counted from when the session
18
+ was asked for, and whichever runs out first ends it. A wound-back system
19
+ clock can't lengthen it, and neither can a sleep the monotonic clock doesn't
20
+ count (Linux): a venue run is re-checked at least every 15 seconds.
21
+ - **A venue run whose grant is ending is stopped and closed with its own
22
+ token** while the 30-second margin still leaves it good — queued or already
23
+ working — instead of being dropped locally and left to the server's
24
+ stale-run sweep. Nothing it was saying is posted.
25
+
26
+ ## 0.13.0
27
+
28
+ - **An agent can answer in a connected team's thread without its own key.**
29
+ When ProhostAI hands this agent a run in a thread that belongs to a
30
+ connected team's workspace, the event carries none of that thread. The CLI
31
+ opens the run's venue session over its own connection to ProhostAI (there is
32
+ no HTTP route to it), which returns the thread and a token minted for that
33
+ one run. It uses the token, not this machine's key, for every request the
34
+ run makes, and keeps it in memory only. The run's progress line and failure
35
+ report are fixed text, so nothing from that thread lands in this
36
+ workspace's run history or in this machine's log. ProhostAI hands such a
37
+ run only to the one connection that holds this pairing: if a second process
38
+ connects with the same key, the run is declined rather than sent to either.
39
+ - The run is answered by a Claude Code session started from your `--exec`
40
+ binary and model only. It has no tools, no MCP servers, no hooks, plugins,
41
+ settings or `CLAUDE.md` (`--safe-mode --setting-sources '' --settings '{}'`),
42
+ and writes no transcript (`--no-session-persistence`). Its environment
43
+ contains only the shell basics and Claude Code's own login, and it runs in an
44
+ empty directory of its own. The other team's messages never reach a session
45
+ that could act on this workspace.
46
+ - The CLI tells ProhostAI it can do this (`capabilities: ["venue_run_token"]`
47
+ on its status frame) only when it drives a Claude Code that has all of those
48
+ flags, on macOS or Linux. ProhostAI sends such runs to no other agent. Codex
49
+ and plain `--exec` commands are unchanged and never receive one.
50
+
51
+ ## 0.12.4
52
+
53
+ - Refuse redirects on credentialed API writes and pairing requests, so a validated endpoint cannot replay keys or pairing codes to another destination. Direct HTTPS and local-loopback requests keep working.
54
+ - Reuse an existing usage or paired-agent key before reading implicit stdin. Explicit `usage connect --key -` still replaces the key from stdin; an unconnected pipe still supplies a key.
55
+
7
56
  ## 0.12.3
8
57
 
9
58
  - **A run stopped by the Claude usage limit says so.** It used to fail with
package/README.md CHANGED
@@ -663,6 +663,27 @@ Other agents: the CLI shapes no command but Claude Code's and Codex's, so give
663
663
  yours its own equivalent flag — whatever your runner calls it — and apply the
664
664
  same judgement.
665
665
 
666
+ **Threads in a connected team's workspace are the exception.** When a
667
+ connected team addresses your agent in one of *their* threads, the prompt is
668
+ another workspace's text, so the run is answered without any of the above. The
669
+ event carries none of the thread: the CLI opens the run's venue session over
670
+ its own connection to ProhostAI (no HTTP route, so a process that only holds
671
+ your key cannot ask the same way), which returns the thread and a token of its
672
+ own that can only read and post in that one thread and report on that one run.
673
+ ProhostAI sends such a run only while this connection is the only one holding
674
+ the pairing, and opens its session only on the connection it went to: another
675
+ process connecting with your key makes the run decline, never receive it. The CLI uses it instead of your key and
676
+ keeps it in memory only. Claude Code is started from your `--exec` binary and
677
+ model only, with `--tools ''`, `--safe-mode`, `--setting-sources ''
678
+ --settings '{}'`, an empty `--strict-mcp-config` and
679
+ `--no-session-persistence`: no tools, no MCP servers, no hooks, plugins,
680
+ settings or `CLAUDE.md`, no transcript on disk. Its environment holds only the
681
+ shell basics and Claude Code's own login, and it runs in an empty directory of
682
+ its own. The agent answers from the thread it is sent; the CLI posts the reply.
683
+ Only a Claude Code that has those flags, on macOS or Linux, tells ProhostAI it
684
+ can do this, and ProhostAI sends these runs to no other agent — Codex and plain
685
+ commands never get one.
686
+
666
687
  ### `agent run` flags
667
688
 
668
689
  | Flag | Default | What it does |
package/dist/agent/api.js CHANGED
@@ -65,6 +65,7 @@ export async function postJson(options, path, body, idempotencyKey, captureBody
65
65
  try {
66
66
  const response = await fetchImpl(`${options.baseUrl.replace(/\/+$/, '')}${path}`, {
67
67
  method: 'POST',
68
+ redirect: 'error',
68
69
  headers: {
69
70
  'Content-Type': 'application/json',
70
71
  'X-API-Key': options.apiKey,
@@ -339,6 +339,36 @@ export interface AgentRunRequest {
339
339
  memories?: RunMemory[];
340
340
  /** Selected records omitted whole by the server's inline context budget. */
341
341
  memories_omitted?: number;
342
+ /**
343
+ * Set when the conversation belongs to a CONNECTED team's workspace rather
344
+ * than this agent's own (see `venue.ts`). Absent on every other run.
345
+ */
346
+ venue?: RunVenue;
347
+ /**
348
+ * The run's own credential, set by the harness once it opened the venue
349
+ * session — never read from the event. The one thing that may read and post
350
+ * in that thread and heartbeat, stream and complete the run. Kept in memory
351
+ * only, and never handed to the agent.
352
+ */
353
+ venue_run_token?: string;
354
+ /**
355
+ * When the venue run's grant ends, on this process's monotonic clock
356
+ * (`performance.now()`): the server's remaining lifetime counted from the
357
+ * moment the session was asked for, so a wrong wall clock cannot extend it.
358
+ */
359
+ venue_deadline_ms?: number;
360
+ /**
361
+ * The same deadline on the wall clock (`Date.now()`): the server's lifetime
362
+ * counted from the wall-clock moment the session was asked for. It keeps
363
+ * counting through a suspend, which the monotonic clock may not.
364
+ */
365
+ venue_wall_deadline_ms?: number;
366
+ }
367
+ /** The connected team's thread a venue run answers in. */
368
+ export interface RunVenue {
369
+ account_id?: string;
370
+ conversation_id?: string;
371
+ team_connection_id?: string;
342
372
  }
343
373
  /** Reply destinations the server can bind a run to, including future surfaces. */
344
374
  export type ReplySurface = 'conversation' | 'task' | 'cleaning' | 'none' | (string & {});
@@ -386,5 +386,16 @@ export function parseRunRequest(data) {
386
386
  recent_messages: contextList(data.recent_messages ?? context.recent_messages, parseMessage),
387
387
  memories: contextList(data.memories ?? context.memories, parseMemory),
388
388
  memories_omitted: omittedMemoryCount(data.memories_omitted ?? context.memories_omitted),
389
+ // Any object counts as a venue, so a run the server marked as one is never
390
+ // mistaken for a run at home — even if a field inside it is malformed.
391
+ ...(data.venue && typeof data.venue === 'object' && !Array.isArray(data.venue)
392
+ ? {
393
+ venue: {
394
+ account_id: str(obj(data.venue).account_id),
395
+ conversation_id: str(obj(data.venue).conversation_id),
396
+ team_connection_id: str(obj(data.venue).team_connection_id),
397
+ },
398
+ }
399
+ : {}),
389
400
  };
390
401
  }
@@ -43,6 +43,12 @@ export interface ExecOptions {
43
43
  cwd?: string;
44
44
  /** Extra environment for the child, merged over ``process.env``. */
45
45
  env?: NodeJS.ProcessEnv;
46
+ /**
47
+ * ``false``: the child gets {@link env} and nothing else — not
48
+ * ``process.env``. A venue session's environment is built by dropping what
49
+ * it must not see, and merging the harness's own back over it would undo that.
50
+ */
51
+ inheritEnv?: boolean;
46
52
  /**
47
53
  * Ends the run early — a person pressed stop, or the server closed the run.
48
54
  *
@@ -103,7 +103,11 @@ export async function execAgent(options) {
103
103
  const spawnOptions = {
104
104
  shell: true,
105
105
  cwd: options.cwd,
106
- env: options.env ? { ...process.env, ...options.env } : process.env,
106
+ env: options.inheritEnv === false
107
+ ? { ...options.env }
108
+ : options.env
109
+ ? { ...process.env, ...options.env }
110
+ : process.env,
107
111
  stdio: ['pipe', 'pipe', 'pipe'],
108
112
  // Own process group. `--exec 'foo | bar'` runs under a shell, and
109
113
  // signalling the shell alone leaves its children holding our stdio
@@ -44,6 +44,19 @@ export type Scheduler = (run: () => void, ms: number) => ScheduledTick;
44
44
  export interface HeartbeatTarget {
45
45
  /** Where the heartbeat POSTs — see `heartbeatPathFor`. */
46
46
  path: string;
47
+ /**
48
+ * The credential this run's beats go out with, when it is not the loop's
49
+ * own: a venue run is heartbeated with its run token (see `venue.ts`).
50
+ */
51
+ api?: ApiOptions;
52
+ /**
53
+ * A venue run (see `venue.ts`): its token's authority ends with the run's
54
+ * grant, so a refusal that means "this token is no longer good" — 401, 403,
55
+ * 404 — ends the run here as if the server had closed it, rather than
56
+ * leaving the agent working on a connected team's thread it may no longer
57
+ * answer.
58
+ */
59
+ venue?: boolean;
47
60
  /**
48
61
  * Latest one-line description of what the agent is doing, read fresh at each
49
62
  * beat. Absent for a queued run, and for brains whose output we don't stream.
@@ -122,7 +122,7 @@ export class HeartbeatLoop {
122
122
  */
123
123
  async beat(target) {
124
124
  try {
125
- const outcome = await heartbeatRun(this.options.api, target.path, target.progress?.());
125
+ const outcome = await heartbeatRun(target.api ?? this.options.api, target.path, target.progress?.());
126
126
  // The run may have finished while its own heartbeat was in flight; acting
127
127
  // on a stale answer would cancel a run that is already reporting itself.
128
128
  if (!this.targets.has(target.runId))
@@ -135,6 +135,14 @@ export class HeartbeatLoop {
135
135
  await target.onCancelRequested();
136
136
  return;
137
137
  }
138
+ if (target.venue &&
139
+ (outcome.kind === 'unknown' ||
140
+ (outcome.kind === 'error' && (outcome.status === 401 || outcome.status === 403)))) {
141
+ this.untrack(target.runId);
142
+ this.options.log(` ⏹ run ${target.runId.slice(0, 12)} lost its connected-team grant — stopping it`);
143
+ await target.onResolved();
144
+ return;
145
+ }
138
146
  if (outcome.kind === 'unknown') {
139
147
  // Said once per run, then silence: on a server that predates
140
148
  // heartbeats this is the answer for every run forever, and a line a
@@ -127,6 +127,7 @@ export async function runPair(options) {
127
127
  try {
128
128
  response = await fetchImpl(`${baseUrl}${bound ? REDEEM_BOUND_PATH : REDEEM_PATH}`, {
129
129
  method: 'POST',
130
+ redirect: 'error',
130
131
  headers: { 'Content-Type': 'application/json', 'User-Agent': USER_AGENT },
131
132
  body: JSON.stringify({
132
133
  code: options.code,