hanoman 0.1.13 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -4164,16 +4164,34 @@ var init_enums = __esm({
4164
4164
  }
4165
4165
  });
4166
4166
 
4167
+ // ../shared/src/agent-engine.ts
4168
+ var zAgentEngine;
4169
+ var init_agent_engine = __esm({
4170
+ "../shared/src/agent-engine.ts"() {
4171
+ "use strict";
4172
+ init_zod();
4173
+ init_enums();
4174
+ zAgentEngine = external_exports.object({
4175
+ enabled: external_exports.boolean().default(false),
4176
+ agent: zAgent.default("claude"),
4177
+ model: external_exports.string().default("claude-opus-5"),
4178
+ effort: external_exports.string().default("xhigh")
4179
+ });
4180
+ }
4181
+ });
4182
+
4167
4183
  // ../shared/src/telegram.ts
4168
4184
  var zTelegramSettings, TELEGRAM_DEFAULTS, TELEGRAM_CONFIG_KEYS, zTelegramReadiness, zTelegramGatewayStatus, zTelegramMemoryRecord, zTelegramChatContext, TELEGRAM_REPLY_KINDS, zTelegramReplyKind, zTelegramConfirmationRequest, zTelegramReplyInput, zTelegramAuditRecord, zTerminalSteerInput;
4169
4185
  var init_telegram = __esm({
4170
4186
  "../shared/src/telegram.ts"() {
4171
4187
  "use strict";
4172
4188
  init_zod();
4189
+ init_agent_engine();
4173
4190
  init_enums();
4174
4191
  zTelegramSettings = external_exports.object({
4175
4192
  enabled: external_exports.boolean().default(false),
4176
- progress: external_exports.boolean().default(true)
4193
+ progress: external_exports.boolean().default(true),
4194
+ engine: zAgentEngine.default({})
4177
4195
  });
4178
4196
  TELEGRAM_DEFAULTS = zTelegramSettings.parse({});
4179
4197
  TELEGRAM_CONFIG_KEYS = [
@@ -4306,6 +4324,9 @@ var init_auto_merge = __esm({
4306
4324
  function codexModel(id) {
4307
4325
  return CODEX_MODELS.find((m) => m.id === id);
4308
4326
  }
4327
+ function codexEfforts(modelId) {
4328
+ return codexModel(modelId)?.efforts ?? E_BASE;
4329
+ }
4309
4330
  function coerceCodexEffort(modelId, effort) {
4310
4331
  const m = codexModel(modelId);
4311
4332
  if (!m) return effort;
@@ -4320,13 +4341,14 @@ function cmpVersion(a, b) {
4320
4341
  }
4321
4342
  return 0;
4322
4343
  }
4323
- var zProject, zBriefPayload, zQaPayload, zGoalPayload, zSpecBlocker, zSpec, NOTIFY_SOUNDS, MODELS, E_5_6, E_LUNA, E_BASE, CODEX_MODELS, RETIRED_CODEX_MODELS, zCodex, CODEX_DEFAULTS, zSourceCommon, zScheduler, SCHEDULER_DEFAULTS, zGoal, GOAL_DEFAULTS, zConflict, CONFLICT_DEFAULTS, zLeadEngine, zLead, LEAD_DEFAULTS, zSetting, zNotification, zDocFile, zDeviceTokenView;
4344
+ var zProject, zBriefPayload, zQaPayload, zGoalPayload, zSpecBlocker, zSpec, NOTIFY_SOUNDS, MODELS, EFFORTS, E_5_6, E_LUNA, E_BASE, CODEX_MODELS, RETIRED_CODEX_MODELS, zCodex, CODEX_DEFAULTS, zSourceCommon, zScheduler, SCHEDULER_DEFAULTS, zGoal, GOAL_DEFAULTS, zConflict, CONFLICT_DEFAULTS, zLeadEngine, zLead, LEAD_DEFAULTS, zSetting, zNotification, zDocFile, zDeviceTokenView;
4324
4345
  var init_entities = __esm({
4325
4346
  "../shared/src/entities.ts"() {
4326
4347
  "use strict";
4327
4348
  init_zod();
4328
4349
  init_enums();
4329
4350
  init_telegram();
4351
+ init_agent_engine();
4330
4352
  init_auto_merge();
4331
4353
  zProject = external_exports.object({
4332
4354
  id: external_exports.string(),
@@ -4420,6 +4442,7 @@ var init_entities = __esm({
4420
4442
  { id: "claude-haiku-4-5", label: "Haiku 4.5" },
4421
4443
  { id: "claude-fable-5", label: "Fable 5" }
4422
4444
  ];
4445
+ EFFORTS = ["xhigh", "high", "medium", "low", "max", "ultracode"];
4423
4446
  E_5_6 = ["ultra", "max", "xhigh", "high", "medium", "low"];
4424
4447
  E_LUNA = ["max", "xhigh", "high", "medium", "low"];
4425
4448
  E_BASE = ["xhigh", "high", "medium", "low"];
@@ -4468,12 +4491,7 @@ var init_entities = __esm({
4468
4491
  effort: external_exports.string().default("xhigh")
4469
4492
  });
4470
4493
  CONFLICT_DEFAULTS = zConflict.parse({});
4471
- zLeadEngine = external_exports.object({
4472
- enabled: external_exports.boolean().default(false),
4473
- agent: zAgent.default("claude"),
4474
- model: external_exports.string().default("claude-opus-5"),
4475
- effort: external_exports.string().default("xhigh")
4476
- });
4494
+ zLeadEngine = zAgentEngine;
4477
4495
  zLead = external_exports.object({
4478
4496
  enabled: external_exports.boolean().default(false),
4479
4497
  // master switch (AC-30)
@@ -5553,6 +5571,9 @@ var init_api = __esm({
5553
5571
  limits: `${API}/limits`,
5554
5572
  // SPEC-339 · versi codex CLI (peringatan lunak model GPT-5.6).
5555
5573
  codexVersion: `${API}/codex/version`,
5574
+ // SPEC-489 · panduan AI agent — markdown MENTAH & PUBLIC (tanpa auth). Satu definisi URL untuk
5575
+ // klien web dan untuk tautan yang disalin operator ke agennya.
5576
+ agentDoc: `${API}/agent-integration.md`,
5556
5577
  docs: (id) => `${API}/projects/${id}/docs`,
5557
5578
  docFile: (id, path) => `${API}/projects/${id}/docs/${path}`,
5558
5579
  // SPEC-210 · dokumen PRD project (freshest-wins: worktree sesi prd hidup > repoDir)
@@ -5879,6 +5900,7 @@ var init_config_registry = __esm({
5879
5900
  kind: "url",
5880
5901
  apply: "live",
5881
5902
  category: "knob",
5903
+ example: "https://hanoman.nafanesia.id",
5882
5904
  help: "Base URL hub tujuan sync (REST + WS). Kosong = instance ini murni HUB."
5883
5905
  },
5884
5906
  {
@@ -5888,6 +5910,7 @@ var init_config_registry = __esm({
5888
5910
  kind: "secret",
5889
5911
  apply: "live",
5890
5912
  category: "credential",
5913
+ example: "43 karakter base64url dari tab Perangkat hub",
5891
5914
  help: "Token yang diterbitkan hub (tab Perangkat di hub). Dikirim sebagai Bearer."
5892
5915
  },
5893
5916
  {
@@ -5897,6 +5920,7 @@ var init_config_registry = __esm({
5897
5920
  kind: "int",
5898
5921
  apply: "live",
5899
5922
  category: "knob",
5923
+ example: "15000",
5900
5924
  default: "15000",
5901
5925
  min: 1e3
5902
5926
  },
@@ -5908,13 +5932,14 @@ var init_config_registry = __esm({
5908
5932
  kind: "secret",
5909
5933
  apply: "new-session",
5910
5934
  category: "credential",
5935
+ example: "sk-ant-oat01-\u2026",
5911
5936
  inheritEnv: true,
5912
5937
  help: "Token `claude setup-token`. Diwarisi proses claude yang di-spawn."
5913
5938
  },
5914
- { key: "ANTHROPIC_API_KEY", group: "claude", label: "Anthropic API key", kind: "secret", apply: "new-session", category: "credential", inheritEnv: true },
5915
- { key: "HANOMAN_CLAUDE_BIN", group: "claude", label: "Biner claude", kind: "path", apply: "new-session", category: "knob", default: "claude" },
5939
+ { key: "ANTHROPIC_API_KEY", group: "claude", label: "Anthropic API key", kind: "secret", apply: "new-session", category: "credential", example: "sk-ant-api03-\u2026", inheritEnv: true },
5940
+ { key: "HANOMAN_CLAUDE_BIN", group: "claude", label: "Biner claude", kind: "path", apply: "new-session", category: "knob", example: "/opt/homebrew/bin/claude", default: "claude" },
5916
5941
  // codex (SPEC-338 · ADR-0074)
5917
- { key: "HANOMAN_CODEX_BIN", group: "claude", label: "Biner codex", kind: "path", apply: "new-session", category: "knob", default: "codex" },
5942
+ { key: "HANOMAN_CODEX_BIN", group: "claude", label: "Biner codex", kind: "path", apply: "new-session", category: "knob", example: "/opt/homebrew/bin/codex", default: "codex" },
5918
5943
  {
5919
5944
  key: "CLAUDE_CONFIG_DIR",
5920
5945
  group: "claude",
@@ -5922,11 +5947,12 @@ var init_config_registry = __esm({
5922
5947
  kind: "path",
5923
5948
  apply: "new-session",
5924
5949
  category: "knob",
5950
+ example: "~/.claude",
5925
5951
  help: "Default ~/.claude. Sumber .credentials.json untuk panel usage/limit."
5926
5952
  },
5927
5953
  // vps
5928
- { key: "HANOMAN_SSH_KEY_DIR", group: "vps", label: "Dir key SSH", kind: "path", apply: "new-session", category: "knob", help: "Default ~/.hanoman." },
5929
- { key: "HANOMAN_SSH_BIN", group: "vps", label: "Biner ssh", kind: "path", apply: "new-session", category: "knob", default: "ssh" },
5954
+ { key: "HANOMAN_SSH_KEY_DIR", group: "vps", label: "Dir key SSH", kind: "path", apply: "new-session", category: "knob", example: "~/.hanoman", help: "Default ~/.hanoman." },
5955
+ { key: "HANOMAN_SSH_BIN", group: "vps", label: "Biner ssh", kind: "path", apply: "new-session", category: "knob", example: "/usr/bin/ssh", default: "ssh" },
5930
5956
  // github (SPEC-471 · ADR-0095 · tarik issue → backlog). Dua mode auth, satu jalur kode:
5931
5957
  // `gh` memakai keyring mesin; bila GITHUB_TOKEN diisi ia diteruskan sebagai GH_TOKEN ke
5932
5958
  // proses gh DAN dipakai jalur REST (hub VPS yang tak punya keyring).
@@ -5937,6 +5963,7 @@ var init_config_registry = __esm({
5937
5963
  kind: "secret",
5938
5964
  apply: "live",
5939
5965
  category: "credential",
5966
+ example: "ghp_\u2026",
5940
5967
  help: "PAT scope `repo` (atau `public_repo`). Kosong = andalkan `gh auth login` di mesin ini."
5941
5968
  },
5942
5969
  {
@@ -5946,6 +5973,7 @@ var init_config_registry = __esm({
5946
5973
  kind: "path",
5947
5974
  apply: "live",
5948
5975
  category: "knob",
5976
+ example: "/opt/homebrew/bin/gh",
5949
5977
  default: "gh",
5950
5978
  help: "Absen = tarik issue lewat HTTPS langsung ke api.github.com."
5951
5979
  },
@@ -5958,6 +5986,7 @@ var init_config_registry = __esm({
5958
5986
  kind: "secret",
5959
5987
  apply: "live",
5960
5988
  category: "credential",
5989
+ example: "123456789:AAE\u2026",
5961
5990
  pattern: "^\\d{5,}:[A-Za-z0-9_-]{30,}$",
5962
5991
  patternError: "format BotFather: <bot_id>:<secret>",
5963
5992
  help: "Token dari BotFather. Disimpan terenkripsi; tak pernah dikembalikan utuh."
@@ -5969,6 +5998,7 @@ var init_config_registry = __esm({
5969
5998
  kind: "secret",
5970
5999
  apply: "live",
5971
6000
  category: "credential",
6001
+ example: "hnm_agt_\u2026",
5972
6002
  pattern: "^\\S{20,}$",
5973
6003
  patternError: "plaintext AgentToken (hnm_agt_\u2026)",
5974
6004
  help: "Plaintext AgentToken ber-capability Telegram. Dipakai session operator, bukan bot."
@@ -5980,6 +6010,7 @@ var init_config_registry = __esm({
5980
6010
  kind: "string",
5981
6011
  apply: "live",
5982
6012
  category: "credential",
6013
+ example: "123456789, 987654321",
5983
6014
  pattern: "^\\d+(?:[\\s,]+\\d+)*$",
5984
6015
  patternError: "numeric user id, dipisah koma/spasi",
5985
6016
  help: "Siapa yang boleh memerintah bot. Numeric Telegram user id, bukan username."
@@ -5991,12 +6022,13 @@ var init_config_registry = __esm({
5991
6022
  kind: "string",
5992
6023
  apply: "live",
5993
6024
  category: "knob",
6025
+ example: "-1001234567890",
5994
6026
  pattern: "^-?\\d+$",
5995
6027
  patternError: "numeric chat id (channel/supergroup negatif)",
5996
6028
  help: "Tujuan Test Connection. Kosong = pakai satu-satunya id di allowlist."
5997
6029
  },
5998
6030
  // runtime
5999
- { key: "HANOMAN_EVENTS_TICK_MS", group: "runtime", label: "Interval events (ms)", kind: "int", apply: "live", category: "knob", default: "1000", min: 100 },
6031
+ { key: "HANOMAN_EVENTS_TICK_MS", group: "runtime", label: "Interval events (ms)", kind: "int", apply: "live", category: "knob", example: "1000", default: "1000", min: 100 },
6000
6032
  { key: "HANOMAN_UPDATE_FETCH", group: "runtime", label: "Deteksi update saat boot", kind: "bool", apply: "live", category: "knob", default: "1" },
6001
6033
  // SPEC-398 · ADR-0087 · deteksi update kini membandingkan semver dengan registry npm, bukan
6002
6034
  // menghitung commit di repo git (HANOMAN_REPO_ROOT dicabut bersama jalur git itu).
@@ -6007,6 +6039,7 @@ var init_config_registry = __esm({
6007
6039
  kind: "string",
6008
6040
  apply: "live",
6009
6041
  category: "knob",
6042
+ example: "https://registry.npmjs.org",
6010
6043
  default: "https://registry.npmjs.org",
6011
6044
  help: "Sumber versi terbaru paket `hanoman`."
6012
6045
  },
@@ -6017,15 +6050,16 @@ var init_config_registry = __esm({
6017
6050
  kind: "string",
6018
6051
  apply: "restart",
6019
6052
  category: "knob",
6053
+ example: "hanoman",
6020
6054
  default: "hanoman",
6021
6055
  help: "Mengubah ini TIDAK memindahkan sesi tmux yang sudah hidup \u2014 berlaku setelah restart."
6022
6056
  },
6023
6057
  // gitGraph (SPEC-233 · preferensi tampilan git graph; semua live, dikonsumsi client)
6024
- { key: "gitGraph.style", group: "gitGraph", label: "Gaya graph", kind: "string", apply: "live", category: "knob", default: "rounded", help: "rounded | angular" },
6025
- { key: "gitGraph.colours", group: "gitGraph", label: "Warna lane (CSV)", kind: "string", apply: "live", category: "knob", help: "Daftar warna hex dipisah koma; kosong = palet bawaan." },
6026
- { key: "gitGraph.dateType", group: "gitGraph", label: "Jenis tanggal", kind: "string", apply: "live", category: "knob", default: "author", help: "author | commit" },
6027
- { key: "gitGraph.commitsInitialLoad", group: "gitGraph", label: "Muat awal", kind: "int", apply: "live", category: "knob", default: "200", min: 1 },
6028
- { key: "gitGraph.commitsLoadMore", group: "gitGraph", label: "Muat lagi", kind: "int", apply: "live", category: "knob", default: "100", min: 1 },
6058
+ { key: "gitGraph.style", group: "gitGraph", label: "Gaya graph", kind: "string", apply: "live", category: "knob", example: "rounded", default: "rounded", help: "rounded | angular" },
6059
+ { key: "gitGraph.colours", group: "gitGraph", label: "Warna lane (CSV)", kind: "string", apply: "live", category: "knob", example: "#c9a227, #6b8f71, #b4614e", help: "Daftar warna hex dipisah koma; kosong = palet bawaan." },
6060
+ { key: "gitGraph.dateType", group: "gitGraph", label: "Jenis tanggal", kind: "string", apply: "live", category: "knob", example: "author", default: "author", help: "author | commit" },
6061
+ { key: "gitGraph.commitsInitialLoad", group: "gitGraph", label: "Muat awal", kind: "int", apply: "live", category: "knob", example: "200", default: "200", min: 1 },
6062
+ { key: "gitGraph.commitsLoadMore", group: "gitGraph", label: "Muat lagi", kind: "int", apply: "live", category: "knob", example: "100", default: "100", min: 1 },
6029
6063
  { key: "gitGraph.showRemoteBranches", group: "gitGraph", label: "Tampilkan remote", kind: "bool", apply: "live", category: "knob", default: "1" },
6030
6064
  { key: "gitGraph.showTags", group: "gitGraph", label: "Tampilkan tag", kind: "bool", apply: "live", category: "knob", default: "1" },
6031
6065
  { key: "gitGraph.showStashes", group: "gitGraph", label: "Tampilkan stash", kind: "bool", apply: "live", category: "knob", default: "1" },
@@ -6035,7 +6069,7 @@ var init_config_registry = __esm({
6035
6069
  { key: "gitGraph.combineLocalRemote", group: "gitGraph", label: "Gabung label local+remote", kind: "bool", apply: "live", category: "knob", default: "0" },
6036
6070
  { key: "gitGraph.markdown", group: "gitGraph", label: "Render markdown pesan", kind: "bool", apply: "live", category: "knob", default: "1" },
6037
6071
  { key: "gitGraph.emoji", group: "gitGraph", label: "Render emoji shortcode", kind: "bool", apply: "live", category: "knob", default: "1" },
6038
- { key: "gitGraph.issueLinkPattern", group: "gitGraph", label: "Pola link issue", kind: "string", apply: "live", category: "knob", help: "URL dengan $1 untuk nomor issue, mis. https://github.com/acme/app/issues/$1" },
6072
+ { key: "gitGraph.issueLinkPattern", group: "gitGraph", label: "Pola link issue", kind: "string", apply: "live", category: "knob", example: "https://github.com/acme/app/issues/$1", help: "URL dengan $1 untuk nomor issue, mis. https://github.com/acme/app/issues/$1" },
6039
6073
  // bootstrap (read-only)
6040
6074
  { key: "DATABASE_URL", group: "bootstrap", label: "DATABASE_URL", kind: "secret", apply: "restart", category: "bootstrap" },
6041
6075
  { key: "TEST_DATABASE_URL", group: "bootstrap", label: "TEST_DATABASE_URL", kind: "secret", apply: "restart", category: "bootstrap" },
@@ -7089,6 +7123,7 @@ var init_src = __esm({
7089
7123
  "../shared/src/index.ts"() {
7090
7124
  "use strict";
7091
7125
  init_enums();
7126
+ init_agent_engine();
7092
7127
  init_entities();
7093
7128
  init_agent();
7094
7129
  init_custom_agent();
@@ -7112,7 +7147,7 @@ var init_src = __esm({
7112
7147
 
7113
7148
  // src/env.ts
7114
7149
  import { readFileSync, existsSync } from "node:fs";
7115
- import { dirname, resolve as resolve2 } from "node:path";
7150
+ import { dirname, resolve as resolve3 } from "node:path";
7116
7151
  import { fileURLToPath } from "node:url";
7117
7152
  var dir;
7118
7153
  var init_env = __esm({
@@ -7120,7 +7155,7 @@ var init_env = __esm({
7120
7155
  "use strict";
7121
7156
  dir = dirname(fileURLToPath(import.meta.url));
7122
7157
  for (let i = 0; i < 6; i++) {
7123
- const file = resolve2(dir, ".env");
7158
+ const file = resolve3(dir, ".env");
7124
7159
  if (existsSync(file)) {
7125
7160
  for (const line of readFileSync(file, "utf8").split("\n")) {
7126
7161
  const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
@@ -7574,7 +7609,7 @@ ${lines2.join("\n")}` : "";
7574
7609
  // ../runner/src/git.ts
7575
7610
  import { spawnSync } from "node:child_process";
7576
7611
  import { rmSync, mkdirSync, realpathSync } from "node:fs";
7577
- import { isAbsolute, resolve as resolve3 } from "node:path";
7612
+ import { isAbsolute, resolve as resolve4 } from "node:path";
7578
7613
  function git(cwd, args) {
7579
7614
  const r = spawnSync("git", args, { cwd, encoding: "utf8" });
7580
7615
  if (r.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${r.stderr || r.stdout || r.error?.message || "gagal spawn"}`);
@@ -7609,7 +7644,7 @@ var init_git = __esm({
7609
7644
  addWorktree: (repo, path, branchFrom) => {
7610
7645
  tryGit(repo, ["worktree", "remove", "--force", path]);
7611
7646
  tryGit(repo, ["worktree", "prune"]);
7612
- rmSync(isAbsolute(path) ? path : resolve3(repo, path), { recursive: true, force: true });
7647
+ rmSync(isAbsolute(path) ? path : resolve4(repo, path), { recursive: true, force: true });
7613
7648
  const base2 = resolveCommit(repo, branchFrom);
7614
7649
  git(repo, ["worktree", "add", "--detach", path, base2]);
7615
7650
  return base2;
@@ -7619,8 +7654,8 @@ var init_git = __esm({
7619
7654
  // `fatal: not a working tree` → membuat DELETE /terminal/sessions balas 500. remove+prune+rm
7620
7655
  // semuanya toleran, jadi penutupan sesi selalu 204 dan registrasi worktree tak stale (SPEC-197).
7621
7656
  removeWorktree: (repo, path) => {
7622
- const target2 = isAbsolute(path) ? resolve3(path) : resolve3(repo, path);
7623
- if (target2 === resolve3(repo)) {
7657
+ const target2 = isAbsolute(path) ? resolve4(path) : resolve4(repo, path);
7658
+ if (target2 === resolve4(repo)) {
7624
7659
  throw new Error(`removeWorktree menolak menghapus repo itu sendiri: ${target2}`);
7625
7660
  }
7626
7661
  tryGit(repo, ["worktree", "remove", "--force", target2]);
@@ -7936,7 +7971,7 @@ var init_custom_agents = __esm({
7936
7971
 
7937
7972
  // ../runner/src/paths.ts
7938
7973
  import { homedir } from "node:os";
7939
- import { dirname as dirname2, isAbsolute as isAbsolute2, join, resolve as resolve4 } from "node:path";
7974
+ import { dirname as dirname2, isAbsolute as isAbsolute2, join, resolve as resolve5 } from "node:path";
7940
7975
  function resolveHome(env = process.env, home2 = homedir()) {
7941
7976
  const v = env.HANOMAN_HOME?.trim();
7942
7977
  return v ? v : join(home2, ".hanoman");
@@ -7961,7 +7996,7 @@ function resolveDbUrl(env, schemaDir2) {
7961
7996
  function absoluteFileUrl(raw, schemaDir2) {
7962
7997
  const p = raw.slice("file:".length);
7963
7998
  if (p.startsWith(":")) return raw;
7964
- return `file:${isAbsolute2(p) ? p : resolve4(schemaDir2, p)}`;
7999
+ return `file:${isAbsolute2(p) ? p : resolve5(schemaDir2, p)}`;
7965
8000
  }
7966
8001
  function dbUrlNotice(env) {
7967
8002
  if (env.HANOMAN_DATABASE_URL?.trim()) return null;
@@ -8011,6 +8046,7 @@ function buildTelegramOperatorPrompt(input) {
8011
8046
  "",
8012
8047
  "- Bahasa natural adalah antarmuka utama; command hanya shortcut yang tetap kamu tangani di sesi ini.",
8013
8048
  `- Command minimum: ${COMMANDS.join(", ")}.`,
8049
+ `- Command runtime sesi ini ditangani server, bukan kamu: ${SERVER_COMMANDS.join(", ")}. Sebutkan di /help apa adanya; jangan pernah mencoba menjawabnya sendiri.`,
8014
8050
  `- Panggil API dengan base $HANOMAN_API_BASE, bearer $HANOMAN_TELEGRAM_AGENT_TOKEN, dan header x-hanoman-telegram-update: ${update2.updateId}.`,
8015
8051
  "- Terbitkan jawaban user-facing secara eksplisit melalui POST $HANOMAN_API_BASE/api/telegram/replies.",
8016
8052
  "- Kind reply hanya progress|final|decision|failure|confirmation. Jangan mengandalkan layar PTY sebagai balasan.",
@@ -8024,7 +8060,7 @@ function buildTelegramOperatorPrompt(input) {
8024
8060
  update2.text
8025
8061
  ].join("\n");
8026
8062
  }
8027
- var COMMANDS;
8063
+ var COMMANDS, SERVER_COMMANDS;
8028
8064
  var init_telegram_operator = __esm({
8029
8065
  "../runner/src/telegram-operator.ts"() {
8030
8066
  "use strict";
@@ -8042,6 +8078,12 @@ var init_telegram_operator = __esm({
8042
8078
  "/personality [nama|reset]",
8043
8079
  "/skills"
8044
8080
  ];
8081
+ SERVER_COMMANDS = [
8082
+ "/engine",
8083
+ "/runtime claude|codex",
8084
+ "/model <id>",
8085
+ "/effort <nilai>"
8086
+ ];
8045
8087
  }
8046
8088
  });
8047
8089
 
@@ -8197,7 +8239,7 @@ var init_tap = __esm({
8197
8239
 
8198
8240
  // src/db.ts
8199
8241
  import { mkdirSync as mkdirSync2 } from "node:fs";
8200
- import { dirname as dirname3, resolve as resolve5 } from "node:path";
8242
+ import { dirname as dirname3, resolve as resolve6 } from "node:path";
8201
8243
  import { fileURLToPath as fileURLToPath2 } from "node:url";
8202
8244
  import { PrismaClient } from "@prisma/client";
8203
8245
  var schemaDir, notice, url, base, prisma;
@@ -8207,7 +8249,7 @@ var init_db = __esm({
8207
8249
  init_env();
8208
8250
  init_src2();
8209
8251
  init_tap();
8210
- schemaDir = resolve5(dirname3(fileURLToPath2(import.meta.url)), "../prisma");
8252
+ schemaDir = resolve6(dirname3(fileURLToPath2(import.meta.url)), "../prisma");
8211
8253
  notice = dbUrlNotice(process.env);
8212
8254
  if (notice) console.warn(notice);
8213
8255
  url = resolveDbUrl(process.env, schemaDir);
@@ -9781,10 +9823,11 @@ var init_client = __esm({
9781
9823
  "src/services/telegram/client.ts"() {
9782
9824
  "use strict";
9783
9825
  TelegramApiError = class extends Error {
9784
- constructor(method, code, message) {
9826
+ constructor(method, code, message, retryAfter) {
9785
9827
  super(`Telegram ${method} gagal${code ? ` (${code})` : ""}: ${message}`);
9786
9828
  this.method = method;
9787
9829
  this.code = code;
9830
+ this.retryAfter = retryAfter;
9788
9831
  this.name = "TelegramApiError";
9789
9832
  }
9790
9833
  };
@@ -9809,7 +9852,10 @@ var init_client = __esm({
9809
9852
  } catch (error) {
9810
9853
  throw new TelegramApiError(method, void 0, this.safe(error.message || "network error"));
9811
9854
  }
9812
- if (!response.ok) throw new TelegramApiError(method, response.status, `HTTP ${response.status}`);
9855
+ if (!response.ok) {
9856
+ const failed = await response.json().catch(() => null);
9857
+ throw new TelegramApiError(method, response.status, `HTTP ${response.status}`, failed?.parameters?.retry_after);
9858
+ }
9813
9859
  let envelope;
9814
9860
  try {
9815
9861
  envelope = await response.json();
@@ -9817,7 +9863,12 @@ var init_client = __esm({
9817
9863
  throw new TelegramApiError(method, response.status, "respons bukan JSON");
9818
9864
  }
9819
9865
  if (!envelope.ok || envelope.result === void 0) {
9820
- throw new TelegramApiError(method, envelope.error_code, this.safe(envelope.description ?? "respons tidak valid"));
9866
+ throw new TelegramApiError(
9867
+ method,
9868
+ envelope.error_code,
9869
+ this.safe(envelope.description ?? "respons tidak valid"),
9870
+ envelope.parameters?.retry_after
9871
+ );
9821
9872
  }
9822
9873
  return envelope.result;
9823
9874
  }
@@ -9842,6 +9893,14 @@ var init_client = __esm({
9842
9893
  ...input.replyMarkup ? { reply_markup: input.replyMarkup } : {}
9843
9894
  });
9844
9895
  }
9896
+ /**
9897
+ * SPEC-493 · indikator "typing…". Argumennya posisional (beda dari tetangganya) sesuai kontrak
9898
+ * yang diminta. Telegram TAK punya API stop-typing: statusnya padam sendiri ~5 detik sesudah
9899
+ * panggilan terakhir, dan pesan masuk apa pun langsung menghapusnya.
9900
+ */
9901
+ sendChatAction(chatId, action = "typing") {
9902
+ return this.call("sendChatAction", { chat_id: chatId, action });
9903
+ }
9845
9904
  editMessageText(input) {
9846
9905
  return this.call("editMessageText", {
9847
9906
  chat_id: input.chatId,
@@ -9859,6 +9918,40 @@ var init_client = __esm({
9859
9918
  }
9860
9919
  });
9861
9920
 
9921
+ // src/services/telegram/config.ts
9922
+ async function setTelegramEngine(next) {
9923
+ const cur = await getSetting();
9924
+ const data = { ...cur, telegram: { ...cur.telegram, engine: next } };
9925
+ await prisma.setting.upsert({ where: { id: 1 }, update: { data }, create: { id: 1, data } });
9926
+ return next;
9927
+ }
9928
+ async function telegramAgentDefaults() {
9929
+ const e = (await getSetting()).telegram.engine;
9930
+ if (!e.enabled) return sessionAgentDefaults();
9931
+ return e.agent === "codex" ? { agent: "codex", model: e.model, effort: coerceCodexEffort(e.model, e.effort) } : { agent: "claude", model: e.model, effort: e.effort };
9932
+ }
9933
+ async function telegramEngineContext() {
9934
+ const s2 = await getSetting();
9935
+ return {
9936
+ enabled: s2.telegram.engine.enabled,
9937
+ effective: await telegramAgentDefaults(),
9938
+ claude: { model: s2.model, effort: s2.effort },
9939
+ codex: { model: s2.codex.model, effort: s2.codex.effort }
9940
+ };
9941
+ }
9942
+ function telegramReloadNeeded(before, after) {
9943
+ const strip = ({ engine: _engine, ...rest }) => rest;
9944
+ return JSON.stringify(strip(before)) !== JSON.stringify(strip(after));
9945
+ }
9946
+ var init_config3 = __esm({
9947
+ "src/services/telegram/config.ts"() {
9948
+ "use strict";
9949
+ init_src();
9950
+ init_db();
9951
+ init_settings2();
9952
+ }
9953
+ });
9954
+
9862
9955
  // src/services/telegram/protocol.ts
9863
9956
  import { createHash as createHash2 } from "node:crypto";
9864
9957
  function normalizeTelegramCommand(text) {
@@ -9959,7 +10052,7 @@ function splitTelegramText(text, max = 4096, exactSecrets = []) {
9959
10052
  if (rest) chunks.push(rest);
9960
10053
  return chunks;
9961
10054
  }
9962
- var record, ANSI, CONTROLS, CREDENTIAL_PATTERNS;
10055
+ var record, ANSI, CONTROLS, CREDENTIAL_PATTERNS, TELEGRAM_GATEWAY_FAILURE_KIND, TELEGRAM_FINAL_REPLY_KINDS;
9963
10056
  var init_protocol = __esm({
9964
10057
  "src/services/telegram/protocol.ts"() {
9965
10058
  "use strict";
@@ -9971,6 +10064,14 @@ var init_protocol = __esm({
9971
10064
  /\bhnm_(?:agent_)?[A-Za-z0-9_-]{16,}\b/g,
9972
10065
  /\bsk-ant-[A-Za-z0-9_-]{16,}\b/g
9973
10066
  ];
10067
+ TELEGRAM_GATEWAY_FAILURE_KIND = "gateway-failure";
10068
+ TELEGRAM_FINAL_REPLY_KINDS = /* @__PURE__ */ new Set([
10069
+ "final",
10070
+ "decision",
10071
+ "failure",
10072
+ "confirmation",
10073
+ TELEGRAM_GATEWAY_FAILURE_KIND
10074
+ ]);
9974
10075
  }
9975
10076
  });
9976
10077
 
@@ -10022,6 +10123,69 @@ var init_runtime = __esm({
10022
10123
  }
10023
10124
  });
10024
10125
 
10126
+ // src/services/telegram/typing.ts
10127
+ var TYPING_ACTIVE_POLL_SEC, TYPING_MIN_INTERVAL_MS, TYPING_COOLDOWN_BASE_MS, TYPING_COOLDOWN_MIN_MS, TYPING_COOLDOWN_MAX_MS, TYPING_MAX_WAIT_MS, clampTypingCooldown, pollTimeoutFor, TelegramTypingIndicator;
10128
+ var init_typing = __esm({
10129
+ "src/services/telegram/typing.ts"() {
10130
+ "use strict";
10131
+ init_client();
10132
+ TYPING_ACTIVE_POLL_SEC = 4;
10133
+ TYPING_MIN_INTERVAL_MS = 3e3;
10134
+ TYPING_COOLDOWN_BASE_MS = 5e3;
10135
+ TYPING_COOLDOWN_MIN_MS = 1e3;
10136
+ TYPING_COOLDOWN_MAX_MS = 3e5;
10137
+ TYPING_MAX_WAIT_MS = 6e5;
10138
+ clampTypingCooldown = (ms) => Math.min(TYPING_COOLDOWN_MAX_MS, Math.max(TYPING_COOLDOWN_MIN_MS, Math.round(ms)));
10139
+ pollTimeoutFor = (waiting, idle) => waiting > 0 ? Math.min(TYPING_ACTIVE_POLL_SEC, idle) : idle;
10140
+ TelegramTypingIndicator = class {
10141
+ constructor(deps) {
10142
+ this.deps = deps;
10143
+ this.now = deps.now ?? (() => Date.now());
10144
+ }
10145
+ state = /* @__PURE__ */ new Map();
10146
+ now;
10147
+ /** Paksa — MENGABAIKAN throttle. Telegram menghapus status typing tiap ada pesan masuk. */
10148
+ async arm(chatId) {
10149
+ await this.send(chatId, true);
10150
+ }
10151
+ /** Denyut loop; ter-throttle supaya iterasi cepat beruntun tak jadi banjir panggilan API. */
10152
+ async refresh(chatIds) {
10153
+ for (const chatId of chatIds) await this.send(chatId, false);
10154
+ this.prune(new Set(chatIds));
10155
+ }
10156
+ async send(chatId, force) {
10157
+ if (!this.deps.enabled) return;
10158
+ const now = this.now();
10159
+ const current = this.state.get(chatId);
10160
+ if (current && now < current.cooldownUntil) return;
10161
+ if (!force && current && now - current.lastArmedAt < TYPING_MIN_INTERVAL_MS) return;
10162
+ try {
10163
+ await this.deps.client.sendChatAction(chatId, "typing");
10164
+ this.state.set(chatId, { lastArmedAt: now, cooldownUntil: 0, nextDelayMs: TYPING_COOLDOWN_BASE_MS });
10165
+ } catch (error) {
10166
+ const retryAfterMs = error instanceof TelegramApiError && typeof error.retryAfter === "number" && error.retryAfter > 0 ? error.retryAfter * 1e3 : null;
10167
+ const delay = clampTypingCooldown(retryAfterMs ?? current?.nextDelayMs ?? TYPING_COOLDOWN_BASE_MS);
10168
+ this.state.set(chatId, {
10169
+ lastArmedAt: current?.lastArmedAt ?? 0,
10170
+ cooldownUntil: now + delay,
10171
+ nextDelayMs: clampTypingCooldown(delay * 2)
10172
+ });
10173
+ }
10174
+ }
10175
+ /** Chat yang sudah lama tak aktif dan tak sedang di-cooldown dibuang agar peta tak tumbuh. */
10176
+ prune(active) {
10177
+ const now = this.now();
10178
+ for (const [chatId, entry] of this.state) {
10179
+ if (active.has(chatId)) continue;
10180
+ if (now < entry.cooldownUntil) continue;
10181
+ if (now - entry.lastArmedAt <= TYPING_MAX_WAIT_MS) continue;
10182
+ this.state.delete(chatId);
10183
+ }
10184
+ }
10185
+ };
10186
+ }
10187
+ });
10188
+
10025
10189
  // src/services/telegram/gateway.ts
10026
10190
  import { createHash as createHash3 } from "node:crypto";
10027
10191
  var digestRaw, TelegramGateway;
@@ -10031,13 +10195,19 @@ var init_gateway = __esm({
10031
10195
  init_client();
10032
10196
  init_protocol();
10033
10197
  init_runtime();
10198
+ init_typing();
10034
10199
  digestRaw = (update2) => createHash3("sha256").update(JSON.stringify(update2)).digest("hex");
10035
10200
  TelegramGateway = class {
10036
10201
  constructor(deps) {
10037
10202
  this.deps = deps;
10203
+ this.typing = deps.typing ?? new TelegramTypingIndicator({
10204
+ client: deps.client,
10205
+ enabled: deps.progress
10206
+ });
10038
10207
  }
10039
10208
  abort = null;
10040
10209
  loopPromise = null;
10210
+ typing;
10041
10211
  async processUpdates(updates) {
10042
10212
  for (const raw of updates) await this.processUpdate(raw);
10043
10213
  }
@@ -10113,11 +10283,15 @@ var init_gateway = __esm({
10113
10283
  userId: input.userId,
10114
10284
  updateId: input.updateId,
10115
10285
  action: "dispatch",
10116
- outcome: target2.created ? "session-created" : "session-reused",
10286
+ // SPEC-492 · command runtime dijawab gateway sendiri; jejaknya harus bisa dibedakan dari
10287
+ // pesan yang benar-benar sampai ke sesi operator.
10288
+ outcome: target2.control ? "control" : target2.created ? "session-created" : "session-reused",
10117
10289
  correlationId: `tg:${input.updateId}`
10118
10290
  });
10291
+ await this.typing.arm(input.chatId);
10119
10292
  updateTelegramRuntimeStatus({ lastUpdateAt: (/* @__PURE__ */ new Date()).toISOString(), lastError: null });
10120
10293
  } catch (error) {
10294
+ const reason = sanitizeTelegramOutput(error.message, this.deps.exactSecrets).slice(0, 200);
10121
10295
  await this.deps.store.markUpdateUncertain(input.updateId, "dispatch-failed");
10122
10296
  await this.deps.store.audit({
10123
10297
  chatId: input.chatId,
@@ -10127,7 +10301,14 @@ var init_gateway = __esm({
10127
10301
  outcome: "uncertain",
10128
10302
  correlationId: `tg:${input.updateId}`
10129
10303
  });
10130
- throw error;
10304
+ await this.deps.store.enqueueReply({
10305
+ chatId: input.chatId,
10306
+ updateId: input.updateId,
10307
+ kind: TELEGRAM_GATEWAY_FAILURE_KIND,
10308
+ text: `Pesan Anda tertangkap tapi gagal diteruskan ke sesi operator: ${reason || "sebab tak diketahui"}. Kirim ulang setelah masalahnya beres \u2014 hanoman tidak pernah mengulanginya sendiri (at-most-once).`
10309
+ }).catch(() => {
10310
+ });
10311
+ updateTelegramRuntimeStatus({ lastError: reason });
10131
10312
  }
10132
10313
  }
10133
10314
  async flushOutbox() {
@@ -10153,6 +10334,7 @@ var init_gateway = __esm({
10153
10334
  } : void 0;
10154
10335
  const message = await this.deps.client.sendMessage({ chatId: row.chatId, text: chunks[index], replyMarkup });
10155
10336
  messageId = message.message_id;
10337
+ if (!(isLast && TELEGRAM_FINAL_REPLY_KINDS.has(row.kind))) await this.typing.arm(row.chatId);
10156
10338
  }
10157
10339
  await this.deps.store.markOutboxSent(row.id, messageId);
10158
10340
  } catch (error) {
@@ -10178,22 +10360,31 @@ var init_gateway = __esm({
10178
10360
  });
10179
10361
  }
10180
10362
  async loop(signal) {
10363
+ const idlePollTimeout = this.deps.pollTimeout ?? 25;
10181
10364
  while (!signal.aborted) {
10182
10365
  try {
10366
+ const waiting = await this.deps.store.chatsAwaitingReply(new Date(Date.now() - TYPING_MAX_WAIT_MS));
10367
+ await this.typing.refresh(waiting);
10183
10368
  const updates = await this.deps.client.getUpdates({
10184
10369
  offset: await this.deps.store.offset(),
10185
10370
  limit: 100,
10186
- timeout: this.deps.pollTimeout ?? 25,
10371
+ timeout: pollTimeoutFor(waiting.length, idlePollTimeout),
10187
10372
  signal
10188
10373
  });
10189
- await this.processUpdates(updates);
10190
- await this.flushOutbox();
10374
+ if (telegramRuntimeStatus().readiness === "error") {
10375
+ updateTelegramRuntimeStatus({ running: true, readiness: "running", lastError: null });
10376
+ }
10377
+ try {
10378
+ await this.processUpdates(updates);
10379
+ } finally {
10380
+ await this.flushOutbox();
10381
+ }
10191
10382
  } catch (error) {
10192
10383
  if (signal.aborted) return;
10193
10384
  const message = sanitizeTelegramOutput(error.message, this.deps.exactSecrets).slice(0, 500);
10194
10385
  updateTelegramRuntimeStatus({ running: false, readiness: "error", lastError: message });
10195
10386
  if (error instanceof TelegramApiError && error.code === 409) return;
10196
- await new Promise((resolve14) => setTimeout(resolve14, 1e3));
10387
+ await new Promise((resolve15) => setTimeout(resolve15, 1e3));
10197
10388
  }
10198
10389
  }
10199
10390
  }
@@ -10205,6 +10396,112 @@ var init_gateway = __esm({
10205
10396
  }
10206
10397
  });
10207
10398
 
10399
+ // src/services/telegram/engine-command.ts
10400
+ function parseEngineCommand(text, ctx) {
10401
+ const match = text.trim().match(/^\/(engine|runtime|model|effort)(?:\s+([\s\S]+))?$/i);
10402
+ if (!match) return null;
10403
+ const verb = match[1].toLowerCase();
10404
+ const arg = (match[2] ?? "").trim();
10405
+ if (verb === "engine") {
10406
+ if (!arg) return { kind: "show" };
10407
+ const word = arg.toLowerCase();
10408
+ if (word === "off" || word === "mati") {
10409
+ return {
10410
+ kind: "set",
10411
+ engine: { enabled: false, ...ctx.effective },
10412
+ label: "Setelan operator Telegram \u2192 ikut default global sesi kerja"
10413
+ };
10414
+ }
10415
+ if (word === "restart" || word === "ulang") return { kind: "restart" };
10416
+ return { kind: "invalid", message: usage };
10417
+ }
10418
+ if (verb === "runtime") {
10419
+ const word = arg.toLowerCase();
10420
+ if (word !== "claude" && word !== "codex") {
10421
+ return { kind: "invalid", message: "Runtime hanya `claude` atau `codex`. Contoh: `/runtime codex`" };
10422
+ }
10423
+ const agent = word;
10424
+ const base2 = agent === "codex" ? ctx.codex : ctx.claude;
10425
+ const effort = agent === "codex" ? coerceCodexEffort(base2.model, base2.effort) : base2.effort;
10426
+ return {
10427
+ kind: "set",
10428
+ engine: { enabled: true, agent, model: base2.model, effort },
10429
+ label: `Runtime \u2192 ${AGENT_LABEL[agent]}`
10430
+ };
10431
+ }
10432
+ if (verb === "model") {
10433
+ const agent = ctx.effective.agent;
10434
+ const own = agent === "codex" ? codexModels() : claudeModels();
10435
+ const other = agent === "codex" ? claudeModels() : codexModels();
10436
+ if (!own.includes(arg)) {
10437
+ if (other.includes(arg)) {
10438
+ const swap = agent === "codex" ? "claude" : "codex";
10439
+ return {
10440
+ kind: "invalid",
10441
+ message: `\`${arg}\` adalah model ${AGENT_LABEL[swap]}, sedangkan runtime aktif ${AGENT_LABEL[agent]}. Jalankan \`/runtime ${swap}\` dulu.`
10442
+ };
10443
+ }
10444
+ return { kind: "invalid", message: `Model ${AGENT_LABEL[agent]} yang sah: ${own.join(", ")}.` };
10445
+ }
10446
+ const effort = agent === "codex" ? coerceCodexEffort(arg, ctx.effective.effort) : ctx.effective.effort;
10447
+ return {
10448
+ kind: "set",
10449
+ engine: { enabled: true, agent, model: arg, effort },
10450
+ label: `Model \u2192 ${arg}`
10451
+ };
10452
+ }
10453
+ const allowed = effortsFor(ctx.effective);
10454
+ if (!arg || !allowed.includes(arg)) {
10455
+ return {
10456
+ kind: "invalid",
10457
+ message: `Effort yang sah untuk \`${ctx.effective.model}\`: ${allowed.join(", ")}.`
10458
+ };
10459
+ }
10460
+ return {
10461
+ kind: "set",
10462
+ engine: { enabled: true, ...ctx.effective, effort: arg },
10463
+ label: `Effort \u2192 ${arg}`
10464
+ };
10465
+ }
10466
+ function formatEngineStatus(ctx, sessionAlive) {
10467
+ return [
10468
+ `Sesi operator berikutnya: ${triple(ctx.effective)}`,
10469
+ ctx.enabled ? "Sumber: setelan sendiri untuk kanal Telegram." : "Sumber: default global sesi kerja (belum pakai setelan sendiri).",
10470
+ sessionAlive ? "Sesi operator sekarang masih hidup dan tetap memakai setelan lamanya \u2014 `/engine restart` untuk menutupnya." : "Belum ada sesi operator yang hidup; pesan berikutnya lahir dengan setelan di atas.",
10471
+ "",
10472
+ usage
10473
+ ].join("\n");
10474
+ }
10475
+ function formatEngineApplied(next, label, sessionAlive) {
10476
+ const head = next.enabled ? `${label}. Sesi operator berikutnya: ${triple(next)}` : `${label}. Sesi operator berikutnya mengikuti default global sesi kerja.`;
10477
+ return [
10478
+ head,
10479
+ sessionAlive ? "Sesi yang sedang jalan tidak diubah \u2014 ia satu proses, satu model seumur hidup. `/engine restart` menutupnya; ringkasan & memory tetap tersimpan." : "Berlaku untuk sesi operator berikutnya yang dibuat."
10480
+ ].join("\n");
10481
+ }
10482
+ var AGENT_LABEL, TELEGRAM_CONTROL_KIND, claudeModels, codexModels, effortsFor, usage, triple;
10483
+ var init_engine_command = __esm({
10484
+ "src/services/telegram/engine-command.ts"() {
10485
+ "use strict";
10486
+ init_src();
10487
+ AGENT_LABEL = { claude: "Claude Code", codex: "Codex CLI" };
10488
+ TELEGRAM_CONTROL_KIND = "gateway-control";
10489
+ claudeModels = () => MODELS.map((m) => m.id);
10490
+ codexModels = () => CODEX_MODELS.map((m) => m.id);
10491
+ effortsFor = (t) => t.agent === "codex" ? codexEfforts(t.model) : EFFORTS;
10492
+ usage = [
10493
+ "Setelan runtime sesi operator Telegram:",
10494
+ "`/engine` \u2014 lihat setelan sekarang",
10495
+ "`/runtime claude|codex` \u2014 tukar runtime",
10496
+ "`/model <id>` \u2014 tukar model",
10497
+ "`/effort <nilai>` \u2014 tukar effort",
10498
+ "`/engine off` \u2014 kembali ikut default global sesi kerja",
10499
+ "`/engine restart` \u2014 tutup sesi operator supaya setelan berlaku sekarang"
10500
+ ].join("\n");
10501
+ triple = (t) => `${AGENT_LABEL[t.agent]} \xB7 \`${t.model}\` \xB7 \`${t.effort}\``;
10502
+ }
10503
+ });
10504
+
10208
10505
  // src/services/telegram/session.ts
10209
10506
  import { createHash as createHash4 } from "node:crypto";
10210
10507
  var chatHash, telegramOperatorSessionId, formatTelegramTurn, TelegramSessionCoordinator;
@@ -10212,6 +10509,7 @@ var init_session = __esm({
10212
10509
  "src/services/telegram/session.ts"() {
10213
10510
  "use strict";
10214
10511
  init_src2();
10512
+ init_engine_command();
10215
10513
  chatHash = (chatId) => createHash4("sha256").update(chatId).digest("hex").slice(0, 16);
10216
10514
  telegramOperatorSessionId = (chatId) => `telegram-${chatHash(chatId)}`;
10217
10515
  formatTelegramTurn = (input) => `[Telegram update ${input.updateId} \xB7 chat ${input.chatId} \xB7 kind ${input.kind}]
@@ -10221,15 +10519,19 @@ ${input.text}`;
10221
10519
  this.deps = deps;
10222
10520
  }
10223
10521
  async dispatch(input) {
10522
+ if (input.kind === "command") {
10523
+ const handled = await this.control(input);
10524
+ if (handled) return handled;
10525
+ }
10224
10526
  let context = await this.deps.store.chatContext(input.chatId);
10225
10527
  if (!context) {
10226
- const defaults = await this.deps.defaults();
10528
+ const seed = await this.deps.defaults();
10227
10529
  await this.deps.store.ensureChat({
10228
10530
  chatId: input.chatId,
10229
10531
  userId: input.userId,
10230
- agent: defaults.agent,
10231
- model: defaults.model,
10232
- effort: defaults.effort
10532
+ agent: seed.agent,
10533
+ model: seed.model,
10534
+ effort: seed.effort
10233
10535
  });
10234
10536
  context = await this.deps.store.chatContext(input.chatId);
10235
10537
  }
@@ -10243,11 +10545,13 @@ ${input.text}`;
10243
10545
  if (context.sessionId !== sessionId2) await this.deps.store.bindSession(input.chatId, sessionId2);
10244
10546
  return { sessionId: sessionId2, created: false };
10245
10547
  }
10548
+ const engine = await this.deps.defaults();
10549
+ await this.deps.store.setChatEngine(input.chatId, engine);
10246
10550
  const hash2 = chatHash(input.chatId);
10247
10551
  const projectId = `telegram:${hash2}`;
10248
10552
  const cwd = `${this.deps.home.replace(/\/$/, "")}/telegram/${hash2}`;
10249
10553
  this.deps.ensureDir(cwd);
10250
- if (context.agent === "codex") this.deps.ensureCodexTrust(cwd);
10554
+ if (engine.agent === "codex") this.deps.ensureCodexTrust(cwd);
10251
10555
  const personality = await this.deps.personality(context.personalityAgentId, context.activeProjectId);
10252
10556
  const prompt = buildTelegramOperatorPrompt({
10253
10557
  update: input,
@@ -10258,9 +10562,9 @@ ${input.text}`;
10258
10562
  const born = this.deps.port.createSession(projectId, cwd, {
10259
10563
  id: sessionId2,
10260
10564
  prompt,
10261
- agent: context.agent,
10262
- model: context.model,
10263
- effort: context.effort,
10565
+ agent: engine.agent,
10566
+ model: engine.model,
10567
+ effort: engine.effort,
10264
10568
  env: {
10265
10569
  HANOMAN_API_BASE: this.deps.apiBase,
10266
10570
  HANOMAN_TELEGRAM_AGENT_TOKEN: this.deps.agentToken,
@@ -10271,6 +10575,48 @@ ${input.text}`;
10271
10575
  await this.deps.store.bindSession(input.chatId, sessionId2);
10272
10576
  return { sessionId: sessionId2, created: true };
10273
10577
  }
10578
+ /**
10579
+ * SPEC-492 · empat command runtime tak pernah menyentuh pane: ia soal transport, bukan isi
10580
+ * hanoman, dan harus tetap bekerja saat agennya justru macet. `null` = bukan command runtime →
10581
+ * pemanggil melanjutkan jalur lama persis seperti sebelumnya.
10582
+ *
10583
+ * Sengaja TIDAK mengetik `/model`/`/effort` ke pane hidup: ADR-0061 mencabut matrix per-fase
10584
+ * karena mekanisme itu tak andal, dan SPEC-487 mengukur kelasnya (ketikan ke pane yang sedang
10585
+ * menjalankan giliran mendarat sebagai pesan liar). Jalur yang dijanjikan ke operator adalah
10586
+ * `/engine restart` — deterministik, dan konteksnya selamat lewat ringkasan + curated memory.
10587
+ */
10588
+ async control(input) {
10589
+ const ctx = await this.deps.engine.read();
10590
+ const cmd = parseEngineCommand(input.text, ctx);
10591
+ if (!cmd) return null;
10592
+ const sessionId2 = telegramOperatorSessionId(input.chatId);
10593
+ const live = this.deps.port.getSession(sessionId2);
10594
+ const alive = Boolean(live && !live.exited);
10595
+ let text;
10596
+ if (cmd.kind === "show") {
10597
+ text = formatEngineStatus(ctx, alive);
10598
+ } else if (cmd.kind === "invalid") {
10599
+ text = cmd.message;
10600
+ } else if (cmd.kind === "restart") {
10601
+ if (alive) {
10602
+ this.deps.port.killSession(sessionId2);
10603
+ await this.deps.store.patchChat(input.chatId, { sessionId: null });
10604
+ text = "Sesi operator ditutup. Pesan berikutnya lahir dengan setelan sekarang \u2014 ringkasan & curated memory tetap tersimpan.";
10605
+ } else {
10606
+ text = "Tak ada sesi operator yang sedang hidup. Pesan berikutnya lahir dengan setelan sekarang.";
10607
+ }
10608
+ } else {
10609
+ await this.deps.engine.write(cmd.engine);
10610
+ text = formatEngineApplied(cmd.engine, cmd.label, alive);
10611
+ }
10612
+ await this.deps.store.enqueueReply({
10613
+ chatId: input.chatId,
10614
+ updateId: input.updateId,
10615
+ kind: TELEGRAM_CONTROL_KIND,
10616
+ text
10617
+ });
10618
+ return { sessionId: sessionId2, created: false, control: true };
10619
+ }
10274
10620
  };
10275
10621
  }
10276
10622
  });
@@ -10281,6 +10627,7 @@ var isUnique, TelegramStore;
10281
10627
  var init_store = __esm({
10282
10628
  "src/services/telegram/store.ts"() {
10283
10629
  "use strict";
10630
+ init_protocol();
10284
10631
  isUnique = (error) => error instanceof Prisma3.PrismaClientKnownRequestError && error.code === "P2002";
10285
10632
  TelegramStore = class {
10286
10633
  // SPEC-481 · klien yang diekspor kini ber-extension (tap webhook) sehingga tak lagi
@@ -10346,6 +10693,15 @@ var init_store = __esm({
10346
10693
  update: { userId: input.userId }
10347
10694
  });
10348
10695
  }
10696
+ /**
10697
+ * SPEC-492 · cermin baris chat terhadap runtime yang BENAR-BENAR dipakai sesi operator terakhir.
10698
+ * `ensureChat` sengaja tak menyentuh ketiganya (ia hanya menyegarkan `userId`), jadi tanpa ini
10699
+ * `GET /telegram/chats/:chatId/context` — yang dibaca agen operator sendiri — melaporkan
10700
+ * snapshot dari chat pertama, selamanya.
10701
+ */
10702
+ async setChatEngine(chatId, engine) {
10703
+ await this.db.telegramChat.update({ where: { chatId }, data: engine });
10704
+ }
10349
10705
  async chatContext(chatId) {
10350
10706
  const chat = await this.db.telegramChat.findUnique({ where: { chatId } });
10351
10707
  if (!chat) return null;
@@ -10379,6 +10735,38 @@ var init_store = __esm({
10379
10735
  data: { state: "uncertain", rejectReason: reason }
10380
10736
  });
10381
10737
  }
10738
+ /**
10739
+ * SPEC-493 · chat yang masih menunggu jawaban: ada `TelegramUpdate` `dispatched` sesudah `since`
10740
+ * yang belum punya baris outbox ber-kind final. Dihitung pada saat **enqueue**, bukan `sent` —
10741
+ * baris outbox lahir begitu session operator memanggil `POST /telegram/replies`, dan jarak
10742
+ * enqueue→kirim paling banyak satu iterasi loop. Menunggu `sent` akan menahan typing melewati
10743
+ * pesan finalnya sendiri.
10744
+ *
10745
+ * `since` adalah pagar keras: update yang session operatornya mati mengendap `dispatched`
10746
+ * SELAMANYA, dan tanpa pagar ini gateway akan mengetik selamanya sekaligus mengunci long-poll
10747
+ * di 4 detik selamanya.
10748
+ */
10749
+ async chatsAwaitingReply(since) {
10750
+ const pending = await this.db.telegramUpdate.findMany({
10751
+ where: { state: "dispatched", chatId: { not: null }, dispatchedAt: { gte: since } },
10752
+ select: { updateId: true, chatId: true },
10753
+ orderBy: { updateId: "asc" }
10754
+ });
10755
+ if (!pending.length) return [];
10756
+ const answered = new Set((await this.db.telegramOutbox.findMany({
10757
+ where: {
10758
+ updateId: { in: pending.map((row) => row.updateId) },
10759
+ kind: { in: [...TELEGRAM_FINAL_REPLY_KINDS] }
10760
+ },
10761
+ select: { updateId: true }
10762
+ })).map((row) => row.updateId));
10763
+ const chats = [];
10764
+ for (const row of pending) {
10765
+ if (answered.has(row.updateId) || !row.chatId || chats.includes(row.chatId)) continue;
10766
+ chats.push(row.chatId);
10767
+ }
10768
+ return chats;
10769
+ }
10382
10770
  async addMemory(chatId, content) {
10383
10771
  return this.memoryView(await this.db.telegramMemory.create({ data: { chatId, content } }));
10384
10772
  }
@@ -10513,7 +10901,8 @@ __export(bootstrap_exports, {
10513
10901
  TELEGRAM_REQUIRED_CAPABILITIES: () => TELEGRAM_REQUIRED_CAPABILITIES,
10514
10902
  installTelegramGateway: () => installTelegramGateway,
10515
10903
  parseTelegramAllowedUserIds: () => parseTelegramAllowedUserIds,
10516
- reloadTelegramGateway: () => reloadTelegramGateway
10904
+ reloadTelegramGateway: () => reloadTelegramGateway,
10905
+ telegramSessionDeps: () => telegramSessionDeps
10517
10906
  });
10518
10907
  import { mkdirSync as mkdirSync7 } from "node:fs";
10519
10908
  function parseTelegramAllowedUserIds(raw) {
@@ -10522,14 +10911,14 @@ function parseTelegramAllowedUserIds(raw) {
10522
10911
  if (ids.some((id) => !/^\d+$/.test(id))) throw new Error("Telegram allowlist hanya menerima numeric user id");
10523
10912
  return new Set(ids);
10524
10913
  }
10525
- async function productionFactory(input) {
10526
- const client = new TelegramApiClient(input.botToken);
10527
- const me = await client.getMe();
10528
- const store3 = new TelegramStore(prisma);
10529
- const coordinator = new TelegramSessionCoordinator({
10530
- store: store3,
10531
- port: { getSession, createSession, sendToPane },
10532
- defaults: sessionAgentDefaults,
10914
+ function telegramSessionDeps(input) {
10915
+ return {
10916
+ store: input.store,
10917
+ port: { getSession, createSession, sendToPane, killSession },
10918
+ // SPEC-492 · BUKAN `sessionAgentDefaults`: sesi operator Telegram sebagian besar membaca API
10919
+ // lalu merangkum, bukan menulis kode, jadi ia boleh punya runtime/model/effort sendiri.
10920
+ defaults: telegramAgentDefaults,
10921
+ engine: { read: telegramEngineContext, write: setTelegramEngine },
10533
10922
  personality: async (id, projectId) => {
10534
10923
  if (!id) return null;
10535
10924
  const row = await prisma.customAgent.findUnique({ where: { id } });
@@ -10541,7 +10930,17 @@ async function productionFactory(input) {
10541
10930
  apiBase: input.apiBase,
10542
10931
  agentToken: input.agentToken,
10543
10932
  ensureDir: (path) => mkdirSync7(path, { recursive: true })
10544
- });
10933
+ };
10934
+ }
10935
+ async function productionFactory(input) {
10936
+ const client = new TelegramApiClient(input.botToken);
10937
+ const me = await client.getMe();
10938
+ const store3 = new TelegramStore(prisma);
10939
+ const coordinator = new TelegramSessionCoordinator(telegramSessionDeps({
10940
+ apiBase: input.apiBase,
10941
+ agentToken: input.agentToken,
10942
+ store: store3
10943
+ }));
10545
10944
  const gateway = new TelegramGateway({
10546
10945
  client,
10547
10946
  store: store3,
@@ -10653,6 +11052,7 @@ var init_bootstrap = __esm({
10653
11052
  init_settings2();
10654
11053
  init_pty();
10655
11054
  init_client();
11055
+ init_config3();
10656
11056
  init_gateway();
10657
11057
  init_session();
10658
11058
  init_store();
@@ -17222,17 +17622,17 @@ var require_stream_wormhole = __commonJS({
17222
17622
  "../node_modules/.pnpm/stream-wormhole@1.1.0/node_modules/stream-wormhole/index.js"(exports, module) {
17223
17623
  "use strict";
17224
17624
  module.exports = (stream, throwError) => {
17225
- return new Promise((resolve14, reject2) => {
17625
+ return new Promise((resolve15, reject2) => {
17226
17626
  if (typeof stream.resume !== "function") {
17227
- return resolve14();
17627
+ return resolve15();
17228
17628
  }
17229
17629
  stream.unpipe && stream.unpipe();
17230
17630
  stream.resume();
17231
17631
  if (stream._readableState && stream._readableState.ended) {
17232
- return resolve14();
17632
+ return resolve15();
17233
17633
  }
17234
17634
  if (!stream.readable || stream.destroyed) {
17235
- return resolve14();
17635
+ return resolve15();
17236
17636
  }
17237
17637
  function cleanup() {
17238
17638
  stream.removeListener("end", onEnd);
@@ -17241,14 +17641,14 @@ var require_stream_wormhole = __commonJS({
17241
17641
  }
17242
17642
  function onEnd() {
17243
17643
  cleanup();
17244
- resolve14();
17644
+ resolve15();
17245
17645
  }
17246
17646
  function onError(err) {
17247
17647
  cleanup();
17248
17648
  if (throwError) {
17249
17649
  reject2(err);
17250
17650
  } else {
17251
- resolve14();
17651
+ resolve15();
17252
17652
  }
17253
17653
  }
17254
17654
  stream.on("end", onEnd);
@@ -17683,16 +18083,16 @@ var require_multipart2 = __commonJS({
17683
18083
  }
17684
18084
  };
17685
18085
  const parts = () => {
17686
- return new Promise((resolve14, reject2) => {
18086
+ return new Promise((resolve15, reject2) => {
17687
18087
  handle((val) => {
17688
18088
  if (val instanceof Error) {
17689
18089
  if (val.message === "Unexpected end of multipart data") {
17690
- resolve14(null);
18090
+ resolve15(null);
17691
18091
  } else {
17692
18092
  reject2(val);
17693
18093
  }
17694
18094
  } else {
17695
- resolve14(val);
18095
+ resolve15(val);
17696
18096
  }
17697
18097
  });
17698
18098
  });
@@ -17975,11 +18375,39 @@ function pickWebDir(distDir, env, exists) {
17975
18375
  return null;
17976
18376
  }
17977
18377
 
18378
+ // src/guide-file.ts
18379
+ import { resolve as resolve2 } from "node:path";
18380
+ var AGENT_DOC_REL = "docs/agent-integration.md";
18381
+ function pickGuideFile(distDir, env, exists) {
18382
+ const forced = env.HANOMAN_AGENT_DOC?.trim();
18383
+ if (forced) {
18384
+ if (!exists(forced)) throw new Error(`HANOMAN_AGENT_DOC menunjuk berkas yang tak ada: ${forced}`);
18385
+ return forced;
18386
+ }
18387
+ for (const c of [resolve2(distDir, "..", AGENT_DOC_REL), resolve2(distDir, "../..", AGENT_DOC_REL)]) {
18388
+ if (exists(c)) return c;
18389
+ }
18390
+ return null;
18391
+ }
18392
+
17978
18393
  // src/routes/health.ts
17979
18394
  async function health_default(app2) {
17980
18395
  app2.get("/health", async () => ({ ok: true }));
17981
18396
  }
17982
18397
 
18398
+ // src/routes/agent-doc.ts
18399
+ import { readFile } from "node:fs/promises";
18400
+ async function agent_doc_default(app2, opts) {
18401
+ app2.get("/agent-integration.md", async (_req, reply) => {
18402
+ if (!opts.file)
18403
+ return reply.code(404).send({
18404
+ error: `dokumen panduan tak ada di instalasi ini (${AGENT_DOC_REL}) \u2014 pasang ulang paket hanoman atau set HANOMAN_AGENT_DOC`
18405
+ });
18406
+ const text = await readFile(opts.file, "utf8");
18407
+ return reply.type("text/markdown; charset=utf-8").send(text);
18408
+ });
18409
+ }
18410
+
17983
18411
  // src/routes/projects.ts
17984
18412
  init_src();
17985
18413
  init_db();
@@ -17994,7 +18422,7 @@ init_src();
17994
18422
  import { execFile } from "node:child_process";
17995
18423
  import { promisify } from "node:util";
17996
18424
  import { readFileSync as readFileSync4, writeFileSync as writeFileSync3, existsSync as existsSync3, rmSync as rmSync2, mkdirSync as mkdirSync5 } from "node:fs";
17997
- import { resolve as resolve6, dirname as dirname6, sep } from "node:path";
18425
+ import { resolve as resolve7, dirname as dirname6, sep } from "node:path";
17998
18426
  var exec = promisify(execFile);
17999
18427
  async function listRepoDocs(repoDir) {
18000
18428
  try {
@@ -18010,7 +18438,7 @@ async function listRepoDocs(repoDir) {
18010
18438
  }
18011
18439
  function docsDirOf(repoDir) {
18012
18440
  try {
18013
- const raw = readFileSync4(resolve6(repoDir, "hanoman.config.json"), "utf8");
18441
+ const raw = readFileSync4(resolve7(repoDir, "hanoman.config.json"), "utf8");
18014
18442
  return zHanomanConfig.parse(JSON.parse(raw)).docsDir;
18015
18443
  } catch {
18016
18444
  return zHanomanConfig.parse({}).docsDir;
@@ -18018,7 +18446,7 @@ function docsDirOf(repoDir) {
18018
18446
  }
18019
18447
  function resolveIndex(repoDir, docsDir) {
18020
18448
  const rel = `${docsDir}/README.md`;
18021
- return existsSync3(resolve6(repoDir, rel)) ? rel : "";
18449
+ return existsSync3(resolve7(repoDir, rel)) ? rel : "";
18022
18450
  }
18023
18451
  var catOf = (rel) => rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : ".";
18024
18452
  var nameOf = (rel) => rel.includes("/") ? rel.slice(rel.lastIndexOf("/") + 1) : rel;
@@ -18029,7 +18457,7 @@ async function scanRepoDocs(repoDir) {
18029
18457
  const index = resolveIndex(repoDir, docsDir);
18030
18458
  const read = (rel) => {
18031
18459
  try {
18032
- return readFileSync4(resolve6(repoDir, rel), "utf8");
18460
+ return readFileSync4(resolve7(repoDir, rel), "utf8");
18033
18461
  } catch {
18034
18462
  return null;
18035
18463
  }
@@ -18052,7 +18480,7 @@ async function scanRepoDocs(repoDir) {
18052
18480
  function docAbsPath(repoDir, rel) {
18053
18481
  if (!rel.endsWith(".md")) throw new Error("hanya file .md yang diizinkan");
18054
18482
  if (rel.split("/").includes(".git")) throw new Error("tidak boleh menyentuh .git");
18055
- const abs = resolve6(repoDir, rel);
18483
+ const abs = resolve7(repoDir, rel);
18056
18484
  if (abs !== repoDir && !abs.startsWith(repoDir + sep)) throw new Error("path keluar dari repo");
18057
18485
  return abs;
18058
18486
  }
@@ -18552,9 +18980,9 @@ init_db();
18552
18980
  // src/services/spec-review.ts
18553
18981
  import { execFile as execFile4 } from "node:child_process";
18554
18982
  import { promisify as promisify4 } from "node:util";
18555
- import { readFile, mkdtemp, copyFile, rm } from "node:fs/promises";
18983
+ import { readFile as readFile2, mkdtemp, copyFile, rm } from "node:fs/promises";
18556
18984
  import { tmpdir as tmpdir2 } from "node:os";
18557
- import { join as join4, resolve as resolve7 } from "node:path";
18985
+ import { join as join4, resolve as resolve8 } from "node:path";
18558
18986
  var exec4 = promisify4(execFile4);
18559
18987
  var GIT3 = { maxBuffer: 1 << 24 };
18560
18988
  var MAX = 256 * 1024;
@@ -18563,7 +18991,7 @@ async function withTempIndex(wt, fn) {
18563
18991
  const idx = (await exec4("git", ["rev-parse", "--git-path", "index"], { cwd: wt, ...GIT3 })).stdout.trim();
18564
18992
  const dir2 = await mkdtemp(join4(tmpdir2(), "hanoman-idx-"));
18565
18993
  const tmp = join4(dir2, "index");
18566
- await copyFile(resolve7(wt, idx), tmp);
18994
+ await copyFile(resolve8(wt, idx), tmp);
18567
18995
  const env = { ...process.env, GIT_INDEX_FILE: tmp };
18568
18996
  try {
18569
18997
  await exec4("git", ["add", "-A", "-N"], { cwd: wt, env, ...GIT3 });
@@ -18687,7 +19115,7 @@ async function reviewFile(repoDir, specId, baseSha, branchFrom, path) {
18687
19115
  let contentRaw = null;
18688
19116
  if (status !== "D") {
18689
19117
  try {
18690
- contentRaw = await readFile(join4(wt, path), "utf8");
19118
+ contentRaw = await readFile2(join4(wt, path), "utf8");
18691
19119
  } catch {
18692
19120
  contentRaw = null;
18693
19121
  }
@@ -21300,7 +21728,7 @@ function renderDocPdf(text, name2, meta) {
21300
21728
  });
21301
21729
  const chunks = [];
21302
21730
  doc.on("data", (c) => chunks.push(c));
21303
- const done = new Promise((resolve14) => doc.on("end", () => resolve14(Buffer.concat(chunks))));
21731
+ const done = new Promise((resolve15) => doc.on("end", () => resolve15(Buffer.concat(chunks))));
21304
21732
  doc.font("Helvetica-Bold").fontSize(8).fillColor(BRASS).text(toWinAnsi(meta.eyebrow.toUpperCase()), { characterSpacing: 0.8 });
21305
21733
  doc.moveDown(0.25);
21306
21734
  doc.font("Helvetica-Bold").fontSize(17).fillColor(STRONG).text(toWinAnsi(name2.split("/").pop() ?? name2));
@@ -21782,6 +22210,7 @@ init_src();
21782
22210
  init_db();
21783
22211
  init_settings2();
21784
22212
  init_bootstrap();
22213
+ init_config3();
21785
22214
  async function settings_default(app2) {
21786
22215
  app2.get("/settings", async () => getSetting());
21787
22216
  app2.put("/settings", async (req, reply) => {
@@ -21793,7 +22222,7 @@ async function settings_default(app2) {
21793
22222
  update: { data: parsed.data },
21794
22223
  create: { id: 1, data: parsed.data }
21795
22224
  });
21796
- if (JSON.stringify(before.telegram) !== JSON.stringify(parsed.data.telegram)) {
22225
+ if (telegramReloadNeeded(before.telegram, parsed.data.telegram)) {
21797
22226
  await reloadTelegramGateway();
21798
22227
  }
21799
22228
  return row.data;
@@ -22009,16 +22438,16 @@ import { promisify as promisify7 } from "node:util";
22009
22438
  // src/services/git-ide.ts
22010
22439
  import { execFile as execFile6 } from "node:child_process";
22011
22440
  import { promisify as promisify6 } from "node:util";
22012
- import { readFile as readFile2, writeFile, mkdir } from "node:fs/promises";
22441
+ import { readFile as readFile3, writeFile, mkdir } from "node:fs/promises";
22013
22442
  import { existsSync as existsSync6 } from "node:fs";
22014
- import { resolve as resolve8, sep as sep2, dirname as dirname8 } from "node:path";
22443
+ import { resolve as resolve9, sep as sep2, dirname as dirname8 } from "node:path";
22015
22444
  var exec6 = promisify6(execFile6);
22016
22445
  var GIT5 = { maxBuffer: 1 << 24 };
22017
22446
  var MAX2 = 256 * 1024;
22018
22447
  var splitZ2 = (s2) => s2.split("\0").filter(Boolean);
22019
22448
  function repoAbsPath(repoDir, rel) {
22020
22449
  if (rel.split(/[\\/]/).includes(".git")) throw new Error("tidak boleh menyentuh .git");
22021
- const abs = resolve8(repoDir, rel);
22450
+ const abs = resolve9(repoDir, rel);
22022
22451
  if (abs !== repoDir && !abs.startsWith(repoDir + sep2)) throw new Error("path keluar dari repo");
22023
22452
  return abs;
22024
22453
  }
@@ -22036,7 +22465,7 @@ async function readRepoFile(repoDir, rel, ref = "") {
22036
22465
  repoAbsPath(repoDir, rel);
22037
22466
  let raw;
22038
22467
  try {
22039
- raw = ref ? (await exec6("git", ["show", `${ref}:${rel}`], { cwd: repoDir, ...GIT5 })).stdout : await readFile2(repoAbsPath(repoDir, rel), "utf8");
22468
+ raw = ref ? (await exec6("git", ["show", `${ref}:${rel}`], { cwd: repoDir, ...GIT5 })).stdout : await readFile3(repoAbsPath(repoDir, rel), "utf8");
22040
22469
  } catch {
22041
22470
  return null;
22042
22471
  }
@@ -22437,7 +22866,7 @@ async function workingFileDiff(repoDir, path, staged) {
22437
22866
  let contentRaw = null;
22438
22867
  if (cf.status !== "D") {
22439
22868
  try {
22440
- contentRaw = staged ? (await exec6("git", ["show", `:${path}`], { cwd: repoDir, ...GIT5 })).stdout : await readFile2(repoAbsPath(repoDir, path), "utf8");
22869
+ contentRaw = staged ? (await exec6("git", ["show", `:${path}`], { cwd: repoDir, ...GIT5 })).stdout : await readFile3(repoAbsPath(repoDir, path), "utf8");
22441
22870
  } catch {
22442
22871
  contentRaw = null;
22443
22872
  }
@@ -22903,11 +23332,11 @@ async function finishGraphOp(reply, id, repoDir, r, verb) {
22903
23332
  // src/routes/fs.ts
22904
23333
  import { readdir } from "node:fs/promises";
22905
23334
  import { homedir as homedir3 } from "node:os";
22906
- import { resolve as resolve9, dirname as dirname9, join as join5 } from "node:path";
23335
+ import { resolve as resolve10, dirname as dirname9, join as join5 } from "node:path";
22907
23336
  async function fs_default(app2) {
22908
23337
  app2.get("/fs/browse", async (req, reply) => {
22909
23338
  const q = req.query.path;
22910
- const dir2 = q && q.trim() ? resolve9(q.trim()) : homedir3();
23339
+ const dir2 = q && q.trim() ? resolve10(q.trim()) : homedir3();
22911
23340
  try {
22912
23341
  const ents = await readdir(dir2, { withFileTypes: true });
22913
23342
  const entries = ents.filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => ({ name: e.name, path: join5(dir2, e.name) })).sort((a, b) => a.name.localeCompare(b.name));
@@ -23043,11 +23472,11 @@ async function startSpecSession(spec, opts) {
23043
23472
  }
23044
23473
 
23045
23474
  // src/services/session-worktree.ts
23046
- import { resolve as resolve10, sep as sep3 } from "node:path";
23475
+ import { resolve as resolve11, sep as sep3 } from "node:path";
23047
23476
  function ownsWorktree(repoDir, cwd) {
23048
- const base2 = resolve10(repoDir);
23049
- const dir2 = resolve10(cwd);
23050
- const container = resolve10(base2, ".worktrees");
23477
+ const base2 = resolve11(repoDir);
23478
+ const dir2 = resolve11(cwd);
23479
+ const container = resolve11(base2, ".worktrees");
23051
23480
  return dir2 !== base2 && dir2 !== container && dir2.startsWith(container + sep3);
23052
23481
  }
23053
23482
 
@@ -23548,14 +23977,14 @@ function sshExec(t, remoteCmd, opts = {}) {
23548
23977
  SSH_ASKPASS_REQUIRE: "force",
23549
23978
  HANOMAN_SSH_PASSWORD: opts.password
23550
23979
  } : process.env;
23551
- return new Promise((resolve14) => {
23980
+ return new Promise((resolve15) => {
23552
23981
  const p = spawn3(sshBin(), args, { stdio: ["pipe", "pipe", "pipe"], env });
23553
23982
  let out3 = "";
23554
23983
  const timer7 = setTimeout(() => p.kill("SIGKILL"), opts.timeoutMs ?? 6e4);
23555
23984
  const done = (r) => {
23556
23985
  clearTimeout(timer7);
23557
23986
  if (askpass) rmSync4(dirname10(askpass), { recursive: true, force: true });
23558
- resolve14(r);
23987
+ resolve15(r);
23559
23988
  };
23560
23989
  p.stdout.on("data", (d) => {
23561
23990
  out3 += d;
@@ -24332,7 +24761,7 @@ init_src();
24332
24761
  init_src();
24333
24762
  init_config2();
24334
24763
  import { readFileSync as readFileSync11 } from "node:fs";
24335
- import { resolve as resolve11, dirname as dirname12 } from "node:path";
24764
+ import { resolve as resolve12, dirname as dirname12 } from "node:path";
24336
24765
  import { fileURLToPath as fileURLToPath3 } from "node:url";
24337
24766
  var UPDATE_COMMAND = "npm i -g hanoman@latest";
24338
24767
  function composeUpdate(x) {
@@ -24358,7 +24787,7 @@ var lastLatest = null;
24358
24787
  var lastStatus = "unavailable";
24359
24788
  function runningVersion() {
24360
24789
  const here = dirname12(fileURLToPath3(import.meta.url));
24361
- for (const p of [resolve11(here, "build-info.json"), resolve11(here, "../package.json")]) {
24790
+ for (const p of [resolve12(here, "build-info.json"), resolve12(here, "../package.json")]) {
24362
24791
  try {
24363
24792
  const v = JSON.parse(readFileSync11(p, "utf8")).version;
24364
24793
  if (typeof v === "string" && v) return v;
@@ -24707,8 +25136,8 @@ init_conflicts();
24707
25136
  // src/services/uploads.ts
24708
25137
  init_config2();
24709
25138
  import { randomUUID as randomUUID3 } from "node:crypto";
24710
- import { mkdir as mkdir2, writeFile as writeFile2, readFile as readFile3, unlink } from "node:fs/promises";
24711
- import { join as join12, resolve as resolve12 } from "node:path";
25139
+ import { mkdir as mkdir2, writeFile as writeFile2, readFile as readFile4, unlink } from "node:fs/promises";
25140
+ import { join as join12, resolve as resolve13 } from "node:path";
24712
25141
  var EXT = {
24713
25142
  "image/png": ".png",
24714
25143
  "image/jpeg": ".jpg",
@@ -24718,7 +25147,7 @@ function extFor(mimeType) {
24718
25147
  return EXT[mimeType] ?? ".bin";
24719
25148
  }
24720
25149
  function uploadDir() {
24721
- return resolve12(effectiveStr("HANOMAN_UPLOAD_DIR") ?? join12(process.cwd(), "data", "uploads"));
25150
+ return resolve13(effectiveStr("HANOMAN_UPLOAD_DIR") ?? join12(process.cwd(), "data", "uploads"));
24722
25151
  }
24723
25152
  async function saveUpload(buf, mimeType) {
24724
25153
  const dir2 = uploadDir();
@@ -24729,13 +25158,13 @@ async function saveUpload(buf, mimeType) {
24729
25158
  }
24730
25159
  async function readUpload(storageKey) {
24731
25160
  const safe = storageKey.replace(/[/\\]/g, "");
24732
- return readFile3(join12(uploadDir(), safe));
25161
+ return readFile4(join12(uploadDir(), safe));
24733
25162
  }
24734
25163
  async function readUploadOrFetch(storageKey) {
24735
25164
  const safe = storageKey.replace(/[/\\]/g, "");
24736
25165
  const target2 = join12(uploadDir(), safe);
24737
25166
  try {
24738
- return await readFile3(target2);
25167
+ return await readFile4(target2);
24739
25168
  } catch {
24740
25169
  const base2 = effectiveStr("SYNC_SERVER_URL");
24741
25170
  const token = effectiveStr("SYNC_DEVICE_TOKEN");
@@ -24882,11 +25311,11 @@ import { randomUUID as randomUUID5 } from "node:crypto";
24882
25311
  // src/services/transcript-store.ts
24883
25312
  init_config2();
24884
25313
  import { randomUUID as randomUUID4 } from "node:crypto";
24885
- import { mkdir as mkdir3, writeFile as writeFile3, readFile as readFile4, unlink as unlink2 } from "node:fs/promises";
24886
- import { join as join13, resolve as resolve13, basename as basename2 } from "node:path";
25314
+ import { mkdir as mkdir3, writeFile as writeFile3, readFile as readFile5, unlink as unlink2 } from "node:fs/promises";
25315
+ import { join as join13, resolve as resolve14, basename as basename2 } from "node:path";
24887
25316
  var MAX_TRANSCRIPT_BYTES = 1024 * 1024;
24888
25317
  function transcriptDir() {
24889
- return resolve13(effectiveStr("HANOMAN_TRANSCRIPT_DIR") ?? join13(process.cwd(), "data", "transcripts"));
25318
+ return resolve14(effectiveStr("HANOMAN_TRANSCRIPT_DIR") ?? join13(process.cwd(), "data", "transcripts"));
24890
25319
  }
24891
25320
  function clamp(text) {
24892
25321
  const buf = Buffer.from(text, "utf8");
@@ -24910,7 +25339,7 @@ async function saveTranscript(text) {
24910
25339
  async function readTranscript(key) {
24911
25340
  if (!key) return null;
24912
25341
  try {
24913
- return await readFile4(join13(transcriptDir(), basename2(key)), "utf8");
25342
+ return await readFile5(join13(transcriptDir(), basename2(key)), "utf8");
24914
25343
  } catch {
24915
25344
  return null;
24916
25345
  }
@@ -25938,7 +26367,7 @@ function leadFailureReason(agent, timeoutMs, err, stdout, stderr) {
25938
26367
  function think(prompt, o) {
25939
26368
  const bin = binFor(o.agent);
25940
26369
  const args = leadArgv({ agent: o.agent, model: o.model, effort: o.effort, prompt });
25941
- return new Promise((resolve14, reject2) => {
26370
+ return new Promise((resolve15, reject2) => {
25942
26371
  const child = execFile9(bin, args, {
25943
26372
  cwd: o.cwd,
25944
26373
  timeout: o.timeoutMs,
@@ -25951,7 +26380,7 @@ function think(prompt, o) {
25951
26380
  reject2(new Error(leadFailureReason(o.agent, o.timeoutMs, err, stdout, stderr)));
25952
26381
  return;
25953
26382
  }
25954
- resolve14(stdout);
26383
+ resolve15(stdout);
25955
26384
  });
25956
26385
  child.stdin?.end();
25957
26386
  });
@@ -25988,9 +26417,9 @@ function acquire(cap, waitMs) {
25988
26417
  return Promise.resolve();
25989
26418
  }
25990
26419
  const startedAt = Date.now();
25991
- return new Promise((resolve14, reject2) => {
26420
+ return new Promise((resolve15, reject2) => {
25992
26421
  const w = {
25993
- grant: resolve14,
26422
+ grant: resolve15,
25994
26423
  deny: reject2,
25995
26424
  timer: setTimeout(() => {
25996
26425
  const i = queue.indexOf(w);
@@ -26507,6 +26936,7 @@ var CODEX_FINISHED = [
26507
26936
  /\btokens used\b/i,
26508
26937
  /\bTo continue this session\b/i
26509
26938
  ];
26939
+ var AGENT_TURN_LINE = /(?:\bfor\s+\d+\s*[hms]\b|\(\s*\d+\s*[hms][^)]*·)/i;
26510
26940
  var ASK_SIGNALS = [
26511
26941
  /\?\s*$/m,
26512
26942
  /^\s*\[?\d+[).\]]\s+\S/m,
@@ -26515,9 +26945,12 @@ var ASK_SIGNALS = [
26515
26945
  function readPaneQuestion(text, agent) {
26516
26946
  const lines2 = tail2(text, 40);
26517
26947
  const body = lines2.join("\n").trim();
26518
- const choices = readChoiceDialog(text)?.options ?? [];
26948
+ const dialog = readChoiceDialog(text);
26949
+ const choices = dialog?.options ?? [];
26519
26950
  if (!body) return { asking: false, question: "", reason: "layar kosong", choices };
26520
26951
  const question = tail2(text, 25).join("\n").trim();
26952
+ if (!dialog && AGENT_TURN_LINE.test(body))
26953
+ return { asking: false, question, reason: "giliran agen, bukan pertanyaan (baris giliran claude di layar)", choices };
26521
26954
  if (agent === "codex") {
26522
26955
  const finished = CODEX_FINISHED.find((re) => re.test(body));
26523
26956
  if (finished) return { asking: false, question, reason: "sesi codex selesai wajar (ADR-0074)", choices };
@@ -26532,14 +26965,20 @@ var answers = /* @__PURE__ */ new Map();
26532
26965
  var capped = /* @__PURE__ */ new Set();
26533
26966
  var failures = /* @__PURE__ */ new Map();
26534
26967
  var failCapped = /* @__PURE__ */ new Set();
26968
+ var failedAt = /* @__PURE__ */ new Map();
26969
+ var FAIL_COOLDOWN_MS = 15 * 6e4;
26970
+ var chainFlows = /* @__PURE__ */ new Map();
26535
26971
  var MAX_CHAIN_STEPS = 6;
26536
26972
  var CHAIN_POLL_MS = 300;
26537
26973
  var CHAIN_POLL_TRIES = 20;
26974
+ var CHAIN_END_TRIES = 5;
26538
26975
  function resetSession(sessionId2) {
26539
26976
  answers.delete(sessionId2);
26540
26977
  capped.delete(sessionId2);
26541
26978
  failures.delete(sessionId2);
26542
26979
  failCapped.delete(sessionId2);
26980
+ failedAt.delete(sessionId2);
26981
+ chainFlows.delete(sessionId2);
26543
26982
  }
26544
26983
  var prodDetectDeps = {
26545
26984
  live: () => {
@@ -26577,6 +27016,12 @@ var prodDetectDeps = {
26577
27016
  },
26578
27017
  submit: (id) => submitPaneDialog(id),
26579
27018
  sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
27019
+ // Alur yang gagal ditutup bukan alasan menggagalkan rantai yang sudah berhasil: penyapu TTL
27020
+ // (ADR-0102) tetap menutupnya.
27021
+ closeChain: async (flowId) => {
27022
+ await closeFlow(flowId, "submit").catch(() => null);
27023
+ },
27024
+ now: Date.now,
26580
27025
  decide,
26581
27026
  decideDeps: prodDecideDeps,
26582
27027
  delivery: takeDelivery,
@@ -26602,7 +27047,7 @@ async function scanAndAnswer(deps = prodDetectDeps) {
26602
27047
  skip("lead dijeda untuk project ini");
26603
27048
  continue;
26604
27049
  }
26605
- if (!deps.filled(s2.decisionFile)) continue;
27050
+ if (!deps.filled(s2.decisionFile) && !(!deps.exited(s2.id) && readDialogScreen(deps.pane(s2.id)))) continue;
26606
27051
  if (deps.exited(s2.id)) {
26607
27052
  skip("pane mati");
26608
27053
  continue;
@@ -26635,6 +27080,12 @@ async function scanAndAnswer(deps = prodDetectDeps) {
26635
27080
  skip("batas jawaban otomatis tercapai");
26636
27081
  continue;
26637
27082
  }
27083
+ const lastFail = failedAt.get(s2.id);
27084
+ if (lastFail !== void 0 && deps.now() - lastFail > FAIL_COOLDOWN_MS) {
27085
+ failures.delete(s2.id);
27086
+ failCapped.delete(s2.id);
27087
+ failedAt.delete(s2.id);
27088
+ }
26638
27089
  if ((failures.get(s2.id) ?? 0) >= cfg.maxAutoAnswers) {
26639
27090
  if (!failCapped.has(s2.id)) {
26640
27091
  failCapped.add(s2.id);
@@ -26680,15 +27131,27 @@ async function scanAndAnswer(deps = prodDetectDeps) {
26680
27131
  out3.answered.push(s2.id);
26681
27132
  return;
26682
27133
  }
26683
- if (chain.failed) failures.set(s2.id, (failures.get(s2.id) ?? 0) + 1);
27134
+ if (chain.failed) {
27135
+ failures.set(s2.id, (failures.get(s2.id) ?? 0) + 1);
27136
+ failedAt.set(s2.id, deps.now());
27137
+ }
26684
27138
  out3.skipped.push({ id: s2.id, reason: chain.reason });
26685
27139
  });
26686
27140
  return out3;
26687
27141
  }
26688
27142
  async function runChain(s2, agent, deps) {
26689
27143
  let acted = false;
27144
+ const done = async (r) => {
27145
+ const flowId = chainFlows.get(s2.id);
27146
+ if (flowId) {
27147
+ chainFlows.delete(s2.id);
27148
+ await deps.closeChain(flowId).catch(() => {
27149
+ });
27150
+ }
27151
+ return r;
27152
+ };
26690
27153
  for (let step = 0; step < MAX_CHAIN_STEPS; step++) {
26691
- const text = deps.pane(s2.id);
27154
+ const text = step === 0 ? deps.pane(s2.id) : await settledPane(s2.id, deps);
26692
27155
  const screen = readDialogScreen(text);
26693
27156
  if (screen?.kind === "review") {
26694
27157
  if (!await deps.submit(s2.id))
@@ -26696,10 +27159,10 @@ async function runChain(s2, agent, deps) {
26696
27159
  acted = true;
26697
27160
  continue;
26698
27161
  }
26699
- if (step > 0 && !screen) return { acted, done: true, failed: false, reason: "" };
27162
+ if (step > 0 && !screen) return done({ acted, done: true, failed: false, reason: "" });
26700
27163
  const read = readPaneQuestion(text, agent);
26701
27164
  if (!read.asking) {
26702
- return step === 0 ? { acted, done: false, failed: false, reason: read.reason } : { acted, done: true, failed: false, reason: "" };
27165
+ return step === 0 ? { acted, done: false, failed: false, reason: read.reason } : done({ acted, done: true, failed: false, reason: "" });
26703
27166
  }
26704
27167
  const notes = [`Sesi ini menunggu di terminal; teks di bawah adalah layar terakhirnya. Jawablah sebagai masukan yang bisa langsung diketik ke terminal itu (isi \`reply\`).`];
26705
27168
  if (read.choices.length) {
@@ -26711,22 +27174,32 @@ async function runChain(s2, agent, deps) {
26711
27174
  `Dialog ini BERANTAI: ${screen.tabs.length} pertanyaan dalam satu tanya (${screen.tabs.map((t) => `${t.answered ? "sudah" : "belum"}: ${t.header}`).join(", ")}). Yang sedang tampil pertanyaan ke-${at + 1}; jawab HANYA pertanyaan itu \u2014 sisanya akan ditanyakan sesudah ini.`
26712
27175
  );
26713
27176
  }
27177
+ const ask = {
27178
+ projectId: s2.projectId,
27179
+ specId: s2.specId,
27180
+ sessionId: s2.id,
27181
+ gate: "detected",
27182
+ kind: "answer",
27183
+ question: read.question,
27184
+ options: read.choices.length ? read.choices : void 0,
27185
+ notes,
27186
+ chain: true
27187
+ };
26714
27188
  let row;
26715
27189
  try {
26716
- row = await deps.decide({
26717
- projectId: s2.projectId,
26718
- specId: s2.specId,
26719
- sessionId: s2.id,
26720
- gate: "detected",
26721
- kind: "answer",
26722
- question: read.question,
26723
- options: read.choices.length ? read.choices : void 0,
26724
- notes
26725
- }, deps.decideDeps);
27190
+ row = await deps.decide({ ...ask, flowId: chainFlows.get(s2.id) ?? null }, deps.decideDeps);
26726
27191
  } catch (e) {
26727
27192
  if (e instanceof LeadBusyError) return { acted, done: false, failed: false, reason: `lead penuh \u2014 ${e.message}` };
26728
- throw e;
27193
+ if (!(e instanceof LeadFlowClosedError)) throw e;
27194
+ chainFlows.delete(s2.id);
27195
+ try {
27196
+ row = await deps.decide({ ...ask, flowId: null }, deps.decideDeps);
27197
+ } catch (e2) {
27198
+ if (e2 instanceof LeadBusyError) return { acted, done: false, failed: false, reason: `lead penuh \u2014 ${e2.message}` };
27199
+ throw e2;
27200
+ }
26729
27201
  }
27202
+ if (row?.flowId) chainFlows.set(s2.id, row.flowId);
26730
27203
  if (!row) return { acted, done: false, failed: false, reason: "lead tak menghasilkan keputusan yang berlaku" };
26731
27204
  if (row.status !== "berlaku")
26732
27205
  return { acted, done: false, failed: true, reason: "lead tak menghasilkan keputusan yang berlaku" };
@@ -26736,12 +27209,20 @@ async function runChain(s2, agent, deps) {
26736
27209
  if (!await deps.send(s2.id, reply, picked))
26737
27210
  return { acted, done: false, failed: true, reason: "gagal mengetik ke pane" };
26738
27211
  acted = true;
26739
- if (!screen) return { acted, done: true, failed: false, reason: "" };
27212
+ if (!screen) return done({ acted, done: true, failed: false, reason: "" });
26740
27213
  if (!await waitScreenChange(s2.id, dialogKey(text), deps))
26741
27214
  return { acted, done: false, failed: true, reason: "layar dialog tak berubah sesudah dijawab" };
26742
27215
  }
26743
27216
  return { acted, done: false, failed: true, reason: "batas langkah rantai dialog tercapai" };
26744
27217
  }
27218
+ async function settledPane(id, deps) {
27219
+ let text = deps.pane(id);
27220
+ for (let i = 0; i < CHAIN_END_TRIES && !readDialogScreen(text); i++) {
27221
+ await deps.sleep(CHAIN_POLL_MS);
27222
+ text = deps.pane(id);
27223
+ }
27224
+ return text;
27225
+ }
26745
27226
  async function waitScreenChange(id, before, deps) {
26746
27227
  for (let i = 0; i < CHAIN_POLL_TRIES; i++) {
26747
27228
  await deps.sleep(CHAIN_POLL_MS);
@@ -26755,6 +27236,8 @@ function sweep(liveIds) {
26755
27236
  for (const id of [...capped]) if (!live.has(id)) capped.delete(id);
26756
27237
  for (const id of [...failures.keys()]) if (!live.has(id)) failures.delete(id);
26757
27238
  for (const id of [...failCapped]) if (!live.has(id)) failCapped.delete(id);
27239
+ for (const id of [...failedAt.keys()]) if (!live.has(id)) failedAt.delete(id);
27240
+ for (const id of [...chainFlows.keys()]) if (!live.has(id)) chainFlows.delete(id);
26758
27241
  }
26759
27242
 
26760
27243
  // src/services/lead/pulse.ts
@@ -27662,7 +28145,7 @@ function issuesFromRest(raw) {
27662
28145
  }
27663
28146
  var GH_FIELDS = "number,title,body,author,labels,url,state,createdAt,updatedAt";
27664
28147
  var API2 = "https://api.github.com";
27665
- var defaultRunGh = (args, env) => new Promise((resolve14, reject2) => {
28148
+ var defaultRunGh = (args, env) => new Promise((resolve15, reject2) => {
27666
28149
  execFile10(
27667
28150
  args[0],
27668
28151
  args.slice(1),
@@ -27670,7 +28153,7 @@ var defaultRunGh = (args, env) => new Promise((resolve14, reject2) => {
27670
28153
  (err, stdout, stderr) => {
27671
28154
  const e = err;
27672
28155
  if (e && (e.code === "ENOENT" || e.code === "EACCES")) return reject2(e);
27673
- resolve14({ code: err ? Number(err.code ?? 1) : 0, stdout, stderr });
28156
+ resolve15({ code: err ? Number(err.code ?? 1) : 0, stdout, stderr });
27674
28157
  }
27675
28158
  );
27676
28159
  });
@@ -27989,8 +28472,42 @@ import { randomBytes as randomBytes5 } from "node:crypto";
27989
28472
  // src/services/telegram/credentials.ts
27990
28473
  init_src();
27991
28474
  init_config2();
28475
+ init_agent_token();
28476
+ init_settings2();
28477
+ init_bootstrap();
27992
28478
  init_client();
27993
28479
  init_protocol();
28480
+ init_runtime();
28481
+ async function verifyTelegramAgentToken(token, deps = {}) {
28482
+ const agent = await (deps.verify ?? verifyAgentToken)(token);
28483
+ if (!agent) {
28484
+ return {
28485
+ ok: false,
28486
+ missing: [...TELEGRAM_REQUIRED_CAPABILITIES],
28487
+ reason: "AgentToken tidak dikenal atau sudah dicabut \u2014 salin plaintext `hnm_agt_\u2026` dari Akses AI Agent, bukan hash-nya."
28488
+ };
28489
+ }
28490
+ const missing = TELEGRAM_REQUIRED_CAPABILITIES.filter((c) => !agent.capabilities.includes(c));
28491
+ return missing.length ? { ok: false, missing, reason: `AgentToken kurang ${missing.length} capability.` } : { ok: true };
28492
+ }
28493
+ async function telegramInboundReadiness(deps = {}) {
28494
+ const polling = deps.polling ?? telegramRuntimeStatus().running;
28495
+ const empty = [];
28496
+ const token = deps.agentToken ?? effectiveStr("HANOMAN_TELEGRAM_AGENT_TOKEN")?.trim();
28497
+ if (!token) {
28498
+ return { ok: false, reason: "AgentToken gateway belum diisi.", missingCapabilities: [...TELEGRAM_REQUIRED_CAPABILITIES], polling };
28499
+ }
28500
+ const setting = await (deps.getSetting ?? getSetting)();
28501
+ if (!setting.agentAccessEnabled) {
28502
+ return { ok: false, reason: "Akses agent mati \u2014 nyalakan master switch di Akses AI Agent.", missingCapabilities: empty, polling };
28503
+ }
28504
+ const gate = await verifyTelegramAgentToken(token, deps);
28505
+ if (!gate.ok) return { ok: false, reason: gate.reason, missingCapabilities: gate.missing, polling };
28506
+ if (!polling) {
28507
+ return { ok: false, reason: "Kredensial sudah sah tapi gateway belum polling \u2014 nyalakan \u201CGateway aktif\u201D.", missingCapabilities: empty, polling };
28508
+ }
28509
+ return { ok: true, reason: null, missingCapabilities: empty, polling };
28510
+ }
27994
28511
  var KEYS = TELEGRAM_CONFIG_KEYS;
27995
28512
  function telegramCredentialView() {
27996
28513
  return {
@@ -28008,7 +28525,7 @@ function telegramCredentialView() {
28008
28525
  })
28009
28526
  };
28010
28527
  }
28011
- async function saveTelegramCredentials(patch) {
28528
+ async function saveTelegramCredentials(patch, deps = {}) {
28012
28529
  const writes = [];
28013
28530
  for (const [key, raw] of Object.entries(patch)) {
28014
28531
  if (!KEYS.includes(key)) return { ok: false, key, error: "key bukan kredensial Telegram" };
@@ -28022,6 +28539,14 @@ async function saveTelegramCredentials(patch) {
28022
28539
  if (!parsed.ok) return { ok: false, key, error: parsed.error };
28023
28540
  writes.push([key, parsed.value]);
28024
28541
  }
28542
+ const agentToken = writes.find(([key]) => key === "HANOMAN_TELEGRAM_AGENT_TOKEN")?.[1];
28543
+ if (agentToken !== void 0) {
28544
+ const gate = await verifyTelegramAgentToken(agentToken, deps);
28545
+ if (!gate.ok) {
28546
+ const detail = gate.missing.length < TELEGRAM_REQUIRED_CAPABILITIES.length ? `${gate.reason} Kurang: ${gate.missing.join(", ")}.` : gate.reason;
28547
+ return { ok: false, key: "HANOMAN_TELEGRAM_AGENT_TOKEN", error: detail };
28548
+ }
28549
+ }
28025
28550
  for (const [key, value] of writes) await setConfig(key, value);
28026
28551
  return { ok: true };
28027
28552
  }
@@ -28036,11 +28561,12 @@ async function clearTelegramCredentials() {
28036
28561
  return { cleared, envFallback: KEYS.filter((k) => sourceOf(k) === "env") };
28037
28562
  }
28038
28563
  async function testTelegramConnection(deps = {}) {
28564
+ const inbound = await telegramInboundReadiness(deps);
28039
28565
  const botToken = deps.botToken ?? effectiveStr("HANOMAN_TELEGRAM_BOT_TOKEN");
28040
- if (!botToken) return { ok: false, error: "Bot token belum diisi." };
28566
+ if (!botToken) return { ok: false, error: "Bot token belum diisi.", inbound };
28041
28567
  const chatId = deps.chatId ?? resolveTestChatId();
28042
28568
  if (!chatId) {
28043
- return { ok: false, error: "Isi Chat / Channel ID target \u2014 atau isi allowlist dengan tepat satu user id." };
28569
+ return { ok: false, inbound, error: "Isi Chat / Channel ID target \u2014 atau isi allowlist dengan tepat satu user id." };
28044
28570
  }
28045
28571
  const timeoutMs = deps.timeoutMs ?? 1e4;
28046
28572
  const controller = new AbortController();
@@ -28052,11 +28578,11 @@ async function testTelegramConnection(deps = {}) {
28052
28578
  const me = await client.getMe();
28053
28579
  const stamp2 = (deps.now?.() ?? /* @__PURE__ */ new Date()).toISOString();
28054
28580
  await client.sendMessage({ chatId, text: `hanoman: uji koneksi Telegram berhasil (${stamp2}).` });
28055
- return { ok: true, botUsername: me.username ?? null, chatId };
28581
+ return { ok: true, botUsername: me.username ?? null, chatId, inbound };
28056
28582
  } catch (error) {
28057
28583
  const raw = error.message || "gagal menghubungi Telegram";
28058
28584
  const message = controller.signal.aborted ? `Timeout ${timeoutMs} ms \u2014 Telegram tidak menjawab.` : raw;
28059
- return { ok: false, error: sanitizeTelegramOutput(message, [botToken]).slice(0, 500) };
28585
+ return { ok: false, inbound, error: sanitizeTelegramOutput(message, [botToken]).slice(0, 500) };
28060
28586
  } finally {
28061
28587
  clearTimeout(timer7);
28062
28588
  }
@@ -28961,9 +29487,15 @@ var PUBLIC = /* @__PURE__ */ new Set([
28961
29487
  "GET /api/health",
28962
29488
  "GET /api/auth/status",
28963
29489
  "POST /api/auth/login",
28964
- "POST /api/auth/setup"
29490
+ "POST /api/auth/setup",
29491
+ // SPEC-489 · panduan AI agent. Sengaja tanpa auth: byte-nya sudah publik di GitHub, dan
29492
+ // "cukup diberi link + token" hanya benar bila link-nya terbaca SEBELUM token disetel —
29493
+ // menggerbanginya berarti agen yang capability-nya kurang menerima 403 pada dokumen yang
29494
+ // justru menjelaskan arti 403 itu.
29495
+ "GET /api/agent-integration.md"
28965
29496
  ]);
28966
- function buildApp({ requireAuth = true } = {}) {
29497
+ function buildApp({ requireAuth = true, agentDocFile } = {}) {
29498
+ const docFile = agentDocFile !== void 0 ? agentDocFile : pickGuideFile(dirname13(fileURLToPath4(import.meta.url)), process.env, existsSync12);
28967
29499
  const app2 = Fastify({ logger: false, trustProxy: true });
28968
29500
  app2.addContentTypeParser("application/json", { parseAs: "string" }, (_req, body, done) => {
28969
29501
  if (!body) return done(null, void 0);
@@ -29017,6 +29549,7 @@ function buildApp({ requireAuth = true } = {}) {
29017
29549
  api.addHook("onResponse", auditTelegramGatewayResponse);
29018
29550
  await api.register(auth_default);
29019
29551
  await api.register(health_default);
29552
+ await api.register(agent_doc_default, { file: docFile });
29020
29553
  await api.register(projects_default);
29021
29554
  await api.register(specs_default);
29022
29555
  await api.register(notifications_default);