@interactive-inc/claude-funnel 0.24.0 → 0.25.1

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/dist/index.js CHANGED
@@ -50,22 +50,22 @@ const channelConfigSchema = z.object({
50
50
  id: z.string(),
51
51
  name: z.string(),
52
52
  delivery: channelDeliveryModeSchema.default("fanout"),
53
- /** Args prepended to the claude argv on every launch bound to this channel. */
53
+ connectors: z.array(connectorConfigSchema).default([])
54
+ });
55
+ const profileConfigSchema = z.object({
56
+ name: z.string(),
57
+ path: z.string(),
58
+ channelId: z.string(),
59
+ /** Args prepended to the claude argv on every launch through this profile. */
54
60
  options: z.array(z.string()).default([]),
55
61
  /** Env vars layered under the launched claude process. process.env wins on collision. */
56
62
  env: z.record(z.string(), z.string()).default({}),
57
63
  /**
58
64
  * When true (the default), funnel injects `--session-id <uuid>` so that
59
65
  * relaunching from the same cwd resumes the previous claude session.
60
- * Set to false for channels that should always start a fresh session.
66
+ * Set to false for profiles that should always start a fresh session.
61
67
  */
62
- resume: z.boolean().default(true),
63
- connectors: z.array(connectorConfigSchema).default([])
64
- });
65
- const profileConfigSchema = z.object({
66
- name: z.string(),
67
- path: z.string(),
68
- channelId: z.string()
68
+ resume: z.boolean().default(true)
69
69
  });
70
70
  const SETTINGS_VERSION = 1;
71
71
  const settingsSchema = z.object({
@@ -312,9 +312,6 @@ var FunnelChannels = class {
312
312
  id: this.idGenerator.generate(),
313
313
  name: input.name,
314
314
  delivery: input.delivery ?? "fanout",
315
- options: input.options ?? [],
316
- env: input.env ?? {},
317
- resume: input.resume ?? true,
318
315
  connectors: []
319
316
  };
320
317
  settings.channels.push(channel);
@@ -327,24 +324,6 @@ var FunnelChannels = class {
327
324
  channel.delivery = delivery;
328
325
  this.store.write(settings);
329
326
  }
330
- setResume(name, resume) {
331
- const settings = this.store.read();
332
- const channel = this.requireChannel(settings, name);
333
- channel.resume = resume;
334
- this.store.write(settings);
335
- }
336
- setOptions(name, options) {
337
- const settings = this.store.read();
338
- const channel = this.requireChannel(settings, name);
339
- channel.options = options;
340
- this.store.write(settings);
341
- }
342
- setEnv(name, env) {
343
- const settings = this.store.read();
344
- const channel = this.requireChannel(settings, name);
345
- channel.env = env;
346
- this.store.write(settings);
347
- }
348
327
  remove(name) {
349
328
  const settings = this.store.read();
350
329
  const index = settings.channels.findIndex((c) => c.name === name);
@@ -598,9 +577,9 @@ var FunnelClaude = class {
598
577
  this.writePidFile(options.profileName);
599
578
  this.installCleanup(options.profileName);
600
579
  }
601
- const session = channel.resume ? this.resolveSession(channel.id, cwd, options.userArgs ?? []) : null;
602
- const claudeArgs = this.buildArgs(channel.options, options.userArgs ?? [], cwd, session);
603
- const env = this.buildEnv(channel.id, channel.env);
580
+ const session = options.resume ?? true ? this.resolveSession(channel.id, cwd, options.userArgs ?? [], options.env ?? {}) : null;
581
+ const claudeArgs = this.buildArgs(options.options ?? [], options.userArgs ?? [], cwd, session);
582
+ const env = this.buildEnv(channel.id, options.env ?? {});
604
583
  this.logger.info(`claude launch`, {
605
584
  channel: options.channel,
606
585
  channelId: channel.id,
@@ -650,8 +629,8 @@ var FunnelClaude = class {
650
629
  isProcessAlive(pid) {
651
630
  return this.process.isAlive(pid);
652
631
  }
653
- buildArgs(channelOptions, userArgs, cwd, session) {
654
- const result = [...channelOptions, ...userArgs];
632
+ buildArgs(recipeOptions, userArgs, cwd, session) {
633
+ const result = [...recipeOptions, ...userArgs];
655
634
  if (session !== null) if (session.mode === "resume") result.push("--resume", session.id);
656
635
  else result.push("--session-id", session.id);
657
636
  const mcpName = this.mcp.findInstalledName(cwd);
@@ -663,15 +642,22 @@ var FunnelClaude = class {
663
642
  * a freshly minted one. Backs off when the user already passed a
664
643
  * session-shaping flag, since combining them would either confuse claude
665
644
  * or override the explicit user intent.
645
+ *
646
+ * A persisted id is only resumed when its session jsonl still exists on
647
+ * disk. claude errors out on `--resume <id>` for a missing conversation, and
648
+ * a persisted id can outlive its jsonl (claude pruned it, or the very first
649
+ * launch was aborted after `create` wrote the id but before the jsonl
650
+ * appeared). When the file is gone we mint a fresh session instead, which
651
+ * overwrites the dangling entry — so the store self-heals.
666
652
  */
667
- resolveSession(channelId, cwd, userArgs) {
653
+ resolveSession(channelId, cwd, userArgs, recipeEnv) {
668
654
  for (const arg of userArgs) {
669
655
  if (arg === "-c" || arg === "--continue") return null;
670
656
  if (arg === "--resume" || arg.startsWith("--resume=")) return null;
671
657
  if (arg === "--session-id" || arg.startsWith("--session-id=")) return null;
672
658
  }
673
659
  const existing = this.sessions.get(channelId, cwd);
674
- if (existing !== null) return {
660
+ if (existing !== null && this.sessionFileExists(cwd, existing, recipeEnv)) return {
675
661
  id: existing,
676
662
  mode: "resume"
677
663
  };
@@ -680,9 +666,21 @@ var FunnelClaude = class {
680
666
  mode: "new"
681
667
  };
682
668
  }
683
- buildEnv(channelId, channelEnv) {
669
+ /**
670
+ * Mirrors claude's session storage path
671
+ * (`<config-dir>/projects/<cwd-with-slashes-as-dashes>/<id>.jsonl`) to check
672
+ * whether a recorded session still exists. Reads the same `CLAUDE_CONFIG_DIR`
673
+ * the child will run under so the check matches reality; a wrong guess can
674
+ * only ever produce a false negative (start fresh), never a bad resume.
675
+ */
676
+ sessionFileExists(cwd, sessionId, recipeEnv) {
677
+ const configDir = recipeEnv.CLAUDE_CONFIG_DIR ?? globalThis.process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
678
+ const projectSlug = cwd.replace(/\//g, "-");
679
+ return this.fs.existsSync(join(configDir, "projects", projectSlug, `${sessionId}.jsonl`));
680
+ }
681
+ buildEnv(channelId, recipeEnv) {
684
682
  const env = {};
685
- for (const [key, value] of Object.entries(channelEnv)) env[key] = value;
683
+ for (const [key, value] of Object.entries(recipeEnv)) env[key] = value;
686
684
  for (const [key, value] of Object.entries(globalThis.process.env)) if (typeof value === "string") env[key] = value;
687
685
  env.FUNNEL_CHANNEL_ID = channelId;
688
686
  return env;
@@ -781,16 +779,17 @@ var MemoryFunnelIdGenerator = class extends FunnelIdGenerator {
781
779
  /**
782
780
  * Per-repo launch config (`funnel.json`).
783
781
  *
784
- * `fnl claude` reads this when no --profile is given and picks one of the
785
- * declared channels (`--channel <name>` selects by name; otherwise the first
786
- * entry wins). The chosen channel is materialized into
787
- * `~/.funnel/settings.json` on launch — token fields in connectors resolve
788
- * via literal / `env.<field>` / TTY prompt.
782
+ * `fnl claude` reads this when no global --profile preset is used. It picks one
783
+ * of the declared channels (`--channel <name>` selects by name; otherwise the
784
+ * first entry wins) and materializes its transport (connectors / delivery) into
785
+ * `~/.funnel/settings.json` on launch — token fields in connectors resolve via
786
+ * literal / `env.<field>` / TTY prompt.
789
787
  *
790
- * Top-level `options` and `env` are defaults shared by every channel: each
791
- * channel's own `options` is appended after the shared ones (CLI semantics
792
- * keep the later flag winning), and `env` is a shallow merge with the
793
- * channel's keys overriding the shared ones.
788
+ * The launch recipe (`options` / `env` / `resume`) lives on `profiles[]`, not on
789
+ * the channel: a channel only describes where events come from. `fnl claude`
790
+ * applies the first profile bound to the chosen channel (or `--profile <name>`
791
+ * to pick another); the recipe is passed straight to the launcher and is not
792
+ * persisted into the global profile list.
794
793
  */
795
794
  const slackEnvSchema = z.object({
796
795
  botToken: z.string().optional(),
@@ -829,7 +828,13 @@ const connectorSpecSchema = z.discriminatedUnion("type", [
829
828
  ]);
830
829
  const channelSpecSchema = z.object({
831
830
  name: z.string(),
832
- /** Args prepended to the claude argv on every launch bound to this channel. */
831
+ connectors: z.array(connectorSpecSchema).optional()
832
+ });
833
+ const profileSpecSchema = z.object({
834
+ name: z.string(),
835
+ /** Name of the channel (declared in `channels[]`) this profile subscribes to. */
836
+ channel: z.string(),
837
+ /** Args prepended to the claude argv on every launch through this profile. */
833
838
  options: z.array(z.string()).optional(),
834
839
  /** Env vars layered under the launched claude process. process.env wins on collision. */
835
840
  env: z.record(z.string(), z.string()).optional(),
@@ -837,15 +842,16 @@ const channelSpecSchema = z.object({
837
842
  * When true (the default), funnel injects `--session-id <uuid>` so that
838
843
  * relaunching from the same cwd resumes the previous claude session
839
844
  * without bleeding into other channels or workspaces. Set to false for
840
- * channels that should always start a fresh session.
845
+ * profiles that should always start a fresh session.
841
846
  */
842
- resume: z.boolean().optional(),
843
- connectors: z.array(connectorSpecSchema).optional()
847
+ resume: z.boolean().optional()
844
848
  });
845
849
  const localConfigSchema = z.object({
846
850
  $schema: z.string().optional(),
847
- /** Declared channels. First entry is the default; --channel <name> selects by name. */
848
- channels: z.array(channelSpecSchema).min(1)
851
+ /** Declared channels (transport only). First entry is the default; --channel <name> selects by name. */
852
+ channels: z.array(channelSpecSchema).min(1),
853
+ /** Launch presets bound to a channel. First entry bound to the chosen channel is the default. */
854
+ profiles: z.array(profileSpecSchema).optional()
849
855
  });
850
856
  const LOCAL_CONFIG_FILENAME = "funnel.json";
851
857
  const LOCAL_ENV_FILENAME = ".env.local";
@@ -932,17 +938,6 @@ var FunnelLocalConfig = class {
932
938
  var FunnelTokenPrompter = class {};
933
939
  //#endregion
934
940
  //#region lib/engine/local-config/local-config-sync.ts
935
- const arraysEqual = (a, b) => {
936
- if (a.length !== b.length) return false;
937
- for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
938
- return true;
939
- };
940
- const recordsEqual = (a, b) => {
941
- const keys = Object.keys(a);
942
- if (keys.length !== Object.keys(b).length) return false;
943
- for (const key of keys) if (a[key] !== b[key]) return false;
944
- return true;
945
- };
946
941
  /**
947
942
  * Reconciles a single funnel.json channel spec with `~/.funnel/settings.json`.
948
943
  * The spec is the source of truth for the channel it declares:
@@ -975,21 +970,7 @@ var FunnelLocalConfigSync = class {
975
970
  Object.freeze(this);
976
971
  }
977
972
  async ensure(channel, cwd) {
978
- const existing = this.channels.get(channel.name);
979
- const nextResume = channel.resume ?? true;
980
- if (!existing) this.channels.add({
981
- name: channel.name,
982
- options: channel.options ?? [],
983
- env: channel.env ?? {},
984
- resume: nextResume
985
- });
986
- else {
987
- const nextOptions = channel.options ?? [];
988
- const nextEnv = channel.env ?? {};
989
- if (!arraysEqual(existing.options, nextOptions)) this.channels.setOptions(channel.name, nextOptions);
990
- if (!recordsEqual(existing.env, nextEnv)) this.channels.setEnv(channel.name, nextEnv);
991
- if (existing.resume !== nextResume) this.channels.setResume(channel.name, nextResume);
992
- }
973
+ if (!this.channels.get(channel.name)) this.channels.add({ name: channel.name });
993
974
  if (channel.connectors === void 0) return {
994
975
  touched: [],
995
976
  removed: []
@@ -1428,9 +1409,10 @@ var MemoryFunnelProcessRunner = class extends FunnelProcessRunner {
1428
1409
  //#region lib/engine/profiles/profiles.ts
1429
1410
  /**
1430
1411
  * Named launch presets for `fnl claude`. Each profile bundles a working
1431
- * directory, a sub-agent name, and the channel id its Claude instance will
1432
- * subscribe to. Implements ProfileChannelChecker so FunnelChannels can refuse
1433
- * to remove a channel that is still referenced.
1412
+ * directory, the channel id its Claude instance subscribes to, and the launch
1413
+ * recipe (`options` prepended to the claude argv, `env` layered under the
1414
+ * process, `resume` toggling session reuse). Implements ProfileChannelChecker
1415
+ * so FunnelChannels can refuse to remove a channel that is still referenced.
1434
1416
  *
1435
1417
  * The first entry in the persisted array is treated as the default profile;
1436
1418
  * `asDefault` reorders the array to put a named profile first.
@@ -1453,11 +1435,18 @@ var FunnelProfiles = class {
1453
1435
  getDefault() {
1454
1436
  return this.list()[0] ?? null;
1455
1437
  }
1456
- add(config) {
1438
+ add(input) {
1457
1439
  const settings = this.store.read();
1458
- if (settings.profiles.some((p) => p.name === config.name)) throw new Error(`profile "${config.name}" already exists`);
1459
- if (!settings.channels.some((c) => c.id === config.channelId)) throw new Error(`channel id "${config.channelId}" not found`);
1460
- settings.profiles.push(config);
1440
+ if (settings.profiles.some((p) => p.name === input.name)) throw new Error(`profile "${input.name}" already exists`);
1441
+ if (!settings.channels.some((c) => c.id === input.channelId)) throw new Error(`channel id "${input.channelId}" not found`);
1442
+ settings.profiles.push({
1443
+ name: input.name,
1444
+ path: input.path,
1445
+ channelId: input.channelId,
1446
+ options: input.options ?? [],
1447
+ env: input.env ?? {},
1448
+ resume: input.resume ?? true
1449
+ });
1461
1450
  this.store.write(settings);
1462
1451
  }
1463
1452
  remove(name) {
@@ -1497,6 +1486,9 @@ var FunnelProfiles = class {
1497
1486
  profile.channelId = fields.channelId;
1498
1487
  }
1499
1488
  if (fields.path !== void 0) profile.path = fields.path;
1489
+ if (fields.options !== void 0) profile.options = fields.options;
1490
+ if (fields.env !== void 0) profile.env = fields.env;
1491
+ if (fields.resume !== void 0) profile.resume = fields.resume;
1500
1492
  this.store.write(settings);
1501
1493
  }
1502
1494
  };
@@ -3847,14 +3839,14 @@ const startChannelServer = async (options = {}) => {
3847
3839
  /**
3848
3840
  * Generates the JSON Schema (draft 2020-12) for `funnel.json`. Useful for
3849
3841
  * `$schema` references in committed `funnel.json` files so editors can give
3850
- * autocomplete and validation for channel / subAgent / env / connectors[]
3851
- * without anyone hand-maintaining a separate schema.
3842
+ * autocomplete and validation for channels[] (transport) and profiles[]
3843
+ * (launch recipe) without anyone hand-maintaining a separate schema.
3852
3844
  */
3853
3845
  const funnelJsonSchema = () => {
3854
3846
  return {
3855
3847
  ...z.toJSONSchema(localConfigSchema, { target: "draft-2020-12" }),
3856
3848
  title: "Funnel per-repo launch config",
3857
- description: "Used by `fnl claude` when no --profile / --channel is given. Declares the channel to subscribe to, optional sub-agent and brief flag, environment variables to layer under process.env, and optional connectors to materialize into ~/.funnel/settings.json on launch."
3849
+ description: "Used by `fnl claude` to declare channels (transport: connectors to materialize into ~/.funnel/settings.json on launch) and profiles (launch recipe: options / env / resume) bound to those channels."
3858
3850
  };
3859
3851
  };
3860
3852
  //#endregion
@@ -4671,31 +4663,78 @@ examples:
4671
4663
  return c.text("funnel gateway: stopped");
4672
4664
  });
4673
4665
  //#endregion
4666
+ //#region lib/cli/routes/parse-profile-recipe.ts
4667
+ /**
4668
+ * Turns the single-string CLI flags (`--agent`, `--options "<argv>"`,
4669
+ * `--env "k=v,k=v"`, `--resume` / `--no-resume`) into the profile recipe.
4670
+ * A field stays `undefined` when its flag is absent so `profiles.update`
4671
+ * leaves it untouched. `--options` is whitespace-split, so values that
4672
+ * themselves contain spaces are not expressible here — set those via
4673
+ * funnel.json instead.
4674
+ */
4675
+ const parseProfileRecipe = (query) => {
4676
+ const recipe = {};
4677
+ if (query.agent !== void 0 || query.options !== void 0) {
4678
+ const options = [];
4679
+ if (query.agent !== void 0) options.push("--agent", query.agent);
4680
+ if (query.options !== void 0) {
4681
+ for (const token of query.options.split(/\s+/)) if (token.length > 0) options.push(token);
4682
+ }
4683
+ recipe.options = options;
4684
+ }
4685
+ if (query.env !== void 0) {
4686
+ const env = {};
4687
+ for (const pair of query.env.split(",")) {
4688
+ const trimmed = pair.trim();
4689
+ if (trimmed.length === 0) continue;
4690
+ const eq = trimmed.indexOf("=");
4691
+ if (eq < 0) continue;
4692
+ env[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
4693
+ }
4694
+ recipe.env = env;
4695
+ }
4696
+ if (query["no-resume"] !== void 0) recipe.resume = false;
4697
+ else if (query.resume !== void 0) recipe.resume = query.resume !== "false";
4698
+ return recipe;
4699
+ };
4700
+ //#endregion
4674
4701
  //#region lib/cli/routes/profiles.add.$profile.ts
4675
4702
  const addHelp = `funnel profiles add — add a profile
4676
4703
 
4677
- usage: funnel profiles add <name> --path <path> --channel <channel-name>
4704
+ usage: funnel profiles add <name> --path <path> --channel <channel-name> [recipe]
4678
4705
 
4679
4706
  options:
4680
- --path working directory passed to claude as cwd
4681
- --channel channel name (resolved to channel id internally)
4707
+ --path working directory passed to claude as cwd
4708
+ --channel channel name (resolved to channel id internally)
4709
+ --agent sub-agent name, prepended to the launch argv as --agent <name>
4710
+ --options extra launch argv as one whitespace-split string (e.g. "--brief")
4711
+ --env env vars layered under the process, as "KEY=VAL,KEY2=VAL2"
4712
+ --no-resume start a fresh claude session every launch (default resumes)
4682
4713
 
4683
- Per-launch flags like --agent or --brief now live on the channel itself
4684
- (set with \`fnl channels <name> set options ...\`), so profiles are only
4685
- \`{ name, path, channelId }\`.`;
4714
+ The launch recipe (--agent / --options / --env / --resume) lives on the
4715
+ profile; the channel only declares transport (connectors / delivery).`;
4686
4716
  const profilesAddHandler = factory.createHandlers(zValidator$1("param", z.object({ profile: z.string() })), zValidator$1("query", z.object({
4687
4717
  path: z.string(),
4688
- channel: z.string()
4718
+ channel: z.string(),
4719
+ agent: z.string().optional(),
4720
+ options: z.string().optional(),
4721
+ env: z.string().optional(),
4722
+ resume: z.string().optional(),
4723
+ "no-resume": z.string().optional()
4689
4724
  }), addHelp), (c) => {
4690
4725
  const param = c.req.valid("param");
4691
4726
  const query = c.req.valid("query");
4692
4727
  const funnel = c.var.funnel;
4693
4728
  const channel = funnel.channels.get(query.channel);
4694
4729
  if (!channel) throw new HTTPException(400, { message: `channel "${query.channel}" not found` });
4730
+ const recipe = parseProfileRecipe(query);
4695
4731
  funnel.profiles.add({
4696
4732
  name: param.profile,
4697
4733
  path: query.path,
4698
- channelId: channel.id
4734
+ channelId: channel.id,
4735
+ options: recipe.options,
4736
+ env: recipe.env,
4737
+ resume: recipe.resume
4699
4738
  });
4700
4739
  return c.text(`added profile "${param.profile}"`);
4701
4740
  });
@@ -4739,7 +4778,10 @@ const profilesLaunchHandler = factory.createHandlers(zValidator$1("param", z.obj
4739
4778
  channel: profile.channelId,
4740
4779
  cwd: profile.path,
4741
4780
  userArgs: queryToCliArgs(c.req.url, RESERVED_KEYS),
4742
- profileName: profile.name
4781
+ profileName: profile.name,
4782
+ options: profile.options,
4783
+ env: profile.env,
4784
+ resume: profile.resume
4743
4785
  });
4744
4786
  process.exit(exitCode);
4745
4787
  });
@@ -4757,19 +4799,39 @@ const profilesRemoveHandler = factory.createHandlers(zValidator$1("param", z.obj
4757
4799
  //#region lib/cli/routes/profiles.set.$profile.ts
4758
4800
  const setHelp = `funnel profiles <name> set — update a profile
4759
4801
 
4760
- usage: funnel profiles <name> set [--path <path>] [--channel <channel-name>]`;
4802
+ usage: funnel profiles <name> set [--path <path>] [--channel <channel-name>] [recipe]
4803
+
4804
+ options:
4805
+ --path working directory passed to claude as cwd
4806
+ --channel channel name (resolved to channel id internally)
4807
+ --agent sub-agent name, prepended to the launch argv as --agent <name>
4808
+ --options extra launch argv as one whitespace-split string (e.g. "--brief")
4809
+ --env env vars layered under the process, as "KEY=VAL,KEY2=VAL2"
4810
+ --resume / --no-resume toggle claude session reuse
4811
+
4812
+ Only the flags you pass are changed; --agent and --options together replace
4813
+ the profile's whole options list.`;
4761
4814
  const profilesSetHandler = factory.createHandlers(zValidator$1("param", z.object({ profile: z.string() })), zValidator$1("query", z.object({
4762
4815
  path: z.string().optional(),
4763
- channel: z.string().optional()
4816
+ channel: z.string().optional(),
4817
+ agent: z.string().optional(),
4818
+ options: z.string().optional(),
4819
+ env: z.string().optional(),
4820
+ resume: z.string().optional(),
4821
+ "no-resume": z.string().optional()
4764
4822
  }), setHelp), (c) => {
4765
4823
  const param = c.req.valid("param");
4766
4824
  const query = c.req.valid("query");
4767
4825
  const funnel = c.var.funnel;
4768
4826
  const channel = query.channel !== void 0 ? funnel.channels.get(query.channel) : null;
4769
4827
  if (query.channel !== void 0 && !channel) throw new HTTPException(400, { message: `channel "${query.channel}" not found` });
4828
+ const recipe = parseProfileRecipe(query);
4770
4829
  funnel.profiles.update(param.profile, {
4771
4830
  path: query.path,
4772
- channelId: channel?.id
4831
+ channelId: channel?.id,
4832
+ options: recipe.options,
4833
+ env: recipe.env,
4834
+ resume: recipe.resume
4773
4835
  });
4774
4836
  return c.text(`updated profile "${param.profile}"`);
4775
4837
  });
@@ -4779,27 +4841,29 @@ usage: funnel profiles [subcommand]
4779
4841
 
4780
4842
  subcommands:
4781
4843
  (none) list (first entry is the default)
4782
- add <name> --path <path> --channel <channel>
4783
- <name> set [--path ...] [--channel ...]
4844
+ add <name> --path <path> --channel <channel> [--agent ...] [--options ...] [--env ...] [--no-resume]
4845
+ <name> set [--path ...] [--channel ...] [--agent ...] [--options ...] [--env ...] [--resume|--no-resume]
4784
4846
  <name> as-default move profile to the front (becomes default)
4785
4847
  rename <old> <new> rename
4786
4848
  remove <name> remove
4787
4849
  <name> run launch (sugar for fnl claude -p <name>)
4788
4850
  <name> launch (alias for run)
4789
4851
 
4790
- Per-launch flags like --agent or --brief now live on the channel itself
4791
- (set with \`fnl channels <name> set options ...\`), so profiles are only
4792
- \`{ name, path, channelId }\`.
4852
+ A profile carries the launch recipe \`--agent\` / \`--options\` prepended to
4853
+ the claude argv, \`--env\` layered under the process, \`--resume\` toggling
4854
+ session reuse. The channel it points at only declares transport (connectors).
4793
4855
 
4794
4856
  examples:
4795
- funnel profiles add cto --path /repo/myapp --channel prod-inbox
4857
+ funnel profiles add cto --path /repo/myapp --channel prod-inbox --agent pm --options "--brief"
4796
4858
  funnel profiles cto as-default
4797
4859
  funnel profiles cto run`), (c) => {
4798
4860
  const profiles = c.var.funnel.profiles.list();
4799
4861
  if (profiles.length === 0) return c.text("no profiles");
4800
4862
  const lines = profiles.map((profile, index) => {
4801
4863
  const tag = index === 0 ? " (default)" : "";
4802
- return `${profile.name}${tag} [path=${profile.path}, channel=${profile.channelId}]`;
4864
+ const recipe = profile.options.length > 0 ? `, options=${profile.options.join(" ")}` : "";
4865
+ const session = profile.resume ? "" : ", resume=false";
4866
+ return `${profile.name}${tag} [path=${profile.path}, channel=${profile.channelId}${recipe}${session}]`;
4803
4867
  });
4804
4868
  return c.text(lines.join("\n"));
4805
4869
  });
@@ -7116,4 +7180,4 @@ async function launchTui(funnel) {
7116
7180
  });
7117
7181
  }
7118
7182
  //#endregion
7119
- export { DEFAULT_GATEWAY_TOKEN_PATH, FUNNEL_DIR, FUNNEL_MCP_COMMAND, FUNNEL_MCP_NAME, Funnel, FunnelBroadcaster, FunnelChannelPublisher, FunnelChannels, FunnelClaude, FunnelClock, FunnelConnectorFactory, FunnelConnectorListener, FunnelDotenvReader, FunnelEventStore, FunnelFileSystem, FunnelGateway, FunnelGatewayServer, FunnelGatewayToken, FunnelIdGenerator, FunnelListenerSupervisor, FunnelListenersClient, FunnelLocalConfig, FunnelLocalConfigSync, FunnelLogger, FunnelMcp, FunnelProcessRunner, FunnelProfiles, FunnelSessions, FunnelSettingsReader, FunnelSettingsStore, FunnelSlackEventProcessor, FunnelTokenPrompter, LOCAL_CONFIG_FILENAME, LOCAL_ENV_FILENAME, MemoryFunnelClock, MemoryFunnelFileSystem, MemoryFunnelIdGenerator, MemoryFunnelLogger, MemoryFunnelProcessRunner, MemoryFunnelTokenPrompter, MockFunnelSettingsReader, NodeFunnelClock, NodeFunnelFileSystem, NodeFunnelIdGenerator, NodeFunnelLogger, NodeFunnelProcessRunner, NodeFunnelTokenPrompter, NoopFunnelLogger, SETTINGS_PATH, SETTINGS_VERSION, channelConfigSchema, channelDeliveryModeSchema, channelSpecSchema, app as cliApp, connectorConfigSchema, connectorSpecSchema, createCliApp, createSettings, discordConnectorSchema, factory, funnelEventSchema, funnelJsonSchema, ghConnectorSchema, launchTui, localConfigSchema, profileConfigSchema, publishRequestSchema, publishResponseSchema, queryToCliArgs, scheduleCatchupPolicySchema, scheduleConnectorSchema, scheduleEntrySchema, settingsSchema, slackConnectorSchema, startChannelServer, toRequest };
7183
+ export { DEFAULT_GATEWAY_TOKEN_PATH, FUNNEL_DIR, FUNNEL_MCP_COMMAND, FUNNEL_MCP_NAME, Funnel, FunnelBroadcaster, FunnelChannelPublisher, FunnelChannels, FunnelClaude, FunnelClock, FunnelConnectorFactory, FunnelConnectorListener, FunnelDotenvReader, FunnelEventStore, FunnelFileSystem, FunnelGateway, FunnelGatewayServer, FunnelGatewayToken, FunnelIdGenerator, FunnelListenerSupervisor, FunnelListenersClient, FunnelLocalConfig, FunnelLocalConfigSync, FunnelLogger, FunnelMcp, FunnelProcessRunner, FunnelProfiles, FunnelSessions, FunnelSettingsReader, FunnelSettingsStore, FunnelSlackEventProcessor, FunnelTokenPrompter, LOCAL_CONFIG_FILENAME, LOCAL_ENV_FILENAME, MemoryFunnelClock, MemoryFunnelFileSystem, MemoryFunnelIdGenerator, MemoryFunnelLogger, MemoryFunnelProcessRunner, MemoryFunnelTokenPrompter, MockFunnelSettingsReader, NodeFunnelClock, NodeFunnelFileSystem, NodeFunnelIdGenerator, NodeFunnelLogger, NodeFunnelProcessRunner, NodeFunnelTokenPrompter, NoopFunnelLogger, SETTINGS_PATH, SETTINGS_VERSION, channelConfigSchema, channelDeliveryModeSchema, channelSpecSchema, app as cliApp, connectorConfigSchema, connectorSpecSchema, createCliApp, createSettings, discordConnectorSchema, factory, funnelEventSchema, funnelJsonSchema, ghConnectorSchema, launchTui, localConfigSchema, profileConfigSchema, profileSpecSchema, publishRequestSchema, publishResponseSchema, queryToCliArgs, scheduleCatchupPolicySchema, scheduleConnectorSchema, scheduleEntrySchema, settingsSchema, slackConnectorSchema, startChannelServer, toRequest };
@@ -14,24 +14,6 @@
14
14
  "name": {
15
15
  "type": "string"
16
16
  },
17
- "options": {
18
- "type": "array",
19
- "items": {
20
- "type": "string"
21
- }
22
- },
23
- "env": {
24
- "type": "object",
25
- "propertyNames": {
26
- "type": "string"
27
- },
28
- "additionalProperties": {
29
- "type": "string"
30
- }
31
- },
32
- "resume": {
33
- "type": "boolean"
34
- },
35
17
  "connectors": {
36
18
  "type": "array",
37
19
  "items": {
@@ -151,6 +133,43 @@
151
133
  ],
152
134
  "additionalProperties": false
153
135
  }
136
+ },
137
+ "profiles": {
138
+ "type": "array",
139
+ "items": {
140
+ "type": "object",
141
+ "properties": {
142
+ "name": {
143
+ "type": "string"
144
+ },
145
+ "channel": {
146
+ "type": "string"
147
+ },
148
+ "options": {
149
+ "type": "array",
150
+ "items": {
151
+ "type": "string"
152
+ }
153
+ },
154
+ "env": {
155
+ "type": "object",
156
+ "propertyNames": {
157
+ "type": "string"
158
+ },
159
+ "additionalProperties": {
160
+ "type": "string"
161
+ }
162
+ },
163
+ "resume": {
164
+ "type": "boolean"
165
+ }
166
+ },
167
+ "required": [
168
+ "name",
169
+ "channel"
170
+ ],
171
+ "additionalProperties": false
172
+ }
154
173
  }
155
174
  },
156
175
  "required": [
@@ -158,6 +177,6 @@
158
177
  ],
159
178
  "additionalProperties": false,
160
179
  "title": "Funnel per-repo launch config",
161
- "description": "Used by `fnl claude` when no --profile / --channel is given. Declares the channel to subscribe to, optional sub-agent and brief flag, environment variables to layer under process.env, and optional connectors to materialize into ~/.funnel/settings.json on launch."
180
+ "description": "Used by `fnl claude` to declare channels (transport: connectors to materialize into ~/.funnel/settings.json on launch) and profiles (launch recipe: options / env / resume) bound to those channels."
162
181
  }
163
182
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@interactive-inc/claude-funnel",
3
- "version": "0.24.0",
3
+ "version": "0.25.1",
4
4
  "description": "Hub CLI that routes external events (Slack / GitHub / Discord) to Claude Code agents through subscription channels over MCP.",
5
5
  "keywords": [
6
6
  "bun",