@hassangameryt/freeflow 0.4.0 → 0.5.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.
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import crypto from "node:crypto";
3
4
  import fs from "node:fs";
4
5
  import path from "node:path";
5
6
  import process from "node:process";
@@ -8,6 +9,9 @@ import { fileURLToPath } from "node:url";
8
9
  const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
9
10
  const PLUGIN_ROOT = path.resolve(SCRIPT_DIR, "..");
10
11
  const VALID_MODES = new Set(["conversation", "workflow", "strict-workflow"]);
12
+ const SESSION_STATE_VERSION = 1;
13
+ const SESSION_STATE_MAX_AGE_MS = 90 * 24 * 60 * 60 * 1000;
14
+ const CLEAR_TRANSFER_MAX_AGE_MS = 60 * 1000;
11
15
 
12
16
  function readStdinJson() {
13
17
  const raw = fs.readFileSync(0, "utf8").trim();
@@ -49,24 +53,16 @@ function findWorkspaceRoot(cwd) {
49
53
  function loadRuntimeContext(options = {}) {
50
54
  const includeInteractionContract = options.interactionContract === true;
51
55
  const includeSkills = options.skills === true;
52
- const includeOutputRouter = options.outputRouter === true;
53
56
  const interactionContract = includeInteractionContract
54
57
  ? readText(path.join(PLUGIN_ROOT, "runtime", "interaction-contract.md"))
55
58
  : null;
56
59
  const workflowSkill = includeSkills ? readText(path.join(PLUGIN_ROOT, "skills", "workflow", "SKILL.md")) : null;
57
- const outputRouterSkill = includeOutputRouter
58
- ? readText(path.join(PLUGIN_ROOT, "skills", "output-router", "SKILL.md"))
59
- : null;
60
60
 
61
- if (
62
- (includeInteractionContract && !interactionContract) ||
63
- (includeSkills && !workflowSkill) ||
64
- (includeOutputRouter && !outputRouterSkill)
65
- ) {
61
+ if ((includeInteractionContract && !interactionContract) || (includeSkills && !workflowSkill)) {
66
62
  throw new Error("Freeflow runtime context files are missing.");
67
63
  }
68
64
 
69
- return { interactionContract, workflowSkill, outputRouterSkill };
65
+ return { interactionContract, workflowSkill };
70
66
  }
71
67
 
72
68
  function readConfig(root) {
@@ -76,7 +72,6 @@ function readConfig(root) {
76
72
  const configured = repository.valid && localValid;
77
73
  const core = resolveCoreConfig(repository.valid ? repository.parsed : {}, local.valid ? local.parsed : {});
78
74
  const enabled = configured && core.config.enabled;
79
- const repositoryConfig = repository.valid ? repository.parsed : {};
80
75
 
81
76
  return {
82
77
  exists: repository.exists,
@@ -91,7 +86,6 @@ function readConfig(root) {
91
86
  skillsEnabled: enabled && core.config.skills.enabled,
92
87
  defaultMode: core.config.defaultMode,
93
88
  sources: core.sources,
94
- outputRouterEnabled: enabled && repositoryConfig?.outputRouter?.enabled === true,
95
89
  };
96
90
  }
97
91
 
@@ -126,23 +120,20 @@ function isRecord(value) {
126
120
  }
127
121
 
128
122
  function validateCoreConfigFields(value) {
129
- if (Object.prototype.hasOwnProperty.call(value, "defaultMode") && !VALID_MODES.has(value.defaultMode)) {
123
+ if (Object.hasOwn(value, "defaultMode") && !VALID_MODES.has(value.defaultMode)) {
130
124
  return "defaultMode must be conversation, workflow, or strict-workflow";
131
125
  }
132
- if (Object.prototype.hasOwnProperty.call(value, "enabled") && typeof value.enabled !== "boolean") {
126
+ if (Object.hasOwn(value, "enabled") && typeof value.enabled !== "boolean") {
133
127
  return "enabled must be a boolean";
134
128
  }
135
- if (
136
- Object.prototype.hasOwnProperty.call(value, "interactionContract") &&
137
- typeof value.interactionContract !== "boolean"
138
- ) {
129
+ if (Object.hasOwn(value, "interactionContract") && typeof value.interactionContract !== "boolean") {
139
130
  return "interactionContract must be a boolean";
140
131
  }
141
- if (Object.prototype.hasOwnProperty.call(value, "skills")) {
132
+ if (Object.hasOwn(value, "skills")) {
142
133
  if (!isRecord(value.skills)) {
143
134
  return "skills must be an object";
144
135
  }
145
- if (Object.prototype.hasOwnProperty.call(value.skills, "enabled") && typeof value.skills.enabled !== "boolean") {
136
+ if (Object.hasOwn(value.skills, "enabled") && typeof value.skills.enabled !== "boolean") {
146
137
  return "skills.enabled must be a boolean";
147
138
  }
148
139
  }
@@ -173,7 +164,7 @@ function isValidSetupConfig(value) {
173
164
  }
174
165
 
175
166
  for (const key of ["outputRouter", "observedRouting", "scriptTransform"]) {
176
- if (Object.prototype.hasOwnProperty.call(value, key) && !isRecord(value[key])) {
167
+ if (Object.hasOwn(value, key) && !isRecord(value[key])) {
177
168
  return `${key} must be an object`;
178
169
  }
179
170
  }
@@ -193,7 +184,7 @@ function isValidLocalConfig(value) {
193
184
  }
194
185
 
195
186
  function hasOwn(value, key) {
196
- return Object.prototype.hasOwnProperty.call(value, key);
187
+ return Object.hasOwn(value, key);
197
188
  }
198
189
 
199
190
  function resolveLayeredValue(repository, local, key, fallback) {
@@ -230,17 +221,246 @@ function resolveCoreConfig(repository, local) {
230
221
  };
231
222
  }
232
223
 
233
- function inspectSetup(root) {
234
- return { config: readConfig(root) };
224
+ function hostName() {
225
+ return process.env.PLUGIN_DATA ? "codex" : "claude";
235
226
  }
236
227
 
237
- function configStatus(config) {
238
- if (!config.valid) {
239
- return "invalid layered configuration";
228
+ function pluginDataRoot() {
229
+ const configured = process.env.PLUGIN_DATA || process.env.CLAUDE_PLUGIN_DATA;
230
+ return configured ? path.resolve(configured) : null;
231
+ }
232
+
233
+ function sessionStatePath(sessionId) {
234
+ const dataRoot = pluginDataRoot();
235
+ if (!dataRoot || typeof sessionId !== "string" || sessionId.length === 0) {
236
+ return null;
237
+ }
238
+
239
+ const key = crypto.createHash("sha256").update(`${hostName()}\0${sessionId}`).digest("hex");
240
+ return path.join(dataRoot, "session-modes", hostName(), `${key}.json`);
241
+ }
242
+
243
+ function workspaceKey(root) {
244
+ return crypto.createHash("sha256").update(path.resolve(root)).digest("hex");
245
+ }
246
+
247
+ function claudeProcessKey() {
248
+ const processId = process.env.CLAUDE_PID;
249
+ if (typeof processId !== "string" || processId.length === 0) {
250
+ return null;
251
+ }
252
+ return crypto.createHash("sha256").update(processId).digest("hex");
253
+ }
254
+
255
+ function clearTransferPath(root) {
256
+ const dataRoot = pluginDataRoot();
257
+ const processKey = claudeProcessKey();
258
+ if (!dataRoot || hostName() !== "claude" || !processKey) {
259
+ return null;
260
+ }
261
+ return path.join(dataRoot, "session-modes", "claude-clear", `${workspaceKey(root)}-${processKey}.json`);
262
+ }
263
+
264
+ function writeJsonAtomic(filePath, value) {
265
+ const directory = path.dirname(filePath);
266
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
267
+ const temporaryPath = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
268
+ try {
269
+ fs.writeFileSync(temporaryPath, `${JSON.stringify(value)}\n`, { encoding: "utf8", mode: 0o600 });
270
+ fs.renameSync(temporaryPath, filePath);
271
+ } catch (error) {
272
+ try {
273
+ fs.unlinkSync(temporaryPath);
274
+ } catch {
275
+ // Best-effort cleanup of a failed atomic write.
276
+ }
277
+ throw error;
278
+ }
279
+ }
280
+
281
+ function readSessionMode(sessionId) {
282
+ const filePath = sessionStatePath(sessionId);
283
+ if (!filePath) {
284
+ return null;
285
+ }
286
+
287
+ try {
288
+ const state = JSON.parse(fs.readFileSync(filePath, "utf8"));
289
+ if (state.version !== SESSION_STATE_VERSION || !VALID_MODES.has(state.mode)) {
290
+ return null;
291
+ }
292
+ return state.mode;
293
+ } catch {
294
+ return null;
295
+ }
296
+ }
297
+
298
+ function writeSessionMode(sessionId, mode) {
299
+ const filePath = sessionStatePath(sessionId);
300
+ if (!filePath) {
301
+ return false;
302
+ }
303
+
304
+ writeJsonAtomic(filePath, {
305
+ version: SESSION_STATE_VERSION,
306
+ mode,
307
+ updatedAt: new Date().toISOString(),
308
+ });
309
+ return true;
310
+ }
311
+
312
+ function clearSessionMode(sessionId) {
313
+ const filePath = sessionStatePath(sessionId);
314
+ if (!filePath) {
315
+ return false;
316
+ }
317
+
318
+ try {
319
+ fs.unlinkSync(filePath);
320
+ } catch (error) {
321
+ if (error?.code !== "ENOENT") {
322
+ throw error;
323
+ }
324
+ }
325
+ return true;
326
+ }
327
+
328
+ function stageClearTransfer(input) {
329
+ if (hostName() !== "claude" || input.reason !== "clear") {
330
+ return;
331
+ }
332
+
333
+ const root = findWorkspaceRoot(input.cwd || process.cwd());
334
+ const mode = readSessionMode(input.session_id);
335
+ const filePath = clearTransferPath(root);
336
+ if (!filePath) {
337
+ return;
338
+ }
339
+
340
+ if (!mode) {
341
+ try {
342
+ fs.unlinkSync(filePath);
343
+ } catch (error) {
344
+ if (error?.code !== "ENOENT") {
345
+ throw error;
346
+ }
347
+ }
348
+ return;
349
+ }
350
+
351
+ writeJsonAtomic(filePath, {
352
+ version: SESSION_STATE_VERSION,
353
+ mode,
354
+ sourceSessionKey: crypto.createHash("sha256").update(String(input.session_id)).digest("hex"),
355
+ createdAt: new Date().toISOString(),
356
+ });
357
+ }
358
+
359
+ function consumeClearTransfer(input, root) {
360
+ if (hostName() !== "claude" || input.source !== "clear") {
361
+ return null;
362
+ }
363
+
364
+ const filePath = clearTransferPath(root);
365
+ if (!filePath) {
366
+ return null;
367
+ }
368
+
369
+ const claimedPath = `${filePath}.${process.pid}.${crypto.randomUUID()}.claimed`;
370
+ try {
371
+ fs.renameSync(filePath, claimedPath);
372
+ } catch (error) {
373
+ if (error?.code === "ENOENT") {
374
+ return null;
375
+ }
376
+ return null;
377
+ }
378
+
379
+ let state;
380
+ try {
381
+ state = JSON.parse(fs.readFileSync(claimedPath, "utf8"));
382
+ } catch {
383
+ return null;
384
+ } finally {
385
+ try {
386
+ fs.unlinkSync(claimedPath);
387
+ } catch {
388
+ // The atomic claim already prevents reuse; cleanup is best-effort.
389
+ }
390
+ }
391
+
392
+ if (!isRecord(state)) {
393
+ return null;
240
394
  }
241
- const source = config.sources.defaultMode;
242
- const personal = config.localExists ? "; personal overrides present" : "";
243
- return `defaultMode \`${config.defaultMode}\` from ${source}${personal}`;
395
+ const createdAt = Date.parse(state.createdAt);
396
+ if (
397
+ state.version !== SESSION_STATE_VERSION ||
398
+ !VALID_MODES.has(state.mode) ||
399
+ !Number.isFinite(createdAt) ||
400
+ Date.now() - createdAt > CLEAR_TRANSFER_MAX_AGE_MS ||
401
+ Date.now() < createdAt
402
+ ) {
403
+ return null;
404
+ }
405
+
406
+ return writeSessionMode(input.session_id, state.mode) ? state.mode : null;
407
+ }
408
+
409
+ function cleanupExpiredSessionModes() {
410
+ const root = pluginDataRoot();
411
+ if (!root) {
412
+ return;
413
+ }
414
+
415
+ const stateRoot = path.join(root, "session-modes");
416
+ const cutoff = Date.now() - SESSION_STATE_MAX_AGE_MS;
417
+ for (const host of ["claude", "codex", "claude-clear"]) {
418
+ const directory = path.join(stateRoot, host);
419
+ let entries = [];
420
+ try {
421
+ entries = fs.readdirSync(directory, { withFileTypes: true });
422
+ } catch {
423
+ continue;
424
+ }
425
+
426
+ for (const entry of entries) {
427
+ if (!entry.isFile() || !entry.name.endsWith(".json")) {
428
+ continue;
429
+ }
430
+ const filePath = path.join(directory, entry.name);
431
+ try {
432
+ if (fs.statSync(filePath).mtimeMs < cutoff) {
433
+ fs.unlinkSync(filePath);
434
+ }
435
+ } catch {
436
+ // Cleanup is best-effort and must not prevent runtime delivery.
437
+ }
438
+ }
439
+ }
440
+ }
441
+
442
+ function sourceLabel(source) {
443
+ if (source === "local") {
444
+ return "personal";
445
+ }
446
+ return source;
447
+ }
448
+
449
+ function resolveRuntimeState(input) {
450
+ const root = findWorkspaceRoot(input.cwd || process.cwd());
451
+ const config = readConfig(root);
452
+ const restoredMode = config.valid && config.enabled ? consumeClearTransfer(input, root) : null;
453
+ const sessionMode = config.valid && config.enabled ? restoredMode || readSessionMode(input.session_id) : null;
454
+ const resolvedMode = sessionMode || config.defaultMode;
455
+
456
+ return {
457
+ root,
458
+ config,
459
+ sessionMode,
460
+ resolvedMode,
461
+ effectiveMode: config.enabled && config.skillsEnabled ? resolvedMode : null,
462
+ modeSource: sessionMode ? "session override" : "configured default",
463
+ };
244
464
  }
245
465
 
246
466
  function modeOverlayGuidance(mode) {
@@ -278,111 +498,179 @@ function modeOverlayGuidance(mode) {
278
498
  return [];
279
499
  }
280
500
 
281
- function modeGuidance(mode) {
501
+ function stateLines(state, sessionChange = null) {
502
+ const { config } = state;
503
+ const lines = [`Configured default: \`${config.defaultMode}\` (${sourceLabel(config.sources.defaultMode)})`];
504
+
505
+ if (sessionChange === "cleared") {
506
+ lines.push("Session override: cleared");
507
+ } else if (state.sessionMode) {
508
+ lines.push(`Session override: \`${state.sessionMode}\``);
509
+ } else {
510
+ lines.push("Session override: none");
511
+ }
512
+
513
+ if (state.effectiveMode) {
514
+ lines.push(`Effective mode: \`${state.effectiveMode}\` (${state.modeSource}, active)`);
515
+ } else {
516
+ lines.push(`Resolved mode: \`${state.resolvedMode}\` (dormant because Skills are disabled)`);
517
+ }
518
+
519
+ return lines;
520
+ }
521
+
522
+ function capabilityLines(config) {
282
523
  return [
283
- `Current Freeflow default mode: \`${mode}\`.`,
284
- "Treat this as the resolved default at session start, resume, clear, and compact.",
285
- "Mode behavior:",
286
- "- `conversation`: answer, discuss, critique, and inspect read-only; switch modes before mutating state.",
287
- "- `workflow`: use the adaptive workflow for normal consequential work.",
288
- "- `strict-workflow`: strengthen user-owned decisions, review, and verification for high-risk or hard-to-reverse work.",
289
- "Do not announce the current mode on every reply. Mention it when the user asks, setup/config is discussed, or the mode changes the next action.",
290
- ...modeOverlayGuidance(mode),
524
+ `Interaction Contract: ${config.interactionContractEnabled ? "enabled" : "disabled"}`,
525
+ `Skills: ${config.skillsEnabled ? "enabled" : "disabled"}`,
291
526
  ];
292
527
  }
293
528
 
294
- function buildSetupStatus(root) {
295
- const { config } = inspectSetup(root);
529
+ function renderSessionStart(state) {
530
+ const { config } = state;
296
531
  if (!config.valid) {
297
- return "Setup status: Freeflow is not configured for this repo.";
532
+ return "";
298
533
  }
299
534
 
300
- const modeStatus = config.skillsEnabled
301
- ? modeGuidance(config.defaultMode)
302
- : [`Resolved default mode: \`${config.defaultMode}\` (dormant because Skills are disabled).`];
303
- return [
304
- `Setup status: configured by \`.freeflow/config.json\` with ${configStatus(config)}.`,
535
+ if (!config.enabled) {
536
+ return [
537
+ "# Freeflow Disabled",
538
+ "",
539
+ `Configured but inactive: \`enabled\` is false (${sourceLabel(config.sources.enabled)}).`,
540
+ "No Freeflow interaction, skill, or mode guidance is effective.",
541
+ ].join("\n");
542
+ }
543
+
544
+ const { interactionContract, workflowSkill } = loadRuntimeContext({
545
+ interactionContract: config.interactionContractEnabled,
546
+ skills: config.skillsEnabled,
547
+ });
548
+ const context = [
549
+ "# Freeflow Runtime Context",
550
+ "",
305
551
  "Runtime delivery: confirmed for this lifecycle-hook invocation.",
306
- ...modeStatus,
307
- ].join("\n");
552
+ "This context guides behavior only; user instructions, repository instructions, and host safety or approval policy retain precedence.",
553
+ "",
554
+ "## Effective State",
555
+ "",
556
+ ...stateLines(state),
557
+ ...capabilityLines(config),
558
+ ];
559
+
560
+ if (config.interactionContractEnabled) {
561
+ context.push("", interactionContract.trim());
562
+ }
563
+ if (config.skillsEnabled) {
564
+ context.push("", "# Freeflow Workflow Bootstrap", "", workflowSkill.trim());
565
+ }
566
+ if (state.effectiveMode) {
567
+ context.push(...modeOverlayGuidance(state.effectiveMode));
568
+ }
569
+
570
+ return context.join("\n");
308
571
  }
309
572
 
310
- function shouldInject(eventName) {
311
- if (process.env.FREEFLOW_DISABLE_RUNTIME_CONTEXT === "1") {
312
- return false;
573
+ function normalizePrompt(prompt) {
574
+ return prompt
575
+ .trim()
576
+ .replace(/[.!]+$/u, "")
577
+ .replace(/\s+/gu, " ");
578
+ }
579
+
580
+ function parseModeControl(prompt) {
581
+ if (typeof prompt !== "string" || prompt.length > 300 || /\?\s*$/u.test(prompt.trim())) {
582
+ return null;
583
+ }
584
+
585
+ const normalized = normalizePrompt(prompt);
586
+ const nativeSet = normalized.match(
587
+ /^(?:\/freeflow mode|\$mode-contract|\/(?:freeflow:)?mode-contract) (conversation|workflow|strict-workflow)$/iu,
588
+ );
589
+ if (nativeSet) {
590
+ return { action: "set", mode: nativeSet[1].toLowerCase() };
591
+ }
592
+ if (/^(?:\/freeflow mode|\$mode-contract|\/(?:freeflow:)?mode-contract) reset$/iu.test(normalized)) {
593
+ return { action: "reset" };
313
594
  }
314
595
 
315
- return eventName === "SessionStart";
596
+ const setPatterns = [
597
+ /^switch to (conversation|workflow|strict-workflow)(?: mode)?$/iu,
598
+ /^switch (?:this|the) session to (conversation|workflow|strict-workflow)(?: mode)?$/iu,
599
+ /^use (conversation|workflow|strict-workflow)(?: mode)? for (?:this|the) session$/iu,
600
+ /^(?:change|set) (?:the )?(?:current|active|session) mode to (conversation|workflow|strict-workflow)$/iu,
601
+ /^switch (?:the )?(?:current|active|session) mode to (conversation|workflow|strict-workflow)$/iu,
602
+ ];
603
+ for (const pattern of setPatterns) {
604
+ const match = normalized.match(pattern);
605
+ if (match) {
606
+ return { action: "set", mode: match[1].toLowerCase() };
607
+ }
608
+ }
609
+
610
+ if (/^(?:reset|clear|remove) (?:the )?(?:current |active )?session mode(?: override)?$/iu.test(normalized)) {
611
+ return { action: "reset" };
612
+ }
613
+
614
+ return null;
316
615
  }
317
616
 
318
- function capabilityStatus(config) {
319
- return [
320
- "## Freeflow Capabilities",
617
+ function renderPromptModeChange(state, sessionChange) {
618
+ const lines = [
619
+ "# Freeflow Mode Update",
321
620
  "",
322
- `- Interaction Contract: ${config.interactionContractEnabled ? "enabled" : "disabled"}.`,
323
- `- Skills: ${config.skillsEnabled ? "enabled" : "disabled"}.`,
324
- `- Output router: ${config.outputRouterEnabled ? "enabled" : "disabled"}.`,
621
+ ...stateLines(state, sessionChange),
325
622
  "",
326
- "Capability-specific instructions are active only while that capability is enabled.",
623
+ "This user-operated session control changed host-managed state before this model request. It changes workflow behavior, not mutation authority, host permissions, or accepted scope.",
624
+ "Acknowledge the effective mode briefly. Do not claim that configured defaults changed.",
327
625
  ];
626
+ if (state.effectiveMode) {
627
+ lines.push(...modeOverlayGuidance(state.effectiveMode));
628
+ }
629
+ return lines.join("\n");
328
630
  }
329
631
 
330
- function buildContext(input) {
331
- const root = findWorkspaceRoot(input.cwd || process.cwd());
332
- const setup = inspectSetup(root);
333
-
334
- if (!setup.config.valid) {
632
+ function handleUserPromptSubmit(input) {
633
+ const control = parseModeControl(input.prompt);
634
+ if (!control) {
335
635
  return "";
336
636
  }
337
637
 
338
- if (!setup.config.enabled) {
638
+ const before = resolveRuntimeState(input);
639
+ if (!before.config.valid || !before.config.enabled) {
640
+ return "";
641
+ }
642
+ if (!before.config.skillsEnabled) {
339
643
  return [
340
- "# Freeflow Disabled",
644
+ "# Freeflow Mode Update Unavailable",
341
645
  "",
342
- `Freeflow is configured for this repo, but effective \`enabled\` is false from ${setup.config.sources.enabled}.`,
343
- "Do not inject the Interaction Contract, Freeflow workflow skills, output routing, or setup pressure while disabled.",
344
- "Re-enable only if the user asks; use /freeflow settings for a personal override or /freeflow settings repo for the shared repository default in Pi.",
646
+ `Resolved mode remains \`${before.resolvedMode}\`, but no Freeflow mode is effective because Skills are disabled.`,
647
+ "Do not claim that the session mode changed.",
345
648
  ].join("\n");
346
649
  }
347
650
 
348
- const { interactionContract, workflowSkill, outputRouterSkill } = loadRuntimeContext({
349
- interactionContract: setup.config.interactionContractEnabled,
350
- skills: setup.config.skillsEnabled,
351
- outputRouter: setup.config.outputRouterEnabled,
352
- });
353
- const interactionSection = setup.config.interactionContractEnabled ? ["", interactionContract.trim()] : [];
354
- const skillSections = setup.config.skillsEnabled
355
- ? ["", "# Freeflow Workflow Bootstrap", "", workflowSkill.trim()]
356
- : [];
357
- const outputRouterSection = setup.config.outputRouterEnabled
358
- ? ["", "## Loaded Output Router Skill", "```md", outputRouterSkill.trim(), "```"]
359
- : [];
360
- return [
361
- "# Freeflow Runtime Context",
362
- "",
363
- "Freeflow plugin lifecycle hook loaded this at session start.",
364
- "These instructions are context-loading only. They do not override user instructions, repo instructions, or host safety and approval policy.",
365
- "",
366
- "## Repo Setup",
367
- buildSetupStatus(root),
368
- "",
369
- ...capabilityStatus(setup.config),
370
- ...interactionSection,
371
- ...skillSections,
372
- ...outputRouterSection,
373
- ].join("\n");
374
- }
651
+ if (control.action === "set") {
652
+ if (!writeSessionMode(input.session_id, control.mode)) {
653
+ return "Freeflow could not establish a session mode override because this host invocation supplied no writable plugin data or session identifier. Do not claim that the mode changed.";
654
+ }
655
+ const state = resolveRuntimeState(input);
656
+ return renderPromptModeChange(state, "set");
657
+ }
375
658
 
376
- function isCodexHookInput(input) {
377
- return typeof input.model === "string" && input.model.length > 0;
659
+ if (!clearSessionMode(input.session_id)) {
660
+ return "Freeflow could not clear the session mode override because this host invocation supplied no writable plugin data or session identifier. Do not claim that the mode changed.";
661
+ }
662
+ const state = resolveRuntimeState(input);
663
+ return renderPromptModeChange(state, "cleared");
378
664
  }
379
665
 
380
- function emitAdditionalContext(eventName, input, additionalContext) {
381
- if (isCodexHookInput(input)) {
382
- process.stdout.write(`${additionalContext}\n`);
383
- return;
666
+ function shouldHandle(eventName) {
667
+ if (process.env.FREEFLOW_DISABLE_RUNTIME_CONTEXT === "1") {
668
+ return false;
384
669
  }
670
+ return eventName === "SessionStart" || eventName === "UserPromptSubmit" || eventName === "SessionEnd";
671
+ }
385
672
 
673
+ function emitAdditionalContext(eventName, additionalContext) {
386
674
  process.stdout.write(
387
675
  `${JSON.stringify({
388
676
  hookSpecificOutput: {
@@ -397,19 +685,21 @@ function main() {
397
685
  const { input, raw } = readStdinJson();
398
686
  const eventName = process.argv[2] || input.hook_event_name || "";
399
687
 
400
- if (!raw && !eventName) {
688
+ if ((!raw && !eventName) || !shouldHandle(eventName)) {
401
689
  return;
402
690
  }
403
691
 
404
- if (!shouldInject(eventName)) {
692
+ cleanupExpiredSessionModes();
693
+ if (eventName === "SessionEnd") {
694
+ stageClearTransfer(input);
405
695
  return;
406
696
  }
407
-
408
- const additionalContext = buildContext(input);
697
+ const additionalContext =
698
+ eventName === "SessionStart" ? renderSessionStart(resolveRuntimeState(input)) : handleUserPromptSubmit(input);
409
699
  if (!additionalContext.trim()) {
410
700
  return;
411
701
  }
412
- emitAdditionalContext(eventName, input, additionalContext);
702
+ emitAdditionalContext(eventName, additionalContext);
413
703
  }
414
704
 
415
705
  try {
package/hooks/hooks.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "description": "Load Freeflow runtime context at session start.",
2
+ "description": "Load Freeflow runtime context and manage session mode.",
3
3
  "hooks": {
4
4
  "SessionStart": [
5
5
  {
@@ -13,6 +13,31 @@
13
13
  }
14
14
  ]
15
15
  }
16
+ ],
17
+ "UserPromptSubmit": [
18
+ {
19
+ "hooks": [
20
+ {
21
+ "type": "command",
22
+ "command": "node \"${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT}}/hooks/freeflow-runtime-context.mjs\" UserPromptSubmit",
23
+ "timeout": 5,
24
+ "statusMessage": "Checking Freeflow mode"
25
+ }
26
+ ]
27
+ }
28
+ ],
29
+ "SessionEnd": [
30
+ {
31
+ "matcher": "clear",
32
+ "hooks": [
33
+ {
34
+ "type": "command",
35
+ "command": "node \"${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT}}/hooks/freeflow-runtime-context.mjs\" SessionEnd",
36
+ "timeout": 3,
37
+ "statusMessage": "Preserving Freeflow mode"
38
+ }
39
+ ]
40
+ }
16
41
  ]
17
42
  }
18
43
  }