flowviant 0.114.1 → 0.114.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/README.md CHANGED
@@ -147,6 +147,15 @@ Two knobs bound the blast radius, and both are worth setting on a shared box:
147
147
  its permission engine is machine-wide — which is surfaced in the app rather
148
148
  than papered over.
149
149
 
150
+ Fenced turns — design, 3D-model, deck and research cards, the pre-review,
151
+ the planner, intake, the wiki and the New task chat — read none of your Claude
152
+ Code settings files: their permissions and hooks are Flowviant's posture
153
+ alone, and of `~/.claude/settings.json` they keep only how Claude signs in
154
+ (`apiKeyHelper`, `env` and the like) and your model defaults. Code agents and
155
+ Terminal tabs still read yours, so your own allow rules and `defaultMode` add
156
+ to `FLOWVIANT_SAFE=1`'s allowlist there. Codex's fenced turns run with
157
+ `--ignore-user-config` the same way.
158
+
150
159
  The posture is reported on every poll and shown in the project's
151
160
  Settings → Machine section, so the team can see whether the box runs the
152
161
  guarded toolset or full permissions.
@@ -0,0 +1,145 @@
1
+ /**
2
+ * A FENCED CLAUDE TURN TAKES ITS PERMISSIONS AND HOOKS FROM FLOWVIANT ALONE
3
+ * (2026-09-29, the owner, asked whether an agent's fence should ignore the
4
+ * person's own permission settings: "yes, the agent's fence can ignore").
5
+ *
6
+ * A 3D-model agent under the design posture, whose whole shell is
7
+ * `Bash(ls:*)`, ran `google-chrome` and Playwright scripts. Every fenced lane
8
+ * read the person's `~/.claude/settings.json` — an agent turn through
9
+ * `--setting-sources user`, the pre-review, planner, intake, wiki and capture
10
+ * chat by naming no source at all, which ALSO reads the cwd's
11
+ * `.claude/settings*.json`, and the pre-review's cwd is the agent's own
12
+ * worktree. A settings file is a second author of the posture. MEASURED on
13
+ * Claude Code 2.1.284 — see claudePersonal.test.mjs for the probes and the
14
+ * offline check that pins them on the real CLI. In short: a personal
15
+ * `permissions.allow` rule admitted a command the list refused, a personal
16
+ * `defaultMode: "bypassPermissions"` turned the fenced turn into a bypass, and
17
+ * personal and worktree hooks fired; under `--setting-sources ''` none of them
18
+ * loaded, and the turn ran on the same login.
19
+ *
20
+ * AN EMPTY SOURCE LIST DROPS THE WHOLE USER SOURCE, not only its permissions:
21
+ * `~/.claude/CLAUDE.md`, personal skills and subagents went too (measured),
22
+ * and with the file go the plugins it enables. No flag keeps half of it — a
23
+ * flag setting's allow rules only add to the person's. On a fenced lane that
24
+ * is the right loss: the posture and the contract are Flowviant's, the base
25
+ * branch's instructions and skills arrive through the prompt and the reviewed
26
+ * plugin (`--plugin-dir` still loads, measured), and a personal plugin brings
27
+ * hooks of its own. Codex's fenced postures are hermetic the same way
28
+ * (`--ignore-user-config`, runtimeCodex.mjs).
29
+ *
30
+ * WHAT IS KEPT is what makes the CLI the person's CLI, never what it may do:
31
+ * how it signs in and reaches its provider, how a model the turn does not pin
32
+ * runs, and how long the person's own store is kept. An ALLOWLIST, so a key a
33
+ * later CLI adds — a sandbox auto-allow, a permission mode — never rides by
34
+ * default. It rides INSIDE the posture's own `--settings`, because the CLI
35
+ * reads one: given two, only the last one's hook fired (measured).
36
+ */
37
+ import { readFileSync, statSync } from 'node:fs';
38
+ import { homedir } from 'node:os';
39
+ import { join } from 'node:path';
40
+ import { MACHINE_CREDENTIAL_ENV } from './machineEnv.mjs';
41
+ import { WINDOW_ENV_REMOVED, withoutWindows } from './noWindowEnv.mjs';
42
+
43
+ /** The flag pair that loads no settings file — user, project or local.
44
+ * Managed (policy) settings and `--settings` still apply. */
45
+ export const NO_SETTING_SOURCES = Object.freeze(['--setting-sources', '']);
46
+
47
+ /**
48
+ * The personal settings a fenced turn keeps, by key.
49
+ *
50
+ * · THE LOGIN — the helpers the CLI runs to authenticate and where it signs
51
+ * in, and `env`, where a login routes (ANTHROPIC_BASE_URL,
52
+ * CLAUDE_CODE_USE_BEDROCK, AWS_PROFILE). `env` gives the person's file no
53
+ * more reach than the operator's shell, which every turn inherits already;
54
+ * what a turn's environment promises is still removed from it
55
+ * (`keptEnv`).
56
+ * · HOW AN UNPINNED MODEL RUNS ("Machine default uses the CLI's own
57
+ * setting"): per-model effort, thinking, the model map a provider needs.
58
+ * `model` itself is not here: every Claude turn names `--model`, the
59
+ * daemon's pin when the card has none (config.mjs), so the person's
60
+ * default model never reached a daemon turn before either.
61
+ * · THE PERSON'S OWN STORE: how long transcripts live and whether memory is
62
+ * kept, so a fenced turn's start never prunes or writes on a default the
63
+ * person turned off.
64
+ */
65
+ export const PERSONAL_KEPT = Object.freeze([
66
+ 'apiKeyHelper',
67
+ 'proxyAuthHelper',
68
+ 'awsAuthRefresh',
69
+ 'awsCredentialExport',
70
+ 'gcpAuthRefresh',
71
+ 'forceLoginMethod',
72
+ 'forceLoginOrgUUID',
73
+ 'forceLoginGatewayUrl',
74
+ 'env',
75
+ 'modelOverrides',
76
+ 'modelSettings',
77
+ 'effortLevel',
78
+ 'maxEffortLevel',
79
+ 'alwaysThinkingEnabled',
80
+ 'fallbackModel',
81
+ 'cleanupPeriodDays',
82
+ 'autoMemoryEnabled',
83
+ ]);
84
+
85
+ /** What a turn nobody is sitting at has set or removed (noWindowEnv.mjs) and
86
+ * the machine credential (machineEnv.mjs): never put back by a settings file. */
87
+ const TURN_OWNED_ENV = new Set([
88
+ ...MACHINE_CREDENTIAL_ENV,
89
+ ...WINDOW_ENV_REMOVED,
90
+ ...Object.keys(withoutWindows({}, '/')),
91
+ ]);
92
+ function keptEnv(env) {
93
+ if (!env || typeof env !== 'object' || Array.isArray(env)) return null;
94
+ const out = {};
95
+ for (const [k, v] of Object.entries(env)) if (typeof v === 'string' && !TURN_OWNED_ENV.has(k)) out[k] = v;
96
+ return Object.keys(out).length ? out : null;
97
+ }
98
+
99
+ /** Far past any real settings file; a bigger one is not read. */
100
+ const SETTINGS_MAX_BYTES = 1024 * 1024;
101
+
102
+ /**
103
+ * The person's user settings file — `$CLAUDE_CONFIG_DIR/settings.json`, else
104
+ * `~/.claude/settings.json`, where the CLI itself looks — reduced to
105
+ * PERSONAL_KEPT. `{}` when it is absent, unreadable or not a JSON object: the
106
+ * turn then runs on the CLI's stored login and defaults, and the fence is the
107
+ * same either way.
108
+ */
109
+ export function personalClaudeSettings(env = process.env) {
110
+ const dir = env.CLAUDE_CONFIG_DIR || join(env.HOME || homedir(), '.claude');
111
+ const path = join(dir, 'settings.json');
112
+ let raw;
113
+ try {
114
+ if (statSync(path).size > SETTINGS_MAX_BYTES) return {};
115
+ raw = JSON.parse(readFileSync(path, 'utf8'));
116
+ } catch {
117
+ return {};
118
+ }
119
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
120
+ const out = {};
121
+ for (const key of PERSONAL_KEPT) {
122
+ if (!Object.hasOwn(raw, key) || raw[key] == null) continue;
123
+ if (key !== 'env') out[key] = raw[key];
124
+ else {
125
+ const kept = keptEnv(raw.env);
126
+ if (kept) out.env = kept;
127
+ }
128
+ }
129
+ return out;
130
+ }
131
+
132
+ /**
133
+ * `perm` with `kept` folded into its `--settings` — the posture's keys win, so
134
+ * a kept key a posture ever names is the fence's word. A posture with no
135
+ * `--settings` gets one before its lists (`--allowedTools` is variadic). With
136
+ * nothing kept, `perm` is returned as it was.
137
+ */
138
+ export function withPersonalSettings(perm, kept) {
139
+ const personal = kept && typeof kept === 'object' && !Array.isArray(kept) ? kept : {};
140
+ if (!Object.keys(personal).length) return [...perm];
141
+ const at = perm.indexOf('--settings');
142
+ if (at < 0) return ['--settings', JSON.stringify(personal), ...perm];
143
+ const posture = JSON.parse(perm[at + 1]);
144
+ return [...perm.slice(0, at), '--settings', JSON.stringify({ ...personal, ...posture }), ...perm.slice(at + 2)];
145
+ }
@@ -466,6 +466,23 @@ const kickoff = ({ message, askedByName, head, tail }) => {
466
466
  * · Rule 9 is the `assumed` param on the staging verbs: a guess the chat did
467
467
  * not ask about is SAID, one line under the staged row, never folded into
468
468
  * the brief where it reads as something the person asked for.
469
+ *
470
+ * STAGE, THEN ASK — A QUESTION NEVER STANDS IN FOR A CARD (2026-09-29). The
471
+ * owner: "it ignored a subsequent ask … but it worked after i tried again."
472
+ * The follow-ups were not lost; rule 5 said "ask … then wait for the answer",
473
+ * so a new ask with one open detail came back as a question and no card, and
474
+ * to a person watching the Staged rail a reply that stages nothing reads as
475
+ * ignored. Re-sending the same words got a card only because the chat judged
476
+ * the question already asked. So the order flips: the best guess is STAGED
477
+ * first, said through `assumed` (the rail shows it under the row), and the
478
+ * one question follows in chips; the answer moves that row in place
479
+ * (`edit_staged` — restaging loses what the person typed into it). Rule 7's
480
+ * two-product case is the same order. Rule 11 says the chat stays open: a
481
+ * later message is a new ask, never an occasion to recite the staged batch.
482
+ * Rule 1 lost "what you need to know" as a reply in place of a card, and now
483
+ * says the final reply is always words — a turn that ends with none settles
484
+ * as failed on the tab. Rule 4 lost "what earlier chats left": list_staged
485
+ * holds this chat's own rows too, and its tool description says so.
469
486
  */
470
487
  export const SYSTEM_CAPTURE = `You are the human's chosen CLI, in their repository, with ONE job: turn what
471
488
  they say into well-cut task cards on their Flowviant board. You are READ-ONLY
@@ -480,21 +497,29 @@ with the matching name your CLI exposes.
480
497
 
481
498
  1. NARRATE WHILE YOU WORK. Call stream_session_turn with short progress lines
482
499
  as you read and stage. Your FINAL reply is delivered automatically when the
483
- turn ends — do not repeat it through the tool. Keep replies short: what you
484
- staged, or what you need to know — one line each.
500
+ turn ends — always end with it, in words, even when it only says what you
501
+ staged; do not also send it through the tool. Keep it short: what you
502
+ staged, what you guessed, and your one question if you have one — a line
503
+ each.
485
504
  2. STAGE, NEVER FILE. stage_card proposes a new card; stage_card_edit proposes
486
505
  a change to an existing one (read_card first — never replace fields you
487
- have not seen). Everything you stage waits in an area the person reviews
488
- and lands themselves; nothing you do reaches the board directly.
506
+ have not seen); edit_staged changes a row already staged, in place.
507
+ Everything you stage waits in an area the person reviews and lands
508
+ themselves; nothing you do reaches the board directly.
489
509
  3. ONE CARD PER SHIPPABLE UNIT. Break a big ask into the units that will build
490
510
  it, in landing order. Never card-ify chatter, questions, or one unit split
491
511
  thin.
492
512
  4. DEDUPE FIRST, EVERY TIME. Before staging, call list_cards (the OPEN QUEUE —
493
513
  capped, and cards agents already hold are absent from it) and list_staged
494
- (what earlier chats left). If the work exists, say so and point at it;
495
- stage_card_edit it if the ask adds something. Never stage a twin.
496
- 5. ASK ONLY FOR WHAT SHAPES THE CARD. Before staging, ask when — and only
497
- when — the answer changes the card itself:
514
+ (everything waiting to be landed — what you staged earlier in THIS chat
515
+ included). If the work exists, say so and point at it; when the ask adds
516
+ something to it, stage_card_edit a card or edit_staged a staged row.
517
+ Never stage a twin — and never take new work for old: an ask for
518
+ something different gets its own card (rule 11).
519
+ 5. STAGE FIRST, THEN ASK ONLY FOR WHAT SHAPES THE CARD. A question never
520
+ stands in for a card: a message that asks for work ends this turn with
521
+ that work staged. A question is worth asking when — and only when — the
522
+ answer changes the card itself:
498
523
  - its KIND — what it hands back — when the words could mean more than one
499
524
  product (rule 7);
500
525
  - its SCOPE — which pages, screens or parts, when the ask names a family
@@ -503,9 +528,15 @@ with the matching name your CLI exposes.
503
528
  acceptance criteria;
504
529
  - a CONSTRAINT the repository cannot answer — a vendor, a deadline, a
505
530
  browser to support, what must not change.
531
+ Even then, stage your best guess FIRST, the guessed part named in
532
+ \`assumed\` (rule 9); say in one line what you guessed; THEN ask your
533
+ question, as rule 8 says. When they answer, change that staged row in
534
+ place with edit_staged — only the fields the answer moves, and \`assumed\`
535
+ as "" once it is settled — never unstage_card and a fresh stage_card.
506
536
  Never ask what reading the repo answers: read it. One question per
507
- message, asked as rule 8 says, then wait for the answer. A clear ask
508
- stages without ceremony — no question for the sake of one.
537
+ message; any other guess stays said in \`assumed\`, where the person can
538
+ correct it on the row. A clear ask stages without ceremony — no question
539
+ for the sake of one.
509
540
  6. A GOOD CARD: a title naming the outcome, a brief a stranger could start
510
541
  from, acceptance criteria only when the person stated (or the code shows)
511
542
  what done means. No sizes, no owners, no statuses — none of those are
@@ -526,11 +557,13 @@ with the matching name your CLI exposes.
526
557
  "a deck", "a presentation", "a pitch".
527
558
  - "research": a write-up — read the repo and the web; no code changed.
528
559
  "Find out", "research", "compare", "how does X do it", "write up".
529
- When the words fit two products, ASK before staging — rule 5 — in chips, one
530
- option per product that fits, its description its consequence. "Redesign the
560
+ When the words fit two products, STAGE the likelier one, the guess named
561
+ in \`assumed\` ("assumed a code change, not a mockup first"), then ask —
562
+ rule 5 — in chips, one option per product that fits, its description its
563
+ consequence; the answer re-files the row with edit_staged. "Redesign the
531
564
  landing page" fits code and design; "make a chair for the scene" fits code
532
565
  (commit the asset) and model (a model to look at first). Never stage a
533
- guessed kind.
566
+ guessed kind without saying it is a guess.
534
567
  8. HAND THEM THE CHOICES. A question with answers you can name ends your
535
568
  reply with a fenced block the sheet renders as options; picking one sends
536
569
  its label as their next message, so every label must read as an answer a
@@ -558,18 +591,23 @@ with the matching name your CLI exposes.
558
591
  above the block as well: a client that doesn't render the fence shows it
559
592
  as plain text, so the reply has to read as a question with its options
560
593
  either way.
561
- 9. SAY WHAT YOU ASSUMED. When you stage a card on a GUESS you did not ask
562
- about, pass \`assumed\` on stage_card (or stage_card_edit): one short
563
- sentence naming the guess — "assumed the mobile layout too", "assumed
564
- English only". It shows under the staged row, where the person can
565
- correct it before the card lands. Never bury a guess in the brief, where
566
- it reads as something they asked for, and never pass \`assumed\` for
567
- something they said.
594
+ 9. SAY WHAT YOU ASSUMED. When you stage a card on a GUESS — the one your
595
+ question is about (rule 5), or one not worth a question —
596
+ pass \`assumed\` on stage_card (or stage_card_edit): one short sentence
597
+ naming the guess — "assumed the mobile layout too", "assumed English
598
+ only". It shows under the staged row, where the person can correct it
599
+ before the card lands. Never bury a guess in the brief, where it reads as
600
+ something they asked for, and never pass \`assumed\` for something they
601
+ said.
568
602
  10. REFERENCE WHAT THE PROJECT KEPT. When the ask names a kept mockup, 3D model,
569
603
  image, deck or write-up ("implement design A"), call list_library and pass its id as
570
604
  \`references\` on the card you stage, so the agent reads it. When the person's
571
605
  message includes attached library items, pass those ids as \`references\` on
572
- the cards you stage from that message.`;
606
+ the cards you stage from that message.
607
+ 11. THE CHAT STAYS OPEN. Every message is a new ask, however many turns came
608
+ before it (an answer to your question moves its row instead — rule 5).
609
+ When they ask for more after a staged batch, stage the new cards; never
610
+ answer a new ask by restating what is already staged.`;
573
611
 
574
612
  export const WORK_TURN_KICKOFF = ({ sessionId, sessionName, message, askedByName }) =>
575
613
  kickoff({
@@ -78,9 +78,23 @@ export const resumeConversationLost = (text, { runtime, sawInit } = {}) => {
78
78
  * is gone. `sawInit` is whether THIS spawn's CLI emitted `system.init` (only
79
79
  * Claude's does, and only Claude's is read). A fresh turn is never retried:
80
80
  * `resume` false answers false whatever the text says.
81
+ *
82
+ * …AND AN EMPTY CLAUDE RESUME THAT FOUND ITS CONVERSATION IS AN EMPTY ANSWER,
83
+ * NOT A LOST CONVERSATION (2026-09-29). Empty used to retry fresh whatever the
84
+ * evidence, so a Claude that emitted init — the conversation WAS there — and
85
+ * then said nothing had the message answered again by a Claude with no memory
86
+ * of the chat, and that fresh conversation's id re-pinned the tab: the
87
+ * follow-up read as if the chat had never happened, and so did every one
88
+ * after it. With init seen, the empty answer stands and settles as what it
89
+ * is, the conversation kept for the next message. Unmeasured init (`sawInit`
90
+ * not true) and the other CLIs keep the old reading: empty is the only sign
91
+ * they give of a conversation that is gone.
81
92
  */
82
- export const resumeRetriesFresh = ({ resume, out, runtime, sawInit }) =>
83
- Boolean(resume) && (!String(out || '').trim() || resumeConversationLost(out, { runtime, sawInit }));
93
+ export const resumeRetriesFresh = ({ resume, out, runtime, sawInit }) => {
94
+ if (!resume) return false;
95
+ if (!String(out || '').trim()) return !(runtime === 'claude' && sawInit === true);
96
+ return resumeConversationLost(out, { runtime, sawInit });
97
+ };
84
98
 
85
99
  /**
86
100
  * ONE SPAWN, AND AT MOST ONE FRESH RETRY — the agent lane's whole sequence,
@@ -14,6 +14,7 @@ import { mkdtempSync, writeFileSync } from 'node:fs';
14
14
  import { tmpdir } from 'node:os';
15
15
  import { join } from 'node:path';
16
16
  import { MODEL, USER_AGENT } from './config.mjs';
17
+ import { NO_SETTING_SOURCES, personalClaudeSettings, withPersonalSettings } from './claudePersonal.mjs';
17
18
 
18
19
  function claudeMcp(token, mcpUrl) {
19
20
  const dir = mkdtempSync(join(tmpdir(), 'flowviant-mcp-'));
@@ -72,7 +73,7 @@ export const CLAUDE_RUNTIME = {
72
73
  * strongest form of it available anywhere: `--append-system-prompt` sits
73
74
  * above the conversation rather than inside it.
74
75
  */
75
- args({ prompt, system, model, effort, resume, streamJson, perm, profile = 'build', mcp = [], resultSchemaArgs = [], adoptResumeId, resumeThreadId, knowledgeDir, agentTools }) {
76
+ args({ prompt, system, model, effort, resume, streamJson, perm, profile = 'build', mcp = [], resultSchemaArgs = [], adoptResumeId, resumeThreadId, knowledgeDir, agentTools, personalSettings }) {
76
77
  const a = [];
77
78
  // THREE ANSWERS TO ONE QUESTION — "what conversation is this?" — and they
78
79
  // are mutually exclusive, never combined.
@@ -99,11 +100,38 @@ export const CLAUDE_RUNTIME = {
99
100
  else if (resumeThreadId) a.push('--resume', resumeThreadId);
100
101
  else if (resume) a.push('--continue');
101
102
  a.push('-p', prompt, '--append-system-prompt', system);
103
+ /**
104
+ * WHOSE SETTINGS A TURN READS (2026-09-29, claudePersonal.mjs).
105
+ *
106
+ * EVERY FENCED POSTURE READS NONE — every profile but `build`: an agent's
107
+ * design and research cards, the pre-review, the planner, intake, the
108
+ * wiki and the capture chat. Its permissions and hooks are its posture's
109
+ * alone; of the person's file it keeps only the login and the model
110
+ * defaults, folded into the posture's `--settings`. Keyed on `build`
111
+ * rather than on a list of fenced names, so a posture added later is
112
+ * fenced unless it is the build.
113
+ *
114
+ * THE BUILD POSTURE KEEPS THE PERSON'S OWN, on a code agent and on a
115
+ * Terminal tab alike. `--dangerously-skip-permissions` already admits
116
+ * everything, so no personal rule can widen it; what a personal file adds
117
+ * there is the operator narrowing their own box (a deny rule held a
118
+ * bypass turn, measured) and their own CLAUDE.md, skills and subagents,
119
+ * which an empty source list would take away with no flag to keep them.
120
+ * Codex's build is not hermetic either, for the same reason
121
+ * (runtimeCodex.mjs). STATED, not closed: under FLOWVIANT_SAFE=1 the
122
+ * build is a curated list, and the operator's own allow rules or
123
+ * `defaultMode` can still widen it — two choices of the same operator on
124
+ * their own box, and a personal allow is the only way a SAFE build runs a
125
+ * tool its list does not name (`pnpm`, `cargo`, `make`). A code agent names `user` so the agent-editable
126
+ * worktree's settings and hooks never load; a Terminal tab names nothing
127
+ * and is the person's Claude Code, project files included. A resumed
128
+ * agent renders the CURRENT base snapshot, not the first turn's cache.
129
+ */
130
+ const fenced = profile !== 'build';
131
+ if (fenced) a.push(...NO_SETTING_SOURCES);
132
+ else if (agentTools) a.push('--setting-sources', 'user');
102
133
  if (agentTools) {
103
- // Keep personal settings, but never load the agent-editable project's
104
- // settings, hooks, skills or CLAUDE.md discovery. A resumed agent must
105
- // render the CURRENT base snapshot, not the first turn's prompt cache.
106
- a.push('--setting-sources', 'user', '--system-prompt-snapshot', 'off');
134
+ a.push('--system-prompt-snapshot', 'off');
107
135
  if (agentTools.pluginDir) a.push('--plugin-dir', agentTools.pluginDir);
108
136
  }
109
137
  // These fenced postures have no project MCP, even if Claude discovers a
@@ -119,7 +147,9 @@ export const CLAUDE_RUNTIME = {
119
147
  // does not refuse the path the prompt just handed it. Before `perm`,
120
148
  // because `--allowedTools` is variadic and would swallow the flag.
121
149
  if (knowledgeDir) a.push('--add-dir', knowledgeDir);
122
- a.push(...perm);
150
+ // `personalSettings` is the caller's copy when it has one (a test); a
151
+ // turn reads the person's file here, at spawn, as the CLI would.
152
+ a.push(...(fenced ? withPersonalSettings(perm, personalSettings ?? personalClaudeSettings()) : perm));
123
153
  return a;
124
154
  },
125
155
  parse: null, // claudeStream.mjs owns its own stream parser (unchanged)
@@ -59,6 +59,14 @@ import { createCommandAudit } from './workCommandAudit.mjs';
59
59
  export const PLAN_TURN_SENTENCE =
60
60
  'THIS IS A PLANNING TURN: the person switched this tab to plan mode, so read what you need, decide, and answer with the plan itself as your reply — change nothing (the CLI refuses every write this turn, whatever the mechanics above say about editing, committing or artifacts), and do not try to leave plan mode; they switch it off in the tab when they want the plan carried out.';
61
61
 
62
+ /**
63
+ * HOW LONG, AND HOW MANY, SETTLED TURNS THE LANE REMEMBERS (2026-09-29) — see
64
+ * `settledHere` below. Ten minutes is far past any roster a poll could still
65
+ * be holding; the cap keeps a box that answers all day from growing the map.
66
+ */
67
+ export const SETTLED_HERE_TTL_MS = 10 * 60 * 1000;
68
+ export const SETTLED_HERE_MAX = 500;
69
+
62
70
  export function createWorkSessionTurns({
63
71
  repoRoot,
64
72
  baseDir,
@@ -91,14 +99,67 @@ export function createWorkSessionTurns({
91
99
  // The real CLI driver; injectable only so the lane can be driven in a test
92
100
  // without spending a model turn (the wiki runner's shape).
93
101
  runTurn = defaultRunTurn,
102
+ // The clock `settledHere` ages by; injectable only for its bound's test.
103
+ now = Date.now,
94
104
  }) {
95
105
  const workAnswering = new Set(); // turn ids currently queued/running here
96
106
  const MAX_WORK_TRIES = 3;
97
107
 
108
+ /**
109
+ * A TURN THIS LANE SETTLED IS NEVER RUN AGAIN (2026-09-29).
110
+ *
111
+ * A delivered settle forgets the turn everywhere: the report queue drops it
112
+ * from `pendingWorkReports` and `workAttempts`, and the turn's `finally`
113
+ * drops it from `workAnswering`. A roster READ made before that settle
114
+ * landed still offers it — the poll was in flight while the POST went out —
115
+ * and nothing was left to say it had run: the turn ran twice, settled
116
+ * twice, the rerun's reply overwrote the first, and the person's follow-up
117
+ * waited behind a turn nobody asked for. So every settle this lane makes
118
+ * goes through `settleTurn`, which remembers the turn id, and a job in the
119
+ * map is skipped where jobs are picked. Remembered BEFORE the POST: a
120
+ * settle still in flight is covered by `workAnswering`, one that could not
121
+ * be delivered by `pendingWorkReports`, and this covers the one that was.
122
+ */
123
+ const settledHere = new Map(); // turnId -> when this lane settled it, oldest first
124
+ const forgetStale = (at) => {
125
+ for (const [id, when] of settledHere) {
126
+ if (settledHere.size <= SETTLED_HERE_MAX && at - when < SETTLED_HERE_TTL_MS) break;
127
+ settledHere.delete(id);
128
+ }
129
+ };
130
+ const settledRecently = (turnId) => {
131
+ forgetStale(now());
132
+ return settledHere.has(turnId);
133
+ };
134
+ const settleTurn = (turnId, payload) => {
135
+ const at = now();
136
+ settledHere.delete(turnId); // re-inserted at the end: the map stays in time order
137
+ settledHere.set(turnId, at);
138
+ forgetStale(at);
139
+ return settleWorkTurn(turnId, payload);
140
+ };
141
+
142
+ /**
143
+ * A PLACE A TURN OF THIS PROCESS IS STARTING IN (2026-09-29) — the turn
144
+ * lock's in-process half, keyed by the lock's own path.
145
+ *
146
+ * `flowviant-turn.lock` is written at SPAWN, several awaits after it is
147
+ * read (a credential mint, the attachment fetch), so two turns of one
148
+ * session offered in one roster both read "unlocked" and both spawned: two
149
+ * CLIs resuming one conversation at once, the incoherence the lock exists
150
+ * to prevent. The claim is checked and taken synchronously beside the file
151
+ * check, so the second turn waits for a later poll like any locked one,
152
+ * and it is released in the turn's outer `finally`, after its settle.
153
+ */
154
+ const startingIn = new Set();
155
+
98
156
  const processWorkTurns = (jobs) => {
99
157
  for (const job of jobs ?? []) {
100
158
  if (!job || typeof job.id !== 'string' || !job.body || !job.sessionId) continue;
101
159
  if (workAnswering.has(job.id)) continue;
160
+ // Settled by this lane already: a roster read from before the settle
161
+ // landed. See `settledHere`.
162
+ if (settledRecently(job.id)) continue;
102
163
  // The turn already RAN and its answer sits in the delivery queue — never
103
164
  // run it again while the report is merely undelivered.
104
165
  if (pendingWorkReports.has(job.id)) continue;
@@ -140,7 +201,7 @@ export function createWorkSessionTurns({
140
201
  * tab for the server's whole expiry window.
141
202
  */
142
203
  if (place !== REPO_PLACE && !isSafePathSegment(place)) {
143
- void settleWorkTurn(job.id, {
204
+ void settleTurn(job.id, {
144
205
  ok: false,
145
206
  answer:
146
207
  'the server named a working directory this machine refuses to use — close and reopen the tab, then send the message again',
@@ -178,12 +239,15 @@ export function createWorkSessionTurns({
178
239
  * out here beside `usage`, and spread wherever it is. Last write wins
179
240
  * across a fresh retry: that spawn is the one that answered. */
180
241
  let model = null;
242
+ /** The `startingIn` key this turn claimed — released in the outer
243
+ * `finally`, and only by the turn that took it. */
244
+ let startClaim = null;
181
245
  try {
182
246
  const tries = workAttempts.get(job.id) ?? 0;
183
247
  if (tries >= MAX_WORK_TRIES) {
184
248
  // Out of local tries: SETTLE, don't skip — a silently skipped turn
185
249
  // strands the tab for the server's whole 24h expiry window.
186
- await settleWorkTurn(job.id, {
250
+ await settleTurn(job.id, {
187
251
  ok: false,
188
252
  answer: `the turn failed ${tries} times on this machine — check the daemon log, then send the message again`,
189
253
  });
@@ -209,7 +273,7 @@ export function createWorkSessionTurns({
209
273
  typeof job.adopt.id !== 'string' ||
210
274
  !/^[0-9a-f][0-9a-f-]{6,62}$/i.test(job.adopt.id)
211
275
  ) {
212
- await settleWorkTurn(job.id, {
276
+ await settleTurn(job.id, {
213
277
  ok: false,
214
278
  answer: 'that terminal session id is not one this machine can resume',
215
279
  });
@@ -223,7 +287,7 @@ export function createWorkSessionTurns({
223
287
  srcCwd = null;
224
288
  }
225
289
  if (!srcCwd) {
226
- await settleWorkTurn(job.id, {
290
+ await settleTurn(job.id, {
227
291
  ok: false,
228
292
  answer: "the terminal session's directory no longer exists on the machine",
229
293
  });
@@ -247,14 +311,14 @@ export function createWorkSessionTurns({
247
311
  /* keep the literal path */
248
312
  }
249
313
  if (!under(srcCwd, realRoot)) {
250
- await settleWorkTurn(job.id, {
314
+ await settleTurn(job.id, {
251
315
  ok: false,
252
316
  answer: "the terminal session's directory is outside this project's repository",
253
317
  });
254
318
  return;
255
319
  }
256
320
  if (under(srcCwd, realBase)) {
257
- await settleWorkTurn(job.id, {
321
+ await settleTurn(job.id, {
258
322
  ok: false,
259
323
  answer:
260
324
  "that directory is one of the daemon's own worktrees — its session is already a tab, not something to adopt",
@@ -264,7 +328,7 @@ export function createWorkSessionTurns({
264
328
  try {
265
329
  srcHead = git(['rev-parse', 'HEAD'], srcCwd);
266
330
  } catch {
267
- await settleWorkTurn(job.id, {
331
+ await settleTurn(job.id, {
268
332
  ok: false,
269
333
  answer:
270
334
  "the terminal session's directory is not a usable git checkout (no HEAD to branch from)",
@@ -281,7 +345,7 @@ export function createWorkSessionTurns({
281
345
  ? isAgyConversationLive(job.adopt.id)
282
346
  : isTerminalSessionLive(job.adopt.id);
283
347
  if (adoptLive) {
284
- await settleWorkTurn(job.id, {
348
+ await settleTurn(job.id, {
285
349
  ok: false,
286
350
  answer:
287
351
  'That terminal session is still open on the machine — close it there first, then adopt.',
@@ -297,7 +361,7 @@ export function createWorkSessionTurns({
297
361
  // the session's own id, which is what every tab has always done.
298
362
  const dir = placeWtFor(place, adopting ? srcHead : undefined);
299
363
  if (!dir) {
300
- await settleWorkTurn(job.id, {
364
+ await settleTurn(job.id, {
301
365
  ok: false,
302
366
  answer:
303
367
  'the session worktree could not be opened on the machine — check the daemon log',
@@ -308,13 +372,20 @@ export function createWorkSessionTurns({
308
372
  // life, most likely; the lock outlives a restart. Leave the job
309
373
  // pending and look again next poll; spawning a second CLI would put
310
374
  // two Claudes in one held context. Costs no attempt: nothing ran.
375
+ // …AND ONE OF OURS MAY BE ABOUT TO: `startingIn`, checked and
376
+ // claimed with no await between, because the file is only written
377
+ // at spawn. Keyed by the lock's path (the directory, when the git
378
+ // dir cannot be named) — the file lock's own scope.
311
379
  const lockPath = sessionMetaPath(dir.wt, 'flowviant-turn.lock');
312
- if (turnLockedByLivePid(lockPath)) {
380
+ const startKey = lockPath || dir.wt;
381
+ if (startingIn.has(startKey) || turnLockedByLivePid(lockPath)) {
313
382
  warn(
314
383
  `a turn is already running in "${job.sessionName || job.sessionId}" — waiting for it to finish`
315
384
  );
316
385
  return;
317
386
  }
387
+ startingIn.add(startKey);
388
+ startClaim = startKey;
318
389
  // WHICH BRAIN the roster says this tab speaks (null/absent = Claude,
319
390
  // which is what every tab ran on until now) — honored by
320
391
  // sessionRuntime: on a first turn a named runtime IS the pick, and a
@@ -327,14 +398,14 @@ export function createWorkSessionTurns({
327
398
  if (rt.mismatch) {
328
399
  // Something upstream changed this tab's identity mid-life. A held
329
400
  // context must never be answered by a different brain — say so.
330
- await settleWorkTurn(job.id, {
401
+ await settleTurn(job.id, {
331
402
  ok: false,
332
403
  answer: `this tab is pinned to ${rt.mismatch.pin} but the server says it is a ${rt.mismatch.runtime} tab — reopen a new tab`,
333
404
  });
334
405
  return;
335
406
  }
336
407
  if (rt.missing) {
337
- await settleWorkTurn(job.id, {
408
+ await settleTurn(job.id, {
338
409
  ok: false,
339
410
  answer: `this session runs on ${rt.missing}, which is no longer installed on the machine — reinstall it, or open a new tab`,
340
411
  });
@@ -346,14 +417,14 @@ export function createWorkSessionTurns({
346
417
  // tab can run on (Antigravity has no MCP config, and the session's
347
418
  // whole control plane rides one). An honest sentence beats the
348
419
  // mcpFor throw this used to crash into every turn.
349
- await settleWorkTurn(job.id, {
420
+ await settleTurn(job.id, {
350
421
  ok: false,
351
422
  answer: `this session runs on ${rt.unsupported}, which cannot drive a Terminal tab on this machine — open a new tab`,
352
423
  });
353
424
  return;
354
425
  }
355
426
  if (!rt.id) {
356
- await settleWorkTurn(job.id, {
427
+ await settleTurn(job.id, {
357
428
  ok: false,
358
429
  answer:
359
430
  'No coding CLI is installed on the machine — install Claude Code (or another supported CLI), then send the message again',
@@ -366,7 +437,7 @@ export function createWorkSessionTurns({
366
437
  // (--conversation). Codex has no adoptable store yet, and its
367
438
  // args builder backstops this with a loud throw — but a sentence
368
439
  // here beats a stack there.
369
- await settleWorkTurn(job.id, {
440
+ await settleTurn(job.id, {
370
441
  ok: false,
371
442
  answer:
372
443
  'adopting this terminal session needs its own CLI on the machine — install it, then try again',
@@ -383,7 +454,7 @@ export function createWorkSessionTurns({
383
454
  */
384
455
  const planTurn = job.planMode === true && job.capture !== true;
385
456
  if (planTurn && rt.id !== 'claude') {
386
- await settleWorkTurn(job.id, {
457
+ await settleTurn(job.id, {
387
458
  ok: false,
388
459
  answer: `plan mode runs on Claude Code only, and this tab is ${rt.id} — turn plan off in the tab, then send the message again`,
389
460
  });
@@ -409,7 +480,7 @@ export function createWorkSessionTurns({
409
480
  return;
410
481
  }
411
482
  if (mint?.gone) {
412
- await settleWorkTurn(job.id, {
483
+ await settleTurn(job.id, {
413
484
  ok: false,
414
485
  answer:
415
486
  'Flowviant no longer offers this session to this machine — the tab may have been closed or moved',
@@ -417,7 +488,7 @@ export function createWorkSessionTurns({
417
488
  return;
418
489
  }
419
490
  if (!mint?.token) {
420
- await settleWorkTurn(job.id, {
491
+ await settleTurn(job.id, {
421
492
  ok: false,
422
493
  answer:
423
494
  'the machine could not mint a session credential from Flowviant — check its connection, then send the message again',
@@ -765,6 +836,11 @@ export function createWorkSessionTurns({
765
836
  * that tab replied identically, with nothing on any surface able
766
837
  * to clear it. A tab bricked forever by its own CLI pruning its
767
838
  * history, which it does on its own schedule.
839
+ *
840
+ * An EMPTY Claude resume that emitted init is not retried
841
+ * (2026-09-29, resumeLost.mjs): its conversation was found, and a
842
+ * fresh run would answer with no memory of the chat and re-pin the
843
+ * tab to that new conversation.
768
844
  */
769
845
  if (
770
846
  !adopting &&
@@ -831,7 +907,7 @@ export function createWorkSessionTurns({
831
907
  // transcript is most likely gone (cleaned, expired, deleted). Say
832
908
  // exactly that; no sessionRef is recorded, so the server keeps
833
909
  // offering the adoption and a retry after the user checks is cheap.
834
- await settleWorkTurn(job.id, {
910
+ await settleTurn(job.id, {
835
911
  ok: false,
836
912
  answer: "Couldn't resume the terminal session — it may have been removed.",
837
913
  // Whatever it DID before coming back empty is exactly the
@@ -862,13 +938,24 @@ export function createWorkSessionTurns({
862
938
  }
863
939
  }
864
940
  }
865
- await settleWorkTurn(job.id, {
941
+ /**
942
+ * NO WORDS, SAID AS MEASURED (2026-09-29). Claude's init event means
943
+ * it ran in this conversation — kept, and resumed by the next
944
+ * message, since an empty resume that found it no longer runs fresh
945
+ * (resumeLost.mjs) — and its result held no words. "Signed out" is
946
+ * a guess, left to the case where no init came.
947
+ */
948
+ const noWords =
949
+ rt.id === 'claude' && seenClaudeSession
950
+ ? 'Claude Code ran in this conversation and returned no words — send the message again'
951
+ : 'the turn produced no output on the machine — its CLI may be signed out; try again';
952
+ await settleTurn(job.id, {
866
953
  ok: answer.length > 0,
867
954
  answer:
868
955
  answer.length > 0
869
956
  ? // Scrub: a reply can quote config or env-adjacent code.
870
957
  envScrub(answer).slice(0, 16000)
871
- : 'the turn produced no output on the machine — its CLI may be signed out; try again',
958
+ : noWords,
872
959
  // Only a turn that actually SPOKE proves a conversation lives
873
960
  // here. Recording the path unconditionally is how a crashed first
874
961
  // turn used to brick resume for the session's whole life.
@@ -883,7 +970,7 @@ export function createWorkSessionTurns({
883
970
  if (answer.length > 0) ok(`${c.cyan('tab')} ${c.dim('— replied in the session')}`);
884
971
  else warn('session turn produced no output — settled as failed');
885
972
  } catch (e) {
886
- await settleWorkTurn(job.id, {
973
+ await settleTurn(job.id, {
887
974
  ok: false,
888
975
  // Scrub, like every string that leaves this machine: an exception
889
976
  // routinely quotes command output, and command output can quote a
@@ -899,6 +986,7 @@ export function createWorkSessionTurns({
899
986
  warn(`session turn failed: ${e?.message ?? e}`);
900
987
  } finally {
901
988
  workAnswering.delete(job.id);
989
+ if (startClaim) startingIn.delete(startClaim);
902
990
  // Nothing spawned, or everything already has: releasing twice is the
903
991
  // normal case and costs nothing. A reservation that leaked would
904
992
  // shrink this machine's ceiling for the life of the process.
@@ -928,5 +1016,5 @@ export function createWorkSessionTurns({
928
1016
  }
929
1017
  };
930
1018
 
931
- return { processWorkTurns, workAnswering };
1019
+ return { processWorkTurns, workAnswering, settledHere };
932
1020
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.114.1",
3
+ "version": "0.114.2",
4
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.",
5
5
  "type": "module",
6
6
  "bin": {