hanoman 0.1.13 → 0.1.14

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
@@ -5553,6 +5553,9 @@ var init_api = __esm({
5553
5553
  limits: `${API}/limits`,
5554
5554
  // SPEC-339 · versi codex CLI (peringatan lunak model GPT-5.6).
5555
5555
  codexVersion: `${API}/codex/version`,
5556
+ // SPEC-489 · panduan AI agent — markdown MENTAH & PUBLIC (tanpa auth). Satu definisi URL untuk
5557
+ // klien web dan untuk tautan yang disalin operator ke agennya.
5558
+ agentDoc: `${API}/agent-integration.md`,
5556
5559
  docs: (id) => `${API}/projects/${id}/docs`,
5557
5560
  docFile: (id, path) => `${API}/projects/${id}/docs/${path}`,
5558
5561
  // SPEC-210 · dokumen PRD project (freshest-wins: worktree sesi prd hidup > repoDir)
@@ -5879,6 +5882,7 @@ var init_config_registry = __esm({
5879
5882
  kind: "url",
5880
5883
  apply: "live",
5881
5884
  category: "knob",
5885
+ example: "https://hanoman.nafanesia.id",
5882
5886
  help: "Base URL hub tujuan sync (REST + WS). Kosong = instance ini murni HUB."
5883
5887
  },
5884
5888
  {
@@ -5888,6 +5892,7 @@ var init_config_registry = __esm({
5888
5892
  kind: "secret",
5889
5893
  apply: "live",
5890
5894
  category: "credential",
5895
+ example: "43 karakter base64url dari tab Perangkat hub",
5891
5896
  help: "Token yang diterbitkan hub (tab Perangkat di hub). Dikirim sebagai Bearer."
5892
5897
  },
5893
5898
  {
@@ -5897,6 +5902,7 @@ var init_config_registry = __esm({
5897
5902
  kind: "int",
5898
5903
  apply: "live",
5899
5904
  category: "knob",
5905
+ example: "15000",
5900
5906
  default: "15000",
5901
5907
  min: 1e3
5902
5908
  },
@@ -5908,13 +5914,14 @@ var init_config_registry = __esm({
5908
5914
  kind: "secret",
5909
5915
  apply: "new-session",
5910
5916
  category: "credential",
5917
+ example: "sk-ant-oat01-\u2026",
5911
5918
  inheritEnv: true,
5912
5919
  help: "Token `claude setup-token`. Diwarisi proses claude yang di-spawn."
5913
5920
  },
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" },
5921
+ { key: "ANTHROPIC_API_KEY", group: "claude", label: "Anthropic API key", kind: "secret", apply: "new-session", category: "credential", example: "sk-ant-api03-\u2026", inheritEnv: true },
5922
+ { key: "HANOMAN_CLAUDE_BIN", group: "claude", label: "Biner claude", kind: "path", apply: "new-session", category: "knob", example: "/opt/homebrew/bin/claude", default: "claude" },
5916
5923
  // codex (SPEC-338 · ADR-0074)
5917
- { key: "HANOMAN_CODEX_BIN", group: "claude", label: "Biner codex", kind: "path", apply: "new-session", category: "knob", default: "codex" },
5924
+ { key: "HANOMAN_CODEX_BIN", group: "claude", label: "Biner codex", kind: "path", apply: "new-session", category: "knob", example: "/opt/homebrew/bin/codex", default: "codex" },
5918
5925
  {
5919
5926
  key: "CLAUDE_CONFIG_DIR",
5920
5927
  group: "claude",
@@ -5922,11 +5929,12 @@ var init_config_registry = __esm({
5922
5929
  kind: "path",
5923
5930
  apply: "new-session",
5924
5931
  category: "knob",
5932
+ example: "~/.claude",
5925
5933
  help: "Default ~/.claude. Sumber .credentials.json untuk panel usage/limit."
5926
5934
  },
5927
5935
  // 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" },
5936
+ { key: "HANOMAN_SSH_KEY_DIR", group: "vps", label: "Dir key SSH", kind: "path", apply: "new-session", category: "knob", example: "~/.hanoman", help: "Default ~/.hanoman." },
5937
+ { key: "HANOMAN_SSH_BIN", group: "vps", label: "Biner ssh", kind: "path", apply: "new-session", category: "knob", example: "/usr/bin/ssh", default: "ssh" },
5930
5938
  // github (SPEC-471 · ADR-0095 · tarik issue → backlog). Dua mode auth, satu jalur kode:
5931
5939
  // `gh` memakai keyring mesin; bila GITHUB_TOKEN diisi ia diteruskan sebagai GH_TOKEN ke
5932
5940
  // proses gh DAN dipakai jalur REST (hub VPS yang tak punya keyring).
@@ -5937,6 +5945,7 @@ var init_config_registry = __esm({
5937
5945
  kind: "secret",
5938
5946
  apply: "live",
5939
5947
  category: "credential",
5948
+ example: "ghp_\u2026",
5940
5949
  help: "PAT scope `repo` (atau `public_repo`). Kosong = andalkan `gh auth login` di mesin ini."
5941
5950
  },
5942
5951
  {
@@ -5946,6 +5955,7 @@ var init_config_registry = __esm({
5946
5955
  kind: "path",
5947
5956
  apply: "live",
5948
5957
  category: "knob",
5958
+ example: "/opt/homebrew/bin/gh",
5949
5959
  default: "gh",
5950
5960
  help: "Absen = tarik issue lewat HTTPS langsung ke api.github.com."
5951
5961
  },
@@ -5958,6 +5968,7 @@ var init_config_registry = __esm({
5958
5968
  kind: "secret",
5959
5969
  apply: "live",
5960
5970
  category: "credential",
5971
+ example: "123456789:AAE\u2026",
5961
5972
  pattern: "^\\d{5,}:[A-Za-z0-9_-]{30,}$",
5962
5973
  patternError: "format BotFather: <bot_id>:<secret>",
5963
5974
  help: "Token dari BotFather. Disimpan terenkripsi; tak pernah dikembalikan utuh."
@@ -5969,6 +5980,7 @@ var init_config_registry = __esm({
5969
5980
  kind: "secret",
5970
5981
  apply: "live",
5971
5982
  category: "credential",
5983
+ example: "hnm_agt_\u2026",
5972
5984
  pattern: "^\\S{20,}$",
5973
5985
  patternError: "plaintext AgentToken (hnm_agt_\u2026)",
5974
5986
  help: "Plaintext AgentToken ber-capability Telegram. Dipakai session operator, bukan bot."
@@ -5980,6 +5992,7 @@ var init_config_registry = __esm({
5980
5992
  kind: "string",
5981
5993
  apply: "live",
5982
5994
  category: "credential",
5995
+ example: "123456789, 987654321",
5983
5996
  pattern: "^\\d+(?:[\\s,]+\\d+)*$",
5984
5997
  patternError: "numeric user id, dipisah koma/spasi",
5985
5998
  help: "Siapa yang boleh memerintah bot. Numeric Telegram user id, bukan username."
@@ -5991,12 +6004,13 @@ var init_config_registry = __esm({
5991
6004
  kind: "string",
5992
6005
  apply: "live",
5993
6006
  category: "knob",
6007
+ example: "-1001234567890",
5994
6008
  pattern: "^-?\\d+$",
5995
6009
  patternError: "numeric chat id (channel/supergroup negatif)",
5996
6010
  help: "Tujuan Test Connection. Kosong = pakai satu-satunya id di allowlist."
5997
6011
  },
5998
6012
  // runtime
5999
- { key: "HANOMAN_EVENTS_TICK_MS", group: "runtime", label: "Interval events (ms)", kind: "int", apply: "live", category: "knob", default: "1000", min: 100 },
6013
+ { key: "HANOMAN_EVENTS_TICK_MS", group: "runtime", label: "Interval events (ms)", kind: "int", apply: "live", category: "knob", example: "1000", default: "1000", min: 100 },
6000
6014
  { key: "HANOMAN_UPDATE_FETCH", group: "runtime", label: "Deteksi update saat boot", kind: "bool", apply: "live", category: "knob", default: "1" },
6001
6015
  // SPEC-398 · ADR-0087 · deteksi update kini membandingkan semver dengan registry npm, bukan
6002
6016
  // menghitung commit di repo git (HANOMAN_REPO_ROOT dicabut bersama jalur git itu).
@@ -6007,6 +6021,7 @@ var init_config_registry = __esm({
6007
6021
  kind: "string",
6008
6022
  apply: "live",
6009
6023
  category: "knob",
6024
+ example: "https://registry.npmjs.org",
6010
6025
  default: "https://registry.npmjs.org",
6011
6026
  help: "Sumber versi terbaru paket `hanoman`."
6012
6027
  },
@@ -6017,15 +6032,16 @@ var init_config_registry = __esm({
6017
6032
  kind: "string",
6018
6033
  apply: "restart",
6019
6034
  category: "knob",
6035
+ example: "hanoman",
6020
6036
  default: "hanoman",
6021
6037
  help: "Mengubah ini TIDAK memindahkan sesi tmux yang sudah hidup \u2014 berlaku setelah restart."
6022
6038
  },
6023
6039
  // 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 },
6040
+ { key: "gitGraph.style", group: "gitGraph", label: "Gaya graph", kind: "string", apply: "live", category: "knob", example: "rounded", default: "rounded", help: "rounded | angular" },
6041
+ { 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." },
6042
+ { key: "gitGraph.dateType", group: "gitGraph", label: "Jenis tanggal", kind: "string", apply: "live", category: "knob", example: "author", default: "author", help: "author | commit" },
6043
+ { key: "gitGraph.commitsInitialLoad", group: "gitGraph", label: "Muat awal", kind: "int", apply: "live", category: "knob", example: "200", default: "200", min: 1 },
6044
+ { key: "gitGraph.commitsLoadMore", group: "gitGraph", label: "Muat lagi", kind: "int", apply: "live", category: "knob", example: "100", default: "100", min: 1 },
6029
6045
  { key: "gitGraph.showRemoteBranches", group: "gitGraph", label: "Tampilkan remote", kind: "bool", apply: "live", category: "knob", default: "1" },
6030
6046
  { key: "gitGraph.showTags", group: "gitGraph", label: "Tampilkan tag", kind: "bool", apply: "live", category: "knob", default: "1" },
6031
6047
  { key: "gitGraph.showStashes", group: "gitGraph", label: "Tampilkan stash", kind: "bool", apply: "live", category: "knob", default: "1" },
@@ -6035,7 +6051,7 @@ var init_config_registry = __esm({
6035
6051
  { key: "gitGraph.combineLocalRemote", group: "gitGraph", label: "Gabung label local+remote", kind: "bool", apply: "live", category: "knob", default: "0" },
6036
6052
  { key: "gitGraph.markdown", group: "gitGraph", label: "Render markdown pesan", kind: "bool", apply: "live", category: "knob", default: "1" },
6037
6053
  { 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" },
6054
+ { 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
6055
  // bootstrap (read-only)
6040
6056
  { key: "DATABASE_URL", group: "bootstrap", label: "DATABASE_URL", kind: "secret", apply: "restart", category: "bootstrap" },
6041
6057
  { key: "TEST_DATABASE_URL", group: "bootstrap", label: "TEST_DATABASE_URL", kind: "secret", apply: "restart", category: "bootstrap" },
@@ -7112,7 +7128,7 @@ var init_src = __esm({
7112
7128
 
7113
7129
  // src/env.ts
7114
7130
  import { readFileSync, existsSync } from "node:fs";
7115
- import { dirname, resolve as resolve2 } from "node:path";
7131
+ import { dirname, resolve as resolve3 } from "node:path";
7116
7132
  import { fileURLToPath } from "node:url";
7117
7133
  var dir;
7118
7134
  var init_env = __esm({
@@ -7120,7 +7136,7 @@ var init_env = __esm({
7120
7136
  "use strict";
7121
7137
  dir = dirname(fileURLToPath(import.meta.url));
7122
7138
  for (let i = 0; i < 6; i++) {
7123
- const file = resolve2(dir, ".env");
7139
+ const file = resolve3(dir, ".env");
7124
7140
  if (existsSync(file)) {
7125
7141
  for (const line of readFileSync(file, "utf8").split("\n")) {
7126
7142
  const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
@@ -7574,7 +7590,7 @@ ${lines2.join("\n")}` : "";
7574
7590
  // ../runner/src/git.ts
7575
7591
  import { spawnSync } from "node:child_process";
7576
7592
  import { rmSync, mkdirSync, realpathSync } from "node:fs";
7577
- import { isAbsolute, resolve as resolve3 } from "node:path";
7593
+ import { isAbsolute, resolve as resolve4 } from "node:path";
7578
7594
  function git(cwd, args) {
7579
7595
  const r = spawnSync("git", args, { cwd, encoding: "utf8" });
7580
7596
  if (r.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${r.stderr || r.stdout || r.error?.message || "gagal spawn"}`);
@@ -7609,7 +7625,7 @@ var init_git = __esm({
7609
7625
  addWorktree: (repo, path, branchFrom) => {
7610
7626
  tryGit(repo, ["worktree", "remove", "--force", path]);
7611
7627
  tryGit(repo, ["worktree", "prune"]);
7612
- rmSync(isAbsolute(path) ? path : resolve3(repo, path), { recursive: true, force: true });
7628
+ rmSync(isAbsolute(path) ? path : resolve4(repo, path), { recursive: true, force: true });
7613
7629
  const base2 = resolveCommit(repo, branchFrom);
7614
7630
  git(repo, ["worktree", "add", "--detach", path, base2]);
7615
7631
  return base2;
@@ -7619,8 +7635,8 @@ var init_git = __esm({
7619
7635
  // `fatal: not a working tree` → membuat DELETE /terminal/sessions balas 500. remove+prune+rm
7620
7636
  // semuanya toleran, jadi penutupan sesi selalu 204 dan registrasi worktree tak stale (SPEC-197).
7621
7637
  removeWorktree: (repo, path) => {
7622
- const target2 = isAbsolute(path) ? resolve3(path) : resolve3(repo, path);
7623
- if (target2 === resolve3(repo)) {
7638
+ const target2 = isAbsolute(path) ? resolve4(path) : resolve4(repo, path);
7639
+ if (target2 === resolve4(repo)) {
7624
7640
  throw new Error(`removeWorktree menolak menghapus repo itu sendiri: ${target2}`);
7625
7641
  }
7626
7642
  tryGit(repo, ["worktree", "remove", "--force", target2]);
@@ -7936,7 +7952,7 @@ var init_custom_agents = __esm({
7936
7952
 
7937
7953
  // ../runner/src/paths.ts
7938
7954
  import { homedir } from "node:os";
7939
- import { dirname as dirname2, isAbsolute as isAbsolute2, join, resolve as resolve4 } from "node:path";
7955
+ import { dirname as dirname2, isAbsolute as isAbsolute2, join, resolve as resolve5 } from "node:path";
7940
7956
  function resolveHome(env = process.env, home2 = homedir()) {
7941
7957
  const v = env.HANOMAN_HOME?.trim();
7942
7958
  return v ? v : join(home2, ".hanoman");
@@ -7961,7 +7977,7 @@ function resolveDbUrl(env, schemaDir2) {
7961
7977
  function absoluteFileUrl(raw, schemaDir2) {
7962
7978
  const p = raw.slice("file:".length);
7963
7979
  if (p.startsWith(":")) return raw;
7964
- return `file:${isAbsolute2(p) ? p : resolve4(schemaDir2, p)}`;
7980
+ return `file:${isAbsolute2(p) ? p : resolve5(schemaDir2, p)}`;
7965
7981
  }
7966
7982
  function dbUrlNotice(env) {
7967
7983
  if (env.HANOMAN_DATABASE_URL?.trim()) return null;
@@ -8197,7 +8213,7 @@ var init_tap = __esm({
8197
8213
 
8198
8214
  // src/db.ts
8199
8215
  import { mkdirSync as mkdirSync2 } from "node:fs";
8200
- import { dirname as dirname3, resolve as resolve5 } from "node:path";
8216
+ import { dirname as dirname3, resolve as resolve6 } from "node:path";
8201
8217
  import { fileURLToPath as fileURLToPath2 } from "node:url";
8202
8218
  import { PrismaClient } from "@prisma/client";
8203
8219
  var schemaDir, notice, url, base, prisma;
@@ -8207,7 +8223,7 @@ var init_db = __esm({
8207
8223
  init_env();
8208
8224
  init_src2();
8209
8225
  init_tap();
8210
- schemaDir = resolve5(dirname3(fileURLToPath2(import.meta.url)), "../prisma");
8226
+ schemaDir = resolve6(dirname3(fileURLToPath2(import.meta.url)), "../prisma");
8211
8227
  notice = dbUrlNotice(process.env);
8212
8228
  if (notice) console.warn(notice);
8213
8229
  url = resolveDbUrl(process.env, schemaDir);
@@ -10024,13 +10040,15 @@ var init_runtime = __esm({
10024
10040
 
10025
10041
  // src/services/telegram/gateway.ts
10026
10042
  import { createHash as createHash3 } from "node:crypto";
10027
- var digestRaw, TelegramGateway;
10043
+ var GATEWAY_PROGRESS_KIND, GATEWAY_FAILURE_KIND, digestRaw, TelegramGateway;
10028
10044
  var init_gateway = __esm({
10029
10045
  "src/services/telegram/gateway.ts"() {
10030
10046
  "use strict";
10031
10047
  init_client();
10032
10048
  init_protocol();
10033
10049
  init_runtime();
10050
+ GATEWAY_PROGRESS_KIND = "gateway-progress";
10051
+ GATEWAY_FAILURE_KIND = "gateway-failure";
10034
10052
  digestRaw = (update2) => createHash3("sha256").update(JSON.stringify(update2)).digest("hex");
10035
10053
  TelegramGateway = class {
10036
10054
  constructor(deps) {
@@ -10116,8 +10134,18 @@ var init_gateway = __esm({
10116
10134
  outcome: target2.created ? "session-created" : "session-reused",
10117
10135
  correlationId: `tg:${input.updateId}`
10118
10136
  });
10137
+ if (this.deps.progress) {
10138
+ await this.deps.store.enqueueReply({
10139
+ chatId: input.chatId,
10140
+ updateId: input.updateId,
10141
+ kind: GATEWAY_PROGRESS_KIND,
10142
+ text: target2.created ? "Diterima. Sesi operator Hanoman untuk chat ini sedang dijalankan \u2014 jawabannya menyusul." : "Diterima. Diteruskan ke sesi operator."
10143
+ }).catch(() => {
10144
+ });
10145
+ }
10119
10146
  updateTelegramRuntimeStatus({ lastUpdateAt: (/* @__PURE__ */ new Date()).toISOString(), lastError: null });
10120
10147
  } catch (error) {
10148
+ const reason = sanitizeTelegramOutput(error.message, this.deps.exactSecrets).slice(0, 200);
10121
10149
  await this.deps.store.markUpdateUncertain(input.updateId, "dispatch-failed");
10122
10150
  await this.deps.store.audit({
10123
10151
  chatId: input.chatId,
@@ -10127,7 +10155,14 @@ var init_gateway = __esm({
10127
10155
  outcome: "uncertain",
10128
10156
  correlationId: `tg:${input.updateId}`
10129
10157
  });
10130
- throw error;
10158
+ await this.deps.store.enqueueReply({
10159
+ chatId: input.chatId,
10160
+ updateId: input.updateId,
10161
+ kind: GATEWAY_FAILURE_KIND,
10162
+ 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).`
10163
+ }).catch(() => {
10164
+ });
10165
+ updateTelegramRuntimeStatus({ lastError: reason });
10131
10166
  }
10132
10167
  }
10133
10168
  async flushOutbox() {
@@ -10186,14 +10221,20 @@ var init_gateway = __esm({
10186
10221
  timeout: this.deps.pollTimeout ?? 25,
10187
10222
  signal
10188
10223
  });
10189
- await this.processUpdates(updates);
10190
- await this.flushOutbox();
10224
+ if (telegramRuntimeStatus().readiness === "error") {
10225
+ updateTelegramRuntimeStatus({ running: true, readiness: "running", lastError: null });
10226
+ }
10227
+ try {
10228
+ await this.processUpdates(updates);
10229
+ } finally {
10230
+ await this.flushOutbox();
10231
+ }
10191
10232
  } catch (error) {
10192
10233
  if (signal.aborted) return;
10193
10234
  const message = sanitizeTelegramOutput(error.message, this.deps.exactSecrets).slice(0, 500);
10194
10235
  updateTelegramRuntimeStatus({ running: false, readiness: "error", lastError: message });
10195
10236
  if (error instanceof TelegramApiError && error.code === 409) return;
10196
- await new Promise((resolve14) => setTimeout(resolve14, 1e3));
10237
+ await new Promise((resolve15) => setTimeout(resolve15, 1e3));
10197
10238
  }
10198
10239
  }
10199
10240
  }
@@ -17222,17 +17263,17 @@ var require_stream_wormhole = __commonJS({
17222
17263
  "../node_modules/.pnpm/stream-wormhole@1.1.0/node_modules/stream-wormhole/index.js"(exports, module) {
17223
17264
  "use strict";
17224
17265
  module.exports = (stream, throwError) => {
17225
- return new Promise((resolve14, reject2) => {
17266
+ return new Promise((resolve15, reject2) => {
17226
17267
  if (typeof stream.resume !== "function") {
17227
- return resolve14();
17268
+ return resolve15();
17228
17269
  }
17229
17270
  stream.unpipe && stream.unpipe();
17230
17271
  stream.resume();
17231
17272
  if (stream._readableState && stream._readableState.ended) {
17232
- return resolve14();
17273
+ return resolve15();
17233
17274
  }
17234
17275
  if (!stream.readable || stream.destroyed) {
17235
- return resolve14();
17276
+ return resolve15();
17236
17277
  }
17237
17278
  function cleanup() {
17238
17279
  stream.removeListener("end", onEnd);
@@ -17241,14 +17282,14 @@ var require_stream_wormhole = __commonJS({
17241
17282
  }
17242
17283
  function onEnd() {
17243
17284
  cleanup();
17244
- resolve14();
17285
+ resolve15();
17245
17286
  }
17246
17287
  function onError(err) {
17247
17288
  cleanup();
17248
17289
  if (throwError) {
17249
17290
  reject2(err);
17250
17291
  } else {
17251
- resolve14();
17292
+ resolve15();
17252
17293
  }
17253
17294
  }
17254
17295
  stream.on("end", onEnd);
@@ -17683,16 +17724,16 @@ var require_multipart2 = __commonJS({
17683
17724
  }
17684
17725
  };
17685
17726
  const parts = () => {
17686
- return new Promise((resolve14, reject2) => {
17727
+ return new Promise((resolve15, reject2) => {
17687
17728
  handle((val) => {
17688
17729
  if (val instanceof Error) {
17689
17730
  if (val.message === "Unexpected end of multipart data") {
17690
- resolve14(null);
17731
+ resolve15(null);
17691
17732
  } else {
17692
17733
  reject2(val);
17693
17734
  }
17694
17735
  } else {
17695
- resolve14(val);
17736
+ resolve15(val);
17696
17737
  }
17697
17738
  });
17698
17739
  });
@@ -17975,11 +18016,39 @@ function pickWebDir(distDir, env, exists) {
17975
18016
  return null;
17976
18017
  }
17977
18018
 
18019
+ // src/guide-file.ts
18020
+ import { resolve as resolve2 } from "node:path";
18021
+ var AGENT_DOC_REL = "docs/agent-integration.md";
18022
+ function pickGuideFile(distDir, env, exists) {
18023
+ const forced = env.HANOMAN_AGENT_DOC?.trim();
18024
+ if (forced) {
18025
+ if (!exists(forced)) throw new Error(`HANOMAN_AGENT_DOC menunjuk berkas yang tak ada: ${forced}`);
18026
+ return forced;
18027
+ }
18028
+ for (const c of [resolve2(distDir, "..", AGENT_DOC_REL), resolve2(distDir, "../..", AGENT_DOC_REL)]) {
18029
+ if (exists(c)) return c;
18030
+ }
18031
+ return null;
18032
+ }
18033
+
17978
18034
  // src/routes/health.ts
17979
18035
  async function health_default(app2) {
17980
18036
  app2.get("/health", async () => ({ ok: true }));
17981
18037
  }
17982
18038
 
18039
+ // src/routes/agent-doc.ts
18040
+ import { readFile } from "node:fs/promises";
18041
+ async function agent_doc_default(app2, opts) {
18042
+ app2.get("/agent-integration.md", async (_req, reply) => {
18043
+ if (!opts.file)
18044
+ return reply.code(404).send({
18045
+ error: `dokumen panduan tak ada di instalasi ini (${AGENT_DOC_REL}) \u2014 pasang ulang paket hanoman atau set HANOMAN_AGENT_DOC`
18046
+ });
18047
+ const text = await readFile(opts.file, "utf8");
18048
+ return reply.type("text/markdown; charset=utf-8").send(text);
18049
+ });
18050
+ }
18051
+
17983
18052
  // src/routes/projects.ts
17984
18053
  init_src();
17985
18054
  init_db();
@@ -17994,7 +18063,7 @@ init_src();
17994
18063
  import { execFile } from "node:child_process";
17995
18064
  import { promisify } from "node:util";
17996
18065
  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";
18066
+ import { resolve as resolve7, dirname as dirname6, sep } from "node:path";
17998
18067
  var exec = promisify(execFile);
17999
18068
  async function listRepoDocs(repoDir) {
18000
18069
  try {
@@ -18010,7 +18079,7 @@ async function listRepoDocs(repoDir) {
18010
18079
  }
18011
18080
  function docsDirOf(repoDir) {
18012
18081
  try {
18013
- const raw = readFileSync4(resolve6(repoDir, "hanoman.config.json"), "utf8");
18082
+ const raw = readFileSync4(resolve7(repoDir, "hanoman.config.json"), "utf8");
18014
18083
  return zHanomanConfig.parse(JSON.parse(raw)).docsDir;
18015
18084
  } catch {
18016
18085
  return zHanomanConfig.parse({}).docsDir;
@@ -18018,7 +18087,7 @@ function docsDirOf(repoDir) {
18018
18087
  }
18019
18088
  function resolveIndex(repoDir, docsDir) {
18020
18089
  const rel = `${docsDir}/README.md`;
18021
- return existsSync3(resolve6(repoDir, rel)) ? rel : "";
18090
+ return existsSync3(resolve7(repoDir, rel)) ? rel : "";
18022
18091
  }
18023
18092
  var catOf = (rel) => rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : ".";
18024
18093
  var nameOf = (rel) => rel.includes("/") ? rel.slice(rel.lastIndexOf("/") + 1) : rel;
@@ -18029,7 +18098,7 @@ async function scanRepoDocs(repoDir) {
18029
18098
  const index = resolveIndex(repoDir, docsDir);
18030
18099
  const read = (rel) => {
18031
18100
  try {
18032
- return readFileSync4(resolve6(repoDir, rel), "utf8");
18101
+ return readFileSync4(resolve7(repoDir, rel), "utf8");
18033
18102
  } catch {
18034
18103
  return null;
18035
18104
  }
@@ -18052,7 +18121,7 @@ async function scanRepoDocs(repoDir) {
18052
18121
  function docAbsPath(repoDir, rel) {
18053
18122
  if (!rel.endsWith(".md")) throw new Error("hanya file .md yang diizinkan");
18054
18123
  if (rel.split("/").includes(".git")) throw new Error("tidak boleh menyentuh .git");
18055
- const abs = resolve6(repoDir, rel);
18124
+ const abs = resolve7(repoDir, rel);
18056
18125
  if (abs !== repoDir && !abs.startsWith(repoDir + sep)) throw new Error("path keluar dari repo");
18057
18126
  return abs;
18058
18127
  }
@@ -18552,9 +18621,9 @@ init_db();
18552
18621
  // src/services/spec-review.ts
18553
18622
  import { execFile as execFile4 } from "node:child_process";
18554
18623
  import { promisify as promisify4 } from "node:util";
18555
- import { readFile, mkdtemp, copyFile, rm } from "node:fs/promises";
18624
+ import { readFile as readFile2, mkdtemp, copyFile, rm } from "node:fs/promises";
18556
18625
  import { tmpdir as tmpdir2 } from "node:os";
18557
- import { join as join4, resolve as resolve7 } from "node:path";
18626
+ import { join as join4, resolve as resolve8 } from "node:path";
18558
18627
  var exec4 = promisify4(execFile4);
18559
18628
  var GIT3 = { maxBuffer: 1 << 24 };
18560
18629
  var MAX = 256 * 1024;
@@ -18563,7 +18632,7 @@ async function withTempIndex(wt, fn) {
18563
18632
  const idx = (await exec4("git", ["rev-parse", "--git-path", "index"], { cwd: wt, ...GIT3 })).stdout.trim();
18564
18633
  const dir2 = await mkdtemp(join4(tmpdir2(), "hanoman-idx-"));
18565
18634
  const tmp = join4(dir2, "index");
18566
- await copyFile(resolve7(wt, idx), tmp);
18635
+ await copyFile(resolve8(wt, idx), tmp);
18567
18636
  const env = { ...process.env, GIT_INDEX_FILE: tmp };
18568
18637
  try {
18569
18638
  await exec4("git", ["add", "-A", "-N"], { cwd: wt, env, ...GIT3 });
@@ -18687,7 +18756,7 @@ async function reviewFile(repoDir, specId, baseSha, branchFrom, path) {
18687
18756
  let contentRaw = null;
18688
18757
  if (status !== "D") {
18689
18758
  try {
18690
- contentRaw = await readFile(join4(wt, path), "utf8");
18759
+ contentRaw = await readFile2(join4(wt, path), "utf8");
18691
18760
  } catch {
18692
18761
  contentRaw = null;
18693
18762
  }
@@ -21300,7 +21369,7 @@ function renderDocPdf(text, name2, meta) {
21300
21369
  });
21301
21370
  const chunks = [];
21302
21371
  doc.on("data", (c) => chunks.push(c));
21303
- const done = new Promise((resolve14) => doc.on("end", () => resolve14(Buffer.concat(chunks))));
21372
+ const done = new Promise((resolve15) => doc.on("end", () => resolve15(Buffer.concat(chunks))));
21304
21373
  doc.font("Helvetica-Bold").fontSize(8).fillColor(BRASS).text(toWinAnsi(meta.eyebrow.toUpperCase()), { characterSpacing: 0.8 });
21305
21374
  doc.moveDown(0.25);
21306
21375
  doc.font("Helvetica-Bold").fontSize(17).fillColor(STRONG).text(toWinAnsi(name2.split("/").pop() ?? name2));
@@ -22009,16 +22078,16 @@ import { promisify as promisify7 } from "node:util";
22009
22078
  // src/services/git-ide.ts
22010
22079
  import { execFile as execFile6 } from "node:child_process";
22011
22080
  import { promisify as promisify6 } from "node:util";
22012
- import { readFile as readFile2, writeFile, mkdir } from "node:fs/promises";
22081
+ import { readFile as readFile3, writeFile, mkdir } from "node:fs/promises";
22013
22082
  import { existsSync as existsSync6 } from "node:fs";
22014
- import { resolve as resolve8, sep as sep2, dirname as dirname8 } from "node:path";
22083
+ import { resolve as resolve9, sep as sep2, dirname as dirname8 } from "node:path";
22015
22084
  var exec6 = promisify6(execFile6);
22016
22085
  var GIT5 = { maxBuffer: 1 << 24 };
22017
22086
  var MAX2 = 256 * 1024;
22018
22087
  var splitZ2 = (s2) => s2.split("\0").filter(Boolean);
22019
22088
  function repoAbsPath(repoDir, rel) {
22020
22089
  if (rel.split(/[\\/]/).includes(".git")) throw new Error("tidak boleh menyentuh .git");
22021
- const abs = resolve8(repoDir, rel);
22090
+ const abs = resolve9(repoDir, rel);
22022
22091
  if (abs !== repoDir && !abs.startsWith(repoDir + sep2)) throw new Error("path keluar dari repo");
22023
22092
  return abs;
22024
22093
  }
@@ -22036,7 +22105,7 @@ async function readRepoFile(repoDir, rel, ref = "") {
22036
22105
  repoAbsPath(repoDir, rel);
22037
22106
  let raw;
22038
22107
  try {
22039
- raw = ref ? (await exec6("git", ["show", `${ref}:${rel}`], { cwd: repoDir, ...GIT5 })).stdout : await readFile2(repoAbsPath(repoDir, rel), "utf8");
22108
+ raw = ref ? (await exec6("git", ["show", `${ref}:${rel}`], { cwd: repoDir, ...GIT5 })).stdout : await readFile3(repoAbsPath(repoDir, rel), "utf8");
22040
22109
  } catch {
22041
22110
  return null;
22042
22111
  }
@@ -22437,7 +22506,7 @@ async function workingFileDiff(repoDir, path, staged) {
22437
22506
  let contentRaw = null;
22438
22507
  if (cf.status !== "D") {
22439
22508
  try {
22440
- contentRaw = staged ? (await exec6("git", ["show", `:${path}`], { cwd: repoDir, ...GIT5 })).stdout : await readFile2(repoAbsPath(repoDir, path), "utf8");
22509
+ contentRaw = staged ? (await exec6("git", ["show", `:${path}`], { cwd: repoDir, ...GIT5 })).stdout : await readFile3(repoAbsPath(repoDir, path), "utf8");
22441
22510
  } catch {
22442
22511
  contentRaw = null;
22443
22512
  }
@@ -22903,11 +22972,11 @@ async function finishGraphOp(reply, id, repoDir, r, verb) {
22903
22972
  // src/routes/fs.ts
22904
22973
  import { readdir } from "node:fs/promises";
22905
22974
  import { homedir as homedir3 } from "node:os";
22906
- import { resolve as resolve9, dirname as dirname9, join as join5 } from "node:path";
22975
+ import { resolve as resolve10, dirname as dirname9, join as join5 } from "node:path";
22907
22976
  async function fs_default(app2) {
22908
22977
  app2.get("/fs/browse", async (req, reply) => {
22909
22978
  const q = req.query.path;
22910
- const dir2 = q && q.trim() ? resolve9(q.trim()) : homedir3();
22979
+ const dir2 = q && q.trim() ? resolve10(q.trim()) : homedir3();
22911
22980
  try {
22912
22981
  const ents = await readdir(dir2, { withFileTypes: true });
22913
22982
  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 +23112,11 @@ async function startSpecSession(spec, opts) {
23043
23112
  }
23044
23113
 
23045
23114
  // src/services/session-worktree.ts
23046
- import { resolve as resolve10, sep as sep3 } from "node:path";
23115
+ import { resolve as resolve11, sep as sep3 } from "node:path";
23047
23116
  function ownsWorktree(repoDir, cwd) {
23048
- const base2 = resolve10(repoDir);
23049
- const dir2 = resolve10(cwd);
23050
- const container = resolve10(base2, ".worktrees");
23117
+ const base2 = resolve11(repoDir);
23118
+ const dir2 = resolve11(cwd);
23119
+ const container = resolve11(base2, ".worktrees");
23051
23120
  return dir2 !== base2 && dir2 !== container && dir2.startsWith(container + sep3);
23052
23121
  }
23053
23122
 
@@ -23548,14 +23617,14 @@ function sshExec(t, remoteCmd, opts = {}) {
23548
23617
  SSH_ASKPASS_REQUIRE: "force",
23549
23618
  HANOMAN_SSH_PASSWORD: opts.password
23550
23619
  } : process.env;
23551
- return new Promise((resolve14) => {
23620
+ return new Promise((resolve15) => {
23552
23621
  const p = spawn3(sshBin(), args, { stdio: ["pipe", "pipe", "pipe"], env });
23553
23622
  let out3 = "";
23554
23623
  const timer7 = setTimeout(() => p.kill("SIGKILL"), opts.timeoutMs ?? 6e4);
23555
23624
  const done = (r) => {
23556
23625
  clearTimeout(timer7);
23557
23626
  if (askpass) rmSync4(dirname10(askpass), { recursive: true, force: true });
23558
- resolve14(r);
23627
+ resolve15(r);
23559
23628
  };
23560
23629
  p.stdout.on("data", (d) => {
23561
23630
  out3 += d;
@@ -24332,7 +24401,7 @@ init_src();
24332
24401
  init_src();
24333
24402
  init_config2();
24334
24403
  import { readFileSync as readFileSync11 } from "node:fs";
24335
- import { resolve as resolve11, dirname as dirname12 } from "node:path";
24404
+ import { resolve as resolve12, dirname as dirname12 } from "node:path";
24336
24405
  import { fileURLToPath as fileURLToPath3 } from "node:url";
24337
24406
  var UPDATE_COMMAND = "npm i -g hanoman@latest";
24338
24407
  function composeUpdate(x) {
@@ -24358,7 +24427,7 @@ var lastLatest = null;
24358
24427
  var lastStatus = "unavailable";
24359
24428
  function runningVersion() {
24360
24429
  const here = dirname12(fileURLToPath3(import.meta.url));
24361
- for (const p of [resolve11(here, "build-info.json"), resolve11(here, "../package.json")]) {
24430
+ for (const p of [resolve12(here, "build-info.json"), resolve12(here, "../package.json")]) {
24362
24431
  try {
24363
24432
  const v = JSON.parse(readFileSync11(p, "utf8")).version;
24364
24433
  if (typeof v === "string" && v) return v;
@@ -24707,8 +24776,8 @@ init_conflicts();
24707
24776
  // src/services/uploads.ts
24708
24777
  init_config2();
24709
24778
  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";
24779
+ import { mkdir as mkdir2, writeFile as writeFile2, readFile as readFile4, unlink } from "node:fs/promises";
24780
+ import { join as join12, resolve as resolve13 } from "node:path";
24712
24781
  var EXT = {
24713
24782
  "image/png": ".png",
24714
24783
  "image/jpeg": ".jpg",
@@ -24718,7 +24787,7 @@ function extFor(mimeType) {
24718
24787
  return EXT[mimeType] ?? ".bin";
24719
24788
  }
24720
24789
  function uploadDir() {
24721
- return resolve12(effectiveStr("HANOMAN_UPLOAD_DIR") ?? join12(process.cwd(), "data", "uploads"));
24790
+ return resolve13(effectiveStr("HANOMAN_UPLOAD_DIR") ?? join12(process.cwd(), "data", "uploads"));
24722
24791
  }
24723
24792
  async function saveUpload(buf, mimeType) {
24724
24793
  const dir2 = uploadDir();
@@ -24729,13 +24798,13 @@ async function saveUpload(buf, mimeType) {
24729
24798
  }
24730
24799
  async function readUpload(storageKey) {
24731
24800
  const safe = storageKey.replace(/[/\\]/g, "");
24732
- return readFile3(join12(uploadDir(), safe));
24801
+ return readFile4(join12(uploadDir(), safe));
24733
24802
  }
24734
24803
  async function readUploadOrFetch(storageKey) {
24735
24804
  const safe = storageKey.replace(/[/\\]/g, "");
24736
24805
  const target2 = join12(uploadDir(), safe);
24737
24806
  try {
24738
- return await readFile3(target2);
24807
+ return await readFile4(target2);
24739
24808
  } catch {
24740
24809
  const base2 = effectiveStr("SYNC_SERVER_URL");
24741
24810
  const token = effectiveStr("SYNC_DEVICE_TOKEN");
@@ -24882,11 +24951,11 @@ import { randomUUID as randomUUID5 } from "node:crypto";
24882
24951
  // src/services/transcript-store.ts
24883
24952
  init_config2();
24884
24953
  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";
24954
+ import { mkdir as mkdir3, writeFile as writeFile3, readFile as readFile5, unlink as unlink2 } from "node:fs/promises";
24955
+ import { join as join13, resolve as resolve14, basename as basename2 } from "node:path";
24887
24956
  var MAX_TRANSCRIPT_BYTES = 1024 * 1024;
24888
24957
  function transcriptDir() {
24889
- return resolve13(effectiveStr("HANOMAN_TRANSCRIPT_DIR") ?? join13(process.cwd(), "data", "transcripts"));
24958
+ return resolve14(effectiveStr("HANOMAN_TRANSCRIPT_DIR") ?? join13(process.cwd(), "data", "transcripts"));
24890
24959
  }
24891
24960
  function clamp(text) {
24892
24961
  const buf = Buffer.from(text, "utf8");
@@ -24910,7 +24979,7 @@ async function saveTranscript(text) {
24910
24979
  async function readTranscript(key) {
24911
24980
  if (!key) return null;
24912
24981
  try {
24913
- return await readFile4(join13(transcriptDir(), basename2(key)), "utf8");
24982
+ return await readFile5(join13(transcriptDir(), basename2(key)), "utf8");
24914
24983
  } catch {
24915
24984
  return null;
24916
24985
  }
@@ -25938,7 +26007,7 @@ function leadFailureReason(agent, timeoutMs, err, stdout, stderr) {
25938
26007
  function think(prompt, o) {
25939
26008
  const bin = binFor(o.agent);
25940
26009
  const args = leadArgv({ agent: o.agent, model: o.model, effort: o.effort, prompt });
25941
- return new Promise((resolve14, reject2) => {
26010
+ return new Promise((resolve15, reject2) => {
25942
26011
  const child = execFile9(bin, args, {
25943
26012
  cwd: o.cwd,
25944
26013
  timeout: o.timeoutMs,
@@ -25951,7 +26020,7 @@ function think(prompt, o) {
25951
26020
  reject2(new Error(leadFailureReason(o.agent, o.timeoutMs, err, stdout, stderr)));
25952
26021
  return;
25953
26022
  }
25954
- resolve14(stdout);
26023
+ resolve15(stdout);
25955
26024
  });
25956
26025
  child.stdin?.end();
25957
26026
  });
@@ -25988,9 +26057,9 @@ function acquire(cap, waitMs) {
25988
26057
  return Promise.resolve();
25989
26058
  }
25990
26059
  const startedAt = Date.now();
25991
- return new Promise((resolve14, reject2) => {
26060
+ return new Promise((resolve15, reject2) => {
25992
26061
  const w = {
25993
- grant: resolve14,
26062
+ grant: resolve15,
25994
26063
  deny: reject2,
25995
26064
  timer: setTimeout(() => {
25996
26065
  const i = queue.indexOf(w);
@@ -26507,6 +26576,7 @@ var CODEX_FINISHED = [
26507
26576
  /\btokens used\b/i,
26508
26577
  /\bTo continue this session\b/i
26509
26578
  ];
26579
+ var AGENT_TURN_LINE = /(?:\bfor\s+\d+\s*[hms]\b|\(\s*\d+\s*[hms][^)]*·)/i;
26510
26580
  var ASK_SIGNALS = [
26511
26581
  /\?\s*$/m,
26512
26582
  /^\s*\[?\d+[).\]]\s+\S/m,
@@ -26515,9 +26585,12 @@ var ASK_SIGNALS = [
26515
26585
  function readPaneQuestion(text, agent) {
26516
26586
  const lines2 = tail2(text, 40);
26517
26587
  const body = lines2.join("\n").trim();
26518
- const choices = readChoiceDialog(text)?.options ?? [];
26588
+ const dialog = readChoiceDialog(text);
26589
+ const choices = dialog?.options ?? [];
26519
26590
  if (!body) return { asking: false, question: "", reason: "layar kosong", choices };
26520
26591
  const question = tail2(text, 25).join("\n").trim();
26592
+ if (!dialog && AGENT_TURN_LINE.test(body))
26593
+ return { asking: false, question, reason: "giliran agen, bukan pertanyaan (baris giliran claude di layar)", choices };
26521
26594
  if (agent === "codex") {
26522
26595
  const finished = CODEX_FINISHED.find((re) => re.test(body));
26523
26596
  if (finished) return { asking: false, question, reason: "sesi codex selesai wajar (ADR-0074)", choices };
@@ -26532,14 +26605,20 @@ var answers = /* @__PURE__ */ new Map();
26532
26605
  var capped = /* @__PURE__ */ new Set();
26533
26606
  var failures = /* @__PURE__ */ new Map();
26534
26607
  var failCapped = /* @__PURE__ */ new Set();
26608
+ var failedAt = /* @__PURE__ */ new Map();
26609
+ var FAIL_COOLDOWN_MS = 15 * 6e4;
26610
+ var chainFlows = /* @__PURE__ */ new Map();
26535
26611
  var MAX_CHAIN_STEPS = 6;
26536
26612
  var CHAIN_POLL_MS = 300;
26537
26613
  var CHAIN_POLL_TRIES = 20;
26614
+ var CHAIN_END_TRIES = 5;
26538
26615
  function resetSession(sessionId2) {
26539
26616
  answers.delete(sessionId2);
26540
26617
  capped.delete(sessionId2);
26541
26618
  failures.delete(sessionId2);
26542
26619
  failCapped.delete(sessionId2);
26620
+ failedAt.delete(sessionId2);
26621
+ chainFlows.delete(sessionId2);
26543
26622
  }
26544
26623
  var prodDetectDeps = {
26545
26624
  live: () => {
@@ -26577,6 +26656,12 @@ var prodDetectDeps = {
26577
26656
  },
26578
26657
  submit: (id) => submitPaneDialog(id),
26579
26658
  sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
26659
+ // Alur yang gagal ditutup bukan alasan menggagalkan rantai yang sudah berhasil: penyapu TTL
26660
+ // (ADR-0102) tetap menutupnya.
26661
+ closeChain: async (flowId) => {
26662
+ await closeFlow(flowId, "submit").catch(() => null);
26663
+ },
26664
+ now: Date.now,
26580
26665
  decide,
26581
26666
  decideDeps: prodDecideDeps,
26582
26667
  delivery: takeDelivery,
@@ -26602,7 +26687,7 @@ async function scanAndAnswer(deps = prodDetectDeps) {
26602
26687
  skip("lead dijeda untuk project ini");
26603
26688
  continue;
26604
26689
  }
26605
- if (!deps.filled(s2.decisionFile)) continue;
26690
+ if (!deps.filled(s2.decisionFile) && !(!deps.exited(s2.id) && readDialogScreen(deps.pane(s2.id)))) continue;
26606
26691
  if (deps.exited(s2.id)) {
26607
26692
  skip("pane mati");
26608
26693
  continue;
@@ -26635,6 +26720,12 @@ async function scanAndAnswer(deps = prodDetectDeps) {
26635
26720
  skip("batas jawaban otomatis tercapai");
26636
26721
  continue;
26637
26722
  }
26723
+ const lastFail = failedAt.get(s2.id);
26724
+ if (lastFail !== void 0 && deps.now() - lastFail > FAIL_COOLDOWN_MS) {
26725
+ failures.delete(s2.id);
26726
+ failCapped.delete(s2.id);
26727
+ failedAt.delete(s2.id);
26728
+ }
26638
26729
  if ((failures.get(s2.id) ?? 0) >= cfg.maxAutoAnswers) {
26639
26730
  if (!failCapped.has(s2.id)) {
26640
26731
  failCapped.add(s2.id);
@@ -26680,15 +26771,27 @@ async function scanAndAnswer(deps = prodDetectDeps) {
26680
26771
  out3.answered.push(s2.id);
26681
26772
  return;
26682
26773
  }
26683
- if (chain.failed) failures.set(s2.id, (failures.get(s2.id) ?? 0) + 1);
26774
+ if (chain.failed) {
26775
+ failures.set(s2.id, (failures.get(s2.id) ?? 0) + 1);
26776
+ failedAt.set(s2.id, deps.now());
26777
+ }
26684
26778
  out3.skipped.push({ id: s2.id, reason: chain.reason });
26685
26779
  });
26686
26780
  return out3;
26687
26781
  }
26688
26782
  async function runChain(s2, agent, deps) {
26689
26783
  let acted = false;
26784
+ const done = async (r) => {
26785
+ const flowId = chainFlows.get(s2.id);
26786
+ if (flowId) {
26787
+ chainFlows.delete(s2.id);
26788
+ await deps.closeChain(flowId).catch(() => {
26789
+ });
26790
+ }
26791
+ return r;
26792
+ };
26690
26793
  for (let step = 0; step < MAX_CHAIN_STEPS; step++) {
26691
- const text = deps.pane(s2.id);
26794
+ const text = step === 0 ? deps.pane(s2.id) : await settledPane(s2.id, deps);
26692
26795
  const screen = readDialogScreen(text);
26693
26796
  if (screen?.kind === "review") {
26694
26797
  if (!await deps.submit(s2.id))
@@ -26696,10 +26799,10 @@ async function runChain(s2, agent, deps) {
26696
26799
  acted = true;
26697
26800
  continue;
26698
26801
  }
26699
- if (step > 0 && !screen) return { acted, done: true, failed: false, reason: "" };
26802
+ if (step > 0 && !screen) return done({ acted, done: true, failed: false, reason: "" });
26700
26803
  const read = readPaneQuestion(text, agent);
26701
26804
  if (!read.asking) {
26702
- return step === 0 ? { acted, done: false, failed: false, reason: read.reason } : { acted, done: true, failed: false, reason: "" };
26805
+ return step === 0 ? { acted, done: false, failed: false, reason: read.reason } : done({ acted, done: true, failed: false, reason: "" });
26703
26806
  }
26704
26807
  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
26808
  if (read.choices.length) {
@@ -26711,22 +26814,32 @@ async function runChain(s2, agent, deps) {
26711
26814
  `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
26815
  );
26713
26816
  }
26817
+ const ask = {
26818
+ projectId: s2.projectId,
26819
+ specId: s2.specId,
26820
+ sessionId: s2.id,
26821
+ gate: "detected",
26822
+ kind: "answer",
26823
+ question: read.question,
26824
+ options: read.choices.length ? read.choices : void 0,
26825
+ notes,
26826
+ chain: true
26827
+ };
26714
26828
  let row;
26715
26829
  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);
26830
+ row = await deps.decide({ ...ask, flowId: chainFlows.get(s2.id) ?? null }, deps.decideDeps);
26726
26831
  } catch (e) {
26727
26832
  if (e instanceof LeadBusyError) return { acted, done: false, failed: false, reason: `lead penuh \u2014 ${e.message}` };
26728
- throw e;
26833
+ if (!(e instanceof LeadFlowClosedError)) throw e;
26834
+ chainFlows.delete(s2.id);
26835
+ try {
26836
+ row = await deps.decide({ ...ask, flowId: null }, deps.decideDeps);
26837
+ } catch (e2) {
26838
+ if (e2 instanceof LeadBusyError) return { acted, done: false, failed: false, reason: `lead penuh \u2014 ${e2.message}` };
26839
+ throw e2;
26840
+ }
26729
26841
  }
26842
+ if (row?.flowId) chainFlows.set(s2.id, row.flowId);
26730
26843
  if (!row) return { acted, done: false, failed: false, reason: "lead tak menghasilkan keputusan yang berlaku" };
26731
26844
  if (row.status !== "berlaku")
26732
26845
  return { acted, done: false, failed: true, reason: "lead tak menghasilkan keputusan yang berlaku" };
@@ -26736,12 +26849,20 @@ async function runChain(s2, agent, deps) {
26736
26849
  if (!await deps.send(s2.id, reply, picked))
26737
26850
  return { acted, done: false, failed: true, reason: "gagal mengetik ke pane" };
26738
26851
  acted = true;
26739
- if (!screen) return { acted, done: true, failed: false, reason: "" };
26852
+ if (!screen) return done({ acted, done: true, failed: false, reason: "" });
26740
26853
  if (!await waitScreenChange(s2.id, dialogKey(text), deps))
26741
26854
  return { acted, done: false, failed: true, reason: "layar dialog tak berubah sesudah dijawab" };
26742
26855
  }
26743
26856
  return { acted, done: false, failed: true, reason: "batas langkah rantai dialog tercapai" };
26744
26857
  }
26858
+ async function settledPane(id, deps) {
26859
+ let text = deps.pane(id);
26860
+ for (let i = 0; i < CHAIN_END_TRIES && !readDialogScreen(text); i++) {
26861
+ await deps.sleep(CHAIN_POLL_MS);
26862
+ text = deps.pane(id);
26863
+ }
26864
+ return text;
26865
+ }
26745
26866
  async function waitScreenChange(id, before, deps) {
26746
26867
  for (let i = 0; i < CHAIN_POLL_TRIES; i++) {
26747
26868
  await deps.sleep(CHAIN_POLL_MS);
@@ -26755,6 +26876,8 @@ function sweep(liveIds) {
26755
26876
  for (const id of [...capped]) if (!live.has(id)) capped.delete(id);
26756
26877
  for (const id of [...failures.keys()]) if (!live.has(id)) failures.delete(id);
26757
26878
  for (const id of [...failCapped]) if (!live.has(id)) failCapped.delete(id);
26879
+ for (const id of [...failedAt.keys()]) if (!live.has(id)) failedAt.delete(id);
26880
+ for (const id of [...chainFlows.keys()]) if (!live.has(id)) chainFlows.delete(id);
26758
26881
  }
26759
26882
 
26760
26883
  // src/services/lead/pulse.ts
@@ -27662,7 +27785,7 @@ function issuesFromRest(raw) {
27662
27785
  }
27663
27786
  var GH_FIELDS = "number,title,body,author,labels,url,state,createdAt,updatedAt";
27664
27787
  var API2 = "https://api.github.com";
27665
- var defaultRunGh = (args, env) => new Promise((resolve14, reject2) => {
27788
+ var defaultRunGh = (args, env) => new Promise((resolve15, reject2) => {
27666
27789
  execFile10(
27667
27790
  args[0],
27668
27791
  args.slice(1),
@@ -27670,7 +27793,7 @@ var defaultRunGh = (args, env) => new Promise((resolve14, reject2) => {
27670
27793
  (err, stdout, stderr) => {
27671
27794
  const e = err;
27672
27795
  if (e && (e.code === "ENOENT" || e.code === "EACCES")) return reject2(e);
27673
- resolve14({ code: err ? Number(err.code ?? 1) : 0, stdout, stderr });
27796
+ resolve15({ code: err ? Number(err.code ?? 1) : 0, stdout, stderr });
27674
27797
  }
27675
27798
  );
27676
27799
  });
@@ -27989,8 +28112,42 @@ import { randomBytes as randomBytes5 } from "node:crypto";
27989
28112
  // src/services/telegram/credentials.ts
27990
28113
  init_src();
27991
28114
  init_config2();
28115
+ init_agent_token();
28116
+ init_settings2();
28117
+ init_bootstrap();
27992
28118
  init_client();
27993
28119
  init_protocol();
28120
+ init_runtime();
28121
+ async function verifyTelegramAgentToken(token, deps = {}) {
28122
+ const agent = await (deps.verify ?? verifyAgentToken)(token);
28123
+ if (!agent) {
28124
+ return {
28125
+ ok: false,
28126
+ missing: [...TELEGRAM_REQUIRED_CAPABILITIES],
28127
+ reason: "AgentToken tidak dikenal atau sudah dicabut \u2014 salin plaintext `hnm_agt_\u2026` dari Akses AI Agent, bukan hash-nya."
28128
+ };
28129
+ }
28130
+ const missing = TELEGRAM_REQUIRED_CAPABILITIES.filter((c) => !agent.capabilities.includes(c));
28131
+ return missing.length ? { ok: false, missing, reason: `AgentToken kurang ${missing.length} capability.` } : { ok: true };
28132
+ }
28133
+ async function telegramInboundReadiness(deps = {}) {
28134
+ const polling = deps.polling ?? telegramRuntimeStatus().running;
28135
+ const empty = [];
28136
+ const token = deps.agentToken ?? effectiveStr("HANOMAN_TELEGRAM_AGENT_TOKEN")?.trim();
28137
+ if (!token) {
28138
+ return { ok: false, reason: "AgentToken gateway belum diisi.", missingCapabilities: [...TELEGRAM_REQUIRED_CAPABILITIES], polling };
28139
+ }
28140
+ const setting = await (deps.getSetting ?? getSetting)();
28141
+ if (!setting.agentAccessEnabled) {
28142
+ return { ok: false, reason: "Akses agent mati \u2014 nyalakan master switch di Akses AI Agent.", missingCapabilities: empty, polling };
28143
+ }
28144
+ const gate = await verifyTelegramAgentToken(token, deps);
28145
+ if (!gate.ok) return { ok: false, reason: gate.reason, missingCapabilities: gate.missing, polling };
28146
+ if (!polling) {
28147
+ return { ok: false, reason: "Kredensial sudah sah tapi gateway belum polling \u2014 nyalakan \u201CGateway aktif\u201D.", missingCapabilities: empty, polling };
28148
+ }
28149
+ return { ok: true, reason: null, missingCapabilities: empty, polling };
28150
+ }
27994
28151
  var KEYS = TELEGRAM_CONFIG_KEYS;
27995
28152
  function telegramCredentialView() {
27996
28153
  return {
@@ -28008,7 +28165,7 @@ function telegramCredentialView() {
28008
28165
  })
28009
28166
  };
28010
28167
  }
28011
- async function saveTelegramCredentials(patch) {
28168
+ async function saveTelegramCredentials(patch, deps = {}) {
28012
28169
  const writes = [];
28013
28170
  for (const [key, raw] of Object.entries(patch)) {
28014
28171
  if (!KEYS.includes(key)) return { ok: false, key, error: "key bukan kredensial Telegram" };
@@ -28022,6 +28179,14 @@ async function saveTelegramCredentials(patch) {
28022
28179
  if (!parsed.ok) return { ok: false, key, error: parsed.error };
28023
28180
  writes.push([key, parsed.value]);
28024
28181
  }
28182
+ const agentToken = writes.find(([key]) => key === "HANOMAN_TELEGRAM_AGENT_TOKEN")?.[1];
28183
+ if (agentToken !== void 0) {
28184
+ const gate = await verifyTelegramAgentToken(agentToken, deps);
28185
+ if (!gate.ok) {
28186
+ const detail = gate.missing.length < TELEGRAM_REQUIRED_CAPABILITIES.length ? `${gate.reason} Kurang: ${gate.missing.join(", ")}.` : gate.reason;
28187
+ return { ok: false, key: "HANOMAN_TELEGRAM_AGENT_TOKEN", error: detail };
28188
+ }
28189
+ }
28025
28190
  for (const [key, value] of writes) await setConfig(key, value);
28026
28191
  return { ok: true };
28027
28192
  }
@@ -28036,11 +28201,12 @@ async function clearTelegramCredentials() {
28036
28201
  return { cleared, envFallback: KEYS.filter((k) => sourceOf(k) === "env") };
28037
28202
  }
28038
28203
  async function testTelegramConnection(deps = {}) {
28204
+ const inbound = await telegramInboundReadiness(deps);
28039
28205
  const botToken = deps.botToken ?? effectiveStr("HANOMAN_TELEGRAM_BOT_TOKEN");
28040
- if (!botToken) return { ok: false, error: "Bot token belum diisi." };
28206
+ if (!botToken) return { ok: false, error: "Bot token belum diisi.", inbound };
28041
28207
  const chatId = deps.chatId ?? resolveTestChatId();
28042
28208
  if (!chatId) {
28043
- return { ok: false, error: "Isi Chat / Channel ID target \u2014 atau isi allowlist dengan tepat satu user id." };
28209
+ return { ok: false, inbound, error: "Isi Chat / Channel ID target \u2014 atau isi allowlist dengan tepat satu user id." };
28044
28210
  }
28045
28211
  const timeoutMs = deps.timeoutMs ?? 1e4;
28046
28212
  const controller = new AbortController();
@@ -28052,11 +28218,11 @@ async function testTelegramConnection(deps = {}) {
28052
28218
  const me = await client.getMe();
28053
28219
  const stamp2 = (deps.now?.() ?? /* @__PURE__ */ new Date()).toISOString();
28054
28220
  await client.sendMessage({ chatId, text: `hanoman: uji koneksi Telegram berhasil (${stamp2}).` });
28055
- return { ok: true, botUsername: me.username ?? null, chatId };
28221
+ return { ok: true, botUsername: me.username ?? null, chatId, inbound };
28056
28222
  } catch (error) {
28057
28223
  const raw = error.message || "gagal menghubungi Telegram";
28058
28224
  const message = controller.signal.aborted ? `Timeout ${timeoutMs} ms \u2014 Telegram tidak menjawab.` : raw;
28059
- return { ok: false, error: sanitizeTelegramOutput(message, [botToken]).slice(0, 500) };
28225
+ return { ok: false, inbound, error: sanitizeTelegramOutput(message, [botToken]).slice(0, 500) };
28060
28226
  } finally {
28061
28227
  clearTimeout(timer7);
28062
28228
  }
@@ -28961,9 +29127,15 @@ var PUBLIC = /* @__PURE__ */ new Set([
28961
29127
  "GET /api/health",
28962
29128
  "GET /api/auth/status",
28963
29129
  "POST /api/auth/login",
28964
- "POST /api/auth/setup"
29130
+ "POST /api/auth/setup",
29131
+ // SPEC-489 · panduan AI agent. Sengaja tanpa auth: byte-nya sudah publik di GitHub, dan
29132
+ // "cukup diberi link + token" hanya benar bila link-nya terbaca SEBELUM token disetel —
29133
+ // menggerbanginya berarti agen yang capability-nya kurang menerima 403 pada dokumen yang
29134
+ // justru menjelaskan arti 403 itu.
29135
+ "GET /api/agent-integration.md"
28965
29136
  ]);
28966
- function buildApp({ requireAuth = true } = {}) {
29137
+ function buildApp({ requireAuth = true, agentDocFile } = {}) {
29138
+ const docFile = agentDocFile !== void 0 ? agentDocFile : pickGuideFile(dirname13(fileURLToPath4(import.meta.url)), process.env, existsSync12);
28967
29139
  const app2 = Fastify({ logger: false, trustProxy: true });
28968
29140
  app2.addContentTypeParser("application/json", { parseAs: "string" }, (_req, body, done) => {
28969
29141
  if (!body) return done(null, void 0);
@@ -29017,6 +29189,7 @@ function buildApp({ requireAuth = true } = {}) {
29017
29189
  api.addHook("onResponse", auditTelegramGatewayResponse);
29018
29190
  await api.register(auth_default);
29019
29191
  await api.register(health_default);
29192
+ await api.register(agent_doc_default, { file: docFile });
29020
29193
  await api.register(projects_default);
29021
29194
  await api.register(specs_default);
29022
29195
  await api.register(notifications_default);