@sayknow-cli/coding-agent 0.2.7 → 0.3.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 (138) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/types/async/job-manager.d.ts +3 -1
  3. package/dist/types/cli/daemon-cli.d.ts +25 -0
  4. package/dist/types/cli/notify-cli.d.ts +23 -0
  5. package/dist/types/cli/setup-cli.d.ts +20 -1
  6. package/dist/types/commands/daemon.d.ts +41 -0
  7. package/dist/types/commands/notify.d.ts +41 -0
  8. package/dist/types/config/model-profile-activation.d.ts +12 -0
  9. package/dist/types/config/model-profiles.d.ts +2 -1
  10. package/dist/types/config/model-registry.d.ts +3 -3
  11. package/dist/types/config/models-config-schema.d.ts +5 -0
  12. package/dist/types/config/settings-schema.d.ts +38 -0
  13. package/dist/types/coordinator/contract.d.ts +1 -1
  14. package/dist/types/daemon/builtin.d.ts +20 -0
  15. package/dist/types/daemon/control-types.d.ts +57 -0
  16. package/dist/types/daemon/runtime.d.ts +25 -0
  17. package/dist/types/defaults/skc-defaults.d.ts +13 -2
  18. package/dist/types/extensibility/extensions/types.d.ts +8 -0
  19. package/dist/types/modes/components/oauth-selector.d.ts +2 -0
  20. package/dist/types/modes/controllers/selector-controller.d.ts +2 -2
  21. package/dist/types/modes/interactive-mode.d.ts +1 -1
  22. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +10 -0
  23. package/dist/types/modes/types.d.ts +7 -1
  24. package/dist/types/notifications/config-commands.d.ts +26 -0
  25. package/dist/types/notifications/config.d.ts +61 -0
  26. package/dist/types/notifications/helpers.d.ts +55 -0
  27. package/dist/types/notifications/html-format.d.ts +62 -0
  28. package/dist/types/notifications/index.d.ts +28 -0
  29. package/dist/types/notifications/rate-limit-pool.d.ts +93 -0
  30. package/dist/types/notifications/telegram-cli.d.ts +19 -0
  31. package/dist/types/notifications/telegram-daemon-cli.d.ts +11 -0
  32. package/dist/types/notifications/telegram-daemon-control.d.ts +56 -0
  33. package/dist/types/notifications/telegram-daemon.d.ts +276 -0
  34. package/dist/types/notifications/telegram-reference.d.ts +111 -0
  35. package/dist/types/notifications/threaded-inbound.d.ts +58 -0
  36. package/dist/types/notifications/threaded-render.d.ts +66 -0
  37. package/dist/types/notifications/topic-registry.d.ts +67 -0
  38. package/dist/types/rlm/index.d.ts +12 -0
  39. package/dist/types/session/agent-session.d.ts +39 -2
  40. package/dist/types/session/auth-storage.d.ts +1 -1
  41. package/dist/types/setup/credential-auto-import.d.ts +63 -0
  42. package/dist/types/setup/credential-import.d.ts +3 -0
  43. package/dist/types/setup/host-plugin-setup.d.ts +39 -0
  44. package/dist/types/skc-runtime/launch-tmux.d.ts +1 -0
  45. package/dist/types/skc-runtime/state-writer.d.ts +2 -0
  46. package/dist/types/skc-runtime/tmux-common.d.ts +3 -0
  47. package/dist/types/skc-runtime/tmux-sessions.d.ts +2 -0
  48. package/dist/types/skc-runtime/ultragoal-guard.d.ts +15 -0
  49. package/dist/types/skc-runtime/ultragoal-runtime.d.ts +14 -0
  50. package/dist/types/tools/ask-answer-registry.d.ts +13 -0
  51. package/dist/types/tools/index.d.ts +18 -0
  52. package/dist/types/tools/subagent.d.ts +3 -0
  53. package/package.json +7 -7
  54. package/scripts/build-binary.ts +3 -0
  55. package/src/async/job-manager.ts +5 -1
  56. package/src/cli/daemon-cli.ts +122 -0
  57. package/src/cli/notify-cli.ts +274 -0
  58. package/src/cli/setup-cli.ts +173 -84
  59. package/src/cli.ts +3 -3
  60. package/src/commands/daemon.ts +47 -0
  61. package/src/commands/notify.ts +61 -0
  62. package/src/commands/setup.ts +11 -1
  63. package/src/config/model-profile-activation.ts +74 -5
  64. package/src/config/model-profiles.ts +7 -4
  65. package/src/config/model-registry.ts +6 -3
  66. package/src/config/models-config-schema.ts +1 -1
  67. package/src/config/settings-schema.ts +29 -0
  68. package/src/coordinator/contract.ts +3 -0
  69. package/src/coordinator-mcp/server.ts +270 -1
  70. package/src/daemon/builtin.ts +46 -0
  71. package/src/daemon/control-types.ts +65 -0
  72. package/src/daemon/runtime.ts +51 -0
  73. package/src/defaults/skc/rules/ponytail.md +68 -0
  74. package/src/defaults/skc/skills/ultragoal/SKILL.md +16 -0
  75. package/src/defaults/skc-defaults.ts +27 -2
  76. package/src/edit/modes/replace.ts +1 -1
  77. package/src/extensibility/extensions/runner.ts +4 -0
  78. package/src/extensibility/extensions/types.ts +8 -0
  79. package/src/goals/tools/goal-tool.ts +11 -2
  80. package/src/hashline/hash.ts +1 -1
  81. package/src/internal-urls/docs-index.generated.ts +10 -8
  82. package/src/main.ts +30 -0
  83. package/src/modes/acp/acp-event-mapper.ts +1 -0
  84. package/src/modes/components/hook-editor.ts +7 -2
  85. package/src/modes/components/oauth-selector.ts +19 -0
  86. package/src/modes/controllers/event-controller.ts +20 -0
  87. package/src/modes/controllers/selector-controller.ts +80 -17
  88. package/src/modes/interactive-mode.ts +6 -2
  89. package/src/modes/runtime-init.ts +1 -0
  90. package/src/modes/shared/agent-wire/event-contract.ts +1 -0
  91. package/src/modes/shared/agent-wire/event-envelope.ts +1 -0
  92. package/src/modes/shared/agent-wire/event-observation.ts +16 -0
  93. package/src/modes/shared/agent-wire/unattended-session.ts +22 -0
  94. package/src/modes/types.ts +7 -1
  95. package/src/modes/utils/ui-helpers.ts +23 -0
  96. package/src/notifications/config-commands.ts +50 -0
  97. package/src/notifications/config.ts +107 -0
  98. package/src/notifications/helpers.ts +135 -0
  99. package/src/notifications/html-format.ts +389 -0
  100. package/src/notifications/index.ts +700 -0
  101. package/src/notifications/rate-limit-pool.ts +179 -0
  102. package/src/notifications/telegram-cli.ts +194 -0
  103. package/src/notifications/telegram-daemon-cli.ts +74 -0
  104. package/src/notifications/telegram-daemon-control.ts +370 -0
  105. package/src/notifications/telegram-daemon.ts +1370 -0
  106. package/src/notifications/telegram-reference.ts +335 -0
  107. package/src/notifications/threaded-inbound.ts +80 -0
  108. package/src/notifications/threaded-render.ts +155 -0
  109. package/src/notifications/topic-registry.ts +133 -0
  110. package/src/rlm/index.ts +19 -0
  111. package/src/sdk.ts +16 -0
  112. package/src/session/agent-session.ts +113 -3
  113. package/src/session/auth-storage.ts +3 -0
  114. package/src/session/session-dump-format.ts +43 -2
  115. package/src/session/session-manager.ts +39 -5
  116. package/src/setup/credential-auto-import.ts +258 -0
  117. package/src/setup/credential-import.ts +17 -0
  118. package/src/setup/hermes/templates/operator-instructions.v1.md +10 -0
  119. package/src/setup/host-plugin-setup.ts +142 -0
  120. package/src/skc-runtime/deep-interview-recorder.ts +2 -2
  121. package/src/skc-runtime/launch-tmux.ts +10 -2
  122. package/src/skc-runtime/state-runtime.ts +18 -10
  123. package/src/skc-runtime/state-writer.ts +8 -8
  124. package/src/skc-runtime/tmux-common.ts +8 -0
  125. package/src/skc-runtime/tmux-sessions.ts +8 -1
  126. package/src/skc-runtime/ultragoal-guard.ts +57 -2
  127. package/src/skc-runtime/ultragoal-runtime.ts +105 -19
  128. package/src/skc-runtime/workflow-manifest.generated.json +27 -2
  129. package/src/skc-runtime/workflow-manifest.ts +11 -1
  130. package/src/slash-commands/builtin-registry.ts +4 -1
  131. package/src/task/executor.ts +5 -1
  132. package/src/tools/ask-answer-registry.ts +25 -0
  133. package/src/tools/ask.ts +77 -6
  134. package/src/tools/image-gen.ts +5 -8
  135. package/src/tools/index.ts +19 -0
  136. package/src/tools/inspect-image.ts +16 -11
  137. package/src/tools/subagent-render.ts +7 -0
  138. package/src/tools/subagent.ts +38 -7
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Configure Telegram notifications.
3
+ */
4
+ import { Args, Command, Flags } from "@sayknow-cli/utils/cli";
5
+ import { type NotifyAction, type NotifyCommandArgs, runNotifyCommand } from "../cli/notify-cli";
6
+ import { initTheme } from "../modes/theme/theme";
7
+
8
+ const ACTIONS: NotifyAction[] = ["setup", "status", "daemon-internal"];
9
+
10
+ export default class Notify extends Command {
11
+ static description = "Configure Telegram notifications";
12
+
13
+ static args = {
14
+ action: Args.string({
15
+ description: "Notify action",
16
+ required: false,
17
+ options: ACTIONS,
18
+ }),
19
+ extra: Args.string({
20
+ description: "Additional internal args",
21
+ required: false,
22
+ multiple: true,
23
+ }),
24
+ };
25
+
26
+ static flags = {
27
+ smoke: Flags.boolean({ description: "Run hidden daemon smoke" }),
28
+ token: Flags.string({ description: "Telegram bot token (non-interactive setup)" }),
29
+ "chat-id": Flags.string({ description: "Telegram chat id to pair (non-interactive setup)" }),
30
+ redact: Flags.boolean({ description: "Enable redaction of remote notification content" }),
31
+ "owner-id": Flags.string({ description: "Internal: daemon owner id" }),
32
+ "agent-dir": Flags.string({ description: "Internal: agent dir for the daemon" }),
33
+ };
34
+
35
+ async run(): Promise<void> {
36
+ const { args, flags } = await this.parse(Notify);
37
+ const action = (args.action ?? "status") as NotifyAction;
38
+ const extra = Array.isArray(args.extra) ? args.extra : args.extra ? [args.extra] : [];
39
+ const flagRec = flags as Record<string, unknown>;
40
+ const ownerId = flagRec["owner-id"] as string | undefined;
41
+ const agentDir = flagRec["agent-dir"] as string | undefined;
42
+ const rawArgs = [
43
+ ...(flags.smoke ? ["--smoke"] : []),
44
+ ...(ownerId ? ["--owner-id", ownerId] : []),
45
+ ...(agentDir ? ["--agent-dir", agentDir] : []),
46
+ ...extra,
47
+ ];
48
+
49
+ const cmd: NotifyCommandArgs = {
50
+ action,
51
+ smoke: flags.smoke,
52
+ rawArgs,
53
+ token: flags.token as string | undefined,
54
+ chatId: (flags as Record<string, unknown>)["chat-id"] as string | undefined,
55
+ redact: Boolean(flags.redact),
56
+ };
57
+
58
+ await initTheme();
59
+ await runNotifyCommand(cmd);
60
+ }
61
+ }
@@ -5,7 +5,17 @@ import { Args, Command, Flags } from "@sayknow-cli/utils/cli";
5
5
  import { runSetupCommand, type SetupCommandArgs, type SetupComponent } from "../cli/setup-cli";
6
6
  import { initTheme } from "../modes/theme/theme";
7
7
 
8
- const COMPONENTS: SetupComponent[] = ["credentials", "defaults", "hermes", "hooks", "provider", "python", "stt"];
8
+ const COMPONENTS: SetupComponent[] = [
9
+ "claude",
10
+ "codex",
11
+ "credentials",
12
+ "defaults",
13
+ "hermes",
14
+ "hooks",
15
+ "provider",
16
+ "python",
17
+ "stt",
18
+ ];
9
19
 
10
20
  export default class Setup extends Command {
11
21
  static description = "Install SKC defaults or optional feature dependencies";
@@ -17,6 +17,8 @@ type ModelProfileActivationSession = Pick<AgentSession, "model" | "thinkingLevel
17
17
  setModelTemporary?: AgentSession["setModelTemporary"];
18
18
  setActiveModelProfile?: (name: string | undefined) => void;
19
19
  getActiveModelProfile?: () => string | undefined;
20
+ getSessionDefaultModelSelector?: () => string | undefined;
21
+ recordResumeDefaultModel?: (selector: string) => void;
20
22
  };
21
23
 
22
24
  export interface PrepareModelProfileActivationOptions {
@@ -43,10 +45,20 @@ export interface PreparedModelProfileActivation {
43
45
  previousModel: Model<Api> | undefined;
44
46
  previousThinkingLevel: ThinkingLevel | undefined;
45
47
  previousAgentModelOverrides: Record<string, string>;
48
+ previousModelRoles: Record<string, string>;
46
49
  defaultModel: Model<Api> | undefined;
47
50
  defaultThinkingLevel: ThinkingLevel | undefined;
51
+ modelRoles: Record<string, string>;
48
52
  agentModelOverrides: Record<string, string>;
49
53
  previousActiveModelProfile: string | undefined;
54
+ /**
55
+ * The session resume default ("provider/id") captured BEFORE activation —
56
+ * the model resume would restore prior to this profile. Snapshotted
57
+ * separately from `previousModel` (the live runtime model, which may be a
58
+ * transient switch) so a failed-activation rollback restores the correct
59
+ * resume default without promoting a transient model to it.
60
+ */
61
+ previousSessionDefaultModel: string | undefined;
50
62
  }
51
63
 
52
64
  export function formatModelProfileCredentialError(profileName: string, providers: readonly string[]): string {
@@ -87,14 +99,24 @@ function rewriteSelectorProvider(
87
99
  }
88
100
 
89
101
  function rewriteBindingsProviders(
90
- bindings: { defaultSelector?: string; agentModelOverrides: Record<string, string> },
102
+ bindings: {
103
+ defaultSelector?: string;
104
+ modelRoles: Record<string, string>;
105
+ agentModelOverrides: Record<string, string>;
106
+ },
91
107
  authenticatedProviders: ReadonlySet<string>,
92
108
  alternativeGroups: readonly (readonly string[])[],
93
- ): { defaultSelector?: string; agentModelOverrides: Record<string, string> } {
109
+ ): { defaultSelector?: string; modelRoles: Record<string, string>; agentModelOverrides: Record<string, string> } {
94
110
  return {
95
111
  defaultSelector: bindings.defaultSelector
96
112
  ? rewriteSelectorProvider(bindings.defaultSelector, authenticatedProviders, alternativeGroups)
97
113
  : undefined,
114
+ modelRoles: Object.fromEntries(
115
+ Object.entries(bindings.modelRoles).map(([role, sel]) => [
116
+ role,
117
+ rewriteSelectorProvider(sel, authenticatedProviders, alternativeGroups),
118
+ ]),
119
+ ),
98
120
  agentModelOverrides: Object.fromEntries(
99
121
  Object.entries(bindings.agentModelOverrides).map(([role, sel]) => [
100
122
  role,
@@ -165,6 +187,18 @@ export async function prepareModelProfileActivation(
165
187
  );
166
188
  }
167
189
 
190
+ const modelRoles: Record<string, string> = {};
191
+ for (const [role, selector] of Object.entries(bindings.modelRoles) as [SkcModelAssignmentTargetId, string][]) {
192
+ const resolved = resolveModelRoleValue(selector, availableModels, {
193
+ settings: options.settings as Settings,
194
+ modelRegistry: options.modelRegistry,
195
+ });
196
+ if (!resolved.model) {
197
+ throw new Error(`Model profile "${options.profileName}" ${role} selector did not resolve: ${selector}`);
198
+ }
199
+ modelRoles[role] = formatClampedModelSelector(selector, resolved.model);
200
+ }
201
+
168
202
  const agentModelOverrides: Record<string, string> = {};
169
203
  for (const [role, selector] of Object.entries(bindings.agentModelOverrides) as [
170
204
  SkcModelAssignmentTargetId,
@@ -187,10 +221,13 @@ export async function prepareModelProfileActivation(
187
221
  previousModel: options.session.model,
188
222
  previousThinkingLevel: options.session.thinkingLevel,
189
223
  previousAgentModelOverrides: { ...options.settings.get("task.agentModelOverrides") },
224
+ previousModelRoles: { ...options.settings.get("modelRoles") },
190
225
  defaultModel: resolvedDefault?.model,
191
226
  defaultThinkingLevel: resolvedDefault?.thinkingLevel,
227
+ modelRoles,
192
228
  agentModelOverrides,
193
229
  previousActiveModelProfile: options.session.getActiveModelProfile?.(),
230
+ previousSessionDefaultModel: options.session.getSessionDefaultModelSelector?.(),
194
231
  };
195
232
  }
196
233
 
@@ -201,17 +238,29 @@ export async function applyPreparedModelProfileActivation(
201
238
  const previousModel = prepared.previousModel;
202
239
  const previousThinkingLevel = prepared.previousThinkingLevel;
203
240
  const previousAgentModelOverrides = prepared.previousAgentModelOverrides;
241
+ const previousModelRoles = prepared.previousModelRoles;
204
242
  const previousPersistedDefault = prepared.settings.get("modelProfile.default");
205
243
  const previousActiveModelProfile = prepared.previousActiveModelProfile;
244
+ const previousSessionDefaultModel = prepared.previousSessionDefaultModel;
206
245
  let modelChanged = false;
207
246
  let overridesChanged = false;
208
247
  let defaultChanged = false;
248
+ let modelRolesChanged = false;
209
249
 
210
250
  try {
211
251
  if (prepared.defaultModel) {
212
- await prepared.session.setModelTemporary(prepared.defaultModel, prepared.defaultThinkingLevel);
252
+ await prepared.session.setModelTemporary(prepared.defaultModel, prepared.defaultThinkingLevel, {
253
+ persistAsSessionDefault: true,
254
+ });
213
255
  modelChanged = true;
214
256
  }
257
+ if (Object.keys(prepared.modelRoles).length > 0) {
258
+ prepared.settings.override("modelRoles", {
259
+ ...prepared.settings.get("modelRoles"),
260
+ ...prepared.modelRoles,
261
+ });
262
+ modelRolesChanged = true;
263
+ }
215
264
  if (Object.keys(prepared.agentModelOverrides).length > 0) {
216
265
  prepared.settings.override("task.agentModelOverrides", {
217
266
  ...prepared.settings.get("task.agentModelOverrides"),
@@ -229,12 +278,32 @@ export async function applyPreparedModelProfileActivation(
229
278
  if (defaultChanged) {
230
279
  prepared.settings.set("modelProfile.default", previousPersistedDefault);
231
280
  }
281
+ if (modelRolesChanged) {
282
+ prepared.settings.override("modelRoles", previousModelRoles);
283
+ }
232
284
  if (overridesChanged) {
233
285
  prepared.settings.override("task.agentModelOverrides", previousAgentModelOverrides);
234
286
  }
235
287
  prepared.session.setActiveModelProfile?.(previousActiveModelProfile);
236
- if (modelChanged && previousModel) {
237
- await prepared.session.setModelTemporary(previousModel, previousThinkingLevel);
288
+ if (modelChanged) {
289
+ // Runtime rolls back to the pre-activation live model. That model may
290
+ // itself be a transient retry/fallback/context-promotion/plan switch,
291
+ // so it is recorded as role:"temporary" (NOT the resume default) to
292
+ // preserve the issue #849 protection.
293
+ if (previousModel) {
294
+ await prepared.session.setModelTemporary(previousModel, previousThinkingLevel);
295
+ }
296
+ // The happy path already appended the profile main model as the resume
297
+ // default (role:"default"). Re-assert the pre-activation resume default
298
+ // so a failed activation does not poison future resume. Fall back to the
299
+ // live model only when there was no explicit pre-activation default
300
+ // (nothing to protect). Append-only — never touches the runtime model.
301
+ const restoreDefaultSelector =
302
+ previousSessionDefaultModel ??
303
+ (previousModel ? `${previousModel.provider}/${previousModel.id}` : undefined);
304
+ if (restoreDefaultSelector) {
305
+ prepared.session.recordResumeDefaultModel?.(restoreDefaultSelector);
306
+ }
238
307
  }
239
308
  throw error;
240
309
  }
@@ -23,7 +23,8 @@ export interface ModelProfileDefinition {
23
23
 
24
24
  export interface ResolvedProfileBinding {
25
25
  defaultSelector?: string;
26
- agentModelOverrides: Partial<Record<Exclude<ModelProfileRole, "default">, string>>;
26
+ modelRoles: Partial<Record<"vision", string>>;
27
+ agentModelOverrides: Partial<Record<Exclude<ModelProfileRole, "default" | "vision">, string>>;
27
28
  }
28
29
 
29
30
  function parseModelSelectorProvider(selector: string): string | undefined {
@@ -56,7 +57,7 @@ export function aggregateModelProfileRequiredProviders(
56
57
  const profile = (
57
58
  name: string,
58
59
  requiredProviders: string[],
59
- modelMapping: Record<ModelProfileRole, string>,
60
+ modelMapping: Partial<Record<ModelProfileRole, string>>,
60
61
  alternativeProviderGroups?: readonly (readonly string[])[],
61
62
  ): ModelProfileDefinition => ({
62
63
  name,
@@ -382,13 +383,15 @@ export function mergeModelProfiles(userProfiles?: ModelsConfig["profiles"]): Map
382
383
  }
383
384
 
384
385
  export function resolveProfileBindings(definition: ModelProfileDefinition): ResolvedProfileBinding {
385
- const { default: defaultSelector, executor, architect, planner, critic } = definition.modelMapping;
386
+ const { default: defaultSelector, vision, executor, architect, planner, critic } = definition.modelMapping;
387
+ const modelRoles: ResolvedProfileBinding["modelRoles"] = {};
388
+ if (vision !== undefined) modelRoles.vision = vision;
386
389
  const agentModelOverrides: ResolvedProfileBinding["agentModelOverrides"] = {};
387
390
  if (executor !== undefined) agentModelOverrides.executor = executor;
388
391
  if (architect !== undefined) agentModelOverrides.architect = architect;
389
392
  if (planner !== undefined) agentModelOverrides.planner = planner;
390
393
  if (critic !== undefined) agentModelOverrides.critic = critic;
391
- return { defaultSelector, agentModelOverrides };
394
+ return { defaultSelector, modelRoles, agentModelOverrides };
392
395
  }
393
396
 
394
397
  export function formatAvailableProfileNames(profiles: ReadonlyMap<string, ModelProfileDefinition>): string {
@@ -70,7 +70,7 @@ export function isAuthenticated(apiKey: string | undefined | null): apiKey is st
70
70
  return Boolean(apiKey) && apiKey !== kNoAuth;
71
71
  }
72
72
 
73
- export type ModelRole = "default";
73
+ export type ModelRole = "default" | "vision";
74
74
 
75
75
  export interface ModelRoleInfo {
76
76
  tag?: string;
@@ -80,11 +80,12 @@ export interface ModelRoleInfo {
80
80
 
81
81
  export const MODEL_ROLES: Record<ModelRole, ModelRoleInfo> = {
82
82
  default: { tag: "DEFAULT", name: "Default", color: "success" },
83
+ vision: { tag: "VISION", name: "Vision", color: "accent" },
83
84
  };
84
85
 
85
- export const MODEL_ROLE_IDS: ModelRole[] = ["default"];
86
+ export const MODEL_ROLE_IDS: ModelRole[] = ["default", "vision"];
86
87
 
87
- export type SkcModelAssignmentTargetId = "default" | "executor" | "architect" | "planner" | "critic";
88
+ export type SkcModelAssignmentTargetId = "default" | "vision" | "executor" | "architect" | "planner" | "critic";
88
89
 
89
90
  export interface SkcModelAssignmentTargetInfo extends ModelRoleInfo {
90
91
  id: SkcModelAssignmentTargetId;
@@ -93,6 +94,7 @@ export interface SkcModelAssignmentTargetInfo extends ModelRoleInfo {
93
94
 
94
95
  export const SKC_MODEL_ASSIGNMENT_TARGET_IDS: SkcModelAssignmentTargetId[] = [
95
96
  "default",
97
+ "vision",
96
98
  "executor",
97
99
  "architect",
98
100
  "planner",
@@ -101,6 +103,7 @@ export const SKC_MODEL_ASSIGNMENT_TARGET_IDS: SkcModelAssignmentTargetId[] = [
101
103
 
102
104
  export const SKC_MODEL_ASSIGNMENT_TARGETS: Record<SkcModelAssignmentTargetId, SkcModelAssignmentTargetInfo> = {
103
105
  default: { id: "default", tag: "DEFAULT", name: "Default", color: "success", settingsPath: "modelRoles" },
106
+ vision: { id: "vision", tag: "VISION", name: "Vision", color: "accent", settingsPath: "modelRoles" },
104
107
  executor: {
105
108
  id: "executor",
106
109
  tag: "EXECUTOR",
@@ -81,7 +81,7 @@ const ModelBindingsSchema = z.object({
81
81
  modelRoles: z.record(z.string(), z.string().min(1)).optional(),
82
82
  agentModelOverrides: z.record(z.string(), z.string().min(1)).optional(),
83
83
  });
84
- export const ProfileRoleSchema = z.enum(["default", "executor", "architect", "planner", "critic"]);
84
+ export const ProfileRoleSchema = z.enum(["default", "vision", "executor", "architect", "planner", "critic"]);
85
85
 
86
86
  function isValidProfileModelSelector(value: string): boolean {
87
87
  if (value.includes(",")) return false;
@@ -255,6 +255,22 @@ export const SETTINGS_SCHEMA = {
255
255
  "auth.broker.url": { type: "string", default: undefined },
256
256
  "auth.broker.token": { type: "string", default: undefined },
257
257
 
258
+ // Notifications (Telegram bundled reference client)
259
+ "notifications.enabled": { type: "boolean", default: false },
260
+ "notifications.telegram.botToken": { type: "string", default: undefined },
261
+ "notifications.telegram.chatId": { type: "string", default: undefined },
262
+ "notifications.redact": { type: "boolean", default: false },
263
+ "notifications.verbosity": {
264
+ type: "string",
265
+ default: "lean",
266
+ validate: (value: string) => value === "lean" || value === "verbose",
267
+ },
268
+ "notifications.daemon.idleTimeoutMs": {
269
+ type: "number",
270
+ default: 60000,
271
+ validate: (value: number) => Number.isFinite(value) && value > 0,
272
+ },
273
+
258
274
  autoResume: {
259
275
  type: "boolean",
260
276
  default: false,
@@ -3209,6 +3225,18 @@ export interface ShellMinimizerSettings {
3209
3225
  maxCaptureBytes: number;
3210
3226
  }
3211
3227
 
3228
+ export interface NotificationsSettings {
3229
+ enabled: boolean;
3230
+ telegram: {
3231
+ botToken: string | undefined;
3232
+ chatId: string | undefined;
3233
+ };
3234
+ redact: boolean;
3235
+ daemon: {
3236
+ idleTimeoutMs: number;
3237
+ };
3238
+ }
3239
+
3212
3240
  /** Map group prefix -> typed settings interface */
3213
3241
  export interface GroupTypeMap {
3214
3242
  compaction: CompactionSettings;
@@ -3227,6 +3255,7 @@ export interface GroupTypeMap {
3227
3255
  modelTags: ModelTagsSettings;
3228
3256
  cycleOrder: string[];
3229
3257
  shellMinimizer: ShellMinimizerSettings;
3258
+ notifications: NotificationsSettings;
3230
3259
  }
3231
3260
 
3232
3261
  export type GroupPrefix = keyof GroupTypeMap;
@@ -17,6 +17,9 @@ export const COORDINATOR_MCP_TOOL_NAMES = [
17
17
  "skc_coordinator_read_turn",
18
18
  "skc_coordinator_await_turn",
19
19
  "skc_coordinator_report_status",
20
+ "skc_delegate_plan",
21
+ "skc_delegate_execute",
22
+ "skc_delegate_team",
20
23
  ] as const;
21
24
 
22
25
  export type CoordinatorToolName = (typeof COORDINATOR_MCP_TOOL_NAMES)[number];
@@ -184,7 +184,8 @@ type CoordinatorEventKind =
184
184
  | "question.answered"
185
185
  | "report.written"
186
186
  | "tmux.delivery_succeeded"
187
- | "tmux.delivery_failed";
187
+ | "tmux.delivery_failed"
188
+ | "delegation.started";
188
189
 
189
190
  interface CoordinatorEvent {
190
191
  schema_version: 1;
@@ -411,9 +412,116 @@ function toolSchema(name: CoordinatorToolName): {
411
412
  },
412
413
  };
413
414
  }
415
+ const delegateWorkflow = workflowForDelegateTool(name);
416
+ if (delegateWorkflow) {
417
+ return {
418
+ name,
419
+ description: delegateToolDescription(delegateWorkflow),
420
+ inputSchema: {
421
+ type: "object",
422
+ properties: {
423
+ cwd,
424
+ task: {
425
+ type: "string",
426
+ description: "Delegated task or objective to run through the selected SKC workflow.",
427
+ },
428
+ prompt: { type: "string", description: "Alias for task; accepted when task is absent." },
429
+ allow_mutation: allowMutation,
430
+ session_id: {
431
+ type: "string",
432
+ description:
433
+ "Optional existing SKC coordinator bridge session id to reuse; omitted starts a fresh session.",
434
+ },
435
+ queue: {
436
+ type: "boolean",
437
+ description: "When reusing a session with an active turn, queue instead of failing.",
438
+ },
439
+ force: {
440
+ type: "boolean",
441
+ description: "When reusing a session with an active turn, supersede it before sending.",
442
+ },
443
+ model: {
444
+ type: "string",
445
+ description: "Optional model hint passed in prompt metadata; no provider default is implied.",
446
+ },
447
+ await_completion: { type: "boolean", description: "If true, poll the turn until terminal or timeout." },
448
+ timeout_ms: {
449
+ type: "number",
450
+ description: "Bounded await timeout; same cap semantics as skc_coordinator_await_turn.",
451
+ },
452
+ poll_interval_ms: { type: "number", description: "Bounded await polling interval." },
453
+ lines: { type: "number", description: "Bounded advisory tail lines returned with await/read payloads." },
454
+ },
455
+ required: ["cwd", "allow_mutation"],
456
+ },
457
+ };
458
+ }
414
459
  return { name, description: "List known scoped SKC coordinator bridge sessions.", inputSchema: common };
415
460
  }
416
461
 
462
+ type DelegateWorkflow = "plan" | "execute" | "team";
463
+
464
+ function workflowForDelegateTool(name: string): DelegateWorkflow | null {
465
+ switch (name) {
466
+ case "skc_delegate_plan":
467
+ return "plan";
468
+ case "skc_delegate_execute":
469
+ return "execute";
470
+ case "skc_delegate_team":
471
+ return "team";
472
+ default:
473
+ return null;
474
+ }
475
+ }
476
+
477
+ function workflowSkill(workflow: DelegateWorkflow): "ralplan" | "ultragoal" | "team" {
478
+ switch (workflow) {
479
+ case "plan":
480
+ return "ralplan";
481
+ case "execute":
482
+ return "ultragoal";
483
+ case "team":
484
+ return "team";
485
+ }
486
+ }
487
+
488
+ function delegateToolDescription(workflow: DelegateWorkflow): string {
489
+ switch (workflow) {
490
+ case "plan":
491
+ return "Delegate consensus planning to SKC: start a session and run /skill:ralplan to completion, returning durable turn status and artifact references.";
492
+ case "execute":
493
+ return "Delegate execution to SKC: start a session and run /skill:ultragoal to completion, returning durable turn status and artifact references.";
494
+ case "team":
495
+ return "Delegate parallel team execution to SKC: start a session and run /skill:team to completion, returning durable turn status and artifact references.";
496
+ }
497
+ }
498
+
499
+ function workflowPrompt(
500
+ workflow: DelegateWorkflow,
501
+ toolName: string,
502
+ canonicalCwd: string,
503
+ task: string,
504
+ options: { mutationRequested: boolean; model?: string | null },
505
+ ): string {
506
+ const skill = workflowSkill(workflow);
507
+ const model = options.model && options.model.trim().length > 0 ? options.model.trim() : "none";
508
+ const mutationIntent = options.mutationRequested ? "mutation requested" : "read-only";
509
+ return [
510
+ `/skill:${skill}`,
511
+ "",
512
+ `Delegated by coordinator MCP tool: ${toolName}`,
513
+ `Workflow: ${workflow}`,
514
+ `CWD: ${canonicalCwd}`,
515
+ `Mutation intent: ${mutationIntent}; coordinator startup policy remains authoritative.`,
516
+ `Optional model hint: ${model}`,
517
+ "",
518
+ "Task:",
519
+ task,
520
+ "",
521
+ "Return durable status and artifact references through SKC runtime/coordinator state. Do not expose host-facing tmux controls.",
522
+ ].join("\n");
523
+ }
524
+
417
525
  function normalizeSession(session: Record<string, unknown>): Record<string, unknown> {
418
526
  return {
419
527
  session_id: session.sessionId ?? session.session_id ?? session.name ?? "unknown",
@@ -424,6 +532,14 @@ function normalizeSession(session: Record<string, unknown>): Record<string, unkn
424
532
  };
425
533
  }
426
534
 
535
+ async function canonicalizePath(value: string): Promise<string> {
536
+ try {
537
+ return await fs.realpath(value);
538
+ } catch {
539
+ return path.resolve(value);
540
+ }
541
+ }
542
+
427
543
  async function ensureDir(dir: string): Promise<void> {
428
544
  await fs.mkdir(dir, { recursive: true });
429
545
  }
@@ -1587,6 +1703,159 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
1587
1703
  transport: { mcp: "long_poll", push_subscriptions: false },
1588
1704
  };
1589
1705
  }
1706
+ const delegateWorkflow = workflowForDelegateTool(name);
1707
+ if (delegateWorkflow) {
1708
+ requireCoordinatorMutation(config, "sessions", args);
1709
+ const canonicalCwd = await assertCoordinatorWorkdir(config, args.cwd);
1710
+ const hasTask = typeof args.task === "string" && args.task.trim().length > 0;
1711
+ const hasPrompt = typeof args.prompt === "string" && args.prompt.trim().length > 0;
1712
+ const task = hasTask ? String(args.task) : hasPrompt ? String(args.prompt) : null;
1713
+ if (!task) return { ok: false, reason: "task_required" };
1714
+ const promptAliasIgnored = hasTask && hasPrompt;
1715
+ const mutationRequested = args.allow_mutation === true;
1716
+ const taggedPrompt = workflowPrompt(delegateWorkflow, name, canonicalCwd, task, {
1717
+ mutationRequested,
1718
+ model: typeof args.model === "string" ? args.model : null,
1719
+ });
1720
+
1721
+ let session: Record<string, unknown>;
1722
+ let reusedSession = false;
1723
+ if (args.session_id != null) {
1724
+ const sessionId = safeExternalId("session", args.session_id);
1725
+ const existing = asRecord(await readJsonFile(sessionFile(sessionId)));
1726
+ if (!existing) return { ok: false, reason: "unknown_session", session_id: sessionId };
1727
+ const storedCwd = typeof existing.cwd === "string" ? existing.cwd : null;
1728
+ const canonicalStored = storedCwd ? await canonicalizePath(storedCwd) : null;
1729
+ const canonicalRequested = await canonicalizePath(canonicalCwd);
1730
+ if (!canonicalStored || canonicalStored !== canonicalRequested) {
1731
+ return { ok: false, reason: "session_cwd_mismatch", session_id: sessionId };
1732
+ }
1733
+ session = existing;
1734
+ reusedSession = true;
1735
+ } else {
1736
+ const input = {
1737
+ cwd: canonicalCwd,
1738
+ prompt: undefined,
1739
+ namespace: config.namespace,
1740
+ worktree: true as const,
1741
+ };
1742
+ const started = services.startSession
1743
+ ? await services.startSession(input)
1744
+ : await startTmuxSession(config, input, namespaceDir, commandRunner);
1745
+ const startedRecord = asRecord(started);
1746
+ if (!startedRecord) throw new Error("coordinator_session_command_required");
1747
+ session = normalizeSession(startedRecord);
1748
+ await writeJsonFile(sessionFile(session.session_id), session);
1749
+ await appendCoordinatorEvent(namespaceDir, {
1750
+ kind: "session.started",
1751
+ sessionId: String(session.session_id),
1752
+ summary: `Session ${String(session.session_id)} started by coordinator delegate`,
1753
+ payloadRef: path.relative(namespaceDir, sessionFile(session.session_id)),
1754
+ metadata: { delegate: true, workflow: delegateWorkflow },
1755
+ });
1756
+ const live = hasTmuxIdentity(session) ? await hasTmuxSession(session, commandRunner) : null;
1757
+ await writeSessionState(namespaceDir, String(session.session_id), "ready_for_input", {
1758
+ live,
1759
+ reason: null,
1760
+ });
1761
+ }
1762
+
1763
+ const sessionId = String(session.session_id);
1764
+ const activeTurn = reusedSession ? await readActiveTurn(namespaceDir, sessionId) : null;
1765
+ if (activeTurn && args.force !== true && args.queue !== true) {
1766
+ return {
1767
+ ok: false,
1768
+ reason: "active_turn_exists",
1769
+ session_id: sessionId,
1770
+ active_turn_id: activeTurn.turn_id,
1771
+ };
1772
+ }
1773
+ if (activeTurn && args.force === true) {
1774
+ const timestamp = new Date().toISOString();
1775
+ const superseded = {
1776
+ ...activeTurn,
1777
+ status: "superseded" as const,
1778
+ updated_at: timestamp,
1779
+ completed_at: timestamp,
1780
+ };
1781
+ await writeTurnRecord(namespaceDir, superseded);
1782
+ await clearActiveTurn(namespaceDir, superseded);
1783
+ }
1784
+ const shouldQueue = args.queue === true && args.force !== true && !!activeTurn;
1785
+ const turn = shouldQueue
1786
+ ? makeTurnRecord(config, sessionId, taggedPrompt, "queued")
1787
+ : await activateTurn(session, makeTurnRecord(config, sessionId, taggedPrompt, "active"));
1788
+ if (shouldQueue) await writeTurnRecord(namespaceDir, turn);
1789
+ await appendCoordinatorEvent(namespaceDir, {
1790
+ kind: "delegation.started",
1791
+ sessionId,
1792
+ turnId: turn.turn_id,
1793
+ summary: `Delegated ${delegateWorkflow} via ${name} on session ${sessionId}`,
1794
+ metadata: {
1795
+ workflow: delegateWorkflow,
1796
+ tool_name: name,
1797
+ reused_session: reusedSession,
1798
+ queued: shouldQueue,
1799
+ allow_mutation: args.allow_mutation === true,
1800
+ },
1801
+ });
1802
+ const sessionState = await readSessionState(namespaceDir, sessionId);
1803
+ const base: Record<string, unknown> = {
1804
+ ok: true,
1805
+ workflow: delegateWorkflow,
1806
+ tool_name: name,
1807
+ session_id: sessionId,
1808
+ turn_id: turn.turn_id,
1809
+ active_turn_id: shouldQueue ? activeTurn?.turn_id : turn.turn_id,
1810
+ status: turn.status,
1811
+ queued: turn.delivery.queued,
1812
+ delivered: turn.delivery.delivered,
1813
+ delivery: turn.delivery,
1814
+ session,
1815
+ session_state: sessionState,
1816
+ turn,
1817
+ awaited: false,
1818
+ artifacts: [],
1819
+ };
1820
+ if (promptAliasIgnored) base.prompt_alias_ignored = true;
1821
+ if (args.await_completion === true && !shouldQueue) {
1822
+ const timeoutMs = boundedTimeoutMs(args.timeout_ms);
1823
+ const pollIntervalMs = boundedPollIntervalMs(args.poll_interval_ms);
1824
+ const deadline = Date.now() + timeoutMs;
1825
+ let payload = await readTurnPayload(turn.turn_id, sessionId, args.lines);
1826
+ while (
1827
+ payload.ok === true &&
1828
+ !TERMINAL_TURN_STATUSES.has((payload.turn as TurnRecord).status) &&
1829
+ Date.now() < deadline
1830
+ ) {
1831
+ const remainingMs = deadline - Date.now();
1832
+ await waitForTurnStateChange(
1833
+ namespaceDir,
1834
+ payload.turn as TurnRecord,
1835
+ Math.min(pollIntervalMs, remainingMs),
1836
+ );
1837
+ payload = await readTurnPayload(turn.turn_id, sessionId, args.lines);
1838
+ }
1839
+ const awaitedTurn = (payload.ok === true ? payload.turn : turn) as TurnRecord;
1840
+ base.awaited = true;
1841
+ base.status = awaitedTurn.status;
1842
+ base.turn = awaitedTurn;
1843
+ base.final_response = (awaitedTurn as unknown as Record<string, unknown>).final_response ?? null;
1844
+ base.evidence = (awaitedTurn as unknown as Record<string, unknown>).evidence ?? [];
1845
+ if (payload.ok === true) {
1846
+ base.session_state = payload.session_state;
1847
+ base.advisory_status = payload.advisory_status;
1848
+ }
1849
+ // Mirror skc_coordinator_await_turn timeout semantics: a still-active
1850
+ // turn at the deadline is a bounded timeout, not a completion.
1851
+ if (!TERMINAL_TURN_STATUSES.has(awaitedTurn.status)) {
1852
+ base.timed_out = true;
1853
+ base.reason = "timeout";
1854
+ base.ok = false;
1855
+ }
1856
+ }
1857
+ return base;
1858
+ }
1590
1859
  if (name === "skc_coordinator_start_session") {
1591
1860
  requireCoordinatorMutation(config, "sessions", args);
1592
1861
  const cwd = await assertCoordinatorWorkdir(config, args.cwd);