@mrciphersmith/keryx 0.3.66 → 0.3.68

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 (4) hide show
  1. package/README.md +22 -2
  2. package/dist/cli.js +1653 -172
  3. package/dist/core.js +18 -0
  4. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -20107,7 +20107,7 @@ var package_default;
20107
20107
  var init_package = __esm(() => {
20108
20108
  package_default = {
20109
20109
  name: "@mrciphersmith/keryx",
20110
- version: "0.3.66",
20110
+ version: "0.3.68",
20111
20111
  description: "Version-controlled project context for AI coding agents: code graph, architecture wiki, project memory, relevant tests, quality signals, and task flows.",
20112
20112
  private: false,
20113
20113
  publishConfig: {
@@ -81424,7 +81424,7 @@ ${shown.join(`
81424
81424
  result = precomputedResult;
81425
81425
  } else {
81426
81426
  try {
81427
- result = await executeCall(call, toolByName, io.requestApproval, io.permissionMode, io.readOnly, io.onAutoApproved, hasInvocationCapacity, reserveInvocation, invocationBudget.maxCalls, signal, deps.busLeases, deps.hardDeny === undefined ? undefined : { check: deps.hardDeny, onDenied: io.onUnattendedDenial }, deps.hooks, untrustedOrigin, io.trustedMcpTools, io.mcpToolFingerprint, io.mcpToolDestructive, deps.unattended === true ? undefined : io.beforeMutation);
81427
+ result = await executeCall(call, toolByName, io.requestApproval, io.permissionMode, io.readOnly, io.onAutoApproved, hasInvocationCapacity, reserveInvocation, invocationBudget.maxCalls, signal, deps.busLeases, deps.hardDeny === undefined ? undefined : { check: deps.hardDeny, onDenied: io.onUnattendedDenial }, deps.hooks, untrustedOrigin, io.mcpGrantsApply?.() === false ? undefined : io.trustedMcpTools, io.mcpToolFingerprint, io.mcpToolDestructive, deps.unattended === true ? undefined : io.beforeMutation);
81428
81428
  } catch (err) {
81429
81429
  const message = err instanceof Error ? err.message : String(err);
81430
81430
  result = { output: `${call.name} failed: ${message}`, isError: true };
@@ -91621,6 +91621,34 @@ function allowShellPattern(pattern, dir) {
91621
91621
  }
91622
91622
  return trimmed;
91623
91623
  }
91624
+ function removeShellPattern(pattern, dir) {
91625
+ const wanted = pattern.trim();
91626
+ if (wanted.length === 0)
91627
+ return false;
91628
+ try {
91629
+ const file = shellPermissionsPath(dir);
91630
+ if (!existsSync36(file))
91631
+ return false;
91632
+ const read = readConfigFile(file);
91633
+ if (!read.ok)
91634
+ return false;
91635
+ const raw = JSON.parse(read.text);
91636
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
91637
+ return false;
91638
+ const allowRaw = raw.allow;
91639
+ if (!Array.isArray(allowRaw))
91640
+ return false;
91641
+ const kept = allowRaw.filter((entry) => !(typeof entry === "string" && entry.trim() === wanted));
91642
+ if (kept.length === allowRaw.length)
91643
+ return false;
91644
+ ensureKeryxConfigDir(path212.dirname(file));
91645
+ writeOwnerOnlyFile(file, `${JSON.stringify({ ...raw, allow: kept }, null, 2)}
91646
+ `);
91647
+ return true;
91648
+ } catch {
91649
+ return false;
91650
+ }
91651
+ }
91624
91652
  function matchShellPattern(pattern, command) {
91625
91653
  const p = pattern.trim();
91626
91654
  const c = command.trim();
@@ -91844,7 +91872,16 @@ function evaluateShellApproval(input) {
91844
91872
  const hookAsk = input.meta?.hookAsk === true;
91845
91873
  const untrustedOrigin = input.meta?.untrustedOrigin === true;
91846
91874
  const audit = io.loadAudit();
91847
- for (const pattern of audit.permissions.allow) {
91875
+ const current = new Set(audit.permissions.allow);
91876
+ const loadedBefore = loadedFromDisk.get(input.sessionAllow);
91877
+ if (loadedBefore !== undefined) {
91878
+ for (const pattern of loadedBefore) {
91879
+ if (!current.has(pattern))
91880
+ input.sessionAllow.delete(pattern);
91881
+ }
91882
+ }
91883
+ loadedFromDisk.set(input.sessionAllow, current);
91884
+ for (const pattern of current) {
91848
91885
  input.sessionAllow.add(pattern);
91849
91886
  }
91850
91887
  const tampered = io.fingerprint() !== input.fingerprintAtStart;
@@ -91899,7 +91936,7 @@ function formatShellApprovalHints(evaled) {
91899
91936
  }
91900
91937
  return lines;
91901
91938
  }
91902
- var defaultApprovalIO;
91939
+ var defaultApprovalIO, loadedFromDisk;
91903
91940
  var init_shell_approval = __esm(() => {
91904
91941
  init_command_risk();
91905
91942
  init_shell_permissions();
@@ -91907,6 +91944,7 @@ var init_shell_approval = __esm(() => {
91907
91944
  loadAudit: () => loadShellPermissionsWithAudit(),
91908
91945
  fingerprint: () => shellPermissionsFingerprint()
91909
91946
  };
91947
+ loadedFromDisk = new WeakMap;
91910
91948
  });
91911
91949
 
91912
91950
  // src/mcp-servers/json-utils.ts
@@ -101030,9 +101068,34 @@ function parseRemoteConfig(value) {
101030
101068
  if (!orphan.ok) {
101031
101069
  return orphan;
101032
101070
  }
101033
- const run = timeout("runTimeoutMs", DEFAULT_RUN_TIMEOUT_MS);
101034
- if (!run.ok) {
101035
- return run;
101071
+ let runTimeoutMs = DEFAULT_RUN_TIMEOUT_MS;
101072
+ if (doc.runTimeoutMs !== undefined) {
101073
+ if (doc.runTimeoutMs === 0) {
101074
+ runTimeoutMs = 0;
101075
+ } else {
101076
+ const run = timeout("runTimeoutMs", DEFAULT_RUN_TIMEOUT_MS);
101077
+ if (!run.ok) {
101078
+ return { ok: false, reason: `remote config runTimeoutMs must be 0 (no limit) or an integer from 1 to ${MAX_TIMEOUT_MS3} (milliseconds)` };
101079
+ }
101080
+ runTimeoutMs = run.value;
101081
+ }
101082
+ }
101083
+ let permissionMode = DEFAULT_REMOTE_PERMISSION_MODE;
101084
+ if (doc.permissionMode !== undefined) {
101085
+ if (doc.permissionMode !== "ask" && doc.permissionMode !== "trust") {
101086
+ return { ok: false, reason: 'remote config permissionMode must be "ask" or "trust" (auto is not available here)' };
101087
+ }
101088
+ permissionMode = doc.permissionMode;
101089
+ }
101090
+ let approvalTimeoutMs = DEFAULT_APPROVAL_WAIT_MS;
101091
+ if (doc.approvalTimeoutMs !== undefined) {
101092
+ if (!isSafeInt(doc.approvalTimeoutMs) || doc.approvalTimeoutMs < MIN_APPROVAL_WAIT_MS || doc.approvalTimeoutMs > MAX_APPROVAL_WAIT_MS) {
101093
+ return {
101094
+ ok: false,
101095
+ reason: `remote config approvalTimeoutMs must be an integer from ${MIN_APPROVAL_WAIT_MS} to ${MAX_APPROVAL_WAIT_MS} (milliseconds)`
101096
+ };
101097
+ }
101098
+ approvalTimeoutMs = doc.approvalTimeoutMs;
101036
101099
  }
101037
101100
  if (doc.rendering !== undefined && !isRenderMode(doc.rendering)) {
101038
101101
  return { ok: false, reason: `remote config rendering must be ${RENDER_MODE_CHOICES}` };
@@ -101044,7 +101107,9 @@ function parseRemoteConfig(value) {
101044
101107
  chatId: doc.chatId,
101045
101108
  allowedUserIds: [...new Set(ids)],
101046
101109
  orphanMs: orphan.value,
101047
- runTimeoutMs: run.value,
101110
+ runTimeoutMs,
101111
+ permissionMode,
101112
+ approvalTimeoutMs,
101048
101113
  ...doc.rendering === undefined ? {} : { rendering: doc.rendering }
101049
101114
  }
101050
101115
  };
@@ -101072,7 +101137,46 @@ function saveRemoteConfig(config, dir) {
101072
101137
  return checked;
101073
101138
  }
101074
101139
  ensureRemoteDir(dir);
101075
- writeOwnerOnlyFileAtomic(remoteConfigPath(dir), `${JSON.stringify(checked.value, null, 2)}
101140
+ const { permissionMode, approvalTimeoutMs, ...base } = checked.value;
101141
+ const onDisk = {
101142
+ ...base,
101143
+ ...config.permissionMode === undefined ? {} : { permissionMode },
101144
+ ...config.approvalTimeoutMs === undefined ? {} : { approvalTimeoutMs }
101145
+ };
101146
+ writeOwnerOnlyFileAtomic(remoteConfigPath(dir), `${JSON.stringify(onDisk, null, 2)}
101147
+ `);
101148
+ return checked;
101149
+ }
101150
+ function updateRemotePolicy(patch, dir) {
101151
+ const file = remoteConfigPath(dir);
101152
+ const read = readConfigFile(file);
101153
+ if (!read.ok) {
101154
+ return {
101155
+ ok: false,
101156
+ reason: read.reason === "absent" ? `no remote-control config at ${file}; connect Telegram first` : `remote-control config at ${file} cannot be read (${read.reason})`
101157
+ };
101158
+ }
101159
+ let doc;
101160
+ try {
101161
+ doc = JSON.parse(read.text);
101162
+ } catch {
101163
+ return { ok: false, reason: `remote-control config at ${file} is not valid JSON` };
101164
+ }
101165
+ if (typeof doc !== "object" || doc === null || Array.isArray(doc)) {
101166
+ return { ok: false, reason: "remote config must be a JSON object" };
101167
+ }
101168
+ const next = { ...doc };
101169
+ if (patch.permissionMode !== undefined)
101170
+ next.permissionMode = patch.permissionMode;
101171
+ if (patch.runTimeoutMs !== undefined)
101172
+ next.runTimeoutMs = patch.runTimeoutMs;
101173
+ if (patch.approvalTimeoutMs !== undefined)
101174
+ next.approvalTimeoutMs = patch.approvalTimeoutMs;
101175
+ const checked = parseRemoteConfig(next);
101176
+ if (!checked.ok) {
101177
+ return checked;
101178
+ }
101179
+ writeOwnerOnlyFileAtomic(file, `${JSON.stringify(next, null, 2)}
101076
101180
  `);
101077
101181
  return checked;
101078
101182
  }
@@ -101139,15 +101243,27 @@ function removeBotToken(dir) {
101139
101243
  function removeRemoteConfig(dir) {
101140
101244
  return removeFile2(remoteConfigPath(dir));
101141
101245
  }
101142
- var REMOTE_CONFIG_SCHEMA_VERSION = 1, DEFAULT_ORPHAN_MS, DEFAULT_RUN_TIMEOUT_MS, MAX_TIMEOUT_MS3, CONFIG_KEYS, TOKEN_SHAPE;
101246
+ var REMOTE_CONFIG_SCHEMA_VERSION = 1, DEFAULT_ORPHAN_MS, DEFAULT_RUN_TIMEOUT_MS = 0, LEGACY_RUN_TIMEOUT_MS, DEFAULT_APPROVAL_WAIT_MS, LEGACY_APPROVAL_WAIT_MS, MIN_APPROVAL_WAIT_MS = 30000, MAX_APPROVAL_WAIT_MS, DEFAULT_REMOTE_PERMISSION_MODE = "trust", MAX_TIMEOUT_MS3, CONFIG_KEYS, TOKEN_SHAPE;
101143
101247
  var init_config11 = __esm(() => {
101144
101248
  init_config_dir();
101145
101249
  init_paths4();
101146
101250
  init_rendering_mode();
101147
101251
  DEFAULT_ORPHAN_MS = 10 * 60000;
101148
- DEFAULT_RUN_TIMEOUT_MS = 30 * 60000;
101252
+ LEGACY_RUN_TIMEOUT_MS = 30 * 60000;
101253
+ DEFAULT_APPROVAL_WAIT_MS = 15 * 60000;
101254
+ LEGACY_APPROVAL_WAIT_MS = 5 * 60000;
101255
+ MAX_APPROVAL_WAIT_MS = 60 * 60000;
101149
101256
  MAX_TIMEOUT_MS3 = 7 * 24 * 60 * 60000;
101150
- CONFIG_KEYS = new Set(["schemaVersion", "chatId", "allowedUserIds", "orphanMs", "runTimeoutMs", "rendering"]);
101257
+ CONFIG_KEYS = new Set([
101258
+ "schemaVersion",
101259
+ "chatId",
101260
+ "allowedUserIds",
101261
+ "orphanMs",
101262
+ "runTimeoutMs",
101263
+ "permissionMode",
101264
+ "approvalTimeoutMs",
101265
+ "rendering"
101266
+ ]);
101151
101267
  TOKEN_SHAPE = /^\d{5,}:[A-Za-z0-9_-]{20,}$/;
101152
101268
  });
101153
101269
 
@@ -101219,6 +101335,308 @@ var init_rendering_command = __esm(() => {
101219
101335
  };
101220
101336
  });
101221
101337
 
101338
+ // src/commands/permissions-command.ts
101339
+ function loadPermissionsView(options = {}) {
101340
+ const audit = loadShellPermissionsWithAudit(options.dir);
101341
+ const stored = new Set([...audit.permissions.allow, ...audit.rejected.map((entry) => entry.pattern)]);
101342
+ const rows = [];
101343
+ const push = (pattern, kind, reason) => {
101344
+ rows.push({ n: rows.length + 1, pattern, kind, ...reason !== undefined ? { reason } : {} });
101345
+ };
101346
+ for (const pattern of audit.permissions.allow)
101347
+ push(pattern, "active");
101348
+ audit.rejected.forEach((entry) => push(entry.pattern, "inactive", entry.reason));
101349
+ for (const pattern of options.sessionAllow ?? []) {
101350
+ if (!stored.has(pattern))
101351
+ push(pattern, "session");
101352
+ }
101353
+ return { path: shellPermissionsPath(options.dir), rows };
101354
+ }
101355
+ function patternOnOneLine(pattern) {
101356
+ return pattern.replace(/\r?\n/g, "\\n").replace(CONTROL, " ");
101357
+ }
101358
+ function permissionsLines(view) {
101359
+ const of = (kind) => view.rows.filter((row) => row.kind === kind);
101360
+ const active = of("active");
101361
+ const inactive = of("inactive");
101362
+ const session = of("session");
101363
+ const lines = [];
101364
+ lines.push(active.length === 0 ? "No saved shell rules: every command that is not read-only asks first." : `Saved shell rules (${active.length}): commands that run without asking.`);
101365
+ for (const row of active)
101366
+ lines.push(` ${row.n}. ${patternOnOneLine(row.pattern)}`);
101367
+ if (inactive.length > 0) {
101368
+ lines.push("", `Not honoured (${inactive.length}): kept in the file, they approve nothing.`);
101369
+ for (const row of inactive) {
101370
+ lines.push(` ${row.n}. ${patternOnOneLine(row.pattern)}`);
101371
+ lines.push(` ${row.reason ?? "refused by the validators"}`);
101372
+ }
101373
+ }
101374
+ if (session.length > 0) {
101375
+ lines.push("", `Granted for this session only (${session.length}):`);
101376
+ for (const row of session)
101377
+ lines.push(` ${row.n}. ${patternOnOneLine(row.pattern)}`);
101378
+ }
101379
+ lines.push("", `File: ${view.path}`);
101380
+ if (view.rows.length > 0)
101381
+ lines.push("Remove one with /permissions remove <number>, or keryx permissions remove <number>.");
101382
+ return lines;
101383
+ }
101384
+ function resolvePermissionTarget(view, target) {
101385
+ const wanted = target.trim();
101386
+ if (wanted.length === 0)
101387
+ return;
101388
+ const byText = view.rows.find((row) => row.pattern === wanted);
101389
+ if (byText !== undefined)
101390
+ return byText;
101391
+ if (/^\d+$/.test(wanted))
101392
+ return view.rows.find((row) => row.n === Number(wanted));
101393
+ return;
101394
+ }
101395
+ function removePermission(target, options = {}) {
101396
+ const view = loadPermissionsView({
101397
+ ...options.dir !== undefined ? { dir: options.dir } : {},
101398
+ ...options.sessionAllow !== undefined ? { sessionAllow: options.sessionAllow } : {}
101399
+ });
101400
+ const row = resolvePermissionTarget(view, target);
101401
+ if (row === undefined) {
101402
+ return { ok: false, text: `No saved rule matches ${JSON.stringify(target.trim())}. Run list to see the numbers.` };
101403
+ }
101404
+ if (row.kind === "session") {
101405
+ options.sessionAllow?.delete(row.pattern);
101406
+ return { ok: true, pattern: row.pattern, text: `Removed the session grant: ${patternOnOneLine(row.pattern)}` };
101407
+ }
101408
+ if (!removeShellPattern(row.pattern, options.dir)) {
101409
+ return { ok: false, pattern: row.pattern, text: `Could not remove ${patternOnOneLine(row.pattern)} from ${view.path}.` };
101410
+ }
101411
+ options.sessionAllow?.delete(row.pattern);
101412
+ options.onChanged?.();
101413
+ return { ok: true, pattern: row.pattern, text: `Removed: ${patternOnOneLine(row.pattern)}. It asks again from now on.` };
101414
+ }
101415
+ function permissionsSlashText(rest, options = {}) {
101416
+ const trimmed = rest.trim();
101417
+ const [sub] = trimmed.split(/\s+/);
101418
+ if (sub === "remove" || sub === "rm") {
101419
+ const target = trimmed.slice(sub.length).trim();
101420
+ if (target.length === 0)
101421
+ return `Usage: /permissions remove <number | pattern>
101422
+ `;
101423
+ return `${removePermission(target, options).text}
101424
+ `;
101425
+ }
101426
+ if (trimmed.length > 0 && sub !== "list")
101427
+ return `Usage: /permissions [list | remove <number | pattern>]
101428
+ `;
101429
+ return `${permissionsLines(loadPermissionsView({
101430
+ ...options.dir !== undefined ? { dir: options.dir } : {},
101431
+ ...options.sessionAllow !== undefined ? { sessionAllow: options.sessionAllow } : {}
101432
+ })).join(`
101433
+ `)}
101434
+ `;
101435
+ }
101436
+ function printPermissionsHelp() {
101437
+ console.log([
101438
+ "Usage: keryx permissions <subcommand>",
101439
+ "",
101440
+ " keryx permissions list [--json] The saved shell rules: honoured, and not honoured with the reason.",
101441
+ " keryx permissions remove <number|pattern>",
101442
+ " Take one rule back. The number is the one `list` prints.",
101443
+ "",
101444
+ "A rule is saved when an approval is answered with Always, in the shell or from Telegram. It",
101445
+ "is removed here, never from a chat. A running shell stops using a removed rule on its next",
101446
+ "approval and warns that the file changed."
101447
+ ].join(`
101448
+ `));
101449
+ }
101450
+ async function permissionsCommand(args) {
101451
+ const [sub, ...rest] = args;
101452
+ if (sub === "--help" || sub === "-h" || sub === "help") {
101453
+ printPermissionsHelp();
101454
+ return;
101455
+ }
101456
+ if (sub === undefined || sub === "list" || sub === "--json") {
101457
+ const flags = sub === "--json" ? [sub, ...rest] : rest;
101458
+ const unknown = flags.filter((arg) => arg !== "--json");
101459
+ if (unknown.length > 0) {
101460
+ throw new Error(`Unknown option${unknown.length > 1 ? "s" : ""} for \`keryx permissions list\`: ${unknown.join(", ")}. Accepted: --json.`);
101461
+ }
101462
+ const view = loadPermissionsView();
101463
+ if (flags.includes("--json")) {
101464
+ console.log(JSON.stringify({
101465
+ path: view.path,
101466
+ rules: view.rows.map((row) => ({
101467
+ n: row.n,
101468
+ pattern: row.pattern,
101469
+ honoured: row.kind === "active",
101470
+ ...row.reason !== undefined ? { reason: row.reason } : {}
101471
+ }))
101472
+ }, null, 2));
101473
+ return;
101474
+ }
101475
+ console.log(permissionsLines(view).join(`
101476
+ `));
101477
+ return;
101478
+ }
101479
+ if (sub === "remove" || sub === "rm") {
101480
+ const target = rest.join(" ").trim();
101481
+ if (target.length === 0 || target.startsWith("--")) {
101482
+ throw new Error("Usage: keryx permissions remove <number | pattern>");
101483
+ }
101484
+ const result = removePermission(target);
101485
+ if (!result.ok)
101486
+ throw new Error(result.text);
101487
+ console.log(result.text);
101488
+ return;
101489
+ }
101490
+ throw new Error(`Unknown permissions subcommand: ${sub}. Run \`keryx permissions --help\`.`);
101491
+ }
101492
+ var CONTROL;
101493
+ var init_permissions_command = __esm(() => {
101494
+ init_shell_permissions();
101495
+ CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g;
101496
+ });
101497
+
101498
+ // src/commands/remote-policy-command.ts
101499
+ function formatPolicyDuration(ms) {
101500
+ if (ms <= 0)
101501
+ return "none";
101502
+ const seconds = Math.round(ms / 1000);
101503
+ if (seconds < 60)
101504
+ return `${seconds}s`;
101505
+ const minutes = Math.floor(seconds / 60);
101506
+ const rest = seconds % 60;
101507
+ if (rest !== 0)
101508
+ return `${minutes}m ${rest}s`;
101509
+ if (minutes >= 60 && minutes % 60 === 0)
101510
+ return `${minutes / 60}h`;
101511
+ return `${minutes}m`;
101512
+ }
101513
+ function countSavedRules(dir) {
101514
+ try {
101515
+ return loadPermissionsView(dir !== undefined ? { dir } : {}).rows.filter((row) => row.kind === "active").length;
101516
+ } catch {
101517
+ return 0;
101518
+ }
101519
+ }
101520
+ function postureLines(posture) {
101521
+ const lines = [
101522
+ `Mode: ${posture.defaultMode} - a Telegram turn starts with it until /mode changes the shell's mode`,
101523
+ `Run limit: ${formatPolicyDuration(posture.runTimeoutMs)}${posture.runTimeoutMs === 0 ? " (/stop ends a run)" : ""}`,
101524
+ `Approval wait: ${formatPolicyDuration(posture.approvalTimeoutMs)}`,
101525
+ `Saved shell rules: ${posture.savedRules} (${"/permissions"} lists and removes them)`
101526
+ ];
101527
+ if (posture.inForce !== undefined)
101528
+ lines.unshift(`In force now: ${posture.inForce}`);
101529
+ return lines;
101530
+ }
101531
+ function postureSidebarText(posture) {
101532
+ const limit = posture.runTimeoutMs > 0 ? `limit ${formatPolicyDuration(posture.runTimeoutMs)}` : "no limit";
101533
+ const first = `${posture.defaultMode} \xB7 ${limit} \xB7 wait ${formatPolicyDuration(posture.approvalTimeoutMs)}`;
101534
+ return posture.savedRules > 0 ? `${first}
101535
+ ${posture.savedRules} saved shell rule${posture.savedRules === 1 ? "" : "s"}` : first;
101536
+ }
101537
+ function wholeMinutes(word) {
101538
+ if (word === undefined || !/^\d{1,6}$/.test(word))
101539
+ return;
101540
+ return Number(word);
101541
+ }
101542
+ function parseRemotePolicyArgs(rest) {
101543
+ const words = rest.trim().split(/\s+/).filter((word) => word.length > 0);
101544
+ if (words.length === 0)
101545
+ return { action: "show" };
101546
+ const patch = {};
101547
+ let index = 0;
101548
+ while (index < words.length) {
101549
+ const key = (words[index] ?? "").toLowerCase();
101550
+ const value = words[index + 1];
101551
+ if (value === undefined)
101552
+ return { action: "invalid", message: REMOTE_POLICY_USAGE };
101553
+ const lower = value.toLowerCase();
101554
+ if (key === "mode") {
101555
+ if (patch.permissionMode !== undefined)
101556
+ return { action: "invalid", message: REMOTE_POLICY_USAGE };
101557
+ if (lower === "auto") {
101558
+ return { action: "invalid", message: "auto is not available as a Telegram default: it skips every confirmation. Use /mode auto in the shell if you mean it." };
101559
+ }
101560
+ if (lower !== "ask" && lower !== "trust")
101561
+ return { action: "invalid", message: `Unknown mode '${value}'. Choose ask or trust.` };
101562
+ patch.permissionMode = lower;
101563
+ } else if (key === "limit") {
101564
+ if (patch.runTimeoutMs !== undefined)
101565
+ return { action: "invalid", message: REMOTE_POLICY_USAGE };
101566
+ if (lower === "none" || lower === "off" || lower === "0") {
101567
+ patch.runTimeoutMs = 0;
101568
+ } else {
101569
+ const minutes = wholeMinutes(value);
101570
+ if (minutes === undefined || minutes < 1 || minutes > MAX_LIMIT_MINUTES) {
101571
+ return { action: "invalid", message: `limit must be none or a whole number of minutes from 1 to ${MAX_LIMIT_MINUTES}.` };
101572
+ }
101573
+ patch.runTimeoutMs = minutes * 60000;
101574
+ }
101575
+ } else if (key === "wait") {
101576
+ if (patch.approvalTimeoutMs !== undefined)
101577
+ return { action: "invalid", message: REMOTE_POLICY_USAGE };
101578
+ const minutes = wholeMinutes(value);
101579
+ if (minutes === undefined || minutes < 1 || minutes > MAX_WAIT_MINUTES) {
101580
+ return { action: "invalid", message: `wait must be a whole number of minutes from 1 to ${MAX_WAIT_MINUTES}.` };
101581
+ }
101582
+ patch.approvalTimeoutMs = minutes * 60000;
101583
+ } else {
101584
+ return { action: "invalid", message: REMOTE_POLICY_USAGE };
101585
+ }
101586
+ index += 2;
101587
+ }
101588
+ return { action: "set", patch };
101589
+ }
101590
+ function loadPosture(deps = {}) {
101591
+ const base = deps.running?.() ?? (() => {
101592
+ const loaded = loadRemoteConfig(deps.dir);
101593
+ return loaded.ok ? { defaultMode: loaded.value.permissionMode, runTimeoutMs: loaded.value.runTimeoutMs, approvalTimeoutMs: loaded.value.approvalTimeoutMs } : undefined;
101594
+ })();
101595
+ if (base === undefined)
101596
+ return;
101597
+ const inForce = deps.inForce?.();
101598
+ return { ...base, ...inForce !== undefined ? { inForce } : {}, savedRules: countSavedRules(deps.dir) };
101599
+ }
101600
+ function remotePolicyText(rest, deps = {}) {
101601
+ const request = parseRemotePolicyArgs(rest);
101602
+ if (request.action === "invalid")
101603
+ return `${request.message}
101604
+ `;
101605
+ if (request.action === "set") {
101606
+ const saved = updateRemotePolicy(request.patch, deps.dir);
101607
+ if (!saved.ok)
101608
+ return `Not changed: ${saved.reason}
101609
+ `;
101610
+ deps.applyPolicy?.(request.patch);
101611
+ const posture = loadPosture(deps);
101612
+ const lines = [
101613
+ "Saved. Telegram defaults:",
101614
+ ...posture !== undefined ? postureLines(posture) : [],
101615
+ deps.applyPolicy !== undefined ? "This shell uses them from the next Telegram turn; a shell that registers later gets them when keryx serve restarts." : "Open shells keep their values until they are restarted; keryx serve delivers these to a shell that registers after it restarts.",
101616
+ SHELL_MODE_NOTE
101617
+ ];
101618
+ return `${lines.join(`
101619
+ `)}
101620
+ `;
101621
+ }
101622
+ const posture = loadPosture(deps);
101623
+ if (posture === undefined) {
101624
+ return `Telegram is not connected, so there is nothing to change yet. Connect it with /channels.
101625
+ ${REMOTE_POLICY_USAGE}
101626
+ `;
101627
+ }
101628
+ return `${["Telegram permissions:", ...postureLines(posture), SHELL_MODE_NOTE, REMOTE_POLICY_USAGE].join(`
101629
+ `)}
101630
+ `;
101631
+ }
101632
+ var REMOTE_POLICY_COMMAND = "/remote-policy", REMOTE_POLICY_USAGE, MAX_LIMIT_MINUTES, MAX_WAIT_MINUTES = 60, SHELL_MODE_NOTE = "A /mode in the shell (or typed in the topic) changes the shell's mode and wins for every turn after that; /remote-policy never changes it.";
101633
+ var init_remote_policy_command = __esm(() => {
101634
+ init_config11();
101635
+ init_permissions_command();
101636
+ REMOTE_POLICY_USAGE = `Usage: ${REMOTE_POLICY_COMMAND} [mode ask|trust] [limit none|<minutes>] [wait <minutes>]`;
101637
+ MAX_LIMIT_MINUTES = 7 * 24 * 60;
101638
+ });
101639
+
101222
101640
  // src/tui/reasoning-display.ts
101223
101641
  function parseThinkDisplayMode(raw) {
101224
101642
  if (raw === undefined) {
@@ -101798,6 +102216,63 @@ function reasoningScope(reasoning) {
101798
102216
  return { scope: "saved" };
101799
102217
  }
101800
102218
  }
102219
+ function telegramRows(state) {
102220
+ if (state.telegram === undefined)
102221
+ return [];
102222
+ const posture = state.telegram.posture;
102223
+ const notConnected = "not connected (/channels)";
102224
+ const modeDetail = posture === undefined ? "connect Telegram first" : `${posture.inForce !== undefined ? `in force: ${posture.inForce}; ` : ""}a /mode in the shell wins once used`;
102225
+ const minutes = (ms) => Math.round(ms / 60000);
102226
+ return [
102227
+ {
102228
+ id: "tg-mode",
102229
+ group: "Telegram",
102230
+ label: "Telegram mode",
102231
+ value: posture?.defaultMode ?? notConnected,
102232
+ scope: "saved",
102233
+ detail: modeDetail,
102234
+ command: "/remote-policy",
102235
+ usage: "/remote-policy mode ask|trust",
102236
+ actions: posture === undefined ? [] : ["ask", "trust"].map((mode) => action(mode, `/remote-policy mode ${mode}`, mode === posture.defaultMode))
102237
+ },
102238
+ {
102239
+ id: "tg-limit",
102240
+ group: "Telegram",
102241
+ label: "Telegram run limit",
102242
+ value: posture === undefined ? notConnected : formatPolicyDuration(posture.runTimeoutMs),
102243
+ scope: "saved",
102244
+ detail: "/stop in the topic always ends a run",
102245
+ command: "/remote-policy",
102246
+ usage: "/remote-policy limit none|<minutes>",
102247
+ actions: posture === undefined ? [] : [
102248
+ action("None", "/remote-policy limit none", posture.runTimeoutMs === 0),
102249
+ ...LIMIT_CHOICES_MIN.map((m) => action(`${m} min`, `/remote-policy limit ${m}`, minutes(posture.runTimeoutMs) === m && posture.runTimeoutMs > 0))
102250
+ ]
102251
+ },
102252
+ {
102253
+ id: "tg-wait",
102254
+ group: "Telegram",
102255
+ label: "Telegram approval wait",
102256
+ value: posture === undefined ? notConnected : formatPolicyDuration(posture.approvalTimeoutMs),
102257
+ scope: "saved",
102258
+ detail: "a prompt nobody answers in this time is denied",
102259
+ command: "/remote-policy",
102260
+ usage: "/remote-policy wait <minutes>",
102261
+ actions: posture === undefined ? [] : WAIT_CHOICES_MIN.map((m) => action(`${m} min`, `/remote-policy wait ${m}`, posture.approvalTimeoutMs === m * 60000))
102262
+ },
102263
+ {
102264
+ id: "tg-rules",
102265
+ group: "Telegram",
102266
+ label: "Saved shell rules",
102267
+ value: posture === undefined ? "none" : String(posture.savedRules),
102268
+ scope: "saved",
102269
+ detail: "what Always remembered, from the shell or Telegram",
102270
+ command: "/permissions",
102271
+ usage: "/permissions [list | remove <number | pattern>]",
102272
+ actions: []
102273
+ }
102274
+ ];
102275
+ }
101801
102276
  function buildSettingsRows(state) {
101802
102277
  const reasoning = reasoningScope(state.reasoning);
101803
102278
  return [
@@ -101931,7 +102406,8 @@ function buildSettingsRows(state) {
101931
102406
  command: "/jevprofile",
101932
102407
  usage: "/jevprofile",
101933
102408
  actions: []
101934
- }
102409
+ },
102410
+ ...telegramRows(state)
101935
102411
  ];
101936
102412
  }
101937
102413
  function formatSettingsTable(rows, options = {}) {
@@ -101955,19 +102431,22 @@ function formatSettingsTable(rows, options = {}) {
101955
102431
  `)}
101956
102432
  `;
101957
102433
  }
101958
- var SETTING_GROUPS, onOff = (value) => value ? "on" : "off", EXTERNAL_PRIVACY_DETAIL;
102434
+ var SETTING_GROUPS, onOff = (value) => value ? "on" : "off", EXTERNAL_PRIVACY_DETAIL, LIMIT_CHOICES_MIN, WAIT_CHOICES_MIN;
101959
102435
  var init_settings_model = __esm(() => {
101960
102436
  init_agent();
101961
102437
  init_permission_mode();
101962
102438
  init_rendering_mode();
102439
+ init_remote_policy_command();
101963
102440
  init_reasoning_display();
101964
102441
  init_theme();
101965
- SETTING_GROUPS = ["Safety", "Routing", "Display", "External"];
102442
+ SETTING_GROUPS = ["Safety", "Routing", "Display", "External", "Telegram"];
101966
102443
  EXTERNAL_PRIVACY_DETAIL = {
101967
102444
  project: "this project overrides the per-user setting",
101968
102445
  user: undefined,
101969
102446
  default: "default"
101970
102447
  };
102448
+ LIMIT_CHOICES_MIN = [30, 60];
102449
+ WAIT_CHOICES_MIN = [5, 15, 30];
101971
102450
  });
101972
102451
 
101973
102452
  // src/review/jev-profile.ts
@@ -102630,6 +103109,25 @@ async function orElse(read, fallback) {
102630
103109
  return fallback;
102631
103110
  }
102632
103111
  }
103112
+ function readTelegram(live, configDir) {
103113
+ try {
103114
+ const running = live.telegram;
103115
+ const posture = loadPosture({
103116
+ ...configDir !== undefined ? { dir: configDir } : {},
103117
+ ...running !== undefined ? {
103118
+ running: () => ({
103119
+ defaultMode: running.defaultMode,
103120
+ runTimeoutMs: running.runTimeoutMs,
103121
+ approvalTimeoutMs: running.approvalTimeoutMs
103122
+ }),
103123
+ ...running.inForce !== undefined ? { inForce: () => running.inForce } : {}
103124
+ } : {}
103125
+ });
103126
+ return posture === undefined ? {} : { posture };
103127
+ } catch {
103128
+ return {};
103129
+ }
103130
+ }
102633
103131
  async function loadSettingsSnapshot(cwd, live, configDir) {
102634
103132
  const saved = loadShellConfig(configDir);
102635
103133
  const projectMode = getProjectPermissionMode(cwd, configDir);
@@ -102655,12 +103153,14 @@ async function loadSettingsSnapshot(cwd, live, configDir) {
102655
103153
  total: RECOMMENDED_JEV_PROFILE.length
102656
103154
  },
102657
103155
  externalAgents: agents.ok ? { on: true } : { on: false, reason: agents.reason },
102658
- rendering: readRendering(configDir)
103156
+ rendering: readRendering(configDir),
103157
+ telegram: readTelegram(live, configDir)
102659
103158
  };
102660
103159
  }
102661
103160
  var init_settings_state = __esm(() => {
102662
103161
  init_external_agents();
102663
103162
  init_agent();
103163
+ init_remote_policy_command();
102664
103164
  init_review_jev_profile();
102665
103165
  init_external_switch();
102666
103166
  init_permission_mode_config();
@@ -104124,6 +104624,12 @@ var init_help_groups = __esm(() => {
104124
104624
  group: "Automation",
104125
104625
  summary: "Pending remote approvals: list them, allow or deny one call, once (the local answer path of keryx serve)."
104126
104626
  },
104627
+ {
104628
+ kind: "cli",
104629
+ name: "permissions",
104630
+ group: "Automation",
104631
+ summary: "The saved shell rules an Always answer leaves behind: list them (honoured or not, with the reason) and remove one."
104632
+ },
104127
104633
  {
104128
104634
  kind: "cli",
104129
104635
  name: "governance",
@@ -104178,6 +104684,12 @@ var init_help_groups = __esm(() => {
104178
104684
  group: "Automation",
104179
104685
  summary: "Pending remote approvals with scope, consequence and expiry \u2014 allow or deny one call, once."
104180
104686
  },
104687
+ {
104688
+ kind: "slash",
104689
+ name: "/permissions",
104690
+ group: "Automation",
104691
+ summary: "The saved shell rules (what Always remembered): honoured or not, session grants \u2014 remove one."
104692
+ },
104181
104693
  {
104182
104694
  kind: "slash",
104183
104695
  name: "/external-diff",
@@ -104226,6 +104738,12 @@ var init_help_groups = __esm(() => {
104226
104738
  group: "Automation",
104227
104739
  summary: "How Telegram replies are written: tables and lists as rich messages or HTML, with a fallback. [auto|rich|html|plain]."
104228
104740
  },
104741
+ {
104742
+ kind: "slash",
104743
+ name: "/remote-policy",
104744
+ group: "Automation",
104745
+ summary: "Telegram defaults for a turn started there: mode ask|trust, run limit, approval wait. Never changes the shell's /mode."
104746
+ },
104229
104747
  {
104230
104748
  kind: "cli",
104231
104749
  name: "acp",
@@ -114453,6 +114971,155 @@ var init_reviews_inspector = __esm(() => {
114453
114971
  ];
114454
114972
  });
114455
114973
 
114974
+ // src/tui/permissions-inspector.ts
114975
+ function formatPermissionsModal(view, selected, notice) {
114976
+ const lines = [];
114977
+ const rowStart = [];
114978
+ const active = view.rows.filter((row) => row.kind === "active").length;
114979
+ lines.push(view.rows.length === 0 ? "No saved shell rules: every command that is not read-only asks first." : `Saved shell rules (${active}) - d removes the selected one, it asks again from then on`);
114980
+ if (notice !== undefined && notice.length > 0)
114981
+ lines.push(notice);
114982
+ lines.push("");
114983
+ let section;
114984
+ view.rows.forEach((row, index) => {
114985
+ if (row.kind !== section) {
114986
+ section = row.kind;
114987
+ if (row.kind === "inactive")
114988
+ lines.push("Not honoured: kept in the file, they approve nothing", "");
114989
+ if (row.kind === "session")
114990
+ lines.push("Granted for this session only", "");
114991
+ }
114992
+ rowStart.push(lines.length);
114993
+ lines.push(`${index === selected ? ">" : " "} ${row.n}. ${patternOnOneLine(row.pattern)}`);
114994
+ if (row.reason !== undefined)
114995
+ lines.push(` ${row.reason}`);
114996
+ lines.push("");
114997
+ });
114998
+ lines.push(`File: ${view.path}`);
114999
+ return { rows: view.rows, lines, rowStart };
115000
+ }
115001
+ function openPermissions(otui, chrome, options) {
115002
+ const core = otui;
115003
+ const r = chrome?.renderer;
115004
+ const rendererHint = options.renderer ?? chrome?.renderer;
115005
+ const panelRows = typeof rendererHint?.width === "number" && typeof rendererHint.height === "number" ? modalBodyRows(resolveModalPanelSize(rendererHint.width, rendererHint.height).height) : 13;
115006
+ const bodyRows = Math.max(1, options.visibleRows ?? panelRows);
115007
+ const load = options.load ?? (() => loadPermissionsView(options.sessionAllow !== undefined ? { sessionAllow: options.sessionAllow } : {}));
115008
+ const remove = options.remove ?? ((target) => removePermission(target, {
115009
+ ...options.sessionAllow !== undefined ? { sessionAllow: options.sessionAllow } : {},
115010
+ ...options.onChanged !== undefined ? { onChanged: options.onChanged } : {}
115011
+ }));
115012
+ let selected = 0;
115013
+ let scroll = 0;
115014
+ let width;
115015
+ let bodyNode;
115016
+ let closed = false;
115017
+ let notice;
115018
+ let armed;
115019
+ const keys = {};
115020
+ const model = () => formatPermissionsModal(load(), selected, armed !== undefined ? `Remove ${patternOnOneLine(armed)}? press y to confirm, any other key cancels` : notice);
115021
+ const clampSelected = (rows) => {
115022
+ selected = Math.min(Math.max(0, rows - 1), Math.max(0, selected));
115023
+ };
115024
+ const visible = () => {
115025
+ const view = model();
115026
+ clampSelected(view.rows.length);
115027
+ return windowLines(wrapLines(view.lines.join(`
115028
+ `), width).split(`
115029
+ `), scroll, bodyRows);
115030
+ };
115031
+ const paint = () => {
115032
+ if (closed)
115033
+ return;
115034
+ const view = model();
115035
+ clampSelected(view.rows.length);
115036
+ const wrapped = wrapLines(view.lines.join(`
115037
+ `), width).split(`
115038
+ `);
115039
+ const start = view.rowStart[selected] ?? 0;
115040
+ scroll = clampScroll(scrollToReveal(start, scroll, bodyRows), wrapped.length, bodyRows);
115041
+ if (bodyNode !== undefined)
115042
+ bodyNode.content = core.t`${dimChunk(core, windowLines(wrapped, scroll, bodyRows).join(`
115043
+ `))}`;
115044
+ };
115045
+ let unsubscribeTheme = () => {};
115046
+ const handle = openModal(core, chrome, {
115047
+ title: PERMISSIONS_COMMAND,
115048
+ tabs: [{ id: "rules", label: "Shell rules" }],
115049
+ footer: PERMISSIONS_FOOTER,
115050
+ renderTab: (_tabId, body, ctx) => {
115051
+ width = ctx.width;
115052
+ const parent = body;
115053
+ bodyNode = new core.TextRenderable(r, { id: "permissions-body", content: "" });
115054
+ parent.add(bodyNode);
115055
+ paint();
115056
+ },
115057
+ onClose: () => {
115058
+ closed = true;
115059
+ keys.off?.();
115060
+ unsubscribeTheme();
115061
+ }
115062
+ });
115063
+ if (handle === undefined)
115064
+ return;
115065
+ const modal = handle;
115066
+ unsubscribeTheme = onThemeChange(guardedThemeRepaint("permissions-modal", paint, () => closed || isRenderableGone(bodyNode) || r?.isDestroyed === true));
115067
+ const selectedRow = () => model().rows[selected];
115068
+ keys.off = options.onKeypress((key) => {
115069
+ if (closed || options.inputBlocked?.() === true)
115070
+ return;
115071
+ const token = key.name || key.sequence;
115072
+ if (armed !== undefined) {
115073
+ const pattern = armed;
115074
+ armed = undefined;
115075
+ if (token === "y") {
115076
+ notice = remove(pattern).text;
115077
+ }
115078
+ paint();
115079
+ return;
115080
+ }
115081
+ if (token === "up" || token === "k") {
115082
+ selected = Math.max(0, selected - 1);
115083
+ notice = undefined;
115084
+ } else if (token === "down" || token === "j") {
115085
+ selected += 1;
115086
+ notice = undefined;
115087
+ } else if (token === "d") {
115088
+ const row = selectedRow();
115089
+ if (row === undefined)
115090
+ notice = "Nothing to remove.";
115091
+ else
115092
+ armed = row.pattern;
115093
+ } else {
115094
+ return;
115095
+ }
115096
+ paint();
115097
+ });
115098
+ return {
115099
+ ...modal,
115100
+ visibleLines: visible,
115101
+ selected: selectedRow,
115102
+ reload: () => {
115103
+ paint();
115104
+ }
115105
+ };
115106
+ }
115107
+ var PERMISSIONS_COMMAND = "/permissions", PERMISSIONS_FOOTER;
115108
+ var init_permissions_inspector = __esm(() => {
115109
+ init_permissions_command();
115110
+ init_flow_inspector();
115111
+ init_modal_host();
115112
+ init_theme();
115113
+ init_theme_repaint();
115114
+ init_theme_text();
115115
+ PERMISSIONS_FOOTER = [
115116
+ { key: "\u2191/\u2193", label: "move" },
115117
+ { key: "d", label: "remove" },
115118
+ { key: "y", label: "confirm" },
115119
+ { key: "esc", label: "close" }
115120
+ ];
115121
+ });
115122
+
114456
115123
  // src/lib/serve-config.ts
114457
115124
  import { existsSync as existsSync42 } from "fs";
114458
115125
  import path249 from "path";
@@ -114789,6 +115456,9 @@ function channelsRoutePath(route) {
114789
115456
  function isSessionId(value) {
114790
115457
  return typeof value === "string" && SESSION_ID_PATTERN.test(value);
114791
115458
  }
115459
+ function isApprovalId(value) {
115460
+ return typeof value === "string" && APPROVAL_ID_PATTERN.test(value);
115461
+ }
114792
115462
  function approvalCallbackData(approvalId, decision) {
114793
115463
  return `ap:${approvalId}:${decision}`;
114794
115464
  }
@@ -114861,7 +115531,7 @@ class SseParser {
114861
115531
  return frames;
114862
115532
  }
114863
115533
  }
114864
- var REMOTE_API_PREFIX = "/v1/remote", REMOTE_SCHEMA_VERSION = "1.0.0", REMOTE_ROUTE_METHODS, CHANNELS_ROUTE_METHODS, MAX_REMOTE_BODY_BYTES, MAX_PROJECT_CHARS = 256, MAX_APPROVAL_PROMPT_CHARS = 3000, MAX_KEYBOARD_ROWS = 8, MAX_KEYBOARD_BUTTONS_PER_ROW = 8, MAX_BUTTON_TEXT_CHARS = 64, MAX_CALLBACK_DATA_BYTES = 64, DEFAULT_APPROVAL_TIMEOUT_MS, MAX_APPROVAL_TIMEOUT_MS, MIN_APPROVAL_TIMEOUT_MS = 100, DEFAULT_KEEPALIVE_MS = 15000, DEFAULT_ACK_TIMEOUT_MS = 30000, SESSION_ID_PATTERN, APPROVAL_CALLBACK_PATTERN, RESERVED_CALLBACK_PREFIX = "ap:", CHOICE_CALLBACK_PATTERN, RESERVED_CHOICE_PREFIX = "pk:", MAX_PROMPT_BUTTONS = 24, MESSAGE_STATES, SSE_KEEPALIVE_FRAME = `: keepalive
115534
+ var REMOTE_API_PREFIX = "/v1/remote", REMOTE_SCHEMA_VERSION = "1.0.0", REMOTE_ROUTE_METHODS, CHANNELS_ROUTE_METHODS, MAX_REMOTE_BODY_BYTES, MAX_PROJECT_CHARS = 256, MAX_APPROVAL_PROMPT_CHARS = 3000, MAX_KEYBOARD_ROWS = 8, MAX_KEYBOARD_BUTTONS_PER_ROW = 8, MAX_BUTTON_TEXT_CHARS = 64, MAX_CALLBACK_DATA_BYTES = 64, DEFAULT_APPROVAL_TIMEOUT_MS, MAX_APPROVAL_TIMEOUT_MS, MIN_APPROVAL_TIMEOUT_MS = 100, DEFAULT_APPROVAL_ACK_MS = 5000, DEFAULT_KEEPALIVE_MS = 15000, DEFAULT_ACK_TIMEOUT_MS = 30000, SESSION_ID_PATTERN, APPROVAL_ID_PATTERN, APPROVAL_CALLBACK_PATTERN, RESERVED_CALLBACK_PREFIX = "ap:", CHOICE_CALLBACK_PATTERN, RESERVED_CHOICE_PREFIX = "pk:", MAX_PROMPT_BUTTONS = 24, MAX_REMEMBER_PATTERN_CHARS = 300, APPROVAL_INPUT_PREVIEW_CHARS = 1500, MESSAGE_STATES, SSE_KEEPALIVE_FRAME = `: keepalive
114865
115535
 
114866
115536
  `;
114867
115537
  var init_protocol2 = __esm(() => {
@@ -114871,9 +115541,11 @@ var init_protocol2 = __esm(() => {
114871
115541
  heartbeat: "POST",
114872
115542
  reply: "POST",
114873
115543
  approval: "POST",
115544
+ "approval-result": "POST",
114874
115545
  prompt: "POST",
114875
115546
  state: "POST",
114876
115547
  ack: "POST",
115548
+ "approval-ack": "POST",
114877
115549
  stream: "GET"
114878
115550
  };
114879
115551
  CHANNELS_ROUTE_METHODS = {
@@ -114889,7 +115561,8 @@ var init_protocol2 = __esm(() => {
114889
115561
  DEFAULT_APPROVAL_TIMEOUT_MS = 5 * 60000;
114890
115562
  MAX_APPROVAL_TIMEOUT_MS = 60 * 60000;
114891
115563
  SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
114892
- APPROVAL_CALLBACK_PATTERN = /^ap:(ap[0-9a-f]{12}):(allow|deny)$/;
115564
+ APPROVAL_ID_PATTERN = /^ap[0-9a-f]{12}$/;
115565
+ APPROVAL_CALLBACK_PATTERN = /^ap:(ap[0-9a-f]{12}):(allow|deny|always)$/;
114893
115566
  CHOICE_CALLBACK_PATTERN = /^pk:(pk[0-9a-f]{12}):(\d{1,3})$/;
114894
115567
  MESSAGE_STATES = ["reading", "working", "done", "failed"];
114895
115568
  });
@@ -115197,6 +115870,16 @@ var init_shell_token = __esm(() => {
115197
115870
  });
115198
115871
 
115199
115872
  // src/remote/client.ts
115873
+ function readApprovalEvent(parsed) {
115874
+ if (parsed === null || typeof parsed !== "object")
115875
+ return;
115876
+ const o = parsed;
115877
+ if (typeof o.approvalId !== "string" || o.approvalId.length === 0)
115878
+ return;
115879
+ const decision = o.decision === "allow" || o.decision === "always" ? o.decision : "deny";
115880
+ const fromId = typeof o.fromId === "number" && Number.isSafeInteger(o.fromId) ? o.fromId : undefined;
115881
+ return { approvalId: o.approvalId, pressed: { decision, ...fromId === undefined ? {} : { fromId } } };
115882
+ }
115200
115883
  function ownProcessIsAlive(pid) {
115201
115884
  if (!Number.isInteger(pid) || pid <= 0) {
115202
115885
  return false;
@@ -115253,12 +115936,18 @@ class RemoteClient {
115253
115936
  inboundChain = Promise.resolve();
115254
115937
  waiters = new Map;
115255
115938
  earlyDecisions = new Map;
115939
+ seenApprovals = new Map;
115940
+ earlyDecisionMs;
115941
+ unackedApprovals = new Map;
115942
+ lingerTimers = new Set;
115256
115943
  choiceWaiters = new Map;
115257
115944
  earlyChoices = new Map;
115258
115945
  firstResult;
115259
115946
  name;
115260
115947
  threadId;
115261
115948
  runTimeoutMs;
115949
+ permissionMode;
115950
+ approvalTimeoutMs;
115262
115951
  lastHeartbeatAt;
115263
115952
  constructor(options) {
115264
115953
  this.options = options;
@@ -115269,10 +115958,20 @@ class RemoteClient {
115269
115958
  this.sleep = options.sleep ?? abortableSleep2;
115270
115959
  this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
115271
115960
  this.isAlive = options.isAlive ?? ownProcessIsAlive;
115961
+ this.earlyDecisionMs = options.earlyDecisionMs ?? DEFAULT_EARLY_DECISION_MS;
115272
115962
  }
115273
115963
  get connected() {
115274
115964
  return this.streamOpen && !this.stopped;
115275
115965
  }
115966
+ get unconfirmedApprovals() {
115967
+ const cutoff = Date.now() - UNCONFIRMED_APPROVAL_LINGER_MS;
115968
+ for (const [id, at] of [...this.unackedApprovals.entries()]) {
115969
+ if (at < cutoff) {
115970
+ this.unackedApprovals.delete(id);
115971
+ }
115972
+ }
115973
+ return this.unackedApprovals.size;
115974
+ }
115276
115975
  start() {
115277
115976
  if (this.started) {
115278
115977
  return Promise.resolve({ ok: false, code: "already-started", message: "this client was already started", retrying: false });
@@ -115323,6 +116022,10 @@ class RemoteClient {
115323
116022
  }
115324
116023
  this.lifetime.abort();
115325
116024
  this.streamAbort?.abort();
116025
+ for (const timer of this.lingerTimers) {
116026
+ clearTimeout(timer);
116027
+ }
116028
+ this.lingerTimers.clear();
115326
116029
  this.failApprovals();
115327
116030
  this.firstResult?.({ ok: false, code: "stopped", message: "the client was stopped before it connected", retrying: false });
115328
116031
  this.firstResult = undefined;
@@ -115340,36 +116043,71 @@ class RemoteClient {
115340
116043
  }
115341
116044
  }
115342
116045
  async requestApproval(prompt, timeoutMs) {
115343
- if (!this.connected) {
115344
- return "deny";
116046
+ return (await this.askApproval(prompt, timeoutMs)).decision;
116047
+ }
116048
+ async askApproval(prompt, timeoutMs, options = {}) {
116049
+ if (!this.connected || options.signal?.aborted === true) {
116050
+ return { decision: "deny" };
115345
116051
  }
115346
- const body = { sessionId: this.options.sessionId, prompt, timeoutMs };
116052
+ const body = {
116053
+ sessionId: this.options.sessionId,
116054
+ prompt,
116055
+ timeoutMs,
116056
+ ...options.remember === undefined ? {} : { remember: options.remember }
116057
+ };
115347
116058
  let approvalId;
115348
116059
  try {
115349
116060
  const response = await this.post("approval", body);
115350
116061
  if (!response.ok) {
115351
- return "deny";
116062
+ return { decision: "deny" };
116063
+ }
116064
+ const id = (await response.json())?.approvalId;
116065
+ if (typeof id !== "string" || id.length === 0) {
116066
+ return { decision: "deny" };
115352
116067
  }
115353
- approvalId = (await response.json()).approvalId;
116068
+ approvalId = id;
115354
116069
  } catch {
115355
- return "deny";
116070
+ return { decision: "deny" };
115356
116071
  }
115357
116072
  const early = this.earlyDecisions.get(approvalId);
115358
116073
  if (early !== undefined) {
116074
+ clearTimeout(early.timer);
115359
116075
  this.earlyDecisions.delete(approvalId);
115360
- return early;
116076
+ this.settleApproval(approvalId, early.pressed.decision, true);
116077
+ return { ...early.pressed, approvalId };
115361
116078
  }
115362
116079
  if (!this.connected) {
115363
- return "deny";
116080
+ return { decision: "deny" };
115364
116081
  }
115365
116082
  return new Promise((resolve) => {
116083
+ const signal = options.signal;
116084
+ const onAbort = () => {
116085
+ const waiter = this.waiters.get(approvalId);
116086
+ if (waiter !== undefined)
116087
+ clearTimeout(waiter.timer);
116088
+ this.waiters.delete(approvalId);
116089
+ resolve({ decision: "deny", approvalId });
116090
+ };
115366
116091
  const timer = setTimeout(() => {
116092
+ signal?.removeEventListener("abort", onAbort);
115367
116093
  this.waiters.delete(approvalId);
115368
- resolve("deny");
116094
+ resolve({ decision: "deny" });
115369
116095
  }, timeoutMs);
115370
- this.waiters.set(approvalId, { resolve, timer });
116096
+ this.waiters.set(approvalId, {
116097
+ resolve: (pressed) => {
116098
+ signal?.removeEventListener("abort", onAbort);
116099
+ resolve({ ...pressed, approvalId });
116100
+ },
116101
+ timer
116102
+ });
116103
+ signal?.addEventListener("abort", onAbort, { once: true });
115371
116104
  });
115372
116105
  }
116106
+ async reportApprovalResult(approvalId, remembered) {
116107
+ try {
116108
+ await this.post("approval-result", { sessionId: this.options.sessionId, approvalId, remembered });
116109
+ } catch {}
116110
+ }
115373
116111
  async requestChoice(text, rows, timeoutMs, forUserId) {
115374
116112
  if (!this.connected) {
115375
116113
  return;
@@ -115540,7 +116278,14 @@ class RemoteClient {
115540
116278
  this.lastHeartbeatAt = Date.now();
115541
116279
  this.options.onStatus?.({ state: "connected" });
115542
116280
  if (this.name !== undefined && this.threadId !== undefined && this.runTimeoutMs !== undefined) {
115543
- this.settleFirst({ ok: true, name: this.name, threadId: this.threadId, runTimeoutMs: this.runTimeoutMs });
116281
+ this.settleFirst({
116282
+ ok: true,
116283
+ name: this.name,
116284
+ threadId: this.threadId,
116285
+ runTimeoutMs: this.runTimeoutMs,
116286
+ ...this.permissionMode === undefined ? {} : { permissionMode: this.permissionMode },
116287
+ ...this.approvalTimeoutMs === undefined ? {} : { approvalTimeoutMs: this.approvalTimeoutMs }
116288
+ });
115544
116289
  }
115545
116290
  } else if (status.kind === "superseded") {
115546
116291
  throw new FatalClientError("superseded", "another connection for this session replaced this one");
@@ -115576,6 +116321,8 @@ class RemoteClient {
115576
116321
  this.name = result.name;
115577
116322
  this.threadId = result.threadId;
115578
116323
  this.runTimeoutMs = result.runTimeoutMs;
116324
+ this.permissionMode = result.permissionMode;
116325
+ this.approvalTimeoutMs = result.approvalTimeoutMs;
115579
116326
  return;
115580
116327
  }
115581
116328
  const error = await response.json().catch(() => ({}));
@@ -115599,8 +116346,7 @@ class RemoteClient {
115599
116346
  return;
115600
116347
  });
115601
116348
  } else if (event === "approval") {
115602
- const approval = parsed;
115603
- this.resolveApproval(approval.approvalId, approval.decision);
116349
+ this.handleApprovalFrame(parsed);
115604
116350
  } else if (event === "choice") {
115605
116351
  const choice = parsed;
115606
116352
  this.resolveChoice(choice.promptId, choice.index);
@@ -115645,16 +116391,115 @@ class RemoteClient {
115645
116391
  await this.post("ack", { sessionId: this.options.sessionId, updateId });
115646
116392
  } catch {}
115647
116393
  }
116394
+ handleApprovalFrame(parsed) {
116395
+ const read = readApprovalEvent(parsed);
116396
+ if (read === undefined || !isApprovalId(read.approvalId)) {
116397
+ return;
116398
+ }
116399
+ const { approvalId, pressed } = read;
116400
+ const seen = this.seenApprovals.get(approvalId);
116401
+ if (seen === "pending") {
116402
+ return;
116403
+ }
116404
+ this.countUnconfirmed(approvalId);
116405
+ if (seen !== undefined) {
116406
+ this.ackApproval(approvalId, seen === "applied");
116407
+ return;
116408
+ }
116409
+ if (this.waiters.has(approvalId)) {
116410
+ this.settleApproval(approvalId, pressed.decision, true, () => this.resolveApproval(approvalId, pressed));
116411
+ return;
116412
+ }
116413
+ this.rememberSeen(approvalId, "pending");
116414
+ this.parkEarlyDecision(approvalId, pressed);
116415
+ }
116416
+ rememberSeen(approvalId, outcome) {
116417
+ this.seenApprovals.delete(approvalId);
116418
+ this.seenApprovals.set(approvalId, outcome);
116419
+ if (this.seenApprovals.size > MAX_SEEN_APPROVALS) {
116420
+ const oldest = this.seenApprovals.keys().next().value;
116421
+ if (oldest !== undefined) {
116422
+ this.seenApprovals.delete(oldest);
116423
+ }
116424
+ }
116425
+ }
116426
+ countUnconfirmed(approvalId) {
116427
+ this.unackedApprovals.delete(approvalId);
116428
+ this.unackedApprovals.set(approvalId, Date.now());
116429
+ this.notifyUnconfirmed();
116430
+ while (this.unackedApprovals.size > MAX_UNCONFIRMED_APPROVALS) {
116431
+ const oldest = this.unackedApprovals.keys().next().value;
116432
+ if (oldest === undefined) {
116433
+ break;
116434
+ }
116435
+ this.unackedApprovals.delete(oldest);
116436
+ }
116437
+ }
116438
+ settleApproval(approvalId, decision, applied, apply) {
116439
+ this.rememberSeen(approvalId, applied ? "applied" : "not-applied");
116440
+ try {
116441
+ this.options.onApprovalFrame?.({ approvalId, decision, applied });
116442
+ } catch {}
116443
+ apply?.();
116444
+ this.ackApproval(approvalId, applied);
116445
+ }
116446
+ parkEarlyDecision(approvalId, pressed) {
116447
+ while (this.earlyDecisions.size >= MAX_EARLY_DECISIONS) {
116448
+ const oldest = this.earlyDecisions.keys().next().value;
116449
+ if (oldest === undefined) {
116450
+ break;
116451
+ }
116452
+ this.dropEarlyDecision(oldest);
116453
+ }
116454
+ const timer = setTimeout(() => this.dropEarlyDecision(approvalId), this.earlyDecisionMs);
116455
+ timer.unref?.();
116456
+ this.earlyDecisions.set(approvalId, { pressed, timer });
116457
+ }
116458
+ dropEarlyDecision(approvalId) {
116459
+ const early = this.earlyDecisions.get(approvalId);
116460
+ if (early === undefined) {
116461
+ return;
116462
+ }
116463
+ clearTimeout(early.timer);
116464
+ this.earlyDecisions.delete(approvalId);
116465
+ if (!this.stopped) {
116466
+ this.settleApproval(approvalId, early.pressed.decision, false);
116467
+ }
116468
+ }
116469
+ async ackApproval(approvalId, applied) {
116470
+ let confirmed = false;
116471
+ try {
116472
+ const response = await this.post("approval-ack", { sessionId: this.options.sessionId, approvalId, applied });
116473
+ confirmed = response.ok;
116474
+ } catch {}
116475
+ if (this.stopped) {
116476
+ return;
116477
+ }
116478
+ if (confirmed) {
116479
+ this.unackedApprovals.delete(approvalId);
116480
+ this.notifyUnconfirmed();
116481
+ return;
116482
+ }
116483
+ this.lingerTimers.add(this.scheduleUnref(() => {
116484
+ this.notifyUnconfirmed();
116485
+ }, UNCONFIRMED_APPROVAL_LINGER_MS + 50));
116486
+ }
116487
+ notifyUnconfirmed() {
116488
+ try {
116489
+ this.options.onUnconfirmedChange?.();
116490
+ } catch {}
116491
+ }
116492
+ scheduleUnref(fn, ms) {
116493
+ const timer = setTimeout(() => {
116494
+ this.lingerTimers.delete(timer);
116495
+ fn();
116496
+ }, ms);
116497
+ timer.unref?.();
116498
+ return timer;
116499
+ }
115648
116500
  resolveApproval(approvalId, decision) {
115649
116501
  const waiter = this.waiters.get(approvalId);
115650
116502
  if (waiter === undefined) {
115651
- if (this.earlyDecisions.size >= MAX_EARLY_DECISIONS) {
115652
- const oldest = this.earlyDecisions.keys().next().value;
115653
- if (oldest !== undefined) {
115654
- this.earlyDecisions.delete(oldest);
115655
- }
115656
- }
115657
- this.earlyDecisions.set(approvalId, decision);
115658
116503
  return;
115659
116504
  }
115660
116505
  clearTimeout(waiter.timer);
@@ -115689,13 +116534,15 @@ class RemoteClient {
115689
116534
  for (const [id, waiter] of [...this.waiters.entries()]) {
115690
116535
  clearTimeout(waiter.timer);
115691
116536
  this.waiters.delete(id);
115692
- waiter.resolve("deny");
116537
+ waiter.resolve({ decision: "deny" });
116538
+ }
116539
+ for (const id of [...this.earlyDecisions.keys()]) {
116540
+ this.dropEarlyDecision(id);
115693
116541
  }
115694
- this.earlyDecisions.clear();
115695
116542
  this.failChoices();
115696
116543
  }
115697
116544
  }
115698
- var UNVERIFIED_SERVE_MESSAGE = "the program answering on keryx serve's port did not prove it is the keryx serve this shell trusts; its answer was ignored. Restart `keryx serve` (an older serve cannot prove itself; update keryx on both sides)", FatalClientError, TransientClientError, DEFAULT_BACKOFF_INITIAL_MS = 250, DEFAULT_BACKOFF_MAX_MS = 15000, DEFAULT_REQUEST_TIMEOUT_MS = 1e4, MAX_EARLY_DECISIONS = 32, MAX_COMPLETED_IDS = 256;
116545
+ var UNVERIFIED_SERVE_MESSAGE = "the program answering on keryx serve's port did not prove it is the keryx serve this shell trusts; its answer was ignored. Restart `keryx serve` (an older serve cannot prove itself; update keryx on both sides)", FatalClientError, TransientClientError, DEFAULT_BACKOFF_INITIAL_MS = 250, DEFAULT_BACKOFF_MAX_MS = 15000, DEFAULT_REQUEST_TIMEOUT_MS = 1e4, MAX_EARLY_DECISIONS = 32, DEFAULT_EARLY_DECISION_MS = 2000, MAX_SEEN_APPROVALS = 256, UNCONFIRMED_APPROVAL_LINGER_MS = 60000, MAX_UNCONFIRMED_APPROVALS = 64, MAX_COMPLETED_IDS = 256;
115699
116546
  var init_client3 = __esm(() => {
115700
116547
  init_serve_config();
115701
116548
  init_lease();
@@ -115851,6 +116698,7 @@ var init_command_gateway = __esm(() => {
115851
116698
  { name: "connect", kind: "picker", description: "Switch to a connected provider with buttons" },
115852
116699
  { name: "resume", kind: "picker", description: "Return to an earlier session with buttons" },
115853
116700
  { name: "sessions", kind: "builtin", description: "List recent sessions" },
116701
+ { name: "stop", kind: "builtin", description: "Stop the run you started from Telegram" },
115854
116702
  { name: "new", kind: "text", description: "Start a new session; this topic stays bound to it" },
115855
116703
  { name: "clear", kind: "text", description: "Same as /new" },
115856
116704
  { name: "compact", kind: "text", description: "Compact the conversation: /compact [focus]" },
@@ -115896,7 +116744,9 @@ var init_command_gateway = __esm(() => {
115896
116744
  governance: "it needs the shell's screen",
115897
116745
  decisions: "it opens a panel in the shell",
115898
116746
  expand: "it needs the shell's screen",
115899
- interrupt: "use the Stop button on a message, or stop it in the shell",
116747
+ interrupt: "use /stop here to end a run you started from Telegram, or stop it in the shell",
116748
+ permissions: "it opens a panel in the shell; saved rules are changed there, never from a chat",
116749
+ "remote-policy": "it changes what Telegram may do without asking; use the shell",
115900
116750
  demote: "it works on the shell's own tasks",
115901
116751
  models: "use /model",
115902
116752
  "external-diff": "listing and applying external patches stays in the shell, where the patch hash is typed",
@@ -115947,6 +116797,8 @@ class RemoteCommandRouter {
115947
116797
  await this.listSessionsText();
115948
116798
  ended(end, "done");
115949
116799
  });
116800
+ } else if (decision.command === "stop") {
116801
+ this.stop(end);
115950
116802
  } else {
115951
116803
  this.ctx.reply(this.ctx.compose(remoteHelpText())).catch(() => false);
115952
116804
  ended(end, "done");
@@ -115973,6 +116825,17 @@ class RemoteCommandRouter {
115973
116825
  return;
115974
116826
  }
115975
116827
  }
116828
+ stop(end) {
116829
+ const result = this.ctx.host.stopTelegramTurn?.() ?? "idle";
116830
+ if (result === "stopped") {
116831
+ ended(end, "done");
116832
+ return;
116833
+ }
116834
+ const text = result === "operator" ? "/stop did nothing: the running turn was started in the shell, so only the shell can stop it." : "/stop did nothing: no run started from Telegram is going on.";
116835
+ this.ctx.record(`/stop: ${result === "operator" ? "turn belongs to the shell" : "nothing to stop"}`);
116836
+ this.ctx.reply(text).catch(() => false);
116837
+ ended(end, "failed");
116838
+ }
115976
116839
  async idle() {
115977
116840
  await this.chain;
115978
116841
  if (this.detached > 0) {
@@ -116259,9 +117122,27 @@ function composeReply(text) {
116259
117122
  return `${safe.slice(0, REMOTE_REPLY_MAX_CHARS)}
116260
117123
  [cut: the full reply is in the shell]`;
116261
117124
  }
117125
+ function mapApprovalAnswer(got, offered) {
117126
+ if (got === null || typeof got !== "object")
117127
+ return { decision: "deny", always: false };
117128
+ const o = got;
117129
+ const yes = o.decision === "allow" || o.decision === "always";
117130
+ const approvalId = typeof o.approvalId === "string" && o.approvalId.length > 0 ? o.approvalId : undefined;
117131
+ const fromId = typeof o.fromId === "number" && Number.isSafeInteger(o.fromId) ? o.fromId : undefined;
117132
+ return {
117133
+ decision: yes ? "allow" : "deny",
117134
+ always: o.decision === "always" && offered,
117135
+ ...approvalId === undefined ? {} : { approvalId },
117136
+ ...fromId === undefined ? {} : { fromId }
117137
+ };
117138
+ }
116262
117139
  function composeApprovalPrompt(prompt) {
116263
117140
  const safe = redactSensitiveText(prompt).trim();
116264
- return safe.length <= MAX_APPROVAL_PROMPT_CHARS ? safe : `${safe.slice(0, MAX_APPROVAL_PROMPT_CHARS - 1)}\u2026`;
117141
+ if (safe.length <= MAX_APPROVAL_PROMPT_CHARS)
117142
+ return safe;
117143
+ const note = `
117144
+ [cut: the rest of this prompt is in the shell]`;
117145
+ return `${safe.slice(0, MAX_APPROVAL_PROMPT_CHARS - note.length - 1)}\u2026${note}`;
116265
117146
  }
116266
117147
  function describeApprovalForTopic(tool, inputJson, meta) {
116267
117148
  const lines = [];
@@ -116281,8 +117162,12 @@ function describeApprovalForTopic(tool, inputJson, meta) {
116281
117162
  shown = o.patch;
116282
117163
  }
116283
117164
  } catch {}
116284
- shown = shown.length > APPROVAL_INPUT_PREVIEW_CHARS ? `${shown.slice(0, APPROVAL_INPUT_PREVIEW_CHARS)}\u2026` : shown;
117165
+ const total = shown.length;
117166
+ shown = total > APPROVAL_INPUT_PREVIEW_CHARS ? `${shown.slice(0, APPROVAL_INPUT_PREVIEW_CHARS)}\u2026` : shown;
116285
117167
  lines.push(`Approve ${tool}?`, shown);
117168
+ if (total > APPROVAL_INPUT_PREVIEW_CHARS) {
117169
+ lines.push(`[cut: only the first ${APPROVAL_INPUT_PREVIEW_CHARS} of ${total} characters are shown; the rest is in the shell]`);
117170
+ }
116286
117171
  }
116287
117172
  if (meta?.destructive === true)
116288
117173
  lines.push("Warning: this can delete or overwrite files.");
@@ -116300,28 +117185,33 @@ class RemoteBridge {
116300
117185
  events = [];
116301
117186
  host;
116302
117187
  now;
116303
- approvalTimeoutMs;
117188
+ approvalOverrideMs;
117189
+ policyOverride = {};
116304
117190
  connectedOnce = false;
116305
117191
  clientConnected = false;
116306
117192
  tgTurn = false;
116307
117193
  lastAssistantText = "";
116308
117194
  runTimer;
116309
117195
  timedOut = false;
117196
+ stoppedByUser = false;
117197
+ turnAbort;
116310
117198
  closing;
116311
117199
  router;
116312
117200
  keepTopic = false;
116313
117201
  switched = false;
116314
117202
  waiting = [];
116315
117203
  currentUpdate;
117204
+ currentFromId;
116316
117205
  constructor(options) {
116317
117206
  this.options = options;
116318
117207
  this.host = options.host;
116319
117208
  this.now = options.now ?? Date.now;
116320
- this.approvalTimeoutMs = options.approvalTimeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS;
117209
+ this.approvalOverrideMs = options.approvalTimeoutMs;
116321
117210
  this.router = new RemoteCommandRouter({
116322
117211
  host: {
116323
117212
  isBusy: () => this.host.isBusy(),
116324
117213
  cancelTurn: () => this.host.cancelTurn(),
117214
+ stopTelegramTurn: () => this.stopTelegramTurn(),
116325
117215
  ...this.host.runCommand !== undefined ? { runCommand: (line) => this.host.runCommand(line) } : {},
116326
117216
  ...this.host.busyRefusal !== undefined ? { busyRefusal: (line) => this.host.busyRefusal(line) } : {},
116327
117217
  ...this.host.listModels !== undefined ? { listModels: (id) => this.host.listModels(id) } : {},
@@ -116368,6 +117258,25 @@ class RemoteBridge {
116368
117258
  get telegramTurnActive() {
116369
117259
  return this.tgTurn;
116370
117260
  }
117261
+ get telegramTurnUserId() {
117262
+ return this.tgTurn ? this.currentFromId : undefined;
117263
+ }
117264
+ get configuredPermissionMode() {
117265
+ return this.policyOverride.permissionMode ?? this.client?.permissionMode ?? "ask";
117266
+ }
117267
+ get approvalTimeoutMs() {
117268
+ return this.policyOverride.approvalTimeoutMs ?? this.approvalOverrideMs ?? this.client?.approvalTimeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS;
117269
+ }
117270
+ get runTimeoutMs() {
117271
+ return this.policyOverride.runTimeoutMs ?? this.client?.runTimeoutMs ?? 0;
117272
+ }
117273
+ applyPolicy(next) {
117274
+ this.policyOverride = { ...this.policyOverride, ...next };
117275
+ this.host.onChange?.();
117276
+ }
117277
+ recordApproval(text) {
117278
+ this.push("approval", preview2(text));
117279
+ }
116371
117280
  status() {
116372
117281
  const client = this.client;
116373
117282
  if (client === undefined) {
@@ -116375,10 +117284,12 @@ class RemoteBridge {
116375
117284
  }
116376
117285
  const online = this.clientConnected && client.connected;
116377
117286
  const heartbeat = client.lastHeartbeatAt;
117287
+ const unconfirmed = client.unconfirmedApprovals ?? 0;
116378
117288
  return {
116379
117289
  state: online ? "on" : "offline",
116380
117290
  ...client.name !== undefined ? { name: client.name } : {},
116381
117291
  ...heartbeat !== undefined ? { heartbeatAgeMs: Math.max(0, this.now() - heartbeat) } : {},
117292
+ ...unconfirmed > 0 ? { unconfirmedApprovals: unconfirmed } : {},
116382
117293
  events: [...this.events]
116383
117294
  };
116384
117295
  }
@@ -116405,7 +117316,9 @@ class RemoteBridge {
116405
117316
  ...name !== undefined ? { name } : {},
116406
117317
  ...this.options.dir !== undefined ? { dir: this.options.dir } : {},
116407
117318
  onLine: (text, meta) => this.accept(text, meta),
116408
- onStatus: (status) => this.onStatus(status)
117319
+ onStatus: (status) => this.onStatus(status),
117320
+ onApprovalFrame: (frame) => this.host.notice(frame.applied ? `\u25C7 approval ${frame.approvalId} ${frame.decision === "allow" ? "allowed" : "denied"} from Telegram` : `\u25C7 approval ${frame.approvalId}: ${frame.decision === "allow" ? "an allow" : "a deny"} from Telegram arrived but nothing here was waiting for it; not applied`),
117321
+ onUnconfirmedChange: () => this.host.onChange?.()
116409
117322
  };
116410
117323
  const client = this.options.makeClient !== undefined ? this.options.makeClient(clientOptions) : new RemoteClient(clientOptions);
116411
117324
  this.client = client;
@@ -116486,7 +117399,7 @@ class RemoteBridge {
116486
117399
  return;
116487
117400
  }
116488
117401
  this.push("line", `Telegram: ${preview2(line)}`);
116489
- this.waiting.push({ updateId: meta.updateId, line });
117402
+ this.waiting.push({ updateId: meta.updateId, line, ...meta.fromId === undefined ? {} : { fromId: meta.fromId } });
116490
117403
  if (this.host.isBusy()) {
116491
117404
  this.host.enqueue(line);
116492
117405
  } else {
@@ -116541,20 +117454,37 @@ class RemoteBridge {
116541
117454
  return;
116542
117455
  const next = this.waiting.shift();
116543
117456
  this.currentUpdate = next?.updateId;
117457
+ this.currentFromId = next?.fromId;
116544
117458
  if (next !== undefined)
116545
117459
  this.report(next.updateId, "working");
116546
117460
  this.tgTurn = true;
116547
117461
  this.lastAssistantText = "";
116548
117462
  this.timedOut = false;
116549
- const limit = this.client.runTimeoutMs;
116550
- if (limit !== undefined && limit > 0) {
117463
+ this.stoppedByUser = false;
117464
+ this.turnAbort = new AbortController;
117465
+ const limit = this.runTimeoutMs;
117466
+ if (limit > 0) {
116551
117467
  this.runTimer = setTimeout(() => {
116552
117468
  this.timedOut = true;
116553
117469
  this.push("error", "run time limit reached; stopping the turn");
117470
+ this.turnAbort?.abort();
116554
117471
  this.host.cancelTurn();
116555
117472
  }, limit);
116556
117473
  }
116557
117474
  }
117475
+ stopTelegramTurn() {
117476
+ if (this.client === undefined)
117477
+ return "idle";
117478
+ if (!this.tgTurn)
117479
+ return this.host.isBusy() ? "operator" : "idle";
117480
+ if (this.stoppedByUser)
117481
+ return "stopped";
117482
+ this.stoppedByUser = true;
117483
+ this.push("command", "/stop: stopping the turn");
117484
+ this.turnAbort?.abort();
117485
+ this.host.cancelTurn();
117486
+ return "stopped";
117487
+ }
116558
117488
  assistantText(text) {
116559
117489
  if (!this.tgTurn)
116560
117490
  return;
@@ -116585,15 +117515,18 @@ class RemoteBridge {
116585
117515
  const client = this.client;
116586
117516
  const text = this.lastAssistantText;
116587
117517
  const timedOut = this.timedOut;
117518
+ const stopped = this.stoppedByUser && !timedOut;
116588
117519
  const updateId = this.currentUpdate;
116589
117520
  this.endTurnState();
116590
117521
  if (client === undefined)
116591
117522
  return;
116592
117523
  if (updateId !== undefined)
116593
- this.report(updateId, outcome.failed || timedOut ? "failed" : "done");
117524
+ this.report(updateId, outcome.failed || timedOut || stopped ? "failed" : "done");
116594
117525
  let body;
116595
- if (timedOut) {
116596
- body = "Stopped: this run went over the time limit for runs started from Telegram.";
117526
+ if (stopped) {
117527
+ body = "Stopped by you.";
117528
+ } else if (timedOut) {
117529
+ body = "Stopped: this run went over the time limit you set for runs started from Telegram (runTimeoutMs).";
116597
117530
  } else if (outcome.failed && text.trim().length === 0) {
116598
117531
  body = "The run failed. The details are in the shell.";
116599
117532
  } else if (text.trim().length === 0) {
@@ -116607,31 +117540,56 @@ class RemoteBridge {
116607
117540
  endTurnState() {
116608
117541
  this.tgTurn = false;
116609
117542
  this.currentUpdate = undefined;
117543
+ this.currentFromId = undefined;
116610
117544
  this.lastAssistantText = "";
116611
117545
  this.timedOut = false;
117546
+ this.stoppedByUser = false;
117547
+ this.turnAbort?.abort();
117548
+ this.turnAbort = undefined;
116612
117549
  if (this.runTimer !== undefined) {
116613
117550
  clearTimeout(this.runTimer);
116614
117551
  this.runTimer = undefined;
116615
117552
  }
116616
117553
  }
116617
117554
  async requestApproval(prompt) {
117555
+ return (await this.askApproval(prompt)).decision;
117556
+ }
117557
+ async askApproval(prompt, options = {}) {
116618
117558
  const client = this.client;
116619
117559
  if (client === undefined || !client.connected) {
116620
117560
  this.push("approval", "denied: not connected");
116621
- return "deny";
117561
+ return { decision: "deny", always: false };
116622
117562
  }
116623
117563
  this.push("approval", "asked in the topic");
116624
- let decision;
117564
+ let answer;
116625
117565
  try {
116626
- decision = await client.requestApproval(composeApprovalPrompt(prompt), this.approvalTimeoutMs);
117566
+ const text = composeApprovalPrompt(prompt);
117567
+ const timeoutMs = this.approvalTimeoutMs;
117568
+ if (client.askApproval !== undefined) {
117569
+ const signal = this.turnAbort?.signal;
117570
+ const got = await client.askApproval(text, timeoutMs, {
117571
+ ...options.remember === undefined ? {} : { remember: options.remember },
117572
+ ...signal === undefined ? {} : { signal }
117573
+ });
117574
+ answer = mapApprovalAnswer(got, options.remember !== undefined);
117575
+ } else {
117576
+ const decision = await client.requestApproval(text, timeoutMs);
117577
+ answer = { decision: decision === "allow" || decision === "always" ? "allow" : "deny", always: false };
117578
+ }
116627
117579
  } catch {
116628
- decision = "deny";
117580
+ answer = { decision: "deny", always: false };
116629
117581
  }
116630
- this.push("approval", decision === "allow" ? "allowed in the topic" : "denied (or no answer)");
116631
- return decision;
117582
+ const by = answer.fromId === undefined ? "" : ` by user ${answer.fromId}`;
117583
+ this.push("approval", answer.decision === "allow" ? `${answer.always ? "allowed (always)" : "allowed"} in the topic${by}` : `denied${by === "" ? " (or no answer)" : by}`);
117584
+ return answer;
117585
+ }
117586
+ async reportRemembered(approvalId, remembered) {
117587
+ if (approvalId === undefined)
117588
+ return;
117589
+ await this.client?.reportApprovalResult?.(approvalId, remembered);
116632
117590
  }
116633
117591
  }
116634
- var TG_SOURCE = "tg", REMOTE_EVENT_LIMIT = 20, REMOTE_REPLY_MAX_CHARS = 16000, EVENT_PREVIEW_CHARS = 80, APPROVAL_INPUT_PREVIEW_CHARS = 1500;
117592
+ var TG_SOURCE = "tg", REMOTE_EVENT_LIMIT = 20, REMOTE_REPLY_MAX_CHARS = 16000, EVENT_PREVIEW_CHARS = 80;
116635
117593
  var init_shell_bridge = __esm(() => {
116636
117594
  init_service2();
116637
117595
  init_client3();
@@ -118023,7 +118981,7 @@ function formatAge(ms) {
118023
118981
  return `${minutes}m`;
118024
118982
  return `${Math.round(minutes / 60)}h`;
118025
118983
  }
118026
- function formatRemoteStatusLines(status) {
118984
+ function formatRemoteStatusLines(status, posture) {
118027
118985
  const lines = [`Remote control: ${STATE_WORDS[status.state]}`];
118028
118986
  if (status.state === "off") {
118029
118987
  lines.push("Topic: none");
@@ -118034,7 +118992,14 @@ function formatRemoteStatusLines(status) {
118034
118992
  lines.push(`Last heartbeat: ${status.heartbeatAgeMs === undefined ? "none yet" : `${formatAge(status.heartbeatAgeMs)} ago`}`);
118035
118993
  if (status.state === "offline")
118036
118994
  lines.push("Serve is not reachable. The shell keeps retrying; nothing is lost from Telegram.");
118995
+ if (status.unconfirmedApprovals !== undefined && status.unconfirmedApprovals > 0) {
118996
+ const n = status.unconfirmedApprovals;
118997
+ lines.push(`Approvals not confirmed: ${n}. The topic shows ${n === 1 ? "it" : "them"} as "not confirmed" until serve takes the shell's ack.`);
118998
+ }
118037
118999
  lines.push(`Turn it off with ${REMOTE_CONTROL_COMMAND} off, or press o here. Rename: ${REMOTE_CONTROL_COMMAND} off, then ${REMOTE_CONTROL_COMMAND} <name>.`);
119000
+ if (posture !== undefined && posture.length > 0) {
119001
+ lines.push("", "Telegram permissions (change with /remote-policy):", ...posture.map((line) => ` ${line}`));
119002
+ }
118038
119003
  return lines;
118039
119004
  }
118040
119005
  function clock2(at) {
@@ -118091,7 +119056,7 @@ function presentRemoteControl(open, otui, chrome, options) {
118091
119056
  return formatRemoteEventLines(status);
118092
119057
  if (tab === "commands")
118093
119058
  return formatRemoteCommandLines(status);
118094
- return formatRemoteStatusLines(status);
119059
+ return formatRemoteStatusLines(status, options.getPostureLines?.());
118095
119060
  };
118096
119061
  const content = (tab) => {
118097
119062
  const lines = linesFor(tab);
@@ -118154,7 +119119,18 @@ function projectRemoteRow(status, width) {
118154
119119
  return { text: fit3("off", width), role: "muted", action: "open" };
118155
119120
  if (status.state === "offline")
118156
119121
  return { text: fit3("offline", width), role: "attention", action: "open" };
118157
- return { text: fit3(status.name ?? "connecting", width), role: "ok", action: "open" };
119122
+ const topic = status.name ?? "connecting";
119123
+ const unconfirmed = status.unconfirmedApprovals ?? 0;
119124
+ if (unconfirmed > 0)
119125
+ return { text: fit3(`${topic} \xB7 ${unconfirmed} unconfirmed`, width), role: "attention", action: "open" };
119126
+ return { text: fit3(topic, width), role: "ok", action: "open" };
119127
+ }
119128
+ function projectPostureLine(status, posture, width) {
119129
+ if (status === undefined || status.state === "off" || posture === undefined || posture.length === 0)
119130
+ return;
119131
+ return posture.split(`
119132
+ `).map((line) => fit3(line, width)).join(`
119133
+ `);
118158
119134
  }
118159
119135
  function mountRemotePanel(otui, renderer, parent, options) {
118160
119136
  const core = otui;
@@ -118169,6 +119145,8 @@ function mountRemotePanel(otui, renderer, parent, options) {
118169
119145
  const value = new core.TextRenderable(renderer, { id: "sb-remote-v", content: "" });
118170
119146
  box.add(label);
118171
119147
  box.add(value);
119148
+ const posture = new core.TextRenderable(renderer, { id: "sb-remote-p", content: "" });
119149
+ box.add(posture);
118172
119150
  const currentRow = () => projectRemoteRow(options.getStatus(), options.width);
118173
119151
  const draw = () => {
118174
119152
  if (disposed)
@@ -118176,6 +119154,9 @@ function mountRemotePanel(otui, renderer, parent, options) {
118176
119154
  const row = currentRow();
118177
119155
  label.content = core.t`${dimChunk(core, "Remote")}`;
118178
119156
  value.content = core.t`${roleChunk(core, row.role, row.text)}`;
119157
+ const line = projectPostureLine(options.getStatus(), options.getPosture?.(), options.width);
119158
+ posture.content = line === undefined ? "" : core.t`${dimChunk(core, line)}`;
119159
+ posture.visible = line !== undefined;
118179
119160
  };
118180
119161
  const activate = () => {
118181
119162
  options.onOpen();
@@ -118187,6 +119168,9 @@ function mountRemotePanel(otui, renderer, parent, options) {
118187
119168
  value.onMouseDown = () => {
118188
119169
  activate();
118189
119170
  };
119171
+ posture.onMouseDown = () => {
119172
+ options.onOpenPermissions?.();
119173
+ };
118190
119174
  const unsubscribeTheme = onThemeChange(guardedThemeRepaint("remote-panel", draw, () => disposed || isRenderableGone(box)));
118191
119175
  draw();
118192
119176
  return {
@@ -118450,6 +119434,11 @@ var init_agent_commands = __esm(() => {
118450
119434
  description: "How Telegram replies are written: /rendering [auto|rich|html|plain]",
118451
119435
  modes: AGENT_ONLY
118452
119436
  },
119437
+ {
119438
+ name: "/remote-policy",
119439
+ description: "Telegram defaults: /remote-policy [mode ask|trust] [limit none|<minutes>] [wait <minutes>]. Never changes the shell's /mode",
119440
+ modes: AGENT_ONLY
119441
+ },
118453
119442
  {
118454
119443
  name: "/product",
118455
119444
  description: "Intents closed in code that nobody looked back at, with their outcome criteria",
@@ -118485,6 +119474,11 @@ var init_agent_commands = __esm(() => {
118485
119474
  description: "Pending remote approvals: summary, scope, consequence, expiry \u2014 allow or deny one call, once",
118486
119475
  modes: AGENT_ONLY
118487
119476
  },
119477
+ {
119478
+ name: "/permissions",
119479
+ description: "The saved shell rules (what Always remembered): honoured or not, session grants; remove one",
119480
+ modes: AGENT_ONLY
119481
+ },
118488
119482
  {
118489
119483
  name: "/decisions",
118490
119484
  description: "Recommendation journal: how often you follow the agent's recommendation, by mode and stage, and the deviations; /decisions reason <why> and /decisions change <option> follow up on the last answer",
@@ -118695,10 +119689,14 @@ function classifyBusyDispatch(params) {
118695
119689
  return "remote-control";
118696
119690
  if (commandName === "/channels")
118697
119691
  return "channels";
119692
+ if (commandName === "/remote-policy")
119693
+ return "remote-policy";
118698
119694
  if (commandName === "/schedules")
118699
119695
  return "schedules";
118700
119696
  if (commandName === "/approvals")
118701
119697
  return "approvals";
119698
+ if (commandName === "/permissions")
119699
+ return "permissions";
118702
119700
  if (commandName === "/decisions")
118703
119701
  return "decisions";
118704
119702
  if (commandName === "/external-diff")
@@ -121375,7 +122373,7 @@ var init_serve_turn_store = __esm(() => {
121375
122373
  import { randomUUID as randomUUID34 } from "crypto";
121376
122374
  import { existsSync as existsSync45, readdirSync as readdirSync10 } from "fs";
121377
122375
  import path261 from "path";
121378
- function isApprovalId(value) {
122376
+ function isApprovalId2(value) {
121379
122377
  return ID_PATTERN2.test(value);
121380
122378
  }
121381
122379
  function root(dir) {
@@ -121401,7 +122399,7 @@ function appendEvidence(event, dir) {
121401
122399
  appendOwnerOnlyLine(ledgerFile(dir), JSON.stringify(event));
121402
122400
  }
121403
122401
  function createApproval(input, dir, now = new Date) {
121404
- if (!isApprovalId(input.approvalId)) {
122402
+ if (!isApprovalId2(input.approvalId)) {
121405
122403
  throw new Error("approvalId is not an approval id");
121406
122404
  }
121407
122405
  ensureKeryxSubdir(["approvals"], dir);
@@ -121468,7 +122466,7 @@ function parseResolution(text) {
121468
122466
  }
121469
122467
  }
121470
122468
  function readRaw(id, dir) {
121471
- if (!isApprovalId(id)) {
122469
+ if (!isApprovalId2(id)) {
121472
122470
  return { ok: false, reason: "not-an-approval-id" };
121473
122471
  }
121474
122472
  const read = readConfigFile(requestFile(id, dir));
@@ -121572,7 +122570,7 @@ function approvalIds(dir) {
121572
122570
  if (!existsSync45(base)) {
121573
122571
  return [];
121574
122572
  }
121575
- return readdirSync10(base).filter((name) => name.endsWith(".json") && !name.endsWith(".resolution.json")).map((name) => name.slice(0, -".json".length)).filter(isApprovalId);
122573
+ return readdirSync10(base).filter((name) => name.endsWith(".json") && !name.endsWith(".resolution.json")).map((name) => name.slice(0, -".json".length)).filter(isApprovalId2);
121576
122574
  }
121577
122575
  function listApprovals(dir, options = {}) {
121578
122576
  const views = [];
@@ -138527,6 +139525,10 @@ async function runSettingsCommand(command, handlers) {
138527
139525
  case "/theme":
138528
139526
  handlers.theme(arg);
138529
139527
  return;
139528
+ case "/remote-policy":
139529
+ if (handlers.remotePolicy !== undefined)
139530
+ handlers.onSystem(handlers.remotePolicy(arg));
139531
+ return;
138530
139532
  }
138531
139533
  } catch (error) {
138532
139534
  handlers.onSystem(`${name}: ${error instanceof Error ? error.message : String(error)}
@@ -139601,6 +140603,58 @@ var init_games = __esm(() => {
139601
140603
  init_tic_tac_toe();
139602
140604
  });
139603
140605
 
140606
+ // src/remote/telegram-permission.ts
140607
+ function modeInForce(input) {
140608
+ if (input.changedThisSession)
140609
+ return { mode: input.shellMode, source: "shell-mode" };
140610
+ if (input.telegramTurn)
140611
+ return { mode: input.telegramDefault, source: "telegram-default" };
140612
+ return { mode: input.shellMode, source: "shell-default" };
140613
+ }
140614
+ function formatModeInForce(value) {
140615
+ const source = value.source === "telegram-default" ? "Telegram default" : value.source === "shell-mode" ? "shell /mode" : "shell default";
140616
+ return `${value.mode} (${source})`;
140617
+ }
140618
+ function telegramRememberOffer(ev) {
140619
+ if (ev.destructive || ev.credentials || ev.sacReviewConfirmation || ev.publishLease || ev.hookAsk || ev.untrustedOrigin) {
140620
+ return;
140621
+ }
140622
+ if (ev.command.length > APPROVAL_INPUT_PREVIEW_CHARS)
140623
+ return;
140624
+ const { exact, prefix, offerExact, offerPrefix } = suggestShellPatterns(ev.command);
140625
+ const shownAsStored = (pattern) => redactSensitiveText(pattern) === pattern;
140626
+ if (offerExact) {
140627
+ return exact.length <= MAX_REMEMBER_PATTERN_CHARS && shownAsStored(exact) ? { pattern: exact, kind: "exact" } : undefined;
140628
+ }
140629
+ if (offerPrefix && prefix.length <= MAX_REMEMBER_PATTERN_CHARS && shownAsStored(prefix))
140630
+ return { pattern: prefix, kind: "prefix" };
140631
+ return;
140632
+ }
140633
+ function evaluateTelegramShellApproval(input) {
140634
+ const evaluation = evaluateShellApproval(input);
140635
+ return {
140636
+ evaluation,
140637
+ autoApprove: evaluation.autoApprove,
140638
+ offer: evaluation.autoApprove ? undefined : telegramRememberOffer(evaluation)
140639
+ };
140640
+ }
140641
+ function userSuffix(userId) {
140642
+ return userId === undefined ? "" : `, user ${userId}`;
140643
+ }
140644
+ function modeAutoApprovalAudit(input) {
140645
+ return `auto-approved (${input.mode}${userSuffix(input.userId)}): ${input.preview}`;
140646
+ }
140647
+ function savedRuleAutoApprovalAudit(input) {
140648
+ const shown = input.command.length > 200 ? `${input.command.slice(0, 199)}\u2026` : input.command;
140649
+ return `auto-approved by a saved rule${input.userId === undefined ? "" : ` (user ${input.userId})`}: ${shown}`;
140650
+ }
140651
+ var init_telegram_permission = __esm(() => {
140652
+ init_shell_approval();
140653
+ init_shell_permissions();
140654
+ init_service2();
140655
+ init_protocol2();
140656
+ });
140657
+
139604
140658
  // src/mcp-client/elicitation.ts
139605
140659
  function extractCodexCommand(vendor) {
139606
140660
  const raw = vendor.codex_command;
@@ -141246,8 +142300,12 @@ async function forceForegroundQueueItem(owner, handoff, item, deps) {
141246
142300
  }
141247
142301
  });
141248
142302
  }
141249
- function createForegroundAgentIoFacade(owner, token, io) {
142303
+ function createForegroundAgentIoFacade(owner, token, io, turn = {}) {
141250
142304
  const accepts = () => owner.accepts(token);
142305
+ const approve = () => turn.requestApproval ?? io.requestApproval;
142306
+ const autoApproved = () => turn.onAutoApproved ?? io.onAutoApproved;
142307
+ const modeOf = () => turn.permissionMode ?? io.permissionMode;
142308
+ const mcpGrantsApply = turn.mcpGrantsApply ?? io.mcpGrantsApply;
141251
142309
  return {
141252
142310
  write: (text) => {
141253
142311
  if (accepts())
@@ -141296,15 +142354,16 @@ function createForegroundAgentIoFacade(owner, token, io) {
141296
142354
  requestApproval: async (tool, input, meta) => {
141297
142355
  if (!accepts())
141298
142356
  return false;
141299
- const response = await io.requestApproval?.(tool, input, meta);
142357
+ const response = await approve()?.(tool, input, meta);
141300
142358
  return accepts() ? response ?? false : false;
141301
142359
  },
141302
142360
  onAutoApproved: (tool, input, meta) => {
141303
142361
  if (accepts())
141304
- io.onAutoApproved?.(tool, input, meta);
142362
+ autoApproved()?.(tool, input, meta);
141305
142363
  },
141306
- permissionMode: () => accepts() ? io.permissionMode?.() ?? "ask" : "ask",
142364
+ permissionMode: () => accepts() ? modeOf()?.() ?? "ask" : "ask",
141307
142365
  ...io.trustedMcpTools === undefined ? {} : { trustedMcpTools: io.trustedMcpTools },
142366
+ ...mcpGrantsApply === undefined ? {} : { mcpGrantsApply },
141308
142367
  ...io.mcpToolFingerprint === undefined ? {} : { mcpToolFingerprint: io.mcpToolFingerprint },
141309
142368
  ...io.mcpToolDestructive === undefined ? {} : { mcpToolDestructive: io.mcpToolDestructive },
141310
142369
  ...io.beforeMutation === undefined ? {} : {
@@ -141950,6 +143009,98 @@ function drainIdleMainQueue(items, options) {
141950
143009
  return true;
141951
143010
  }
141952
143011
 
143012
+ // src/tui/remote-queue-wiring.ts
143013
+ function createRemoteQueueWiring(deps) {
143014
+ let pendingEdit;
143015
+ return {
143016
+ removeMainQueue(index) {
143017
+ const queue = deps.getQueue();
143018
+ if (index < 0 || index >= queue.length)
143019
+ return;
143020
+ const removed = queue[index];
143021
+ if (removed?.source === TG_SOURCE) {
143022
+ deps.getBridge()?.queuedLineRemoved(removed.question);
143023
+ }
143024
+ deps.setQueue(removeMainQueueItem(queue, index));
143025
+ deps.paint();
143026
+ },
143027
+ editMainQueue(index) {
143028
+ const edited = editMainQueueItem(deps.getQueue(), index);
143029
+ if (edited === undefined)
143030
+ return;
143031
+ deps.setQueue(edited.rest);
143032
+ pendingEdit = pendingQueueEditFor(edited.removed, index);
143033
+ deps.composer.setText(edited.text);
143034
+ deps.composer.focus();
143035
+ deps.paint();
143036
+ },
143037
+ requeuePendingEdit(line, displayLine) {
143038
+ if (pendingEdit === undefined)
143039
+ return false;
143040
+ const edit = pendingEdit;
143041
+ pendingEdit = undefined;
143042
+ deps.setQueue(reinsertEditedMainQueueItem(deps.getQueue(), edit, line, displayLine));
143043
+ deps.paint();
143044
+ return true;
143045
+ },
143046
+ hasPendingEdit: () => pendingEdit !== undefined,
143047
+ stopRemoteForSessionSwitch() {
143048
+ const bridge = deps.getBridge();
143049
+ if (bridge?.keepingTopic === true) {
143050
+ bridge.sessionLeaving();
143051
+ return;
143052
+ }
143053
+ if (bridge?.active !== true)
143054
+ return;
143055
+ const closing = bridge.disable();
143056
+ deps.say(`\u25C7 remote control is turning off: the session changed, and its Telegram topic is being deleted. Turn it on again with /remote-control <name>.
143057
+ `);
143058
+ closing.then(() => deps.say(`\u25C7 remote control off: the topic is deleted.
143059
+ `));
143060
+ },
143061
+ dropQueuedTelegramLines() {
143062
+ const { kept, dropped } = dropQueuedBySource(deps.getQueue(), TG_SOURCE);
143063
+ if (dropped.length === 0)
143064
+ return [];
143065
+ deps.setQueue(kept);
143066
+ deps.paint();
143067
+ return dropped.map((item) => item.question);
143068
+ },
143069
+ enqueueTelegramLine(text) {
143070
+ const queue = deps.getQueue();
143071
+ queue.push({ id: deps.nextId(), question: text, displayQuestion: deps.summarize(text), source: TG_SOURCE });
143072
+ deps.paint();
143073
+ deps.say(`\u25C7 a line from Telegram is queued as q${queue.length}.
143074
+ `);
143075
+ },
143076
+ runTelegramLine(text) {
143077
+ deps.runLine(text, "operator", TG_SOURCE);
143078
+ },
143079
+ runQueued(item) {
143080
+ deps.runLine(item.question, "operator", item.source);
143081
+ },
143082
+ turnStarted(source) {
143083
+ deps.getBridge()?.turnStarted(source);
143084
+ },
143085
+ assistantText(text) {
143086
+ deps.getBridge()?.assistantText(text);
143087
+ },
143088
+ wrapOnToolCall(base) {
143089
+ return (...args) => {
143090
+ deps.getBridge()?.toolCall();
143091
+ base?.(...args);
143092
+ };
143093
+ },
143094
+ turnSettled(outcome) {
143095
+ const bridge = deps.getBridge();
143096
+ return bridge === undefined ? Promise.resolve() : bridge.turnSettled({ failed: outcome.failed || outcome.aborted });
143097
+ }
143098
+ };
143099
+ }
143100
+ var init_remote_queue_wiring = __esm(() => {
143101
+ init_shell_bridge();
143102
+ });
143103
+
141953
143104
  // src/tui/worker-fleet.ts
141954
143105
  function clip4(s, max) {
141955
143106
  if (s.length <= max) {
@@ -151120,7 +152271,7 @@ async function launchTuiAgentShell(opts) {
151120
152271
  });
151121
152272
  let uid = 0;
151122
152273
  const herdr = createHerdrReporter();
151123
- const sessionShellAllow = new Set(loadShellPermissions().allow);
152274
+ const sessionShellAllow = new Set;
151124
152275
  let permissionMigrationShown = false;
151125
152276
  let permissionsFingerprintAtStart = shellPermissionsFingerprint();
151126
152277
  let permissionTamperShown = false;
@@ -151465,7 +152616,12 @@ async function launchTuiAgentShell(opts) {
151465
152616
  liveRemotePanel = mountRemotePanel(otui, r, sidebar, {
151466
152617
  width: SIDEBAR_TEXT_WIDTH,
151467
152618
  getStatus: remoteStatus,
151468
- onOpen: () => showRemoteControl()
152619
+ onOpen: () => showRemoteControl(),
152620
+ onOpenPermissions: () => showPermissions(),
152621
+ getPosture: () => {
152622
+ const posture = remoteBridge?.active === true ? telegramPosture() : undefined;
152623
+ return posture === undefined ? undefined : postureSidebarText(posture);
152624
+ }
151469
152625
  });
151470
152626
  liveChannelsPanel = mountChannelsPanel(otui, r, sidebar, {
151471
152627
  width: SIDEBAR_TEXT_WIDTH,
@@ -151763,17 +152919,31 @@ async function launchTuiAgentShell(opts) {
151763
152919
  guardCollector.onToolResult(name, toolResult);
151764
152920
  baseOnToolResultForGuard?.(name, toolResult);
151765
152921
  };
152922
+ const remoteQueue = createRemoteQueueWiring({
152923
+ getBridge: () => remoteBridge,
152924
+ getQueue: () => mainQueue,
152925
+ setQueue: (next) => {
152926
+ mainQueue = next;
152927
+ },
152928
+ nextId: () => `mq${mainQueueSeq++}`,
152929
+ summarize: summarizeSubmittedLine,
152930
+ paint: () => paintMainQueue(),
152931
+ composer: {
152932
+ setText: (text) => {
152933
+ input.value = text;
152934
+ },
152935
+ focus: () => input.focus()
152936
+ },
152937
+ say: (text) => io.onSystem?.(text),
152938
+ runLine: (line, origin, source) => runLine(line, origin, source)
152939
+ });
151766
152940
  const baseOnAssistantTextForGuard = io.onAssistantText?.bind(io);
151767
152941
  io.onAssistantText = (text) => {
151768
152942
  guardCollector.onAssistantText(text);
151769
- remoteBridge?.assistantText(text);
152943
+ remoteQueue.assistantText(text);
151770
152944
  baseOnAssistantTextForGuard?.(text);
151771
152945
  };
151772
- const baseOnToolCallForRemote = io.onToolCall?.bind(io);
151773
- io.onToolCall = (name, toolInput) => {
151774
- remoteBridge?.toolCall();
151775
- baseOnToolCallForRemote?.(name, toolInput);
151776
- };
152946
+ io.onToolCall = remoteQueue.wrapOnToolCall(io.onToolCall?.bind(io));
151777
152947
  const refreshGuardSidebar = () => {
151778
152948
  clearTranscriptChildren(sbGuard);
151779
152949
  if (!guardEnabled)
@@ -152266,24 +153436,60 @@ async function launchTuiAgentShell(opts) {
152266
153436
  setBusyPhase("running approved shell");
152267
153437
  return true;
152268
153438
  };
152269
- io.requestApproval = async (tool, inputJson, meta) => {
153439
+ const requestApprovalForTelegramTurn = async (tool, inputJson, meta) => {
152270
153440
  const bridge = remoteBridge;
152271
153441
  if (bridge === undefined || !bridge.telegramTurnActive) {
152272
153442
  return requestApprovalLocally(tool, inputJson, meta);
152273
153443
  }
152274
153444
  const brief = tool.length > 60 ? `${tool.slice(0, 59)}\u2026` : tool;
153445
+ const approvedAnswer = () => meta?.fingerprint !== undefined ? { approved: true, fingerprint: meta.fingerprint } : true;
153446
+ let offer;
153447
+ if (tool === "shell_exec") {
153448
+ const judged = evaluateTelegramShellApproval({
153449
+ inputJson,
153450
+ ...meta !== undefined ? { meta } : {},
153451
+ sessionAllow: sessionShellAllow,
153452
+ fingerprintAtStart: permissionsFingerprintAtStart
153453
+ });
153454
+ if (!permissionTamperShown && judged.evaluation.tampered) {
153455
+ permissionTamperShown = true;
153456
+ io.onSystem?.(`\u26A0 the saved shell permissions changed outside this approval UI \u2014 review them before trusting an auto-approve
153457
+ `);
153458
+ }
153459
+ if (judged.autoApprove) {
153460
+ const shown = judged.evaluation.command.length > 200 ? `${judged.evaluation.command.slice(0, 199)}\u2026` : judged.evaluation.command;
153461
+ io.onSystem?.(`\u2713 auto-approved shell (saved rule): ${shown}
153462
+ `);
153463
+ bridge.recordApproval(savedRuleAutoApprovalAudit({ userId: bridge.telegramTurnUserId, command: judged.evaluation.command }));
153464
+ return approvedAnswer();
153465
+ }
153466
+ offer = judged.offer;
153467
+ }
152275
153468
  io.onSystem?.(`\u25C7 approval for ${brief} sent to the Telegram topic; waiting for the answer there.
152276
153469
  `);
152277
153470
  setMainAgent("blocked", "approval (telegram)");
152278
- const decision = await bridge.requestApproval(describeApprovalForTopic(tool, inputJson, meta));
153471
+ const answer = await bridge.askApproval(describeApprovalForTopic(tool, inputJson, meta), offer === undefined ? {} : { remember: offer.pattern });
153472
+ const decision = answer.decision;
153473
+ let rememberedNote = "";
153474
+ if (decision === "allow" && answer.always && offer !== undefined) {
153475
+ const stored = allowShellPattern(offer.pattern);
153476
+ if (stored.length > 0) {
153477
+ sessionShellAllow.add(stored);
153478
+ permissionsFingerprintAtStart = shellPermissionsFingerprint();
153479
+ }
153480
+ rememberedNote = stored.length > 0 ? ` and remembered \u201C${stored}\u201D` : ` once; \u201C${offer.pattern}\u201D cannot be remembered`;
153481
+ bridge.recordApproval(stored.length > 0 ? `saved a rule: ${stored}` : "a rule was asked for and refused by the store");
153482
+ await bridge.reportRemembered(answer.approvalId, stored.length > 0);
153483
+ }
152279
153484
  setMainAgent("running", decision === "allow" ? "approved (telegram)" : "denied");
152280
- io.onSystem?.(decision === "allow" ? `\u25C7 ${brief} approved in Telegram.
153485
+ io.onSystem?.(decision === "allow" ? `\u25C7 ${brief} approved in Telegram${rememberedNote}.
152281
153486
  ` : `\u25C7 ${brief} denied (Telegram answer, timeout or no connection).
152282
153487
  `);
152283
153488
  if (decision !== "allow")
152284
153489
  return false;
152285
- return meta?.fingerprint !== undefined ? { approved: true, fingerprint: meta.fingerprint } : true;
153490
+ return approvedAnswer();
152286
153491
  };
153492
+ io.requestApproval = requestApprovalLocally;
152287
153493
  const askUserInteractive = async (req) => {
152288
153494
  chrome.hideMenu();
152289
153495
  setMainAgent("blocked", "ask");
@@ -152364,7 +153570,15 @@ async function launchTuiAgentShell(opts) {
152364
153570
  });
152365
153571
  const sessionCwd = opts.session?.cwd ?? process.cwd();
152366
153572
  let permissionMode = opts.initialPermissionMode ?? getProjectPermissionMode(sessionCwd) ?? DEFAULT_PERMISSION_MODE;
152367
- io.permissionMode = () => permissionMode;
153573
+ let modeChangedThisSession = false;
153574
+ const modeForTurn = (telegramTurn) => modeInForce({
153575
+ shellMode: permissionMode,
153576
+ changedThisSession: modeChangedThisSession,
153577
+ telegramTurn,
153578
+ telegramDefault: remoteBridge?.configuredPermissionMode ?? "ask"
153579
+ });
153580
+ const modeNow = () => modeForTurn(remoteBridge?.telegramTurnActive === true);
153581
+ io.permissionMode = () => modeForTurn(false).mode;
152368
153582
  io.trustedMcpTools = new Map;
152369
153583
  io.mcpToolFingerprint = (fqn) => catalogFingerprintResolver(deps.mcpRuntime?.()?.catalog())(fqn);
152370
153584
  io.mcpToolDestructive = (fqn) => catalogDestructiveResolver(deps.mcpRuntime?.()?.catalog())(fqn);
@@ -152372,18 +153586,30 @@ async function launchTuiAgentShell(opts) {
152372
153586
  io.readOnly = () => readOnly;
152373
153587
  const paintModeRow = () => {
152374
153588
  const row = describeModeRow(permissionMode, readOnly);
153589
+ const inForce = remoteBridge?.active === true ? formatModeInForce(modeNow()) : undefined;
152375
153590
  const hint = settingsSidebarHint(row.readOnly !== undefined);
152376
- sbModeV.content = row.readOnly !== undefined ? otui.t`${dimChunk(otui, `mode ${row.mode}`)} ${roleChunk(otui, "attention", row.readOnly)}` : otui.t`${dimChunk(otui, `mode ${row.mode}${hint === undefined ? "" : ` ${hint}`}`)}`;
153591
+ sbModeV.content = row.readOnly !== undefined ? otui.t`${dimChunk(otui, `mode ${inForce ?? row.mode}`)} ${roleChunk(otui, "attention", row.readOnly)}` : otui.t`${dimChunk(otui, `mode ${inForce ?? row.mode}${hint === undefined || inForce !== undefined ? "" : ` ${hint}`}`)}`;
152377
153592
  };
152378
153593
  paintModeRow();
152379
- io.onAutoApproved = (tool, input, meta) => {
153594
+ const autoApprovedFor = (telegramTurn) => (tool, input, meta) => {
152380
153595
  const preview = tool === "shell_exec" ? parseShellExecCommand(input) : isMcpToolCall(tool) ? `${tool} ${describeUseToolApproval(input, catalogResolver(deps.mcpRuntime?.()?.catalog())).fqn}` : tool;
152381
- const label = `\u25C7 auto-approved (${permissionMode})` + (meta.destructive ? " [destructive]" : "") + (meta.mcpTrusted === true ? ` ${TRUSTED_MARKER}` : "");
153596
+ const inForce = modeForTurn(telegramTurn);
153597
+ const label = `\u25C7 auto-approved (${inForce.mode})` + (meta.destructive ? " [destructive]" : "") + (meta.mcpTrusted === true ? ` ${TRUSTED_MARKER}` : "");
153598
+ if (telegramTurn && remoteBridge !== undefined) {
153599
+ remoteBridge.recordApproval(modeAutoApprovalAudit({ mode: inForce.mode, userId: remoteBridge.telegramTurnUserId, preview }));
153600
+ }
152382
153601
  transcript.add(new otui.TextRenderable(r, {
152383
153602
  id: `ap${uid++}`,
152384
153603
  content: otui.t`${roleChunk(otui, "attention", label)} ${dimChunk(otui, preview)}`
152385
153604
  }));
152386
153605
  };
153606
+ io.onAutoApproved = autoApprovedFor(false);
153607
+ const telegramTurnIo = {
153608
+ requestApproval: requestApprovalForTelegramTurn,
153609
+ permissionMode: () => modeForTurn(true).mode,
153610
+ onAutoApproved: autoApprovedFor(true),
153611
+ mcpGrantsApply: () => false
153612
+ };
152387
153613
  let liveSession;
152388
153614
  let history = [];
152389
153615
  let archive = [];
@@ -152419,19 +153645,7 @@ async function launchTuiAgentShell(opts) {
152419
153645
  deps = { ...deps, onContextCompaction };
152420
153646
  liveDeps = deps;
152421
153647
  let splash = undefined;
152422
- const stopRemoteForSessionSwitch = () => {
152423
- if (remoteBridge?.keepingTopic === true) {
152424
- remoteBridge.sessionLeaving();
152425
- return;
152426
- }
152427
- if (remoteBridge?.active !== true)
152428
- return;
152429
- const closing = remoteBridge.disable();
152430
- io.onSystem?.(`\u25C7 remote control is turning off: the session changed, and its Telegram topic is being deleted. Turn it on again with /remote-control <name>.
152431
- `);
152432
- closing.then(() => io.onSystem?.(`\u25C7 remote control off: the topic is deleted.
152433
- `));
152434
- };
153648
+ const stopRemoteForSessionSwitch = () => remoteQueue.stopRemoteForSessionSwitch();
152435
153649
  const applyOpened = (opened, previewHistory) => {
152436
153650
  if (opened.history.length > 0) {
152437
153651
  splash?.removeIfShown();
@@ -152909,6 +154123,50 @@ Staying in the current session.
152909
154123
  io.onSystem?.("The product index could not be used. Run `keryx product index`.\n");
152910
154124
  });
152911
154125
  };
154126
+ const permissionsChanged = () => {
154127
+ permissionsFingerprintAtStart = shellPermissionsFingerprint();
154128
+ };
154129
+ const showPermissions = () => {
154130
+ openPermissions(otui, chrome, {
154131
+ sessionAllow: sessionShellAllow,
154132
+ onChanged: permissionsChanged,
154133
+ onKeypress: (handler) => onKeypress4(r, handler),
154134
+ renderer: r,
154135
+ inputBlocked: () => chrome.keyboardOwnedElsewhere()
154136
+ });
154137
+ };
154138
+ const runPermissionsCommand = (line) => {
154139
+ const rest = line.trim().slice(PERMISSIONS_COMMAND.length).trim();
154140
+ if (rest.length === 0) {
154141
+ showPermissions();
154142
+ return;
154143
+ }
154144
+ io.onSystem?.(permissionsSlashText(rest, { sessionAllow: sessionShellAllow, onChanged: permissionsChanged }));
154145
+ };
154146
+ const runningTelegramPolicy = () => {
154147
+ const bridge = remoteBridge;
154148
+ if (bridge === undefined || !bridge.active)
154149
+ return;
154150
+ return {
154151
+ defaultMode: bridge.configuredPermissionMode,
154152
+ runTimeoutMs: bridge.runTimeoutMs,
154153
+ approvalTimeoutMs: bridge.approvalTimeoutMs
154154
+ };
154155
+ };
154156
+ const telegramModeInForce = () => remoteBridge?.active === true ? formatModeInForce(modeNow()) : undefined;
154157
+ const remotePolicyDeps = () => ({
154158
+ running: runningTelegramPolicy,
154159
+ inForce: telegramModeInForce,
154160
+ applyPolicy: (patch) => {
154161
+ remoteBridge?.applyPolicy(patch);
154162
+ paintModeRow();
154163
+ liveRemotePanel?.refresh();
154164
+ }
154165
+ });
154166
+ const telegramPosture = () => loadPosture({ running: runningTelegramPolicy, inForce: telegramModeInForce });
154167
+ const runRemotePolicyCommand = (line) => {
154168
+ io.onSystem?.(remotePolicyText(line.trim().slice(REMOTE_POLICY_COMMAND.length), remotePolicyDeps()));
154169
+ };
152912
154170
  const showReviews = () => {
152913
154171
  openReviews(otui, chrome, { cwd: inspectorCwd(), renderer: r, ...inspectorKeys }).then(() => liveReviewsPanel?.refresh()).catch(() => {
152914
154172
  io.onSystem?.(`The managed reviews could not be read.
@@ -152939,6 +154197,10 @@ Staying in the current session.
152939
154197
  const showRemoteControl = () => {
152940
154198
  openRemoteControl(otui, chrome, {
152941
154199
  getStatus: remoteStatus,
154200
+ getPostureLines: () => {
154201
+ const posture = remoteBridge?.active === true ? telegramPosture() : undefined;
154202
+ return posture === undefined ? undefined : postureLines(posture);
154203
+ },
152942
154204
  onToggle: () => {
152943
154205
  remoteBridge?.active === true ? disableRemoteControl() : enableRemoteControl();
152944
154206
  },
@@ -153336,6 +154598,7 @@ Staying in the current session.
153336
154598
  };
153337
154599
  const commitPermissionMode = (next) => {
153338
154600
  permissionMode = next;
154601
+ modeChangedThisSession = true;
153339
154602
  paintModeRow();
153340
154603
  chrome.showToast(`Permission mode: ${next}`);
153341
154604
  };
@@ -153506,7 +154769,12 @@ ${formatThemeList(getThemeId())}`);
153506
154769
  guard: guardEnabled,
153507
154770
  routing: routingEnabled,
153508
154771
  thinkDisplay: thinkDisplayMode,
153509
- reasoningOverride
154772
+ reasoningOverride,
154773
+ ...(() => {
154774
+ const running = runningTelegramPolicy();
154775
+ const inForce = telegramModeInForce();
154776
+ return running === undefined ? {} : { telegram: { ...running, ...inForce !== undefined ? { inForce } : {} } };
154777
+ })()
153510
154778
  }));
153511
154779
  const runSettingsAction = (command) => runSettingsCommand(command, {
153512
154780
  mode: runModeCommand,
@@ -153521,6 +154789,7 @@ ${formatThemeList(getThemeId())}`);
153521
154789
  reasoning: runReasoningCommand,
153522
154790
  think: runThinkCommand,
153523
154791
  theme: runThemeCommand,
154792
+ remotePolicy: (arg) => remotePolicyText(arg, remotePolicyDeps()),
153524
154793
  onSystem: (text) => io.onSystem?.(text)
153525
154794
  });
153526
154795
  const showSettings = () => {
@@ -153567,7 +154836,6 @@ ${formatThemeList(getThemeId())}`);
153567
154836
  const sideQueue = [];
153568
154837
  let mainQueue = [];
153569
154838
  let mainQueueSeq = 0;
153570
- let pendingQueueEdit;
153571
154839
  const forceHandoff = createForegroundForceHandoff();
153572
154840
  let sideWorkerRunning = false;
153573
154841
  let sideClearTimeout;
@@ -153675,26 +154943,8 @@ ${formatThemeList(getThemeId())}`);
153675
154943
  fleet.remove("agent:queue");
153676
154944
  }
153677
154945
  };
153678
- const removeMainQueue = (index) => {
153679
- if (index < 0 || index >= mainQueue.length)
153680
- return;
153681
- const removedItem = mainQueue[index];
153682
- if (removedItem?.source === TG_SOURCE) {
153683
- remoteBridge?.queuedLineRemoved(removedItem.question);
153684
- }
153685
- mainQueue = removeMainQueueItem(mainQueue, index);
153686
- paintMainQueue();
153687
- };
153688
- const editMainQueue = (index) => {
153689
- const edited = editMainQueueItem(mainQueue, index);
153690
- if (edited === undefined)
153691
- return;
153692
- mainQueue = edited.rest;
153693
- pendingQueueEdit = pendingQueueEditFor(edited.removed, index);
153694
- input.value = edited.text;
153695
- input.focus();
153696
- paintMainQueue();
153697
- };
154946
+ const removeMainQueue = (index) => remoteQueue.removeMainQueue(index);
154947
+ const editMainQueue = (index) => remoteQueue.editMainQueue(index);
153698
154948
  const forceMainQueue = (index) => {
153699
154949
  const item = mainQueue[index];
153700
154950
  if (item === undefined)
@@ -153709,7 +154959,7 @@ ${formatThemeList(getThemeId())}`);
153709
154959
  forceForegroundQueueItem(foregroundOperation, forceHandoff, item, {
153710
154960
  announce: () => io.onSystem?.(`\u25C7 main turn interrupted \u2014 q${index + 1} will run next.
153711
154961
  `),
153712
- run: (next) => runLine(next.question, "operator", next.source)
154962
+ run: (next) => remoteQueue.runQueued(next)
153713
154963
  });
153714
154964
  };
153715
154965
  const handleQueueNavKey = (key) => {
@@ -154082,6 +155332,14 @@ ${formatThemeList(getThemeId())}`);
154082
155332
  routeApprovalsCommand(line, true, approvals);
154083
155333
  return;
154084
155334
  }
155335
+ case "permissions": {
155336
+ runPermissionsCommand(line);
155337
+ return;
155338
+ }
155339
+ case "remote-policy": {
155340
+ runRemotePolicyCommand(line);
155341
+ return;
155342
+ }
154085
155343
  case "decisions": {
154086
155344
  routeDecisionsCommand(line, decisionsPanel);
154087
155345
  return;
@@ -154118,11 +155376,7 @@ ${formatThemeList(getThemeId())}`);
154118
155376
  case "not-a-command":
154119
155377
  break;
154120
155378
  }
154121
- if (pendingQueueEdit !== undefined) {
154122
- const edit = pendingQueueEdit;
154123
- pendingQueueEdit = undefined;
154124
- mainQueue = reinsertEditedMainQueueItem(mainQueue, edit, line, displayLine);
154125
- paintMainQueue();
155379
+ if (remoteQueue.requeuePendingEdit(line, displayLine)) {
154126
155380
  return;
154127
155381
  }
154128
155382
  if (leaseView()?.held() === true) {
@@ -154163,7 +155417,7 @@ ${formatThemeList(getThemeId())}`);
154163
155417
  takeForced: () => forceHandoff.takeNext(),
154164
155418
  dispatch: (next) => {
154165
155419
  paintMainQueue();
154166
- runLine(next.question, "operator", next.source);
155420
+ remoteQueue.runQueued(next);
154167
155421
  }
154168
155422
  });
154169
155423
  } else {
@@ -154410,6 +155664,14 @@ ${formatThemeList(getThemeId())}`);
154410
155664
  if (routeApprovalsCommand(line, false, approvals)) {
154411
155665
  return;
154412
155666
  }
155667
+ if (command.name === PERMISSIONS_COMMAND) {
155668
+ runPermissionsCommand(line);
155669
+ return;
155670
+ }
155671
+ if (command.name === REMOTE_POLICY_COMMAND) {
155672
+ runRemotePolicyCommand(line);
155673
+ return;
155674
+ }
154413
155675
  if (routeDecisionsCommand(line, decisionsPanel)) {
154414
155676
  return;
154415
155677
  }
@@ -154939,7 +156201,7 @@ ${formatThemeList(getThemeId())}`);
154939
156201
  const next = forceHandoff.takeNext() ?? mainQueue.shift();
154940
156202
  paintMainQueue();
154941
156203
  if (next !== undefined)
154942
- runLine(next.question, "operator", next.source);
156204
+ remoteQueue.runQueued(next);
154943
156205
  }
154944
156206
  }
154945
156207
  });
@@ -154951,7 +156213,8 @@ ${formatThemeList(getThemeId())}`);
154951
156213
  sessions.clear();
154952
156214
  deps.resetSubagentBudget?.();
154953
156215
  const operation = foregroundOperation.begin();
154954
- remoteBridge?.turnStarted(source);
156216
+ remoteQueue.turnStarted(source);
156217
+ const telegramTurn = source === TG_SOURCE && remoteBridge?.telegramTurnActive === true;
154955
156218
  blockIo.resetReasoningLiveState();
154956
156219
  setMainAgent("running", "waiting");
154957
156220
  startBusy("waiting for model");
@@ -155038,7 +156301,7 @@ ${formatThemeList(getThemeId())}`);
155038
156301
  guardCollector.reset(line);
155039
156302
  syncArchive();
155040
156303
  rewindRecorder.beginTurn({ archiveIndex: archive.length, prompt: line });
155041
- const foregroundIo = createForegroundAgentIoFacade(foregroundOperation, operation, io);
156304
+ const foregroundIo = createForegroundAgentIoFacade(foregroundOperation, operation, io, telegramTurn ? telegramTurnIo : {});
155042
156305
  const turnSignal = foregroundOperation.signal;
155043
156306
  runAgentTurn(foregroundIo, deps, history, line, {
155044
156307
  signal: turnSignal,
@@ -155046,7 +156309,7 @@ ${formatThemeList(getThemeId())}`);
155046
156309
  ...slateSession !== undefined ? { slateSession, pruneArchive: true } : {}
155047
156310
  }).finally(() => {
155048
156311
  foregroundOperation.settle(operation);
155049
- remoteBridge?.turnSettled({ failed: turnFailed || turnSignal.aborted });
156312
+ remoteQueue.turnSettled({ failed: turnFailed, aborted: turnSignal.aborted });
155050
156313
  if (foregroundOperation.isDisposed)
155051
156314
  return;
155052
156315
  refreshRewindSidebar();
@@ -155099,7 +156362,7 @@ ${formatThemeList(getThemeId())}`);
155099
156362
  const next = forceHandoff.takeNext() ?? mainQueue.shift();
155100
156363
  paintMainQueue();
155101
156364
  if (next !== undefined) {
155102
- runLine(next.question, "operator", next.source);
156365
+ remoteQueue.runQueued(next);
155103
156366
  } else {
155104
156367
  busWakeController?.onSettle();
155105
156368
  }
@@ -155143,7 +156406,7 @@ ${formatThemeList(getThemeId())}`);
155143
156406
  const drained = forceHandoff.takeNext() ?? mainQueue.shift();
155144
156407
  paintMainQueue();
155145
156408
  if (drained !== undefined)
155146
- runLine(drained.question, "operator", drained.source);
156409
+ remoteQueue.runQueued(drained);
155147
156410
  }
155148
156411
  });
155149
156412
  const captureShellOutput = async (run) => {
@@ -155226,7 +156489,7 @@ ${formatThemeList(getThemeId())}`);
155226
156489
  });
155227
156490
  return `${formatSessionInfoText(snapshot).trimEnd()}
155228
156491
 
155229
- Mode: ${permissionMode}${readOnly ? " (read-only on)" : ""}
156492
+ Mode: ${formatModeInForce(modeForTurn(true))}${readOnly ? " (read-only on)" : ""}
155230
156493
  Turn: ${chrome.isBusy() ? "running" : "idle"} \xB7 queue: ${mainQueue.length} waiting`;
155231
156494
  };
155232
156495
  const runRemoteCommand = async (line) => {
@@ -155239,12 +156502,13 @@ Turn: ${chrome.isBusy() ? "running" : "idle"} \xB7 queue: ${mainQueue.length} wa
155239
156502
  `);
155240
156503
  });
155241
156504
  }
155242
- if (name === "/mode" && (first === "" || first === "auto")) {
156505
+ const modeWords = line.trim().split(/\s+/).slice(1).filter((word) => word.length > 0);
156506
+ if (name === "/mode" && (first === "" || (first === "ask" || first === "trust" || first === "auto") && modeWords.length === 1)) {
155243
156507
  echoRemoteCommand(line);
155244
156508
  return await captureShellOutput(() => {
155245
- if (first === "auto")
155246
- commitPermissionMode("auto");
155247
- io.onSystem?.(`Permission mode: ${permissionMode} (ask, trust or auto)
156509
+ if (first === "ask" || first === "trust" || first === "auto")
156510
+ commitPermissionMode(first);
156511
+ io.onSystem?.(`Permission mode: ${formatModeInForce(modeForTurn(true))} (ask, trust or auto)
155248
156512
  `);
155249
156513
  });
155250
156514
  }
@@ -155255,14 +156519,8 @@ Turn: ${chrome.isBusy() ? "running" : "idle"} \xB7 queue: ${mainQueue.length} wa
155255
156519
  sessionId: () => liveSession.summary.id,
155256
156520
  project: () => sessionCwd,
155257
156521
  isBusy: () => chrome.isBusy() || foregroundOperation.isActive || forceHandoff.isAwaitingSettlement || mainQueue.length > 0 || leaseView()?.held() === true,
155258
- runLine: (text) => runLine(text, "operator", TG_SOURCE),
155259
- enqueue: (text) => {
155260
- const id = `mq${mainQueueSeq++}`;
155261
- mainQueue.push({ id, question: text, displayQuestion: summarizeSubmittedLine(text), source: TG_SOURCE });
155262
- paintMainQueue();
155263
- io.onSystem?.(`\u25C7 a line from Telegram is queued as q${mainQueue.length}.
155264
- `);
155265
- },
156522
+ runLine: (text) => remoteQueue.runTelegramLine(text),
156523
+ enqueue: (text) => remoteQueue.enqueueTelegramLine(text),
155266
156524
  notice: (text) => io.onSystem?.(`${text}
155267
156525
  `),
155268
156526
  cancelTurn: () => foregroundOperation.cancel("remote run time limit"),
@@ -155280,14 +156538,7 @@ Turn: ${chrome.isBusy() ? "running" : "idle"} \xB7 queue: ${mainQueue.length} wa
155280
156538
  liveSession = recordRemoteOff(liveSession);
155281
156539
  } catch {}
155282
156540
  },
155283
- dropQueuedTelegramLines: () => {
155284
- const { kept, dropped } = dropQueuedBySource(mainQueue, TG_SOURCE);
155285
- if (dropped.length === 0)
155286
- return [];
155287
- mainQueue = kept;
155288
- paintMainQueue();
155289
- return dropped.map((item) => item.question);
155290
- },
156541
+ dropQueuedTelegramLines: () => remoteQueue.dropQueuedTelegramLines(),
155291
156542
  runCommand: runRemoteCommand,
155292
156543
  busyRefusal: (line) => {
155293
156544
  const command = findAgentCommand(line, "agent");
@@ -155360,7 +156611,10 @@ Turn: ${chrome.isBusy() ? "running" : "idle"} \xB7 queue: ${mainQueue.length} wa
155360
156611
  pendingResumeTarget = undefined;
155361
156612
  return liveSession.summary.id === found.id ? { ...outcome, ok: true } : { ...outcome, ok: false };
155362
156613
  },
155363
- onChange: () => liveRemotePanel?.refresh()
156614
+ onChange: () => {
156615
+ liveRemotePanel?.refresh();
156616
+ paintModeRow();
156617
+ }
155364
156618
  }
155365
156619
  });
155366
156620
  onKeypress4(r, (key) => {
@@ -155412,6 +156666,9 @@ var init_tui_shell = __esm(() => {
155412
156666
  init_flow_inspector();
155413
156667
  init_product_open_surface();
155414
156668
  init_reviews_inspector();
156669
+ init_permissions_command();
156670
+ init_remote_policy_command();
156671
+ init_permissions_inspector();
155415
156672
  init_shell_bridge();
155416
156673
  init_command_gateway();
155417
156674
  init_channels_client();
@@ -155501,6 +156758,7 @@ var init_tui_shell = __esm(() => {
155501
156758
  init_provider_config();
155502
156759
  init_shell_permissions();
155503
156760
  init_shell_approval();
156761
+ init_telegram_permission();
155504
156762
  init_elicitation();
155505
156763
  init_permission_mode_config();
155506
156764
  init_permission_mode();
@@ -155514,6 +156772,7 @@ var init_tui_shell = __esm(() => {
155514
156772
  init_composer_choice();
155515
156773
  init_filter_list();
155516
156774
  init_shell_chrome();
156775
+ init_remote_queue_wiring();
155517
156776
  init_queue_nav();
155518
156777
  init_subagent_inspector();
155519
156778
  init_subagent_session();
@@ -157340,7 +158599,7 @@ async function runAgentRepl(lines, rich, deps, metaprojectPort, sessionOpts, ini
157340
158599
  }
157341
158600
  };
157342
158601
  const searchProviderController = createDefaultSearchProviderController();
157343
- const sessionShellAllow = new Set(loadShellPermissions(configDir).allow);
158602
+ const sessionShellAllow = new Set;
157344
158603
  let fingerprintAtStart = shellPermissionsFingerprint(configDir);
157345
158604
  let permissionMigrationShown = false;
157346
158605
  let permissionTamperShown = false;
@@ -158032,6 +159291,8 @@ ${GUTTER}${turnSeparator()}
158032
159291
  agentIo.onSystem?.(readlineChannelsText(line, await loadChannelsSnapshot(new ChannelsClient)));
158033
159292
  } else if (isRenderingCommand(command)) {
158034
159293
  agentIo.onSystem?.(runRenderingCommand(rest));
159294
+ } else if (command === "/remote-policy") {
159295
+ agentIo.onSystem?.(remotePolicyText(rest, { ...configDir !== undefined ? { dir: configDir } : {} }));
158035
159296
  } else if (isSessionInfoCommand(command)) {
158036
159297
  const cwd = sessionCwd;
158037
159298
  const [workspaces, flows] = await Promise.all([
@@ -158055,6 +159316,13 @@ ${GUTTER}${turnSeparator()}
158055
159316
  })));
158056
159317
  } else if (command === "/approvals") {
158057
159318
  agentIo.onSystem?.(approvalsSlashText(rest));
159319
+ } else if (command === "/permissions") {
159320
+ agentIo.onSystem?.(permissionsSlashText(rest, {
159321
+ sessionAllow: sessionShellAllow,
159322
+ onChanged: () => {
159323
+ fingerprintAtStart = shellPermissionsFingerprint(configDir);
159324
+ }
159325
+ }));
158058
159326
  } else if (command === "/external-diff") {
158059
159327
  agentIo.onSystem?.(externalDiffSlashText(defaultExternalDiffDeps(sessionCwd)));
158060
159328
  } else if (isFlowsCommand(command)) {
@@ -159163,6 +160431,8 @@ var init_shell = __esm(() => {
159163
160431
  init_flow_inspector();
159164
160432
  init_inspector_sources();
159165
160433
  init_approvals();
160434
+ init_permissions_command();
160435
+ init_remote_policy_command();
159166
160436
  init_external_diff_modal();
159167
160437
  init_doctor2();
159168
160438
  init_setup_guide();
@@ -159229,6 +160499,7 @@ var init_shell = __esm(() => {
159229
160499
  "/status",
159230
160500
  "/flows",
159231
160501
  "/approvals",
160502
+ "/permissions",
159232
160503
  "/external-diff",
159233
160504
  "/doctor",
159234
160505
  "/setup",
@@ -159236,6 +160507,7 @@ var init_shell = __esm(() => {
159236
160507
  "/remote-control",
159237
160508
  "/channels",
159238
160509
  "/rendering",
160510
+ "/remote-policy",
159239
160511
  "/theme",
159240
160512
  "/settings",
159241
160513
  "/mode",
@@ -192911,7 +194183,7 @@ function listApprovalsRoute(request, ctx) {
192911
194183
  });
192912
194184
  }
192913
194185
  async function answerApprovalRoute(request, approvalId, ctx) {
192914
- if (!isApprovalId(approvalId)) {
194186
+ if (!isApprovalId2(approvalId)) {
192915
194187
  return approvalNotFound();
192916
194188
  }
192917
194189
  const declared = Number(request.headers.get("content-length") ?? "0");
@@ -193175,9 +194447,11 @@ var init_serve_server = __esm(() => {
193175
194447
  ["heartbeat", "POST"],
193176
194448
  ["reply", "POST"],
193177
194449
  ["approval", "POST"],
194450
+ ["approval-result", "POST"],
193178
194451
  ["prompt", "POST"],
193179
194452
  ["state", "POST"],
193180
194453
  ["ack", "POST"],
194454
+ ["approval-ack", "POST"],
193181
194455
  ["stream", "GET"],
193182
194456
  ["channels-status", "GET"],
193183
194457
  ["channels-pair", "POST"],
@@ -195889,7 +197163,12 @@ class RemoteHub {
195889
197163
  }));
195890
197164
  }
195891
197165
  limits() {
195892
- return { runTimeoutMs: this.config.runTimeoutMs, orphanMs: this.config.orphanMs };
197166
+ return {
197167
+ runTimeoutMs: this.config.runTimeoutMs,
197168
+ orphanMs: this.config.orphanMs,
197169
+ permissionMode: this.config.permissionMode,
197170
+ approvalTimeoutMs: this.config.approvalTimeoutMs
197171
+ };
195893
197172
  }
195894
197173
  hasSession(sessionId) {
195895
197174
  return this.registry.bySession(sessionId) !== undefined;
@@ -196665,6 +197944,7 @@ class RemoteHttpSurface {
196665
197944
  timers;
196666
197945
  keepaliveMs;
196667
197946
  ackTimeoutMs;
197947
+ approvalAckMs;
196668
197948
  newApprovalId;
196669
197949
  newPromptId;
196670
197950
  streams = new Map;
@@ -196673,6 +197953,8 @@ class RemoteHttpSurface {
196673
197953
  bound = new Map;
196674
197954
  pendingAcks = new Map;
196675
197955
  approvals = new Map;
197956
+ awaiting = new Map;
197957
+ decided = new Map;
196676
197958
  choices = new Map;
196677
197959
  finished = new Map;
196678
197960
  constructor(options) {
@@ -196681,6 +197963,7 @@ class RemoteHttpSurface {
196681
197963
  this.timers = options.timers ?? realTimers;
196682
197964
  this.keepaliveMs = options.keepaliveMs ?? DEFAULT_KEEPALIVE_MS;
196683
197965
  this.ackTimeoutMs = options.ackTimeoutMs ?? DEFAULT_ACK_TIMEOUT_MS;
197966
+ this.approvalAckMs = options.approvalAckMs ?? DEFAULT_APPROVAL_ACK_MS;
196684
197967
  this.newApprovalId = options.approvalIds ?? (() => `ap${randomBytes15(6).toString("hex")}`);
196685
197968
  this.newPromptId = options.promptIds ?? (() => `pk${randomBytes15(6).toString("hex")}`);
196686
197969
  this.consumer = {
@@ -196741,12 +198024,16 @@ class RemoteHttpSurface {
196741
198024
  return this.reply(hub, request);
196742
198025
  case "approval":
196743
198026
  return this.requestApproval(hub, request);
198027
+ case "approval-result":
198028
+ return this.approvalResult(hub, request);
196744
198029
  case "prompt":
196745
198030
  return this.requestPrompt(hub, request);
196746
198031
  case "state":
196747
198032
  return this.messageState(hub, request);
196748
198033
  case "ack":
196749
198034
  return this.ack(hub, request);
198035
+ case "approval-ack":
198036
+ return this.approvalAck(hub, request);
196750
198037
  case "stream":
196751
198038
  return this.openStream(hub, request, io);
196752
198039
  }
@@ -196765,6 +198052,9 @@ class RemoteHttpSurface {
196765
198052
  for (const id of [...this.approvals.keys()]) {
196766
198053
  this.dropApproval(id);
196767
198054
  }
198055
+ for (const id of [...this.awaiting.keys()]) {
198056
+ this.dropAwaiting(id);
198057
+ }
196768
198058
  for (const id of [...this.choices.keys()]) {
196769
198059
  this.dropChoice(id);
196770
198060
  }
@@ -196799,7 +198089,15 @@ class RemoteHttpSurface {
196799
198089
  this.sent.delete(checked.sessionId);
196800
198090
  this.bound.set(checked.sessionId, binding);
196801
198091
  }
196802
- return ok2({ name: result.name, threadId: result.threadId, reused: result.reused, runTimeoutMs: hub.limits().runTimeoutMs });
198092
+ const limits = hub.limits();
198093
+ return ok2({
198094
+ name: result.name,
198095
+ threadId: result.threadId,
198096
+ reused: result.reused,
198097
+ runTimeoutMs: limits.runTimeoutMs,
198098
+ permissionMode: limits.permissionMode,
198099
+ approvalTimeoutMs: limits.approvalTimeoutMs
198100
+ });
196803
198101
  }
196804
198102
  const status = result.code === "name-taken" ? 409 : result.code === "invalid-name" ? 400 : 502;
196805
198103
  return fail4(status, result.code, result.message);
@@ -196926,6 +198224,13 @@ class RemoteHttpSurface {
196926
198224
  if (typeof timeoutMs !== "number" || !Number.isSafeInteger(timeoutMs) || timeoutMs < MIN_APPROVAL_TIMEOUT_MS || timeoutMs > MAX_APPROVAL_TIMEOUT_MS) {
196927
198225
  return invalid2(`timeoutMs must be an integer from ${MIN_APPROVAL_TIMEOUT_MS} to ${MAX_APPROVAL_TIMEOUT_MS}.`);
196928
198226
  }
198227
+ let remember;
198228
+ if (value.remember !== undefined) {
198229
+ if (typeof value.remember !== "string" || value.remember.length === 0 || value.remember.length > MAX_REMEMBER_PATTERN_CHARS || CONTROL_CHARS2.test(value.remember)) {
198230
+ return invalid2(`remember must be a string of 1 to ${MAX_REMEMBER_PATTERN_CHARS} characters without control characters.`);
198231
+ }
198232
+ remember = redactSensitiveText(value.remember);
198233
+ }
196929
198234
  const sessionId = value.sessionId;
196930
198235
  if (!hub.hasSession(sessionId)) {
196931
198236
  return fail4(404, "unknown-session", "This session is not registered for remote control; register again.");
@@ -196947,15 +198252,20 @@ class RemoteHttpSurface {
196947
198252
  this.approvals.set(approvalId, {
196948
198253
  sessionId,
196949
198254
  expiresAt,
196950
- timer: this.timers.setTimeout(() => this.expireApproval(approvalId), timeoutMs)
198255
+ timer: this.timers.setTimeout(() => this.expireApproval(approvalId), timeoutMs),
198256
+ ...remember === undefined ? {} : { remember }
196951
198257
  });
198258
+ const offer = remember === undefined ? "" : `
198259
+ Always would remember:
198260
+ ${asCodeBlock(remember)}`;
196952
198261
  const sent = await hub.send(sessionId, `Approval needed:
196953
- ${asCodeBlock(redactSensitiveText(value.prompt))}`, {
198262
+ ${asCodeBlock(redactSensitiveText(value.prompt))}${offer}`, {
196954
198263
  keyboard: [
196955
198264
  [
196956
198265
  { text: "Allow", callback_data: approvalCallbackData(approvalId, "allow") },
196957
198266
  { text: "Deny", callback_data: approvalCallbackData(approvalId, "deny") }
196958
- ]
198267
+ ],
198268
+ ...remember === undefined ? [] : [[{ text: alwaysButtonText(remember), callback_data: approvalCallbackData(approvalId, "always") }]]
196959
198269
  ],
196960
198270
  onSent: (info) => this.onApprovalSent(approvalId, sessionId, info.messageId, info.text)
196961
198271
  });
@@ -196965,6 +198275,45 @@ ${asCodeBlock(redactSensitiveText(value.prompt))}`, {
196965
198275
  }
196966
198276
  return ok2({ approvalId, expiresAt });
196967
198277
  }
198278
+ async approvalResult(hub, request) {
198279
+ const body = await readJsonBody(request);
198280
+ if (!body.ok) {
198281
+ return body.response;
198282
+ }
198283
+ const value = body.value;
198284
+ if (!isSessionId(value.sessionId)) {
198285
+ return invalid2("sessionId must be 1 to 64 characters of letters, digits, '-' and '_'.");
198286
+ }
198287
+ if (!isApprovalId(value.approvalId) || typeof value.remembered !== "boolean") {
198288
+ return invalid2("approvalId must be an approval id and remembered a boolean.");
198289
+ }
198290
+ const waiting = this.awaiting.get(value.approvalId);
198291
+ if (waiting !== undefined && waiting.sessionId === value.sessionId && waiting.remember !== undefined) {
198292
+ waiting.reported ??= value.remembered;
198293
+ return ok2({ settled: true });
198294
+ }
198295
+ const done = this.finished.get(value.approvalId);
198296
+ if (done === undefined || done.sessionId !== value.sessionId || done.rememberOf === undefined) {
198297
+ return fail4(404, "unknown-approval", "No approval of this session is waiting for that report.");
198298
+ }
198299
+ this.settleRemembered(hub, value.sessionId, done, value.remembered);
198300
+ return ok2({ settled: true });
198301
+ }
198302
+ settleRemembered(hub, sessionId, done, remembered) {
198303
+ const info = done.rememberOf;
198304
+ if (info === undefined) {
198305
+ return;
198306
+ }
198307
+ done.rememberOf = undefined;
198308
+ const result = remembered ? `Allowed by ${info.who} at ${info.when}. Remembered: ${info.pattern}` : `Allowed by ${info.who} at ${info.when}. Not remembered: ${info.pattern} (approved this once).`;
198309
+ done.shortReply = remembered ? "Approval granted and remembered." : "Approval granted once; the rule was not remembered.";
198310
+ done.finalText = done.messageId === undefined ? result : settledText(info.original, result);
198311
+ if (done.messageId !== undefined) {
198312
+ hub.settleMessage(sessionId, done.messageId, done.finalText, done.shortReply).catch(() => {
198313
+ return;
198314
+ });
198315
+ }
198316
+ }
196968
198317
  async requestPrompt(hub, request) {
196969
198318
  const body = await readJsonBody(request);
196970
198319
  if (!body.ok) {
@@ -197088,6 +198437,62 @@ ${asCodeBlock(redactSensitiveText(value.prompt))}`, {
197088
198437
  this.complete(value.sessionId, value.updateId);
197089
198438
  return ok2({ acknowledged: true });
197090
198439
  }
198440
+ async approvalAck(hub, request) {
198441
+ const body = await readJsonBody(request);
198442
+ if (!body.ok) {
198443
+ return body.response;
198444
+ }
198445
+ const value = body.value;
198446
+ if (!isSessionId(value.sessionId)) {
198447
+ return invalid2("sessionId must be 1 to 64 characters of letters, digits, '-' and '_'.");
198448
+ }
198449
+ if (!isApprovalId(value.approvalId)) {
198450
+ return invalid2("approvalId must be 'ap' and 12 lowercase hex characters.");
198451
+ }
198452
+ if (!hub.hasSession(value.sessionId)) {
198453
+ return fail4(404, "unknown-session", "This session is not registered for remote control; register again.");
198454
+ }
198455
+ if (value.applied !== undefined && typeof value.applied !== "boolean") {
198456
+ return invalid2("applied must be true or false when present.");
198457
+ }
198458
+ const sessionId = value.sessionId;
198459
+ const id = value.approvalId;
198460
+ const waiting = this.awaiting.get(id);
198461
+ if (waiting !== undefined && waiting.sessionId === sessionId) {
198462
+ this.dropAwaiting(id);
198463
+ this.rememberDecided(id, sessionId);
198464
+ if (value.applied === false) {
198465
+ this.finishApproval(id, sessionId, waiting.messageId, waiting.text, {
198466
+ result: `Not applied at ${waiting.sentAt}: the shell was no longer waiting for this question, so the ${waiting.decision === "deny" ? "Deny" : "Allow"} from ${waiting.who} changed nothing.`,
198467
+ shortReply: "Not applied: the shell was no longer waiting for this question."
198468
+ });
198469
+ return ok2({ acknowledged: true });
198470
+ }
198471
+ if (waiting.decision === "always" && waiting.remember !== undefined) {
198472
+ this.finishApproval(id, sessionId, waiting.messageId, waiting.text, {
198473
+ result: `Allowed by ${waiting.who} at ${waiting.sentAt}. Saving the rule: ${waiting.remember}`,
198474
+ shortReply: "Approval granted; saving the rule."
198475
+ });
198476
+ const done = this.finished.get(id);
198477
+ if (done !== undefined) {
198478
+ done.rememberOf = { original: waiting.text, who: waiting.who, when: waiting.sentAt, pattern: waiting.remember };
198479
+ if (waiting.reported !== undefined) {
198480
+ this.settleRemembered(hub, sessionId, done, waiting.reported);
198481
+ }
198482
+ }
198483
+ return ok2({ acknowledged: true });
198484
+ }
198485
+ this.finishApproval(id, sessionId, waiting.messageId, waiting.text, {
198486
+ result: waiting.decision === "allow" ? `Allowed by ${waiting.who} at ${waiting.sentAt}.` : `Denied by ${waiting.who} at ${waiting.sentAt}.`,
198487
+ shortReply: waiting.decision === "allow" ? "Approval granted." : "Approval denied."
198488
+ });
198489
+ return ok2({ acknowledged: true });
198490
+ }
198491
+ if (this.decided.get(id) === sessionId) {
198492
+ return ok2({ acknowledged: true });
198493
+ }
198494
+ return fail4(404, "unknown-approval", "No approval with that id was sent to this session.");
198495
+ }
197091
198496
  openStream(hub, request, io) {
197092
198497
  const sessionId = new URL(request.url).searchParams.get("sessionId");
197093
198498
  if (!isSessionId(sessionId)) {
@@ -197232,8 +198637,11 @@ ${asCodeBlock(redactSensitiveText(value.prompt))}`, {
197232
198637
  return;
197233
198638
  }
197234
198639
  if (entry !== undefined && entry.expiresAt > this.now()) {
198640
+ if (approval.decision === "always" && entry.remember === undefined) {
198641
+ return;
198642
+ }
197235
198643
  this.dropApproval(id);
197236
- const event = { updateId: callback.updateId, approvalId: id, decision: approval.decision };
198644
+ const event = { updateId: callback.updateId, approvalId: id, decision: approval.decision, fromId: callback.fromId };
197237
198645
  const told = stream?.write(encodeSseEvent("approval", event, callback.updateId)) === true;
197238
198646
  const messageId = entry.messageId ?? callback.messageId;
197239
198647
  const when = clockText(this.now());
@@ -197244,10 +198652,15 @@ ${asCodeBlock(redactSensitiveText(value.prompt))}`, {
197244
198652
  });
197245
198653
  return;
197246
198654
  }
197247
- const who = `user ${callback.fromId}`;
197248
- this.finishApproval(id, sessionId, messageId, entry.text, {
197249
- result: approval.decision === "allow" ? `Allowed by ${who} at ${when}.` : `Denied by ${who} at ${when}.`,
197250
- shortReply: approval.decision === "allow" ? "Approval granted." : "Approval denied."
198655
+ this.awaiting.set(id, {
198656
+ sessionId,
198657
+ decision: approval.decision,
198658
+ who: `user ${callback.fromId}`,
198659
+ sentAt: when,
198660
+ ...messageId === undefined ? {} : { messageId },
198661
+ ...entry.text === undefined ? {} : { text: entry.text },
198662
+ ...approval.decision === "always" && entry.remember !== undefined ? { remember: entry.remember } : {},
198663
+ timer: this.timers.setTimeout(() => this.giveUpOnAck(id), this.approvalAckMs)
197251
198664
  });
197252
198665
  return;
197253
198666
  }
@@ -197255,6 +198668,9 @@ ${asCodeBlock(redactSensitiveText(value.prompt))}`, {
197255
198668
  this.expireApproval(id);
197256
198669
  return;
197257
198670
  }
198671
+ if (this.awaiting.has(id)) {
198672
+ return;
198673
+ }
197258
198674
  const done = this.finished.get(id);
197259
198675
  if (done !== undefined) {
197260
198676
  if (done.sessionId === sessionId) {
@@ -197275,7 +198691,7 @@ ${asCodeBlock(redactSensitiveText(value.prompt))}`, {
197275
198691
  }
197276
198692
  }
197277
198693
  carriesLivePrompt(messageId) {
197278
- return [...this.approvals.values(), ...this.choices.values()].some((live) => live.messageId === messageId);
198694
+ return [...this.approvals.values(), ...this.awaiting.values(), ...this.choices.values()].some((live) => live.messageId === messageId);
197279
198695
  }
197280
198696
  pressChoice(sessionId, callback, choice, stream) {
197281
198697
  const id = choice.promptId;
@@ -197358,7 +198774,7 @@ ${asCodeBlock(redactSensitiveText(value.prompt))}`, {
197358
198774
  });
197359
198775
  }
197360
198776
  onApprovalSent(approvalId, sessionId, messageId, text) {
197361
- const live = this.approvals.get(approvalId);
198777
+ const live = this.approvals.get(approvalId) ?? this.awaiting.get(approvalId);
197362
198778
  if (live !== undefined) {
197363
198779
  live.messageId = messageId;
197364
198780
  live.text = text;
@@ -197411,6 +198827,37 @@ ${asCodeBlock(redactSensitiveText(value.prompt))}`, {
197411
198827
  this.timers.clearTimeout(entry.timer);
197412
198828
  this.approvals.delete(approvalId);
197413
198829
  }
198830
+ dropAwaiting(approvalId) {
198831
+ const entry = this.awaiting.get(approvalId);
198832
+ if (entry === undefined) {
198833
+ return;
198834
+ }
198835
+ this.timers.clearTimeout(entry.timer);
198836
+ this.awaiting.delete(approvalId);
198837
+ }
198838
+ rememberDecided(approvalId, sessionId) {
198839
+ this.decided.delete(approvalId);
198840
+ this.decided.set(approvalId, sessionId);
198841
+ while (this.decided.size > MAX_DECIDED_APPROVALS) {
198842
+ const oldest = this.decided.keys().next().value;
198843
+ if (oldest === undefined) {
198844
+ break;
198845
+ }
198846
+ this.decided.delete(oldest);
198847
+ }
198848
+ }
198849
+ giveUpOnAck(approvalId) {
198850
+ const entry = this.awaiting.get(approvalId);
198851
+ if (entry === undefined) {
198852
+ return;
198853
+ }
198854
+ this.dropAwaiting(approvalId);
198855
+ this.rememberDecided(approvalId, entry.sessionId);
198856
+ this.finishApproval(approvalId, entry.sessionId, entry.messageId, entry.text, {
198857
+ result: `Sent to the shell at ${entry.sentAt}, not confirmed; the shell denies by itself if it did not receive it.`,
198858
+ shortReply: "Sent to the shell, not confirmed; it denies by itself if it did not receive it."
198859
+ });
198860
+ }
197414
198861
  expireApproval(approvalId) {
197415
198862
  const entry = this.approvals.get(approvalId);
197416
198863
  if (entry === undefined) {
@@ -197447,6 +198894,11 @@ ${asCodeBlock(redactSensitiveText(value.prompt))}`, {
197447
198894
  }
197448
198895
  }
197449
198896
  }
198897
+ for (const [id, entry] of [...this.awaiting.entries()]) {
198898
+ if (entry.sessionId === stream.sessionId && !this.closed) {
198899
+ this.giveUpOnAck(id);
198900
+ }
198901
+ }
197450
198902
  for (const [id, entry] of [...this.choices.entries()]) {
197451
198903
  if (entry.sessionId === stream.sessionId) {
197452
198904
  this.dropChoice(id);
@@ -197463,6 +198915,10 @@ ${asCodeBlock(redactSensitiveText(value.prompt))}`, {
197463
198915
  }
197464
198916
  }
197465
198917
  }
198918
+ function alwaysButtonText(pattern) {
198919
+ const text = `Always: ${pattern.replace(/\s+/g, " ")}`;
198920
+ return text.length <= MAX_BUTTON_TEXT_CHARS ? text : `${text.slice(0, MAX_BUTTON_TEXT_CHARS - 1)}\u2026`;
198921
+ }
197466
198922
  function asCodeBlock(text) {
197467
198923
  const safe = (text.trim().length === 0 ? "(empty)" : text).replace(/\r\n?/g, `
197468
198924
  `).replace(/`{9,}/g, (run) => run.match(/`{1,8}/g).join("\u200B"));
@@ -197472,7 +198928,7 @@ function asCodeBlock(text) {
197472
198928
  ${safe}
197473
198929
  ${fence}`;
197474
198930
  }
197475
- var MAX_REPLY_CHARS = 20000, MAX_PENDING_APPROVALS_PER_SESSION = 8, MAX_PENDING_CHOICES_PER_SESSION = 8, MAX_REMEMBERED_IDS_PER_SESSION = 512, MAX_PROJECT_NAME_CHARS = 128, CONTROL_CHARS2, JSON_HEADERS2, MAX_FINISHED_PROMPTS = 128, MAX_SETTLED_TEXT_CHARS = 3600, MAX_BACKTICK_RUN = 8;
198931
+ var MAX_REPLY_CHARS = 20000, MAX_PENDING_APPROVALS_PER_SESSION = 8, MAX_PENDING_CHOICES_PER_SESSION = 8, MAX_REMEMBERED_IDS_PER_SESSION = 512, MAX_PROJECT_NAME_CHARS = 128, CONTROL_CHARS2, JSON_HEADERS2, MAX_FINISHED_PROMPTS = 128, MAX_DECIDED_APPROVALS = 256, MAX_SETTLED_TEXT_CHARS = 3600, MAX_BACKTICK_RUN = 8;
197476
198932
  var init_http_surface = __esm(() => {
197477
198933
  init_service2();
197478
198934
  init_hub();
@@ -198612,11 +200068,24 @@ function runStatus7(args) {
198612
200068
  };
198613
200069
  const credentialState = credential.status === "ok" ? "present" : credential.status;
198614
200070
  const fingerprint = credential.status === "ok" ? credentialFingerprint(credential.record) : undefined;
200071
+ const telegram = loadPosture();
200072
+ const remoteConfig = telegram === undefined ? undefined : loadRemoteConfig();
200073
+ const allowedUsers = remoteConfig?.ok === true ? remoteConfig.value.allowedUserIds.length : undefined;
198615
200074
  if (asJson) {
198616
200075
  console.log(JSON.stringify({
198617
200076
  ...report,
198618
200077
  credential: credentialState,
198619
200078
  ...fingerprint === undefined ? {} : { credentialFingerprint: fingerprint },
200079
+ ...telegram === undefined ? {} : {
200080
+ telegramPermissions: {
200081
+ defaultMode: telegram.defaultMode,
200082
+ runTimeoutMs: telegram.runTimeoutMs,
200083
+ approvalTimeoutMs: telegram.approvalTimeoutMs,
200084
+ savedRules: telegram.savedRules,
200085
+ ...allowedUsers !== undefined ? { allowedUsers } : {},
200086
+ shellModeOverridesDefault: true
200087
+ }
200088
+ },
198620
200089
  warnings: [...warnings].sort()
198621
200090
  }, null, 2));
198622
200091
  return;
@@ -198634,6 +200103,10 @@ function runStatus7(args) {
198634
200103
  }
198635
200104
  console.log(` credential: ${credentialState}${fingerprint === undefined ? "" : ` (fingerprint ${fingerprint})`}`);
198636
200105
  console.log(` pending approvals: ${report.pendingApprovals}`);
200106
+ if (telegram !== undefined) {
200107
+ console.log(` telegram: default mode ${telegram.defaultMode}, run limit ${formatPolicyDuration(telegram.runTimeoutMs)}, approval wait ${formatPolicyDuration(telegram.approvalTimeoutMs)}, ${telegram.savedRules} saved shell rule${telegram.savedRules === 1 ? "" : "s"}${allowedUsers !== undefined ? `, ${allowedUsers} allowed user${allowedUsers === 1 ? "" : "s"}` : ""}`);
200108
+ console.log(" a running shell's /mode overrides the default mode; /remote-policy changes the defaults");
200109
+ }
198637
200110
  if (report.message !== undefined) {
198638
200111
  console.log("");
198639
200112
  console.log(` ${style.yellow(symbols.bullet)} ${sanitizeForDisplay(report.message)}`);
@@ -198891,6 +200364,8 @@ function printHelp23() {
198891
200364
  }
198892
200365
  var BIND_FLAGS, ACK_FLAG = "--acknowledge-non-loopback", FORCE_FLAG = "--force", NO_ACK_FLAG = "--no-acknowledge-non-loopback", ENABLE_FLAG = "--enable", DISABLE_FLAG = "--disable";
198893
200366
  var init_serve = __esm(() => {
200367
+ init_config11();
200368
+ init_remote_policy_command();
198894
200369
  init_project_registry();
198895
200370
  init_serve_config();
198896
200371
  init_serve_credential();
@@ -210604,6 +212079,7 @@ var init_group_subcommands = __esm(() => {
210604
212079
  ["external", ["on", "off", "status", "list"]],
210605
212080
  ["auth", ["list", "login", "logout", "status"]],
210606
212081
  ["approvals", ["list", "allow", "deny"]],
212082
+ ["permissions", ["list", "remove"]],
210607
212083
  ["version", ["check"]],
210608
212084
  ["gdgraph", ["build", "query", "find", "symbol", "symbols", "path", "affected", "repomap", "context", "assets"]],
210609
212085
  ["ctx", ["status", "diff", "rg", "read", "run", "show", "hook", "install-hook", "uninstall-hook"]],
@@ -210928,6 +212404,8 @@ var VERSION3, CLI_ROUTES, MCP_KNOWN_SUBCOMMANDS, USAGE_BODY = `Usage:
210928
212404
  keryx serve config init|set|show
210929
212405
  keryx approvals list [--all] [--json] | allow <id> | deny <id>
210930
212406
  Answer, from this machine, a call a remote turn is waiting on (once, that call only)
212407
+ keryx permissions list [--json] | remove <number|pattern>
212408
+ The saved shell rules an Always answer left behind; take one back
210931
212409
  keryx update [--skip-runtime] [--hooks]
210932
212410
  keryx dashboard build
210933
212411
  keryx dashboard open
@@ -211108,6 +212586,7 @@ Commands:
211108
212586
  projects Inspect the user-global registry of initialized projects
211109
212587
  serve Loopback-bound authenticated HTTP entry (off by default; read-only routes)
211110
212588
  approvals Pending remote approvals: list them, allow or deny one call, once (the local answer path of the serve entry)
212589
+ permissions The saved shell rules (what Always remembered): list them, remove one
211111
212590
  update Refresh managed service files without touching data artifacts
211112
212591
  dashboard Build or open the project admin dashboard
211113
212592
  dash Rebuild and open .metaproject/keryx-dashboard.html
@@ -211188,6 +212667,7 @@ var init_cli_registry = __esm(() => {
211188
212667
  init_projects();
211189
212668
  init_serve();
211190
212669
  init_approvals();
212670
+ init_permissions_command();
211191
212671
  init_update();
211192
212672
  init_dashboard();
211193
212673
  init_agents2();
@@ -211231,6 +212711,7 @@ var init_cli_registry = __esm(() => {
211231
212711
  auth: authCommand2,
211232
212712
  serve: serveCommand,
211233
212713
  approvals: approvalsCommand,
212714
+ permissions: permissionsCommand,
211234
212715
  update: updateCommand,
211235
212716
  dashboard: dashboardCommand,
211236
212717
  dash: (rest) => dashboardCommand(rest.length > 0 ? rest : ["open"]),