flowviant 0.103.0 → 0.104.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.
Files changed (140) hide show
  1. package/bin/cli.mjs +87 -474
  2. package/bin/lib/admission.mjs +19 -3
  3. package/bin/lib/agentPlan.mjs +2 -2
  4. package/bin/lib/agentTaskKinds.mjs +93 -0
  5. package/bin/lib/agySessions.mjs +158 -0
  6. package/bin/lib/artifactPolicy.mjs +44 -0
  7. package/bin/lib/artifactPreview.mjs +52 -0
  8. package/bin/lib/artifactReporter.mjs +163 -0
  9. package/bin/lib/artifacts.mjs +47 -227
  10. package/bin/lib/authReject.mjs +16 -4
  11. package/bin/lib/authproxy.mjs +47 -304
  12. package/bin/lib/boxIdentity.mjs +245 -0
  13. package/bin/lib/childEnv.mjs +11 -9
  14. package/bin/lib/claude.mjs +14 -851
  15. package/bin/lib/claudeAuth.mjs +3 -3
  16. package/bin/lib/claudePosture.mjs +367 -0
  17. package/bin/lib/claudeSessions.mjs +411 -0
  18. package/bin/lib/claudeStream.mjs +207 -0
  19. package/bin/lib/cliLimit.mjs +34 -0
  20. package/bin/lib/commandSpecs.mjs +173 -0
  21. package/bin/lib/config.mjs +11 -42
  22. package/bin/lib/credentialDate.mjs +22 -0
  23. package/bin/lib/credentials.mjs +21 -26
  24. package/bin/lib/daemonLogging.mjs +55 -0
  25. package/bin/lib/daemonState.mjs +64 -0
  26. package/bin/lib/deploy.mjs +148 -647
  27. package/bin/lib/deployCheckout.mjs +265 -0
  28. package/bin/lib/deployConfig.mjs +142 -0
  29. package/bin/lib/deployRunner.mjs +311 -0
  30. package/bin/lib/deployWire.mjs +41 -0
  31. package/bin/lib/desktopContract.mjs +56 -105
  32. package/bin/lib/env.mjs +49 -484
  33. package/bin/lib/fleet.mjs +183 -2383
  34. package/bin/lib/fleetJobs.mjs +120 -0
  35. package/bin/lib/fleetPost.mjs +40 -0
  36. package/bin/lib/fleetReports.mjs +392 -0
  37. package/bin/lib/fleetRoster.mjs +470 -0
  38. package/bin/lib/fleetStartup.mjs +196 -0
  39. package/bin/lib/fleetWake.mjs +45 -0
  40. package/bin/lib/fleetWire.mjs +46 -0
  41. package/bin/lib/git.mjs +2 -2
  42. package/bin/lib/gitMergeIdentity.mjs +51 -0
  43. package/bin/lib/grant.mjs +5 -3
  44. package/bin/lib/help.mjs +3 -125
  45. package/bin/lib/holder.mjs +482 -0
  46. package/bin/lib/instance.mjs +124 -46
  47. package/bin/lib/knowledge.mjs +16 -799
  48. package/bin/lib/knowledgeFetch.mjs +38 -0
  49. package/bin/lib/knowledgeFileSync.mjs +112 -0
  50. package/bin/lib/knowledgeFiles.mjs +487 -0
  51. package/bin/lib/knowledgeLibrary.mjs +249 -0
  52. package/bin/lib/landed.mjs +2 -1
  53. package/bin/lib/listeners.mjs +99 -141
  54. package/bin/lib/localSessions.mjs +30 -555
  55. package/bin/lib/login.mjs +3 -2
  56. package/bin/lib/loginPath.mjs +3 -5
  57. package/bin/lib/machineDisconnect.mjs +203 -0
  58. package/bin/lib/machineEnv.mjs +22 -0
  59. package/bin/lib/machineLimits.mjs +61 -0
  60. package/bin/lib/machineListing.mjs +338 -0
  61. package/bin/lib/machines.mjs +78 -455
  62. package/bin/lib/machinesCommand.mjs +214 -0
  63. package/bin/lib/mcp-cli.mjs +2 -1
  64. package/bin/lib/patchRevert.mjs +60 -0
  65. package/bin/lib/prWorkflow.mjs +157 -0
  66. package/bin/lib/preflight.mjs +2 -1
  67. package/bin/lib/pressurePolicy.mjs +104 -0
  68. package/bin/lib/preview.mjs +2 -272
  69. package/bin/lib/previewFramePolicy.mjs +112 -0
  70. package/bin/lib/previewGatePolicy.mjs +236 -0
  71. package/bin/lib/previewRegistry.mjs +235 -0
  72. package/bin/lib/printable.mjs +2 -2
  73. package/bin/lib/procRegistry.mjs +28 -18
  74. package/bin/lib/procScan.mjs +146 -0
  75. package/bin/lib/processes.mjs +29 -37
  76. package/bin/lib/projectToolRuntimes.mjs +138 -0
  77. package/bin/lib/projectTools.mjs +13 -88
  78. package/bin/lib/prompts.mjs +28 -42
  79. package/bin/lib/repoPath.mjs +26 -0
  80. package/bin/lib/resources.mjs +7 -69
  81. package/bin/lib/resumeLost.mjs +107 -0
  82. package/bin/lib/runTurn.mjs +324 -0
  83. package/bin/lib/runtimeAntigravity.mjs +230 -0
  84. package/bin/lib/runtimeCapabilities.mjs +334 -0
  85. package/bin/lib/runtimeClaude.mjs +126 -0
  86. package/bin/lib/runtimeCodex.mjs +217 -0
  87. package/bin/lib/runtimeDetection.mjs +121 -0
  88. package/bin/lib/runtimeEvents.mjs +482 -0
  89. package/bin/lib/runtimeOfferings.mjs +4 -1
  90. package/bin/lib/runtimes.mjs +42 -1402
  91. package/bin/lib/safeFileName.mjs +74 -0
  92. package/bin/lib/standDownExit.mjs +181 -0
  93. package/bin/lib/standDownGate.mjs +48 -0
  94. package/bin/lib/startProject.mjs +279 -0
  95. package/bin/lib/trace.mjs +3 -3
  96. package/bin/lib/turnLock.mjs +68 -0
  97. package/bin/lib/turnProfile.mjs +77 -0
  98. package/bin/lib/uninstall.mjs +55 -45
  99. package/bin/lib/uninstallCopies.mjs +112 -0
  100. package/bin/lib/update.mjs +30 -17
  101. package/bin/lib/uplinkScrub.mjs +265 -0
  102. package/bin/lib/vault.mjs +22 -151
  103. package/bin/lib/vaultDiff.mjs +183 -0
  104. package/bin/lib/versionOrder.mjs +42 -0
  105. package/bin/lib/views.mjs +17 -17
  106. package/bin/lib/wikiRunner.mjs +630 -0
  107. package/bin/lib/work.mjs +188 -3034
  108. package/bin/lib/workAdoptCarry.mjs +94 -0
  109. package/bin/lib/workAgentCheck.mjs +278 -0
  110. package/bin/lib/workAgentMerges.mjs +54 -142
  111. package/bin/lib/workAgentPlans.mjs +14 -12
  112. package/bin/lib/workAgentPrecheck.mjs +393 -0
  113. package/bin/lib/workAgentPublish.mjs +177 -0
  114. package/bin/lib/workAgentReview.mjs +8 -666
  115. package/bin/lib/workAgentTurnBegun.mjs +109 -0
  116. package/bin/lib/workAgentTurnExecution.mjs +604 -0
  117. package/bin/lib/workAgentTurnOutcome.mjs +207 -0
  118. package/bin/lib/workAgentTurnReports.mjs +189 -0
  119. package/bin/lib/workAgentTurns.mjs +91 -898
  120. package/bin/lib/workAttachments.mjs +93 -0
  121. package/bin/lib/workBrain.mjs +53 -0
  122. package/bin/lib/workChildren.mjs +110 -0
  123. package/bin/lib/workCommandAudit.mjs +62 -0
  124. package/bin/lib/workDiffs.mjs +2 -1
  125. package/bin/lib/workNarration.mjs +152 -0
  126. package/bin/lib/workPlaceReports.mjs +426 -0
  127. package/bin/lib/workPlaces.mjs +228 -0
  128. package/bin/lib/workPreviews.mjs +31 -9
  129. package/bin/lib/workProcesses.mjs +4 -5
  130. package/bin/lib/workPullRequests.mjs +52 -126
  131. package/bin/lib/workReportQueue.mjs +254 -0
  132. package/bin/lib/workRetire.mjs +185 -0
  133. package/bin/lib/workSessionGroups.mjs +166 -0
  134. package/bin/lib/workSessionRuntime.mjs +164 -0
  135. package/bin/lib/workSessionTokens.mjs +68 -0
  136. package/bin/lib/workSessionTurns.mjs +892 -0
  137. package/bin/lib/workShip.mjs +9 -29
  138. package/bin/lib/workToolLog.mjs +76 -0
  139. package/package.json +3 -2
  140. package/bin/lib/patch.mjs +0 -363
@@ -0,0 +1,173 @@
1
+ /**
2
+ * EVERY TERMINAL COMMAND, ONCE (2026-09-26, SOLID F054).
3
+ *
4
+ * A command used to live in three inventories: help.mjs's table, cli.mjs's
5
+ * `noCheckoutCommand` set (whether `--dir` moves the process into the
6
+ * checkout before config.mjs reads the project bound to cwd), and cli.mjs's
7
+ * chain of `process.argv[2] === '<name>'` conditionals — and help.test.mjs
8
+ * only kept two of them aligned. This table is the one list: help.mjs renders
9
+ * it, cli.mjs asks `commandUsesCheckout` for the --dir policy and dispatches
10
+ * by `findCommand(...).name` to a named handler in its HANDLERS map.
11
+ * help.test.mjs pins that every row has exactly one handler (or is one of the
12
+ * BUILTIN rows cli.mjs answers before dispatch).
13
+ *
14
+ * NO IMPORTS, on purpose: cli.mjs reads this before `--dir` is applied, which
15
+ * is before config.mjs may load.
16
+ *
17
+ * `hidden` rows are real commands a person rarely types (an agent calls
18
+ * `shot`; `mcp` manages connectors from a script); they answer
19
+ * `help <name>` but stay off the main screen. `usesCheckout` is true where
20
+ * the command works on the checkout `--dir` names.
21
+ */
22
+
23
+ export const COMMANDS = [
24
+ {
25
+ name: 'start',
26
+ usesCheckout: true,
27
+ usage: 'flowviant [start] [--project <name|id>]',
28
+ summary: "Serve this repo's project: run agents and Workbench tabs here. Plain `flowviant` does the same.",
29
+ details: [
30
+ 'Run it inside the repository you connected. The first run in a new repo asks you to approve this computer.',
31
+ '--project <name|id> serve that stored project instead of the one this repo is bound to',
32
+ ],
33
+ group: 'run',
34
+ },
35
+ {
36
+ name: 'login',
37
+ usesCheckout: true,
38
+ usage: 'flowviant login [--no-start]',
39
+ summary: 'Connect this repo to a project: approve the code in your browser, then start serving.',
40
+ details: ['--no-start save the connection without starting the daemon'],
41
+ group: 'run',
42
+ },
43
+ {
44
+ name: 'stop',
45
+ usesCheckout: false,
46
+ usage: 'flowviant stop [--project <name|id>]',
47
+ summary: 'Stop every flowviant daemon on this computer (or just one project’s).',
48
+ group: 'run',
49
+ },
50
+ {
51
+ name: 'status',
52
+ usesCheckout: false,
53
+ usage: 'flowviant status',
54
+ summary: 'Each project connected here: its repo, whether it is running and serving, and its live agents.',
55
+ details: ['--json the machine-readable form the Windows tray reads'],
56
+ group: 'look',
57
+ },
58
+ {
59
+ name: 'logs',
60
+ usesCheckout: true,
61
+ usage: 'flowviant logs [-f] [--project <name|id>]',
62
+ summary: "The daemon's log for this repo's project.",
63
+ details: ['-f, --follow keep printing new lines as they are written', '-n <lines> how many lines to show first (default 80)'],
64
+ group: 'look',
65
+ },
66
+ {
67
+ name: 'open',
68
+ usesCheckout: true,
69
+ usage: 'flowviant open [--project <name|id>]',
70
+ summary: "Open this project's board in your browser (prints the link too).",
71
+ group: 'look',
72
+ },
73
+ {
74
+ name: 'projects',
75
+ usesCheckout: false,
76
+ usage: 'flowviant projects',
77
+ summary: 'Every project this computer has a login for, and the repo each one serves.',
78
+ group: 'look',
79
+ },
80
+ {
81
+ name: 'machines',
82
+ usesCheckout: false,
83
+ usage: 'flowviant machines [--remove <id> | --forget <id>] [--yes]',
84
+ summary: 'Every computer that serves your projects. On a terminal, a menu to disconnect or forget one here.',
85
+ group: 'look',
86
+ },
87
+ {
88
+ name: 'doctor',
89
+ usesCheckout: true,
90
+ usage: 'flowviant doctor',
91
+ summary: 'Check what this computer needs: git, a signed-in CLI, a connection to Flowviant, a running daemon.',
92
+ group: 'look',
93
+ },
94
+ {
95
+ name: 'update',
96
+ usesCheckout: false,
97
+ usage: 'flowviant update',
98
+ summary: 'Install the newest flowviant now. It also updates itself when idle.',
99
+ group: 'manage',
100
+ },
101
+ {
102
+ name: 'clean',
103
+ usesCheckout: false,
104
+ usage: 'flowviant clean',
105
+ summary: 'Delete the kept agent worktrees under ~/.flowviant/worktrees. Stop the daemon first.',
106
+ group: 'manage',
107
+ },
108
+ {
109
+ name: 'uninstall',
110
+ usesCheckout: false,
111
+ usage: 'flowviant uninstall [--purge] [--yes]',
112
+ summary: 'Remove every flowviant copy on this computer. Logins are kept unless you add --purge.',
113
+ details: ['--purge also disconnect this computer from its projects and delete ~/.flowviant', '--yes do not ask first'],
114
+ group: 'manage',
115
+ },
116
+ {
117
+ name: 'gh-auth',
118
+ usesCheckout: false,
119
+ usage: 'flowviant gh-auth',
120
+ summary: 'Sign in the GitHub CLI flowviant uses to open pull requests.',
121
+ group: 'manage',
122
+ },
123
+ {
124
+ name: 'version',
125
+ usesCheckout: false,
126
+ usage: 'flowviant --version',
127
+ summary: 'Print the installed version.',
128
+ group: 'manage',
129
+ },
130
+ {
131
+ name: 'help',
132
+ usesCheckout: false,
133
+ usage: 'flowviant help [command]',
134
+ summary: 'This list, or one command in detail.',
135
+ group: 'manage',
136
+ },
137
+ {
138
+ name: 'shot',
139
+ usesCheckout: false,
140
+ usage: 'flowviant shot <url> [--out <file>]',
141
+ summary: 'Screenshot a running page with a headless browser (agents use it to see their change).',
142
+ hidden: true,
143
+ },
144
+ {
145
+ name: 'mcp',
146
+ usesCheckout: false,
147
+ usage: 'flowviant mcp <subcommand>',
148
+ summary: "Manage the project's MCP connectors from a script.",
149
+ hidden: true,
150
+ },
151
+ ];
152
+
153
+ /** Rows cli.mjs answers itself rather than through HANDLERS: `version` and
154
+ * `help` before any command runs, `start` as the fall-through daemon path. */
155
+ export const BUILTIN_COMMANDS = ['start', 'help', 'version'];
156
+
157
+ export const ALIASES = { '--version': 'version', '-v': 'version', '--help': 'help', '-h': 'help' };
158
+
159
+ export function findCommand(name) {
160
+ const key = ALIASES[name] ?? name;
161
+ return COMMANDS.find((cmd) => cmd.name === key) ?? null;
162
+ }
163
+
164
+ /**
165
+ * Does `--dir` move the process into the checkout before this word runs?
166
+ * Anything the table does not name — a flag such as `--project`, or no word at
167
+ * all — is the start path, which serves the checkout. (An unknown word is
168
+ * refused after the move, exactly as before.)
169
+ */
170
+ export function commandUsesCheckout(word) {
171
+ const cmd = findCommand(word);
172
+ return cmd ? cmd.usesCheckout : true;
173
+ }
@@ -4,7 +4,8 @@ import { randomBytes } from 'node:crypto';
4
4
  import { readFileSync } from 'node:fs';
5
5
  import { dirname, join } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
- import { cpus, hostname, totalmem } from 'node:os';
7
+ import { hostname } from 'node:os';
8
+ import { measureMachineLimits } from './machineLimits.mjs';
8
9
 
9
10
  // Read the daemon's version from its OWN package.json (always shipped in the npm
10
11
  // tarball) — never hardcode it. The hardcoded constant drifted: it sat at
@@ -36,7 +37,7 @@ export const MODEL = process.env.FLOWVIANT_MODEL || 'opus';
36
37
  // no-env-var path. An explicit --fleet flag or FLOWVIANT_FLEET env still wins.
37
38
  // Since 0.55.0 the store holds MANY projects and resolution is BY REPO — see
38
39
  // credentials.mjs for the whole rule. `CREDENTIAL` carries the resolution so
39
- // cli.mjs can turn an ambiguity into a picker instead of a guess.
40
+ // startProject.mjs can turn an ambiguity into a picker instead of a guess.
40
41
  import { resolveStoredCredential } from './credentials.mjs';
41
42
  export const CREDENTIAL = resolveStoredCredential();
42
43
 
@@ -77,42 +78,10 @@ export const STREAM_URL =
77
78
  FLEET_URL.replace(/\/agents(\/?)$/, '/stream$1').replace(/^http/, 'ws');
78
79
  export const POLL_SECONDS = Number(process.env.POLL_SECONDS || 20);
79
80
 
80
- /**
81
- * What this machine can actually be given, read from the machine.
82
- *
83
- * `os.totalmem()` and `cpus().length` report the HOST inside a container: a
84
- * 4GB container on a 256GB box reads 256GB and cheerfully oversubscribes until
85
- * the OOM killer picks a victim — which, because it picks by resident size, is
86
- * frequently not the task that caused it. cgroup v2 publishes the real limits,
87
- * so read those first and treat the os module as the fallback it is.
88
- */
89
- function machineLimits() {
90
- const readCgroup = (f) => {
91
- try {
92
- return readFileSync(`/sys/fs/cgroup/${f}`, 'utf8').trim();
93
- } catch {
94
- return null;
95
- }
96
- };
97
- let memBytes = totalmem();
98
- const memMax = readCgroup('memory.max');
99
- if (memMax && memMax !== 'max') {
100
- const n = Number(memMax);
101
- if (Number.isFinite(n) && n > 0) memBytes = Math.min(memBytes, n);
102
- }
103
- let cores = cpus().length || 2;
104
- // "<quota> <period>" in microseconds, or "max <period>" for unlimited.
105
- const cpuMax = readCgroup('cpu.max');
106
- if (cpuMax && !cpuMax.startsWith('max')) {
107
- const [q, p] = cpuMax.split(/\s+/).map(Number);
108
- if (Number.isFinite(q) && Number.isFinite(p) && p > 0) {
109
- cores = Math.max(1, Math.min(cores, Math.floor(q / p)));
110
- }
111
- }
112
- return { memBytes, cores };
113
- }
114
-
115
- export const MACHINE = machineLimits();
81
+ // What this machine can actually be given — the cgroup's limits before the
82
+ // host's. The rule and its reader live in machineLimits.mjs; this is the
83
+ // measured result every importer of `MACHINE` has always read.
84
+ export const MACHINE = measureMachineLimits();
116
85
 
117
86
  /**
118
87
  * How many tasks THIS MACHINE will build at once.
@@ -167,7 +136,7 @@ export const MACHINE = machineLimits();
167
136
  * behind it. Most do not — an agent turn is a CLI that reads, edits and
168
137
  * commits, and it is API-bound for nearly all of its life. 1GB per turn with
169
138
  * the same 2GB held back for the operating system is the honest sizing, and
170
- * the PRESSURE guard (resources.mjs) is what catches the turn that really
139
+ * the PRESSURE guard (pressurePolicy.mjs) is what catches the turn that really
171
140
  * does hold gigabytes: it measures at the moment of spawning, which is a
172
141
  * thing a static divisor cannot do.
173
142
  * · `cores − 1` is the laptop assumption this comment already claims to have
@@ -290,7 +259,7 @@ export const PROCESS_STARTED_AT = new Date(
290
259
  // --tokens) stood beside it and carried WORKER tokens into the pre-daemon loop;
291
260
  // that principal owns zero tools since dispatch was deleted, and the kind can no
292
261
  // longer be minted, so the plumbing went with the entrypoint (2026-08-19).
293
- // `let`, because ES named imports are LIVE bindings: when cli.mjs answers an
262
+ // `let`, because ES named imports are LIVE bindings: when startProject.mjs answers an
294
263
  // ambiguous store with a picker, adoptStoredCredential updates every importer
295
264
  // before the daemon touches the network. Everything before that point (the
296
265
  // resolution, the flags) is settled synchronously at import, as it always was.
@@ -325,7 +294,7 @@ export let FLEET_TOKEN =
325
294
  * hashes would render as a confident "these boxes differ" beside a box holding
326
295
  * the identical value. Ignorance renders nothing.
327
296
  *
328
- * `let`, for the same reason `FLEET_TOKEN` is: when cli.mjs answers an
297
+ * `let`, for the same reason `FLEET_TOKEN` is: when startProject.mjs answers an
329
298
  * ambiguous store with a picker, `adoptStoredCredential` is what settles both,
330
299
  * and ES named imports are LIVE bindings so every importer sees the answer.
331
300
  * A frozen copy read at import would salt a picked project's report with null
@@ -356,7 +325,7 @@ export function storedCredentialInUse() {
356
325
  return Boolean(CREDENTIAL.entry?.fleetToken) && CREDENTIAL.entry.fleetToken === FLEET_TOKEN;
357
326
  }
358
327
 
359
- /** cli.mjs's picker chose. Must run BEFORE runFleetDaemon — nothing here
328
+ /** startProject.mjs's picker chose. Must run BEFORE runFleetDaemon — nothing here
360
329
  * re-authenticates a connection already made. */
361
330
  export function adoptStoredCredential(entry) {
362
331
  if (entry?.fleetToken) FLEET_TOKEN = entry.fleetToken;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * WHEN A CREDENTIAL WAS STORED, as a date a person can match against their own
3
+ * memory of setting it up ("I connected that one last month") — `Sep 16`.
4
+ *
5
+ * One home because three terminal sites spelled it independently (SOLID audit
6
+ * 2026-09-26, F168): the collision suffix in `credentials.mjs`
7
+ * (`projectRowLabel`), the `machines` listing, and `flowviant projects` in
8
+ * cli.mjs — each with its own copy of the en-US short-month format and its own
9
+ * decision about when the clause is dropped. The decision is the part that
10
+ * matters and is stated once here: ABSENT OR UNPARSEABLE STAYS ABSENT (null).
11
+ * A credential stored before `savedAt` existed has no date, and inventing one
12
+ * would be the listing asserting a day nobody recorded.
13
+ *
14
+ * Each caller places the returned date in its own sentence; only the date is
15
+ * shared. A leaf module on purpose: `credentials.mjs` imports it, so it must
16
+ * import nothing back (a cycle through the credential store).
17
+ */
18
+ export function connectedOn(iso) {
19
+ const t = Date.parse(iso ?? '');
20
+ if (Number.isNaN(t)) return null;
21
+ return new Date(t).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
22
+ }
@@ -33,7 +33,7 @@
33
33
  * binds it (headless keeps the old behaviour —
34
34
  * a systemd restart must not hang on a prompt).
35
35
  * anything else a CHOICE, never a guess: the CLI lists every
36
- * stored project and asks (cli.mjs), or — with
36
+ * stored project and asks (startProject.mjs), or — with
37
37
  * no TTY — refuses in words that name them all.
38
38
  *
39
39
  * The repoRoot binding is a SAFETY line, not bookkeeping: serving project X
@@ -49,7 +49,9 @@ import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from
49
49
  import { homedir } from 'node:os';
50
50
  import { join } from 'node:path';
51
51
  import { printable } from './printable.mjs';
52
+ import { connectedOn } from './credentialDate.mjs';
52
53
  import { terminalCommand } from './launchCommand.mjs';
54
+ import { samePath } from './repoPath.mjs';
53
55
 
54
56
  const CRED_DIR = join(homedir(), '.flowviant');
55
57
  const CRED_FILE = join(CRED_DIR, 'credentials.json');
@@ -86,19 +88,6 @@ export function detectRepoRoot(cwd = process.cwd()) {
86
88
  }
87
89
  }
88
90
 
89
- /** Same directory whatever it is spelled as — the instance.mjs rule. */
90
- function samePath(a, b) {
91
- if (!a || !b) return false;
92
- const norm = (v) => {
93
- try {
94
- return realpathSync(v);
95
- } catch {
96
- return String(v).replace(/\/+$/, '');
97
- }
98
- };
99
- return norm(a) === norm(b);
100
- }
101
-
102
91
  /**
103
92
  * Every stored project, NORMALIZED: the v2 map, plus the legacy trio surfaced
104
93
  * as an entry when no map row carries its token (a pre-0.55.0 file is one
@@ -187,11 +176,8 @@ export function projectRowLabel(e, entries) {
187
176
  Array.isArray(entries) &&
188
177
  entries.filter((x) => x !== e && projectLabel(x) === label).length > 0;
189
178
  if (!collides) return label;
190
- const d = e?.savedAt ? new Date(e.savedAt) : null;
191
- const when =
192
- d && !Number.isNaN(d.getTime())
193
- ? `, connected ${d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`
194
- : '';
179
+ const connected = connectedOn(e?.savedAt);
180
+ const when = connected ? `, connected ${connected}` : '';
195
181
  return `${label} (id ${String(e?.projectId ?? '').slice(0, 8)}…${when})`;
196
182
  }
197
183
 
@@ -363,6 +349,19 @@ export function selectStoredProject(projectId, { bindRepoRoot } = {}) {
363
349
  });
364
350
  }
365
351
 
352
+ /**
353
+ * WHICH STORED PROJECTS AN ID NAMES — the full project id, or a prefix of at
354
+ * least six characters. The one spelling of the boundary for both the lookup
355
+ * (`matchStoredProject`, which falls back to a name when no id matches) and
356
+ * the destructive forget (`forgetStoredProject`, which never matches a name):
357
+ * two copies of a rule that decides which credential gets DELETED is a second
358
+ * place for the boundary to drift (SOLID audit 2026-09-26, F169). Returns every
359
+ * hit — each caller refuses more than one in its own words.
360
+ */
361
+ function storedProjectsById(q, entries) {
362
+ return (entries ?? []).filter((e) => e.projectId === q || (q.length >= 6 && e.projectId.startsWith(q)));
363
+ }
364
+
366
365
  /**
367
366
  * FORGET A STORED CREDENTIAL — `flowviant machines --forget <id>` (2026-09-19).
368
367
  *
@@ -386,9 +385,7 @@ export function selectStoredProject(projectId, { bindRepoRoot } = {}) {
386
385
  export function forgetStoredProject(ref) {
387
386
  const q = String(ref ?? '').trim();
388
387
  if (!q) return { error: 'empty --forget value' };
389
- const hits = listStoredProjects().filter(
390
- (e) => e.projectId === q || (q.length >= 6 && e.projectId.startsWith(q))
391
- );
388
+ const hits = storedProjectsById(q, listStoredProjects());
392
389
  if (hits.length === 0) return { error: `no stored project matches "${q}"` };
393
390
  if (hits.length > 1) {
394
391
  return { error: `"${q}" matches ${hits.length} stored projects — use the full project id` };
@@ -431,9 +428,7 @@ export function matchStoredProject(ref) {
431
428
  const entries = listStoredProjects();
432
429
  const q = String(ref ?? '').trim();
433
430
  if (!q) return { error: 'empty --project value' };
434
- const byId = entries.filter(
435
- (e) => e.projectId === q || (q.length >= 6 && e.projectId.startsWith(q))
436
- );
431
+ const byId = storedProjectsById(q, entries);
437
432
  const byName = entries.filter((e) => e.name && e.name.toLowerCase() === q.toLowerCase());
438
433
  const hits = byId.length > 0 ? byId : byName;
439
434
  if (hits.length === 1) return { entry: hits[0] };
@@ -447,7 +442,7 @@ export function matchStoredProject(ref) {
447
442
  * this returns. Shapes:
448
443
  * { entry, source: 'project-flag' | 'repo' | 'only', needsConfirm? }
449
444
  * { choices, reason: 'no-match' | 'multiple-bound' | 'outside-repo', repoRoot }
450
- * (`multiple-bound` is a REFUSAL in cli.mjs since 2026-09-23 — a directory
445
+ * (`multiple-bound` is a REFUSAL on the start path (startProject.mjs) since 2026-09-23 — a directory
451
446
  * serves one project — and never a picker; the shape is kept because it
452
447
  * names the projects the refusal has to print)
453
448
  * { none: true }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * THE DAEMON'S OUTPUT: bounded per-project logs and the desktop JSONL events
3
+ * (2026-09-26, SOLID F061).
4
+ *
5
+ * Split out of desktopContract.mjs, which also built the status readout — so a
6
+ * change to how the daemon writes its output (console redirection, log
7
+ * rotation, the JSON event stream) and a change to the status wire edited one
8
+ * module. This one owns output only; it is handed the log path and never
9
+ * decides where per-project files live (daemonState.mjs does).
10
+ */
11
+ import { appendFileSync, mkdirSync, renameSync, statSync } from 'node:fs';
12
+ import { dirname } from 'node:path';
13
+ import { format } from 'node:util';
14
+
15
+ export const MAX_DAEMON_LOG_BYTES = 2 * 1024 * 1024;
16
+
17
+ let events = false;
18
+ let eventWriter = process.stdout.write.bind(process.stdout);
19
+ export function enableMachineEvents(write = eventWriter) { events = true; eventWriter = write; }
20
+ export function emitMachineEvent(value) {
21
+ if (events) eventWriter(`${JSON.stringify(value)}\n`);
22
+ }
23
+
24
+ /** All human output stays on its existing stream in terminal mode. In desktop
25
+ * mode stdout is reserved for JSONL and those same lines go to stderr. Every
26
+ * line is also appended to `logPath`, rotated at MAX_DAEMON_LOG_BYTES. */
27
+ export function installDaemonLogging(logPath, { jsonEvents = false } = {}) {
28
+ if (!logPath) return;
29
+ if (jsonEvents) {
30
+ enableMachineEvents();
31
+ process.stdout.write = (...args) => process.stderr.write(...args);
32
+ }
33
+ const original = { log: console.log, info: console.info, warn: console.warn, error: console.error };
34
+ for (const method of Object.keys(original)) {
35
+ console[method] = (...args) => {
36
+ const line = format(...args);
37
+ try {
38
+ mkdirSync(dirname(logPath), { recursive: true });
39
+ const encoded = Buffer.from(`${line}\n`);
40
+ const tail = Buffer.from('\n[truncated]\n');
41
+ const chunk = encoded.length > MAX_DAEMON_LOG_BYTES
42
+ ? Buffer.concat([encoded.subarray(0, MAX_DAEMON_LOG_BYTES - tail.length), tail])
43
+ : encoded;
44
+ let size = 0;
45
+ try { size = statSync(logPath).size; } catch { /* new file */ }
46
+ if (size + chunk.length > MAX_DAEMON_LOG_BYTES) {
47
+ try { renameSync(logPath, `${logPath}.1`); } catch { /* no old log */ }
48
+ }
49
+ appendFileSync(logPath, chunk, { mode: 0o600 });
50
+ } catch { /* logging must never stop service */ }
51
+ if (jsonEvents) process.stderr.write(`${line}\n`);
52
+ else original[method](...args);
53
+ };
54
+ }
55
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * WHERE A PROJECT'S DAEMON KEEPS ITS LOCAL READOUT, AND WHAT IT WRITES THERE
3
+ * (2026-09-26, SOLID F061).
4
+ *
5
+ * The per-project state file (holder, last poll, CLI limits) and log file
6
+ * under ~/.flowviant, named by a hash of the project id. Split out of
7
+ * desktopContract.mjs so the status readout only READS this, and so the
8
+ * daemon's writers (fleet.mjs, workAgentTurnExecution.mjs) do not import the status
9
+ * builder. A limit change is announced as a machine event (daemonLogging.mjs).
10
+ */
11
+ import { createHash } from 'node:crypto';
12
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
13
+ import { homedir } from 'node:os';
14
+ import { join } from 'node:path';
15
+ import { emitMachineEvent } from './daemonLogging.mjs';
16
+ import { instanceLockPath } from './instance.mjs';
17
+
18
+ const store = () => join(homedir(), '.flowviant');
19
+ const key = (projectId) => createHash('sha256').update(String(projectId)).digest('hex').slice(0, 16);
20
+ export const daemonLogPath = (projectId) => join(store(), `daemon-${key(projectId)}.log`);
21
+ export const daemonStatePath = (projectId) => join(store(), `daemon-${key(projectId)}.state.json`);
22
+
23
+ export function readDaemonState(projectId) {
24
+ try {
25
+ const value = JSON.parse(readFileSync(daemonStatePath(projectId), 'utf8'));
26
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
27
+ } catch { return {}; }
28
+ }
29
+
30
+ export function writeDaemonState(projectId, change) {
31
+ if (!projectId) return;
32
+ try {
33
+ mkdirSync(store(), { recursive: true });
34
+ const path = daemonStatePath(projectId);
35
+ const current = readDaemonState(projectId);
36
+ const next = { ...current, ...change, pid: process.pid };
37
+ const tmp = `${path}.${process.pid}.tmp`;
38
+ writeFileSync(tmp, JSON.stringify(next), { mode: 0o600 });
39
+ renameSync(tmp, path);
40
+ } catch { /* a local readout must never interrupt a daemon */ }
41
+ }
42
+
43
+ export function setRuntimeLimit(projectId, runtime, words) {
44
+ if (!projectId || !runtime) return false;
45
+ const state = readDaemonState(projectId);
46
+ const limits = { ...(state.limits ?? {}) };
47
+ if (words == null) {
48
+ if (!Object.hasOwn(limits, runtime)) return false;
49
+ delete limits[runtime];
50
+ } else {
51
+ if (limits[runtime] === words) return false;
52
+ limits[runtime] = words;
53
+ }
54
+ writeDaemonState(projectId, { limits });
55
+ emitMachineEvent({ event: words == null ? 'limit-cleared' : 'limit-hit', runtime, ...(words == null ? {} : { message: words }) });
56
+ return true;
57
+ }
58
+
59
+ /** The pid in the instance lock for this credential, or null. */
60
+ export function daemonLockPid(token) {
61
+ try {
62
+ return JSON.parse(readFileSync(instanceLockPath(token), 'utf8')).pid;
63
+ } catch { return null; }
64
+ }