@prohost/cli 0.12.4 → 0.13.2

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,60 @@ 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.2
8
+
9
+ - **A run stopped by the Claude usage limit tells ProhostAI when the limit
10
+ lifts.** The completion now carries `failure_kind: "usage_limit"` and, when
11
+ Claude Code's stream said, `retry_after` (the reset time). When the limit
12
+ lifts within a few hours, ProhostAI runs the same request again shortly after,
13
+ unless someone asks again first; either way it alerts the person who asked
14
+ instead of leaving a note nobody is notified of. The thread keeps its session, as before, so the retry continues it. A
15
+ server that predates the fields ignores them.
16
+
17
+ ## 0.13.1
18
+
19
+ - **A connected team's thread is never answered after the run's grant ended.**
20
+ A venue session that has already expired (or has less than 30 seconds left)
21
+ is refused when it opens, and the grant is checked again right before
22
+ Claude Code starts. A run that waited in the queue past its grant is never
23
+ started; it is closed as failed with its own token while the token still
24
+ works, so the thread's thinking indicator clears. A `--dry-run` only logs
25
+ that it would.
26
+ - **The grant is timed on both of this machine's clocks**, from the server's
27
+ own remaining lifetime (`expires_in_seconds`) counted from when the session
28
+ was asked for, and whichever runs out first ends it. A wound-back system
29
+ clock can't lengthen it, and neither can a sleep the monotonic clock doesn't
30
+ count (Linux): a venue run is re-checked at least every 15 seconds.
31
+ - **A venue run whose grant is ending is stopped and closed with its own
32
+ token** while the 30-second margin still leaves it good — queued or already
33
+ working — instead of being dropped locally and left to the server's
34
+ stale-run sweep. Nothing it was saying is posted.
35
+
36
+ ## 0.13.0
37
+
38
+ - **An agent can answer in a connected team's thread without its own key.**
39
+ When ProhostAI hands this agent a run in a thread that belongs to a
40
+ connected team's workspace, the event carries none of that thread. The CLI
41
+ opens the run's venue session over its own connection to ProhostAI (there is
42
+ no HTTP route to it), which returns the thread and a token minted for that
43
+ one run. It uses the token, not this machine's key, for every request the
44
+ run makes, and keeps it in memory only. The run's progress line and failure
45
+ report are fixed text, so nothing from that thread lands in this
46
+ workspace's run history or in this machine's log. ProhostAI hands such a
47
+ run only to the one connection that holds this pairing: if a second process
48
+ connects with the same key, the run is declined rather than sent to either.
49
+ - The run is answered by a Claude Code session started from your `--exec`
50
+ binary and model only. It has no tools, no MCP servers, no hooks, plugins,
51
+ settings or `CLAUDE.md` (`--safe-mode --setting-sources '' --settings '{}'`),
52
+ and writes no transcript (`--no-session-persistence`). Its environment
53
+ contains only the shell basics and Claude Code's own login, and it runs in an
54
+ empty directory of its own. The other team's messages never reach a session
55
+ that could act on this workspace.
56
+ - The CLI tells ProhostAI it can do this (`capabilities: ["venue_run_token"]`
57
+ on its status frame) only when it drives a Claude Code that has all of those
58
+ flags, on macOS or Linux. ProhostAI sends such runs to no other agent. Codex
59
+ and plain `--exec` commands are unchanged and never receive one.
60
+
7
61
  ## 0.12.4
8
62
 
9
63
  - 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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # `@prohost/cli`
2
2
 
3
- The [ProhostAI](https://www.prohost.ai) command-line tool. Three things it does:
3
+ The [ProhostAI](https://prohost.ai) command-line tool. Three things it does:
4
4
 
5
5
  - **`prohost agent`** — run your own local agent as a ProhostAI teammate. Someone
6
6
  @-mentions it in the app, it runs on your machine, and its answer is posted
@@ -233,9 +233,12 @@ and gets a reply that has forgotten asking. The CLI reads the `session_id` out
233
233
  of Claude Code's JSON output, records it per conversation in
234
234
  `~/.prohost/sessions.json`, and `--resume`s it next time. Sessions don't live
235
235
  forever; when one is gone the run is retried once from scratch rather than
236
- failing. A run stopped by your Claude usage limit is not retried: it fails
236
+ failing. A run stopped by your Claude usage limit is not retried here: it fails
237
237
  with Claude Code's own message, which says when the limit resets, and the
238
- conversation keeps its session for the next run.
238
+ conversation keeps its session for the next run. The failure also tells
239
+ ProhostAI when the limit lifts: if that is within a few hours, ProhostAI asks
240
+ the agent again shortly after (unless someone asks again first), and either way
241
+ it alerts whoever asked.
239
242
 
240
243
  **It lets the agent actually do things.** Runs are invoked with
241
244
  `--dangerously-skip-permissions`, so tool calls execute without stopping to ask.
@@ -663,6 +666,27 @@ Other agents: the CLI shapes no command but Claude Code's and Codex's, so give
663
666
  yours its own equivalent flag — whatever your runner calls it — and apply the
664
667
  same judgement.
665
668
 
669
+ **Threads in a connected team's workspace are the exception.** When a
670
+ connected team addresses your agent in one of *their* threads, the prompt is
671
+ another workspace's text, so the run is answered without any of the above. The
672
+ event carries none of the thread: the CLI opens the run's venue session over
673
+ its own connection to ProhostAI (no HTTP route, so a process that only holds
674
+ your key cannot ask the same way), which returns the thread and a token of its
675
+ own that can only read and post in that one thread and report on that one run.
676
+ ProhostAI sends such a run only while this connection is the only one holding
677
+ the pairing, and opens its session only on the connection it went to: another
678
+ process connecting with your key makes the run decline, never receive it. The CLI uses it instead of your key and
679
+ keeps it in memory only. Claude Code is started from your `--exec` binary and
680
+ model only, with `--tools ''`, `--safe-mode`, `--setting-sources ''
681
+ --settings '{}'`, an empty `--strict-mcp-config` and
682
+ `--no-session-persistence`: no tools, no MCP servers, no hooks, plugins,
683
+ settings or `CLAUDE.md`, no transcript on disk. Its environment holds only the
684
+ shell basics and Claude Code's own login, and it runs in an empty directory of
685
+ its own. The agent answers from the thread it is sent; the CLI posts the reply.
686
+ Only a Claude Code that has those flags, on macOS or Linux, tells ProhostAI it
687
+ can do this, and ProhostAI sends these runs to no other agent — Codex and plain
688
+ commands never get one.
689
+
666
690
  ### `agent run` flags
667
691
 
668
692
  | Flag | Default | What it does |
@@ -129,6 +129,20 @@ export type HeartbeatOutcome =
129
129
  * work send none either.
130
130
  */
131
131
  export declare function heartbeatRun(options: ApiOptions, path: string, progress?: string): Promise<HeartbeatOutcome>;
132
+ /**
133
+ * A run's terminal state as the completion reports it. A failure the brain's
134
+ * usage limit caused says so, with when the limit lifts if the brain said.
135
+ */
136
+ export type CompletionOutcome = {
137
+ ok: true;
138
+ silent?: boolean;
139
+ } | {
140
+ ok: false;
141
+ error: string;
142
+ usageLimit?: {
143
+ retryAfter?: string;
144
+ };
145
+ };
132
146
  /**
133
147
  * Report a run's terminal state. Called for every run — including one whose
134
148
  * command crashed — so the server never waits on a run this harness abandoned.
@@ -140,13 +154,7 @@ export declare function heartbeatRun(options: ApiOptions, path: string, progress
140
154
  * ``model`` is the model id the run used, when the harness knows it. Omitted
141
155
  * otherwise, so a plain ``--exec`` command's completion body is unchanged.
142
156
  */
143
- export declare function completeRun(options: ApiOptions, path: string, outcome: {
144
- ok: true;
145
- silent?: boolean;
146
- } | {
147
- ok: false;
148
- error: string;
149
- }, model?: string): Promise<ApiResult>;
157
+ export declare function completeRun(options: ApiOptions, path: string, outcome: CompletionOutcome, model?: string): Promise<ApiResult>;
150
158
  /**
151
159
  * Report a batch of tool events for a run in flight.
152
160
  *
package/dist/agent/api.js CHANGED
@@ -10,7 +10,7 @@
10
10
  * `completion.path`), which is why these take a path rather than building one.
11
11
  */
12
12
  import { USER_AGENT } from '../version.js';
13
- import { MAX_ERROR_CHARS, MAX_MODEL_CHARS, MAX_PROGRESS_CHARS, MAX_TOOL_EVENTS_PER_BATCH, RUN_OUTCOME_SILENT, RUN_STATUS_FAILED, RUN_STATUS_SUCCEEDED, } from './contract.js';
13
+ import { MAX_ERROR_CHARS, MAX_MODEL_CHARS, MAX_PROGRESS_CHARS, MAX_TOOL_EVENTS_PER_BATCH, RUN_FAILURE_USAGE_LIMIT, RUN_OUTCOME_SILENT, RUN_STATUS_FAILED, RUN_STATUS_SUCCEEDED, } from './contract.js';
14
14
  /** Deadline for the cheap calls — a heartbeat answers in milliseconds. */
15
15
  export const DEFAULT_TIMEOUT_MS = 30_000;
16
16
  /**
@@ -184,6 +184,11 @@ export async function completeRun(options, path, outcome, model) {
184
184
  const body = outcome.ok
185
185
  ? { status: RUN_STATUS_SUCCEEDED, ...(outcome.silent ? { outcome: RUN_OUTCOME_SILENT } : {}) }
186
186
  : { status: RUN_STATUS_FAILED, error: outcome.error.slice(0, MAX_ERROR_CHARS) };
187
+ if (!outcome.ok && outcome.usageLimit) {
188
+ body.failure_kind = RUN_FAILURE_USAGE_LIMIT;
189
+ if (outcome.usageLimit.retryAfter)
190
+ body.retry_after = outcome.usageLimit.retryAfter;
191
+ }
187
192
  if (model)
188
193
  body.model = model.slice(0, MAX_MODEL_CHARS);
189
194
  const result = await postJson(options, path, body, undefined, false, SEND_TIMEOUT_MS);
@@ -199,6 +199,13 @@ export interface ClaudeStream {
199
199
  * help until the limit resets, so a caller must not retry it.
200
200
  */
201
201
  usageLimit(): string | undefined;
202
+ /**
203
+ * When the usage limit that stopped the turn lifts, in epoch seconds: the
204
+ * `resetsAt` of the rejecting `rate_limit_event`. `undefined` when no such
205
+ * event arrived, even if {@link usageLimit} recognised the limit from Claude
206
+ * Code's message alone — that message's "resets 11pm" is for people.
207
+ */
208
+ usageLimitResetsAt(): number | undefined;
202
209
  /**
203
210
  * The turn's outcome. ``stdout`` is the complete captured output, used only
204
211
  * when the stream held no `result` event — a crashed binary, a login prompt,
@@ -655,6 +655,10 @@ export function readClaudeStream() {
655
655
  ? 'usage limit reached'
656
656
  : `usage limit reached · resets ${new Date(resetsAt * 1000).toISOString()}`;
657
657
  },
658
+ usageLimitResetsAt: () => {
659
+ flushTail();
660
+ return limitRejected?.resetsAt;
661
+ },
658
662
  finish(stdout) {
659
663
  // A binary that exits without flushing a final `\n` must not lose its
660
664
  // result.
@@ -50,6 +50,13 @@ export declare const RUN_OUTCOME_SILENT = "silent";
50
50
  export declare const NO_REPLY_TOKEN = "[NO_REPLY]";
51
51
  /** Whether the agent's output is the no-reply token and nothing else. */
52
52
  export declare function isNoReply(output: string): boolean;
53
+ /**
54
+ * Completion `failure_kind` for a run the brain's subscription usage limit
55
+ * stopped. Sent with `status: failed`, plus `retry_after` when the brain said
56
+ * when the limit lifts, so the server can retry the work then instead of
57
+ * leaving the question unanswered. A server that predates the field ignores it.
58
+ */
59
+ export declare const RUN_FAILURE_USAGE_LIMIT = "usage_limit";
53
60
  /** Server-side cap on the `error` field of a completion (`MAX_ERROR_CHARS`). */
54
61
  export declare const MAX_ERROR_CHARS = 1000;
55
62
  /** Server-side cap on the `model` field of a completion (`MAX_MODEL_CHARS`). */
@@ -339,6 +346,36 @@ export interface AgentRunRequest {
339
346
  memories?: RunMemory[];
340
347
  /** Selected records omitted whole by the server's inline context budget. */
341
348
  memories_omitted?: number;
349
+ /**
350
+ * Set when the conversation belongs to a CONNECTED team's workspace rather
351
+ * than this agent's own (see `venue.ts`). Absent on every other run.
352
+ */
353
+ venue?: RunVenue;
354
+ /**
355
+ * The run's own credential, set by the harness once it opened the venue
356
+ * session — never read from the event. The one thing that may read and post
357
+ * in that thread and heartbeat, stream and complete the run. Kept in memory
358
+ * only, and never handed to the agent.
359
+ */
360
+ venue_run_token?: string;
361
+ /**
362
+ * When the venue run's grant ends, on this process's monotonic clock
363
+ * (`performance.now()`): the server's remaining lifetime counted from the
364
+ * moment the session was asked for, so a wrong wall clock cannot extend it.
365
+ */
366
+ venue_deadline_ms?: number;
367
+ /**
368
+ * The same deadline on the wall clock (`Date.now()`): the server's lifetime
369
+ * counted from the wall-clock moment the session was asked for. It keeps
370
+ * counting through a suspend, which the monotonic clock may not.
371
+ */
372
+ venue_wall_deadline_ms?: number;
373
+ }
374
+ /** The connected team's thread a venue run answers in. */
375
+ export interface RunVenue {
376
+ account_id?: string;
377
+ conversation_id?: string;
378
+ team_connection_id?: string;
342
379
  }
343
380
  /** Reply destinations the server can bind a run to, including future surfaces. */
344
381
  export type ReplySurface = 'conversation' | 'task' | 'cleaning' | 'none' | (string & {});
@@ -52,6 +52,13 @@ export const NO_REPLY_TOKEN = '[NO_REPLY]';
52
52
  export function isNoReply(output) {
53
53
  return output.trim().toUpperCase() === NO_REPLY_TOKEN;
54
54
  }
55
+ /**
56
+ * Completion `failure_kind` for a run the brain's subscription usage limit
57
+ * stopped. Sent with `status: failed`, plus `retry_after` when the brain said
58
+ * when the limit lifts, so the server can retry the work then instead of
59
+ * leaving the question unanswered. A server that predates the field ignores it.
60
+ */
61
+ export const RUN_FAILURE_USAGE_LIMIT = 'usage_limit';
55
62
  /** Server-side cap on the `error` field of a completion (`MAX_ERROR_CHARS`). */
56
63
  export const MAX_ERROR_CHARS = 1000;
57
64
  /** Server-side cap on the `model` field of a completion (`MAX_MODEL_CHARS`). */
@@ -386,5 +393,16 @@ export function parseRunRequest(data) {
386
393
  recent_messages: contextList(data.recent_messages ?? context.recent_messages, parseMessage),
387
394
  memories: contextList(data.memories ?? context.memories, parseMemory),
388
395
  memories_omitted: omittedMemoryCount(data.memories_omitted ?? context.memories_omitted),
396
+ // Any object counts as a venue, so a run the server marked as one is never
397
+ // mistaken for a run at home — even if a field inside it is malformed.
398
+ ...(data.venue && typeof data.venue === 'object' && !Array.isArray(data.venue)
399
+ ? {
400
+ venue: {
401
+ account_id: str(obj(data.venue).account_id),
402
+ conversation_id: str(obj(data.venue).conversation_id),
403
+ team_connection_id: str(obj(data.venue).team_connection_id),
404
+ },
405
+ }
406
+ : {}),
389
407
  };
390
408
  }
@@ -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