flowviant 0.80.0 → 0.82.0

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.
@@ -0,0 +1,164 @@
1
+ /**
2
+ * WHICH CREDENTIAL THIS MACHINE'S CLI WILL RESOLVE — presence and expiry only,
3
+ * never a token value, and never a judgement about which one is correct.
4
+ *
5
+ * ── THE CONFUSION THIS EXISTS TO END ──
6
+ *
7
+ * A turn failed with the CLI's own words relayed verbatim:
8
+ *
9
+ * Failed to authenticate: OAuth session expired and could not be refreshed
10
+ *
11
+ * and the operator did the obvious thing — opened a terminal on that same
12
+ * machine, typed `claude`, and watched it work. Both observations were true at
13
+ * once, and nothing in the product could explain how, because the one fact that
14
+ * reconciles them is invisible from a browser: THE DAEMON'S CLI AND YOUR SHELL'S
15
+ * CLI DO NOT ALWAYS RESOLVE THE SAME CREDENTIAL.
16
+ *
17
+ * Two ways they diverge, and this module reports both:
18
+ *
19
+ * · THE ENVIRONMENT. `claude.mjs` spawns the CLI with `{...process.env}` on
20
+ * purpose — its header says so: "the CLI's own credentials live in this
21
+ * environment, and handing it a curated one signs it out". It also stopped
22
+ * deleting ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN deliberately, because on
23
+ * a machine the project leaves running an inherited org key is the POINT.
24
+ * The consequence is that an auth variable in the daemon's environment is
25
+ * handed to every turn — and `process.env` is a SNAPSHOT taken when the
26
+ * daemon started, so a variable exported weeks ago is still in there long
27
+ * after it left your shell. A stale one fails in a way a fresh `claude`
28
+ * never reproduces.
29
+ * · THE HOME. The CLI reads its store from $HOME. A daemon under systemd, a
30
+ * different user, or a container has a different one than the shell you
31
+ * tested in, so "I am logged in" was measured on the wrong file.
32
+ *
33
+ * ── WHAT THIS IS NOT ──
34
+ *
35
+ * NOT A POLICY. Flowviant does not pick a credential, does not prefer the
36
+ * subscription over a key, and does not call either one wrong — `claude.mjs`
37
+ * settled that: "Which credential is correct, and whether an account may be
38
+ * shared, is between the operator and the vendor." This reports what is
39
+ * PRESENT so a person can see the divergence; it never resolves it.
40
+ *
41
+ * NOT A PRECEDENCE CLAIM. Which source the CLI actually prefers is the CLI's
42
+ * own business and it is free to change it. We report that both exist, which is
43
+ * the fact that explains the symptom, and we do not assert which one won.
44
+ *
45
+ * NOT A TOKEN READER. Values never leave — not to the server, not to a log, not
46
+ * into an error. Presence, the variable's NAME, and the refresh expiry, which
47
+ * is a date.
48
+ *
49
+ * ── THE THREE-STATE RULE, WHICH THIS FILE LIVES OR DIES BY ──
50
+ *
51
+ * `source: 'unknown'` is NOT "signed out". Claude Code can keep credentials in
52
+ * an OS keychain (macOS especially), where a file simply does not exist and
53
+ * everything is fine. Reporting a confident "no login" for a machine that is
54
+ * working perfectly is precisely the invented state this product forbids, and
55
+ * it would be worse than saying nothing. Absent means absent; the surface
56
+ * renders nothing for it.
57
+ */
58
+
59
+ import { readFileSync } from 'node:fs';
60
+ import { join } from 'node:path';
61
+
62
+ /**
63
+ * The variables that can carry auth into the CLI from the environment.
64
+ *
65
+ * The first two are the pair `claude.mjs` documents itself as deliberately NOT
66
+ * deleting, and both appear in childEnv.mjs's allowlist. The third is Claude
67
+ * Code's own headless/CI token. Names only ever leave this module — a NAME is
68
+ * what a person greps for, and it is not a secret.
69
+ */
70
+ export const AUTH_ENV_VARS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN'];
71
+
72
+ /** Claude Code's OAuth store, relative to the HOME the CLI will be handed. */
73
+ const CRED_REL = ['.claude', '.credentials.json'];
74
+
75
+ /** A stamp that may be seconds or milliseconds, to ISO — or null if it is
76
+ * neither. Same tolerance the rest of the daemon applies to numbers it did not
77
+ * write: an unreadable stamp is ABSENT, never zero and never "now". */
78
+ function isoFromStamp(v) {
79
+ const n = Number(v);
80
+ if (!Number.isFinite(n) || n <= 0) return null;
81
+ const ms = n > 1e11 ? n : n * 1000;
82
+ const d = new Date(ms);
83
+ return Number.isNaN(d.getTime()) ? null : d.toISOString();
84
+ }
85
+
86
+ /**
87
+ * What the CLI on this machine will resolve, as far as it can be seen without
88
+ * spending anything: no process spawned, no request made, no quota touched.
89
+ * Just this process's own environment and one file's metadata.
90
+ *
91
+ * `env` and `home` are parameters rather than reads of `process` so the tests
92
+ * can drive every branch — and so the caller passes the SAME environment the
93
+ * child will actually receive, which is the whole point of the report.
94
+ */
95
+ export function claudeAuthContext({ env = process.env, home = null } = {}) {
96
+ const HOME = home ?? env.HOME ?? env.USERPROFILE ?? null;
97
+
98
+ // Presence, by NAME. An empty or whitespace-only value is not set: an
99
+ // exported-but-blank variable is a common way to try to UNSET one, and
100
+ // reporting it as present would send somebody hunting a variable that is
101
+ // doing nothing.
102
+ const envVars = AUTH_ENV_VARS.filter((n) => typeof env[n] === 'string' && env[n].trim() !== '');
103
+
104
+ let hasFile = false;
105
+ let refreshExpiresAt = null;
106
+ let accessExpiresAt = null;
107
+ let subscriptionType = null;
108
+ if (HOME) {
109
+ try {
110
+ const raw = readFileSync(join(HOME, ...CRED_REL), 'utf8');
111
+ const oauth = JSON.parse(raw)?.claudeAiOauth;
112
+ if (oauth && typeof oauth === 'object') {
113
+ hasFile = true;
114
+ refreshExpiresAt = isoFromStamp(oauth.refreshTokenExpiresAt);
115
+ accessExpiresAt = isoFromStamp(oauth.expiresAt);
116
+ // A plan name is not a secret and it is the one field that tells an
117
+ // operator WHICH kind of account the machine is spending — the thing
118
+ // "the machine is shared and so is its CLI login" makes everyone's
119
+ // business. Anything unexpected is dropped rather than relayed.
120
+ if (typeof oauth.subscriptionType === 'string' && oauth.subscriptionType.length <= 32) {
121
+ subscriptionType = oauth.subscriptionType;
122
+ }
123
+ }
124
+ } catch {
125
+ // Absent, unreadable, or not JSON. All three mean the same thing here:
126
+ // we cannot see a file, which is NOT the same as there not being a
127
+ // login. See the three-state rule in this file's header.
128
+ }
129
+ }
130
+
131
+ /**
132
+ * THE SOURCE, and the honest thing to say about it.
133
+ *
134
+ * 'env' — at least one auth variable is set. Reported first because it
135
+ * is the state that is invisible from a shell and therefore the
136
+ * one that misleads; NOT because we know it wins.
137
+ * 'file' — no variable, and an OAuth store we could read.
138
+ * 'unknown' — neither. A keychain machine lives here, and so does a machine
139
+ * with no login at all. We cannot tell them apart, so we say
140
+ * nothing about which it is.
141
+ */
142
+ const source = envVars.length ? 'env' : hasFile ? 'file' : 'unknown';
143
+
144
+ return {
145
+ /** The OS user the daemon runs as — the other half of "which store". */
146
+ user: env.USER ?? env.LOGNAME ?? env.USERNAME ?? null,
147
+ home: HOME,
148
+ source,
149
+ /** NAMES ONLY. Never a value, never a prefix, never a length. */
150
+ envVars,
151
+ /** Is there a login sitting behind the variable that may be shadowing it?
152
+ * This — not `source` on its own — is the confusing state, and it is the
153
+ * only one the surface is allowed to raise unprompted. */
154
+ envOverridesLogin: envVars.length > 0 && hasFile,
155
+ /** The 22-day clock. Null when we cannot see the file, which includes every
156
+ * keychain machine, so null must never render as "expired". */
157
+ refreshExpiresAt,
158
+ /** The ~12-hour clock. Reported for completeness and deliberately NOT what
159
+ * any warning is built from: it lapses constantly and refreshes itself, so
160
+ * a note built on it would cry wolf twice a day. */
161
+ accessExpiresAt,
162
+ subscriptionType,
163
+ };
164
+ }
package/bin/lib/fleet.mjs CHANGED
@@ -85,6 +85,7 @@ import {
85
85
  import { createWorkManager } from './work.mjs';
86
86
  import { scanLocalSessions, ourConversationIds } from './localSessions.mjs';
87
87
  import { repoState } from './repoState.mjs';
88
+ import { claudeAuthContext } from './claudeAuth.mjs';
88
89
 
89
90
  async function fetchRoster(
90
91
  haveIds,
@@ -327,7 +328,29 @@ async function maybeReportRepoState({ repoRoot, baseRef }) {
327
328
  // time, so the value here is always current.
328
329
  const state = repoState(repoRoot, baseRef);
329
330
  if (!state) return; // not readable — say nothing rather than say "none"
330
- payload = JSON.stringify(state);
331
+ /**
332
+ * WHICH CREDENTIAL THIS MACHINE'S CLI RESOLVES, on the same beat.
333
+ *
334
+ * It rides this report rather than getting an endpoint of its own because
335
+ * it is the same KIND of fact — something only the machine can see, pushed
336
+ * because a pull client can never be asked — and it inherits this
337
+ * function's three economies for free: scanned once a minute, deduped
338
+ * against the last ACCEPTED payload, and silent forever once an older
339
+ * server 404s.
340
+ *
341
+ * The dedup keeps it cheap: `claudeAuthContext` is presence plus two dates,
342
+ * so the string is stable across scans and only moves when something about
343
+ * the credential actually moves. It DOES move when the CLI refreshes its
344
+ * access token — that stamp is carried for diagnostics — which costs a
345
+ * write roughly twice a day and is the whole of the extra traffic. The
346
+ * WARNING is built on the 22-day refresh clock instead, so a note never
347
+ * fires on the ~12h cycle.
348
+ *
349
+ * NO VERSION FLOOR, and none is possible to need: this is a daemon→server
350
+ * report, and an older SERVER strips the unknown key in its zod parse and
351
+ * stores the rest exactly as before.
352
+ */
353
+ payload = JSON.stringify({ ...state, auth: claudeAuthContext() });
331
354
  } catch {
332
355
  return; // a readout must never throw into the poll loop
333
356
  }
@@ -458,6 +458,43 @@ const kickoff = ({ message, askedByName, head, tail }) => {
458
458
  return `${scaffold}${fence('WHAT THEY SAID', message)}\n\n${tail}`;
459
459
  };
460
460
 
461
+ /**
462
+ * A CAPTURE TAB (server: work_session.kind='capture', flagged on the job as
463
+ * `capture: true`) — the board's "New task" conversation. Its whole job is
464
+ * turning what the person says into STAGED cards a human lands; it edits
465
+ * nothing, and the permission profile enforces that (PLAN_PERM: read-only +
466
+ * MCP — the same fence the scratch planner runs behind). This prompt is the
467
+ * QUALITY half; the token scope and the permission list are the safety.
468
+ */
469
+ export const SYSTEM_CAPTURE = `You are the human's own Claude, in their repository, with ONE job: turn what
470
+ they say into well-cut task cards on their Flowviant board. You are READ-ONLY
471
+ here — read code freely to ground what you stage; the permission profile
472
+ refuses edits, and staging is the only write you have.
473
+
474
+ MECHANICS OF THIS CHAT:
475
+
476
+ 1. NARRATE WHILE YOU WORK. Call stream_session_turn with short progress lines
477
+ as you read and stage. Your FINAL reply is delivered automatically when the
478
+ turn ends — do not repeat it through the tool. Keep replies short: what you
479
+ staged, or what you need to know — one line each.
480
+ 2. STAGE, NEVER FILE. stage_card proposes a new card; stage_card_edit proposes
481
+ a change to an existing one (read_card first — never replace fields you
482
+ have not seen). Everything you stage waits in an area the person reviews
483
+ and lands themselves; nothing you do reaches the board directly.
484
+ 3. ONE CARD PER SHIPPABLE UNIT. Break a big ask into the units that will build
485
+ it, in landing order. Never card-ify chatter, questions, or one unit split
486
+ thin.
487
+ 4. DEDUPE FIRST, EVERY TIME. Before staging, call list_cards (the OPEN QUEUE —
488
+ capped, and cards agents already hold are absent from it) and list_staged
489
+ (what earlier chats left). If the work exists, say so and point at it;
490
+ stage_card_edit it if the ask adds something. Never stage a twin.
491
+ 5. CLARIFY BEFORE STAGING. A vague ask gets one or two sharp questions and a
492
+ wait — never a staged guess. A clear ask gets staged without ceremony.
493
+ 6. A GOOD CARD: a title naming the outcome, a brief a stranger could start
494
+ from, acceptance criteria only when the person stated (or the code shows)
495
+ what done means. No sizes, no owners, no statuses — none of those are
496
+ yours to set, here or anywhere.`;
497
+
461
498
  export const WORK_TURN_KICKOFF = ({ sessionId, sessionName, message, askedByName }) =>
462
499
  kickoff({
463
500
  message,
@@ -468,6 +505,19 @@ export const WORK_TURN_KICKOFF = ({ sessionId, sessionName, message, askedByName
468
505
  tail: `Stream your reply with stream_session_turn as you work.`,
469
506
  });
470
507
 
508
+ /** The capture chat's kickoff — the same shape as a work turn's, with the
509
+ * head restating the posture so the message is read as capture input even
510
+ * deep in a long conversation. */
511
+ export const CAPTURE_TURN_KICKOFF = ({ sessionId, sessionName, message, askedByName }) =>
512
+ kickoff({
513
+ message,
514
+ askedByName,
515
+ head:
516
+ `Continue the task-capture chat${sessionName ? ` "${sessionName}"` : ''} — stage cards, change nothing.\n\n` +
517
+ `SESSION ID (pass this to stream_session_turn / the staging tools): ${sessionId}`,
518
+ tail: `Stream your reply with stream_session_turn as you work.`,
519
+ });
520
+
471
521
  /** The plain tab's kickoff: no session id (there is no tool to pass it to)
472
522
  * and no streaming instruction — the final message is the reply. */
473
523
  export const WORK_TURN_KICKOFF_PLAIN = ({ sessionName, message, askedByName }) =>
package/bin/lib/work.mjs CHANGED
@@ -52,7 +52,9 @@ import { c, note, ok, warn } from './ui.mjs';
52
52
  import { mcpFor, runTurn } from './claude.mjs';
53
53
  import {
54
54
  SYSTEM_WORK,
55
+ SYSTEM_CAPTURE,
55
56
  WORK_TURN_KICKOFF,
57
+ CAPTURE_TURN_KICKOFF,
56
58
  SYSTEM_WORK_PLAIN,
57
59
  WORK_TURN_KICKOFF_PLAIN,
58
60
  SYSTEM_PLAN,
@@ -2982,6 +2984,14 @@ export function createWorkManager({
2982
2984
  const message = [job.body, filesNote, adoptNote, carryNote]
2983
2985
  .filter(Boolean)
2984
2986
  .join('\n\n');
2987
+ // A CAPTURE chat (the board's New task conversation): its own
2988
+ // system prompt, its own kickoff, and the scratch planner's
2989
+ // READ-ONLY permission profile — the prompt says stage-never-file
2990
+ // and the profile is what makes "read-only" true rather than
2991
+ // asserted. Server-flagged per job; a server too old to flag it
2992
+ // simply runs an ordinary tab, which the web's version floor
2993
+ // prevents ever being offered.
2994
+ const captureTab = job.capture === true;
2985
2995
  const turnArgs = {
2986
2996
  // A plain tab has no tools to name and no session id to pass —
2987
2997
  // its kickoff asks for one complete report instead of a stream.
@@ -2991,18 +3001,26 @@ export function createWorkManager({
2991
3001
  message,
2992
3002
  askedByName: job.askedByName,
2993
3003
  })
2994
- : WORK_TURN_KICKOFF({
3004
+ : captureTab
3005
+ ? CAPTURE_TURN_KICKOFF({
3006
+ sessionId: job.sessionId,
3007
+ sessionName: job.sessionName,
3008
+ message,
3009
+ askedByName: job.askedByName,
3010
+ })
3011
+ : WORK_TURN_KICKOFF({
2995
3012
  sessionId: job.sessionId,
2996
3013
  sessionName: job.sessionName,
2997
3014
  message,
2998
3015
  askedByName: job.askedByName,
2999
3016
  }),
3017
+ planPerm: captureTab,
3000
3018
  // The adopt turn resumes the TERMINAL conversation by forking it
3001
3019
  // into this cwd (claude: --resume <id> --fork-session). After it
3002
3020
  // speaks once, the fork lives natively here and turn 2+ is the
3003
3021
  // ordinary --continue resume path, unchanged.
3004
3022
  ...(adopting ? { adoptResumeId: job.adopt.id } : {}),
3005
- system: plainTab ? SYSTEM_WORK_PLAIN : SYSTEM_WORK,
3023
+ system: plainTab ? SYSTEM_WORK_PLAIN : captureTab ? SYSTEM_CAPTURE : SYSTEM_WORK,
3006
3024
  // Present only when the tab named one — see brainFor.
3007
3025
  ...brain,
3008
3026
  // The tab watches the CLI work. Claude needs the flag to speak
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.80.0",
4
- "description": "Run your own coding CLIs as build agents for Flowviant Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
3
+ "version": "0.82.0",
4
+ "description": "Run your own coding CLIs as build agents for Flowviant \u2014 Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "flowviant": "bin/cli.mjs"